├── .resources ├── restore-mongo.sh ├── instruction.md ├── wimm-db-dump │ ├── roles.bson │ ├── users.bson │ ├── api_keys.bson │ ├── contents.bson │ ├── mentors.bson │ ├── topics.bson │ ├── roles.metadata.json │ ├── api_keys.metadata.json │ ├── topics.metadata.json │ ├── mentors.metadata.json │ ├── users.metadata.json │ └── contents.metadata.json ├── documentations │ └── assets │ │ ├── cover.jpg │ │ ├── display-dark.png │ │ └── display-light.png ├── init-mongo.js └── init-project.ts ├── .gitmodules ├── src ├── core │ ├── http │ │ ├── header.ts │ │ ├── request.ts │ │ └── response.ts │ ├── core.controller.ts │ ├── interceptors │ │ ├── response.transformer.ts │ │ ├── response.validations.ts │ │ ├── response.transformer.spec.ts │ │ ├── response.validations.spec.ts │ │ ├── exception.handler.ts │ │ └── exception.handler.spec.ts │ └── core.module.ts ├── auth │ ├── dto │ │ ├── token-refresh.dto.ts │ │ ├── signin-basic.dto.ts │ │ ├── user-tokens.dto.ts │ │ ├── signup-basic.dto.ts │ │ └── user-auth.dto.ts │ ├── decorators │ │ ├── public.decorator.ts │ │ ├── roles.decorator.ts │ │ └── permissions.decorator.ts │ ├── token │ │ ├── token.payload.ts │ │ └── token.factory.ts │ ├── schemas │ │ ├── role.schema.ts │ │ ├── keystore.schema.ts │ │ └── apikey.schema.ts │ ├── guards │ │ ├── roles.guard.ts │ │ ├── apikey.guard.ts │ │ ├── auth.guard.ts │ │ ├── roles.guard.spec.ts │ │ └── apikey.guard.spec.ts │ ├── auth.module.ts │ ├── auth.controller.ts │ └── auth.service.spec.ts ├── topic │ ├── dto │ │ ├── update-topic.dto.ts │ │ ├── create-topic.dto.ts │ │ └── topic-info.dto.ts │ ├── topic.module.ts │ ├── topic.controller.ts │ ├── topics.controller.ts │ ├── topic-admin.controller.ts │ ├── schemas │ │ └── topic.schema.ts │ └── topic.service.ts ├── mentor │ ├── dto │ │ ├── update-mentor.dto.ts │ │ ├── create-mentor.dto.ts │ │ └── mentor-info.dto.ts │ ├── mentor.module.ts │ ├── mentor.controller.ts │ ├── mentors.controller.ts │ ├── mentor-admin.controller.ts │ ├── schemas │ │ └── mentor.schema.ts │ └── mentor.service.ts ├── content │ ├── dto │ │ ├── update-content.dto.ts │ │ ├── create-private-content.dto.ts │ │ ├── pagination-rotated.dto.ts │ │ ├── create-content.dto.ts │ │ └── content-info.dto.ts │ ├── content.module.ts │ ├── contents.controller.ts │ ├── content.controller.ts │ ├── content-private.controller.ts │ ├── schemas │ │ └── content.schema.ts │ └── content-admin.controller.ts ├── message │ ├── dto │ │ └── create-message.dto.ts │ ├── message.module.ts │ ├── message.service.ts │ ├── schemas │ │ └── message.schema.ts │ ├── message.controller.ts │ └── message.service.spec.ts ├── scrapper │ ├── scrapper.module.ts │ ├── scrapper.controller.ts │ ├── dto │ │ └── meta-content.dto.ts │ └── scrapper.service.ts ├── common │ ├── pagination.dto.ts │ ├── copier.ts │ ├── mongoid.dto.ts │ ├── mongoid.transformer.ts │ └── mongo.validation.ts ├── search │ ├── dto │ │ ├── search-query.dto.ts │ │ └── search-result.dto.ts │ ├── search.controller.ts │ ├── search.module.ts │ └── search.service.ts ├── config │ ├── authkey.config.ts │ ├── disk.config.ts │ ├── server.config.ts │ ├── cache.config.ts │ ├── token.config.ts │ └── database.config.ts ├── subscription │ ├── dto │ │ ├── topic-subscription.dto.ts │ │ ├── mentor-subscription.dto.ts │ │ ├── subscription-info.dto.ts │ │ └── subscription.dto.ts │ ├── subscription.module.ts │ ├── schemas │ │ └── subscription.schema.ts │ └── subscription.controller.ts ├── user │ ├── dto │ │ ├── role.dto.ts │ │ ├── upadte-profile.dto.ts │ │ ├── user-info.dto.ts │ │ └── user.dto.ts │ ├── user.module.ts │ ├── user.controller.ts │ ├── schemas │ │ └── user.schema.ts │ └── user.service.ts ├── main.ts ├── cache │ ├── redis-cache.module.ts │ ├── cache.service.ts │ └── cache.factory.ts ├── files │ ├── files.module.ts │ ├── file.factory.ts │ ├── files.service.ts │ └── files.controller.ts ├── bookmark │ ├── bookmark.module.ts │ ├── schemas │ │ └── bookmark.schema.ts │ ├── bookmark.controller.ts │ └── bookmark.service.ts ├── setup │ ├── database.factory.ts │ └── winston.logger.ts └── app.module.ts ├── .prettierrc ├── tsconfig.build.json ├── keys ├── instruction.md ├── public.pem.example └── private.pem.example ├── nest-cli.json ├── SECURITY.md ├── .dockerignore ├── .vscode ├── settings.json ├── tasks.json └── launch.json ├── .gitignore ├── Dockerfile.test ├── tsconfig.json ├── Dockerfile ├── .eslintrc.js ├── test ├── app.e2e-spec.ts ├── app-auth.e2e-spec.ts ├── app-apikey.e2e-spec.ts ├── subscription.e2e-spec.ts └── content-cache.e2e-spec.ts ├── .github └── workflows │ └── docker_compose.yml ├── .env.example ├── .env.test.example ├── CONTRIBUTING.md ├── package.json ├── CODE_OF_CONDUCT.md ├── docker-compose.yml └── README.md /.resources/restore-mongo.sh: -------------------------------------------------------------------------------- 1 | mongorestore -d $DB_NAME /db-dump -------------------------------------------------------------------------------- /.resources/instruction.md: -------------------------------------------------------------------------------- 1 | # The resouces provided in this directory can be used to setup the project -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "disk"] 2 | path = disk 3 | url = https://github.com/fifocode/wimm-disk.git 4 | -------------------------------------------------------------------------------- /.resources/wimm-db-dump/roles.bson: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fifocode/wimm-apis/HEAD/.resources/wimm-db-dump/roles.bson -------------------------------------------------------------------------------- /.resources/wimm-db-dump/users.bson: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fifocode/wimm-apis/HEAD/.resources/wimm-db-dump/users.bson -------------------------------------------------------------------------------- /.resources/wimm-db-dump/api_keys.bson: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fifocode/wimm-apis/HEAD/.resources/wimm-db-dump/api_keys.bson -------------------------------------------------------------------------------- /.resources/wimm-db-dump/contents.bson: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fifocode/wimm-apis/HEAD/.resources/wimm-db-dump/contents.bson -------------------------------------------------------------------------------- /.resources/wimm-db-dump/mentors.bson: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fifocode/wimm-apis/HEAD/.resources/wimm-db-dump/mentors.bson -------------------------------------------------------------------------------- /.resources/wimm-db-dump/topics.bson: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fifocode/wimm-apis/HEAD/.resources/wimm-db-dump/topics.bson -------------------------------------------------------------------------------- /src/core/http/header.ts: -------------------------------------------------------------------------------- 1 | export const enum HeaderName { 2 | API_KEY = 'x-api-key', 3 | AUTHORIZATION = 'authorization', 4 | } 5 | -------------------------------------------------------------------------------- /.resources/documentations/assets/cover.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fifocode/wimm-apis/HEAD/.resources/documentations/assets/cover.jpg -------------------------------------------------------------------------------- /.resources/documentations/assets/display-dark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fifocode/wimm-apis/HEAD/.resources/documentations/assets/display-dark.png -------------------------------------------------------------------------------- /.resources/documentations/assets/display-light.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fifocode/wimm-apis/HEAD/.resources/documentations/assets/display-light.png -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "useTabs": false, 3 | "semi": true, 4 | "trailingComma": "all", 5 | "singleQuote": true, 6 | "printWidth": 80, 7 | "tabWidth": 2 8 | } -------------------------------------------------------------------------------- /src/auth/dto/token-refresh.dto.ts: -------------------------------------------------------------------------------- 1 | import { MaxLength } from 'class-validator'; 2 | 3 | export class TokenRefreshDto { 4 | @MaxLength(2000) 5 | refreshToken: string; 6 | } 7 | -------------------------------------------------------------------------------- /tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "exclude": ["node_modules", "test", "dist", "**/*spec.ts", ".templates", "disk", "keys", "logs", ".vscode"] 4 | } 5 | -------------------------------------------------------------------------------- /keys/instruction.md: -------------------------------------------------------------------------------- 1 | # Add in this directory your RSA Keys (You can generate these keys online also) 2 | 3 | 1. private.pem 4 | 2. public.pem 5 | 6 | Example files are provided in the directory 7 | -------------------------------------------------------------------------------- /src/auth/decorators/public.decorator.ts: -------------------------------------------------------------------------------- 1 | import { SetMetadata } from '@nestjs/common'; 2 | 3 | export const IS_PUBLIC_KEY = 'isPublic'; 4 | export const Public = () => SetMetadata(IS_PUBLIC_KEY, true); 5 | -------------------------------------------------------------------------------- /src/auth/decorators/roles.decorator.ts: -------------------------------------------------------------------------------- 1 | import { Reflector } from '@nestjs/core'; 2 | import { RoleCode } from '../schemas/role.schema'; 3 | 4 | export const Roles = Reflector.createDecorator(); 5 | -------------------------------------------------------------------------------- /nest-cli.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/nest-cli", 3 | "collection": "@nestjs/schematics", 4 | "sourceRoot": "src", 5 | "compilerOptions": { 6 | "deleteOutDir": true 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /src/topic/dto/update-topic.dto.ts: -------------------------------------------------------------------------------- 1 | import { PartialType } from '@nestjs/mapped-types'; 2 | import { CreateTopicDto } from './create-topic.dto'; 3 | 4 | export class UpdateTopicDto extends PartialType(CreateTopicDto) {} 5 | -------------------------------------------------------------------------------- /src/mentor/dto/update-mentor.dto.ts: -------------------------------------------------------------------------------- 1 | import { PartialType } from '@nestjs/mapped-types'; 2 | import { CreateMentorDto } from './create-mentor.dto'; 3 | 4 | export class UpdateMentorDto extends PartialType(CreateMentorDto) {} 5 | -------------------------------------------------------------------------------- /src/content/dto/update-content.dto.ts: -------------------------------------------------------------------------------- 1 | import { PartialType } from '@nestjs/mapped-types'; 2 | import { CreateContentDto } from './create-content.dto'; 3 | 4 | export class UpdateContentDto extends PartialType(CreateContentDto) {} 5 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security Policy 2 | 3 | ## Supported Versions 4 | 5 | | Version | Supported | 6 | | ------- | ------------------ | 7 | | 1.0.0 | :white_check_mark: | 8 | 9 | ## Reporting a Vulnerability 10 | 11 | Report using issue 12 | -------------------------------------------------------------------------------- /src/auth/decorators/permissions.decorator.ts: -------------------------------------------------------------------------------- 1 | import { Reflector } from '@nestjs/core'; 2 | import { Permission } from '../schemas/apikey.schema'; 3 | 4 | /** 5 | * API KEY permission 6 | */ 7 | export const Permissions = Reflector.createDecorator(); 8 | -------------------------------------------------------------------------------- /src/auth/dto/signin-basic.dto.ts: -------------------------------------------------------------------------------- 1 | import { IsEmail, MaxLength, MinLength } from 'class-validator'; 2 | 3 | export class SignInBasicDto { 4 | @IsEmail() 5 | readonly email: string; 6 | 7 | @MinLength(6) 8 | @MaxLength(100) 9 | readonly password: string; 10 | } 11 | -------------------------------------------------------------------------------- /src/message/dto/create-message.dto.ts: -------------------------------------------------------------------------------- 1 | import { IsNotEmpty, MaxLength } from 'class-validator'; 2 | 3 | export class CreateMessageDto { 4 | @IsNotEmpty() 5 | readonly type: string; 6 | 7 | @IsNotEmpty() 8 | @MaxLength(1000) 9 | readonly message: string; 10 | } 11 | -------------------------------------------------------------------------------- /src/content/dto/create-private-content.dto.ts: -------------------------------------------------------------------------------- 1 | import { OmitType } from '@nestjs/mapped-types'; 2 | import { CreateContentDto } from './create-content.dto'; 3 | 4 | export class CreatePrivateContentDto extends OmitType(CreateContentDto, [ 5 | 'mentors', 6 | 'topics', 7 | 'score', 8 | ]) {} 9 | -------------------------------------------------------------------------------- /src/core/core.controller.ts: -------------------------------------------------------------------------------- 1 | import { Controller, Get } from '@nestjs/common'; 2 | import { Public } from '../auth/decorators/public.decorator'; 3 | 4 | @Public() 5 | @Controller() 6 | export class CoreController { 7 | @Get('heartbeat') 8 | async heartbeat() { 9 | return 'alive'; 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /.resources/wimm-db-dump/roles.metadata.json: -------------------------------------------------------------------------------- 1 | {"indexes":[{"v":{"$numberInt":"2"},"key":{"_id":{"$numberInt":"1"}},"name":"_id_"},{"v":{"$numberInt":"2"},"key":{"code":{"$numberInt":"1"},"status":{"$numberInt":"1"}},"name":"code_1_status_1","background":true}],"uuid":"afb0834c13374f95b09e74ad3fee9e8c","collectionName":"roles","type":"collection"} -------------------------------------------------------------------------------- /src/scrapper/scrapper.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { ScrapperService } from './scrapper.service'; 3 | import { ScrapperController } from './scrapper.controller'; 4 | 5 | @Module({ 6 | providers: [ScrapperService], 7 | controllers: [ScrapperController], 8 | }) 9 | export class ScrapperModule {} 10 | -------------------------------------------------------------------------------- /src/auth/dto/user-tokens.dto.ts: -------------------------------------------------------------------------------- 1 | import { IsNotEmpty } from 'class-validator'; 2 | 3 | export class UserTokensDto { 4 | @IsNotEmpty() 5 | readonly accessToken: string; 6 | 7 | @IsNotEmpty() 8 | readonly refreshToken: string; 9 | 10 | constructor(tokens: UserTokensDto) { 11 | Object.assign(this, tokens); 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /.resources/init-mongo.js: -------------------------------------------------------------------------------- 1 | function seed(dbName, user, password) { 2 | db = db.getSiblingDB(dbName); 3 | db.createUser({ 4 | user: user, 5 | pwd: password, 6 | roles: [{ role: 'readWrite', db: dbName }], 7 | }); 8 | } 9 | 10 | seed('wimm-db', 'wimm-db-user', 'changeit'); 11 | seed('wimm-test-db', 'wimm-test-db-user', 'changeit'); 12 | -------------------------------------------------------------------------------- /src/common/pagination.dto.ts: -------------------------------------------------------------------------------- 1 | import { Type } from 'class-transformer'; 2 | import { IsInt, Min } from 'class-validator'; 3 | 4 | export class PaginationDto { 5 | @IsInt() 6 | @Min(1) 7 | @Type(() => Number) 8 | readonly pageNumber: number; 9 | 10 | @IsInt() 11 | @Min(1) 12 | @Type(() => Number) 13 | readonly pageItemCount: number; 14 | } 15 | -------------------------------------------------------------------------------- /src/search/dto/search-query.dto.ts: -------------------------------------------------------------------------------- 1 | import { IsEnum, IsOptional, MaxLength, MinLength } from 'class-validator'; 2 | import { Category } from '../../content/schemas/content.schema'; 3 | 4 | export class SearchQueryDto { 5 | @MinLength(1) 6 | @MaxLength(300) 7 | readonly query: string; 8 | 9 | @IsOptional() 10 | @IsEnum(Category) 11 | readonly filter?: Category; 12 | } 13 | -------------------------------------------------------------------------------- /src/content/dto/pagination-rotated.dto.ts: -------------------------------------------------------------------------------- 1 | import { IsBoolean, IsOptional } from 'class-validator'; 2 | import { PaginationDto } from '../../common/pagination.dto'; 3 | import { Type } from 'class-transformer'; 4 | 5 | export class PaginationRotatedDto extends PaginationDto { 6 | @IsOptional() 7 | @IsBoolean() 8 | @Type(() => Boolean) 9 | readonly empty: boolean = false; 10 | } 11 | -------------------------------------------------------------------------------- /src/common/copier.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * 3 | * @param source the object whose properties need to be copied 4 | * @param keys the propeties name array to copy 5 | * @returns object with selected properites 6 | */ 7 | export function copy(source: T, keys: K[]): Pick { 8 | return keys.reduce( 9 | (acc, key) => ({ ...acc, [key]: source[key] }), 10 | {} as Pick, 11 | ); 12 | } 13 | -------------------------------------------------------------------------------- /src/config/authkey.config.ts: -------------------------------------------------------------------------------- 1 | import { registerAs } from '@nestjs/config'; 2 | 3 | export const AuthKeyConfigName = 'authkey'; 4 | 5 | export interface AuthKeyConfig { 6 | publicKeyPath: string; 7 | privateKeyPath: string; 8 | } 9 | 10 | export default registerAs(AuthKeyConfigName, () => ({ 11 | publicKeyPath: process.env.AUTH_PUBLIC_KEY_PATH, 12 | privateKeyPath: process.env.AUTH_PRIVATE_KEY_PATH, 13 | })); 14 | -------------------------------------------------------------------------------- /src/config/disk.config.ts: -------------------------------------------------------------------------------- 1 | import { registerAs } from '@nestjs/config'; 2 | 3 | export const DiskConfigName = 'disk'; 4 | 5 | export interface DiskConfig { 6 | path: string; 7 | imageCacheDuration: number; 8 | } 9 | 10 | export default registerAs(DiskConfigName, () => ({ 11 | path: process.env.DISK_STORAGE_PATH || 'disk', 12 | imageCacheDuration: process.env.IMAGE_CACHE_DURATION || 31536000, // 1yr 13 | })); 14 | -------------------------------------------------------------------------------- /src/scrapper/scrapper.controller.ts: -------------------------------------------------------------------------------- 1 | import { Controller, Get, Query } from '@nestjs/common'; 2 | import { ScrapperService } from './scrapper.service'; 3 | 4 | @Controller('meta') 5 | export class ScrapperController { 6 | constructor(private readonly scapperService: ScrapperService) {} 7 | 8 | @Get('content') 9 | async findContent(@Query('url') url: string) { 10 | return await this.scapperService.scrape(url); 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /.resources/wimm-db-dump/api_keys.metadata.json: -------------------------------------------------------------------------------- 1 | {"indexes":[{"v":{"$numberInt":"2"},"key":{"_id":{"$numberInt":"1"}},"name":"_id_"},{"v":{"$numberInt":"2"},"key":{"key":{"$numberInt":"1"}},"name":"key_1","background":true,"unique":true},{"v":{"$numberInt":"2"},"key":{"key":{"$numberInt":"1"},"status":{"$numberInt":"1"}},"name":"key_1_status_1","background":true}],"uuid":"d8c145c6f4ce4936b342fac3671bec9d","collectionName":"api_keys","type":"collection"} -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | # Add any directories, files, or patterns you don't want to be tracked by version control 2 | .idea 3 | 4 | # ignore vs code project config files 5 | .github 6 | .templates 7 | .vscode 8 | .vs 9 | 10 | # ignore logs 11 | logs 12 | *.log 13 | 14 | # ignore 3rd party lib 15 | node_modules 16 | 17 | # Ignore built files 18 | dist 19 | build 20 | 21 | # ignore test converage 22 | coverage 23 | 24 | # git 25 | .git 26 | 27 | .DS_Store -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "javascript.validate.enable": true, 3 | "typescript.preferences.importModuleSpecifier": "relative", 4 | "editor.defaultFormatter": "esbenp.prettier-vscode", 5 | "editor.insertSpaces": true, 6 | "editor.detectIndentation": false, 7 | "editor.tabSize": 2, 8 | "typescript.validate.enable": true, 9 | "typescript.tsdk": "node_modules/typescript/lib", 10 | "jestrunner.codeLensSelector": "**/*.{test,spec,e2e-spec}.{js,ts}" 11 | } 12 | -------------------------------------------------------------------------------- /src/subscription/dto/topic-subscription.dto.ts: -------------------------------------------------------------------------------- 1 | import { IsBoolean } from 'class-validator'; 2 | import { TopicInfoDto } from '../../topic/dto/topic-info.dto'; 3 | import { Topic } from '../../topic/schemas/topic.schema'; 4 | 5 | export class TopicSubscriptionDto extends TopicInfoDto { 6 | @IsBoolean() 7 | subscribed: boolean; 8 | 9 | constructor(topic: Topic, subscribed: boolean) { 10 | super(topic); 11 | Object.assign(this, { subscribed: subscribed }); 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/search/search.controller.ts: -------------------------------------------------------------------------------- 1 | import { Controller, Get, Query } from '@nestjs/common'; 2 | import { SearchService } from './search.service'; 3 | import { SearchQueryDto } from './dto/search-query.dto'; 4 | 5 | @Controller('search') 6 | export class SearchController { 7 | constructor(private readonly searchService: SearchService) {} 8 | 9 | @Get() 10 | async create(@Query() searchQueryDto: SearchQueryDto) { 11 | return this.searchService.makeSearch(searchQueryDto); 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/config/server.config.ts: -------------------------------------------------------------------------------- 1 | import { registerAs } from '@nestjs/config'; 2 | 3 | export const ServerConfigName = 'server'; 4 | 5 | export interface ServerConfig { 6 | nodeEnv: string; 7 | port: number; 8 | timezone: string; 9 | logDirectory: string; 10 | } 11 | 12 | export default registerAs(ServerConfigName, () => ({ 13 | nodeEnv: process.env.NODE_ENV, 14 | port: parseInt(process.env.PORT || '3000'), 15 | timezone: process.env.TZ, 16 | logDirectory: process.env.LOG_DIR, 17 | })); 18 | -------------------------------------------------------------------------------- /src/subscription/dto/mentor-subscription.dto.ts: -------------------------------------------------------------------------------- 1 | import { IsBoolean } from 'class-validator'; 2 | import { MentorInfoDto } from '../../mentor/dto/mentor-info.dto'; 3 | import { Mentor } from '../../mentor/schemas/mentor.schema'; 4 | 5 | export class MentorSubscriptionDto extends MentorInfoDto { 6 | @IsBoolean() 7 | subscribed: boolean; 8 | 9 | constructor(mentor: Mentor, subscribed: boolean) { 10 | super(mentor); 11 | Object.assign(this, { subscribed: subscribed }); 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/user/dto/role.dto.ts: -------------------------------------------------------------------------------- 1 | import { Types } from 'mongoose'; 2 | import { Role, RoleCode } from '../../auth/schemas/role.schema'; 3 | import { IsNotEmpty } from 'class-validator'; 4 | import { IsMongoIdObject } from '../../common/mongo.validation'; 5 | 6 | export class RoleDto { 7 | @IsMongoIdObject() 8 | readonly _id: Types.ObjectId; 9 | 10 | @IsNotEmpty() 11 | readonly code: RoleCode; 12 | 13 | constructor(role: Role) { 14 | this._id = role._id; 15 | this.code = role.code; 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # dependencies 2 | /node_modules 3 | 4 | # IDE 5 | /.idea 6 | /.awcache 7 | /.vs 8 | 9 | # misc 10 | npm-debug.log 11 | 12 | # ignore logs 13 | /logs 14 | *.log 15 | 16 | # tests 17 | /coverage 18 | /.nyc_output 19 | 20 | # dist 21 | /dist 22 | /build 23 | 24 | #keys 25 | keys/* 26 | !keys/*.md 27 | !keys/*.example 28 | 29 | #temp 30 | temp 31 | .DS_Store 32 | 33 | *.save 34 | *.save.* 35 | 36 | disk/* 37 | 38 | # Environment varibles 39 | *.env 40 | *.env.test 41 | 42 | # ignore key 43 | *.pem -------------------------------------------------------------------------------- /src/user/dto/upadte-profile.dto.ts: -------------------------------------------------------------------------------- 1 | import { IsOptional, IsUrl, MaxLength } from 'class-validator'; 2 | 3 | export class UpdateProfileDto { 4 | @IsOptional() 5 | @MaxLength(200) 6 | readonly name?: string; 7 | 8 | @IsOptional() 9 | @IsUrl({ require_tld: false }) 10 | @MaxLength(500) 11 | readonly profilePicUrl?: string; 12 | 13 | @IsOptional() 14 | @MaxLength(2000) 15 | readonly firebaseToken?: string; 16 | 17 | @IsOptional() 18 | @MaxLength(500) 19 | readonly tagline?: string; 20 | } 21 | -------------------------------------------------------------------------------- /src/config/cache.config.ts: -------------------------------------------------------------------------------- 1 | import { registerAs } from '@nestjs/config'; 2 | 3 | export const CacheConfigName = 'redis'; 4 | 5 | export interface CacheConfig { 6 | host: string; 7 | port: number; 8 | password: string; 9 | ttl: number; 10 | } 11 | 12 | export default registerAs(CacheConfigName, () => ({ 13 | host: process.env.REDIS_HOST || '', 14 | port: process.env.REDIS_PORT || '', 15 | password: process.env.REDIS_PASSWORD || '', 16 | minPoolSize: parseInt(process.env.REDIS_TTL || '60'), 17 | })); 18 | -------------------------------------------------------------------------------- /.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "2.0.0", 3 | "tasks": [ 4 | { 5 | "label": "Template: Generate Feature", 6 | "type": "shell", 7 | "command": "npx ts-node .templates/feature.generator.ts ${input:featureName}", 8 | "presentation": { 9 | "reveal": "never", 10 | "panel": "shared" 11 | } 12 | } 13 | ], 14 | "inputs": [ 15 | { 16 | "type": "promptString", 17 | "id": "featureName", 18 | "description": "Feature name to create?" 19 | } 20 | ] 21 | } 22 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import { NestFactory } from '@nestjs/core'; 2 | import { AppModule } from './app.module'; 3 | import { ConfigService } from '@nestjs/config'; 4 | import { ServerConfig, ServerConfigName } from './config/server.config'; 5 | 6 | async function server() { 7 | const app = await NestFactory.create(AppModule); 8 | 9 | const configService = app.get(ConfigService); 10 | const serverConfig = configService.getOrThrow(ServerConfigName); 11 | 12 | await app.listen(serverConfig.port); 13 | } 14 | 15 | server(); 16 | -------------------------------------------------------------------------------- /src/user/user.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { MongooseModule } from '@nestjs/mongoose'; 3 | import { User, UserSchema } from './schemas/user.schema'; 4 | import { UserService } from './user.service'; 5 | import { UserController } from './user.controller'; 6 | 7 | @Module({ 8 | imports: [ 9 | MongooseModule.forFeature([{ name: User.name, schema: UserSchema }]), 10 | ], 11 | providers: [UserService], 12 | exports: [UserService], 13 | controllers: [UserController], 14 | }) 15 | export class UserModule {} 16 | -------------------------------------------------------------------------------- /src/auth/dto/signup-basic.dto.ts: -------------------------------------------------------------------------------- 1 | import { 2 | IsEmail, 3 | IsNotEmpty, 4 | IsOptional, 5 | IsUrl, 6 | MaxLength, 7 | MinLength, 8 | } from 'class-validator'; 9 | 10 | export class SignUpBasicDto { 11 | @IsEmail() 12 | readonly email: string; 13 | 14 | @MinLength(6) 15 | @MaxLength(100) 16 | readonly password: string; 17 | 18 | @IsNotEmpty() 19 | @MinLength(2) 20 | @MaxLength(200) 21 | readonly name: string; 22 | 23 | @IsUrl({ require_tld: false }) 24 | @IsOptional() 25 | readonly profilePicUrl?: string; 26 | } 27 | -------------------------------------------------------------------------------- /src/message/message.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { Message, MessageSchema } from './schemas/message.schema'; 3 | import { MongooseModule } from '@nestjs/mongoose'; 4 | import { MessageController } from './message.controller'; 5 | import { MessageService } from './message.service'; 6 | 7 | @Module({ 8 | imports: [ 9 | MongooseModule.forFeature([{ name: Message.name, schema: MessageSchema }]), 10 | ], 11 | controllers: [MessageController], 12 | providers: [MessageService], 13 | }) 14 | export class MessageModule {} 15 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.2.0", 3 | "configurations": [ 4 | { 5 | "type": "node", 6 | "request": "launch", 7 | "name": "Debug App", 8 | "args": ["${workspaceFolder}/src/main.ts"], 9 | "runtimeArgs": [ 10 | "--nolazy", 11 | "-r", 12 | "ts-node/register", 13 | "-r", 14 | "tsconfig-paths/register" 15 | ], 16 | "sourceMaps": true, 17 | "cwd": "${workspaceRoot}", 18 | "console": "integratedTerminal", 19 | "protocol": "inspector" 20 | } 21 | ] 22 | } 23 | -------------------------------------------------------------------------------- /src/common/mongoid.dto.ts: -------------------------------------------------------------------------------- 1 | import { Transform } from 'class-transformer'; 2 | import { Types } from 'mongoose'; 3 | import { IsMongoIdObject } from './mongo.validation'; 4 | import { IsNotEmpty } from 'class-validator'; 5 | 6 | export class MongoIdDto { 7 | @IsNotEmpty() 8 | @Transform(({ value }) => { 9 | if (Types.ObjectId.isValid(value)) return new Types.ObjectId(value); 10 | return null; 11 | }) 12 | @IsMongoIdObject() 13 | readonly id: Types.ObjectId; 14 | 15 | constructor(props: MongoIdDto) { 16 | Object.assign(this, props); 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/core/http/request.ts: -------------------------------------------------------------------------------- 1 | import { Request } from 'express'; 2 | import { User } from '../../user/schemas/user.schema'; 3 | import { ApiKey } from '../../auth/schemas/apikey.schema'; 4 | import { Keystore } from '../../auth/schemas/keystore.schema'; 5 | 6 | export interface PublicRequest extends Request { 7 | apiKey: ApiKey; 8 | } 9 | 10 | export interface RoleRequest extends PublicRequest { 11 | currentRoleCodes: string[]; 12 | } 13 | 14 | export interface ProtectedRequest extends RoleRequest { 15 | user: User; 16 | accessToken: string; 17 | keystore: Keystore; 18 | } 19 | -------------------------------------------------------------------------------- /.resources/wimm-db-dump/topics.metadata.json: -------------------------------------------------------------------------------- 1 | {"indexes":[{"v":{"$numberInt":"2"},"key":{"_id":{"$numberInt":"1"}},"name":"_id_"},{"v":{"$numberInt":"2"},"key":{"createdBy":{"$numberInt":"1"}},"name":"createdBy_1","background":true},{"v":{"$numberInt":"2"},"key":{"_fts":"text","_ftsx":{"$numberInt":"1"}},"name":"name_text_title_text","background":false,"weights":{"name":{"$numberInt":"3"},"title":{"$numberInt":"1"}},"default_language":"english","language_override":"language","textIndexVersion":{"$numberInt":"3"}}],"uuid":"c682f2fc2114423fa866a41944697525","collectionName":"topics","type":"collection"} -------------------------------------------------------------------------------- /src/search/search.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { SearchController } from './search.controller'; 3 | import { SearchService } from './search.service'; 4 | import { MentorModule } from '../mentor/mentor.module'; 5 | import { TopicModule } from '../topic/topic.module'; 6 | import { ContentModule } from '../content/content.module'; 7 | 8 | @Module({ 9 | imports: [MentorModule, TopicModule, ContentModule], 10 | controllers: [SearchController], 11 | providers: [SearchService], 12 | exports: [SearchService], 13 | }) 14 | export class SearchModule {} 15 | -------------------------------------------------------------------------------- /src/auth/token/token.payload.ts: -------------------------------------------------------------------------------- 1 | export class TokenPayload { 2 | readonly aud: string; 3 | readonly sub: string; 4 | readonly iss: string; 5 | readonly iat: number; 6 | readonly exp: number; 7 | readonly prm: string; 8 | 9 | constructor( 10 | issuer: string, 11 | audience: string, 12 | subject: string, 13 | param: string, 14 | validity: number, 15 | ) { 16 | this.iss = issuer; 17 | this.aud = audience; 18 | this.sub = subject; 19 | this.iat = Math.floor(Date.now() / 1000); 20 | this.exp = this.iat + validity; 21 | this.prm = param; 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/cache/redis-cache.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { CacheModule } from '@nestjs/cache-manager'; 3 | import { ConfigModule } from '@nestjs/config'; 4 | import { CacheConfigFactory } from './cache.factory'; 5 | import { CacheService } from './cache.service'; 6 | 7 | @Module({ 8 | imports: [ 9 | ConfigModule, 10 | CacheModule.registerAsync({ 11 | imports: [ConfigModule], 12 | useClass: CacheConfigFactory, 13 | }), 14 | ], 15 | providers: [CacheService], 16 | exports: [CacheService, CacheModule], 17 | }) 18 | export class RedisCacheModule {} 19 | -------------------------------------------------------------------------------- /src/subscription/dto/subscription-info.dto.ts: -------------------------------------------------------------------------------- 1 | import { IsBoolean, IsEnum } from 'class-validator'; 2 | import { IsMongoIdObject } from '../../common/mongo.validation'; 3 | import { Types } from 'mongoose'; 4 | import { Category } from '../../content/schemas/content.schema'; 5 | 6 | export class SubscriptionInfoDto { 7 | @IsMongoIdObject() 8 | itemId: Types.ObjectId; 9 | 10 | @IsEnum(Category) 11 | category: Category; 12 | 13 | @IsBoolean() 14 | subscribed: boolean; 15 | 16 | constructor(readonly props: Partial) { 17 | Object.assign(this, props); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/auth/dto/user-auth.dto.ts: -------------------------------------------------------------------------------- 1 | import { UserDto } from '../../user/dto/user.dto'; 2 | import { User } from '../../user/schemas/user.schema'; 3 | import { IsNotEmptyObject, ValidateNested } from 'class-validator'; 4 | import { UserTokensDto } from './user-tokens.dto'; 5 | 6 | export class UserAuthDto { 7 | @ValidateNested() 8 | @IsNotEmptyObject() 9 | readonly user: UserDto; 10 | 11 | @ValidateNested() 12 | @IsNotEmptyObject() 13 | readonly tokens: UserTokensDto; 14 | 15 | constructor(user: User, tokens: UserTokensDto) { 16 | this.user = new UserDto(user); 17 | this.tokens = tokens; 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/config/token.config.ts: -------------------------------------------------------------------------------- 1 | import { registerAs } from '@nestjs/config'; 2 | 3 | export const TokenConfigName = 'token'; 4 | 5 | export interface TokenConfig { 6 | accessTokenValidity: number; 7 | refreshTokenValidity: number; 8 | issuer: string; 9 | audience: string; 10 | } 11 | 12 | export default registerAs(TokenConfigName, () => ({ 13 | accessTokenValidity: parseInt(process.env.ACCESS_TOKEN_VALIDITY_SEC || '0'), 14 | refreshTokenValidity: parseInt(process.env.REFRESH_TOKEN_VALIDITY_SEC || '0'), 15 | issuer: process.env.TOKEN_ISSUER || '', 16 | audience: process.env.TOKEN_AUDIENCE || '', 17 | })); 18 | -------------------------------------------------------------------------------- /Dockerfile.test: -------------------------------------------------------------------------------- 1 | # Here we are getting our node as Base image 2 | FROM node:lts 3 | 4 | # create user in the docker image 5 | USER node 6 | 7 | # Creating a new directory for app files and setting path in the container 8 | RUN mkdir -p /home/node/app && chown -R node:node /home/node/app 9 | 10 | # setting working directory in the container 11 | WORKDIR /home/node/app 12 | 13 | # grant permission of node project directory to node user 14 | COPY --chown=node:node . . 15 | 16 | # installing the dependencies into the container 17 | RUN npm install 18 | 19 | # command to run within the container 20 | CMD [ "npm", "start" ] 21 | -------------------------------------------------------------------------------- /.resources/wimm-db-dump/mentors.metadata.json: -------------------------------------------------------------------------------- 1 | {"indexes":[{"v":{"$numberInt":"2"},"key":{"_id":{"$numberInt":"1"}},"name":"_id_"},{"v":{"$numberInt":"2"},"key":{"createdBy":{"$numberInt":"1"}},"name":"createdBy_1","background":true},{"v":{"$numberInt":"2"},"key":{"_fts":"text","_ftsx":{"$numberInt":"1"}},"name":"name_text_occupation_text_title_text","background":false,"weights":{"name":{"$numberInt":"5"},"occupation":{"$numberInt":"1"},"title":{"$numberInt":"2"}},"default_language":"english","language_override":"language","textIndexVersion":{"$numberInt":"3"}}],"uuid":"10365a88fa4147a59c18c05d2ea6349c","collectionName":"mentors","type":"collection"} -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "declaration": true, 5 | "removeComments": true, 6 | "emitDecoratorMetadata": true, 7 | "experimentalDecorators": true, 8 | "allowSyntheticDefaultImports": true, 9 | "target": "ESNext", 10 | "sourceMap": true, 11 | "outDir": "./dist", 12 | "baseUrl": "./", 13 | "incremental": true, 14 | "skipLibCheck": true, 15 | "strictNullChecks": true, 16 | "noImplicitAny": true, 17 | "strictBindCallApply": true, 18 | "forceConsistentCasingInFileNames": true, 19 | "noFallthroughCasesInSwitch": true 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /.resources/wimm-db-dump/users.metadata.json: -------------------------------------------------------------------------------- 1 | {"indexes":[{"v":{"$numberInt":"2"},"key":{"_id":{"$numberInt":"1"}},"name":"_id_"},{"v":{"$numberInt":"2"},"key":{"email":{"$numberInt":"1"}},"name":"email_1","background":true,"unique":true,"sparse":true},{"v":{"$numberInt":"2"},"key":{"_id":{"$numberInt":"1"},"status":{"$numberInt":"1"}},"name":"_id_1_status_1","background":true},{"v":{"$numberInt":"2"},"key":{"status":{"$numberInt":"1"}},"name":"status_1","background":true},{"v":{"$numberInt":"2"},"key":{"deviceId":{"$numberInt":"1"}},"name":"deviceId_1","background":true,"unique":true,"sparse":true}],"uuid":"b694c76f7f04401799016dc6d1a6067c","collectionName":"users","type":"collection"} -------------------------------------------------------------------------------- /src/core/http/response.ts: -------------------------------------------------------------------------------- 1 | export enum StatusCode { 2 | SUCCESS = 10000, 3 | FAILURE = 10001, 4 | RETRY = 10002, 5 | INVALID_ACCESS_TOKEN = 10003, 6 | } 7 | 8 | export class MessageResponse { 9 | readonly statusCode: StatusCode; 10 | readonly message: string; 11 | 12 | constructor(statusCode: StatusCode, message: string) { 13 | this.statusCode = statusCode; 14 | this.message = message; 15 | } 16 | } 17 | 18 | export class DataResponse extends MessageResponse { 19 | readonly data: T; 20 | 21 | constructor(statusCode: StatusCode, message: string, data: T) { 22 | super(statusCode, message); 23 | this.data = data; 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/files/files.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { FilesController } from './files.controller'; 3 | import { ConfigModule } from '@nestjs/config'; 4 | import { MulterModule } from '@nestjs/platform-express'; 5 | import { FileDiskFactory } from './file.factory'; 6 | import { FilesService } from './files.service'; 7 | 8 | @Module({ 9 | imports: [ 10 | ConfigModule, 11 | MulterModule.registerAsync({ 12 | imports: [FilesModule], 13 | useClass: FileDiskFactory, 14 | }), 15 | ], 16 | controllers: [FilesController], 17 | providers: [FilesService], 18 | exports: [FilesService], 19 | }) 20 | export class FilesModule {} 21 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # Here we are getting our node as Base image 2 | FROM node:lts 3 | 4 | # create user in the docker image 5 | USER node 6 | 7 | # Creating a new directory for app files and setting path in the container 8 | RUN mkdir -p /home/node/app && chown -R node:node /home/node/app 9 | 10 | # setting working directory in the container 11 | WORKDIR /home/node/app 12 | 13 | # grant permission of node project directory to node user 14 | COPY --chown=node:node . . 15 | 16 | # installing the dependencies into the container 17 | RUN npm install 18 | 19 | # container exposed network port number 20 | EXPOSE 3000 21 | 22 | # command to run within the container 23 | CMD [ "npm", "start" ] -------------------------------------------------------------------------------- /src/search/dto/search-result.dto.ts: -------------------------------------------------------------------------------- 1 | import { IsEnum, IsNotEmpty, IsUrl } from 'class-validator'; 2 | import { Types } from 'mongoose'; 3 | import { Category } from '../../content/schemas/content.schema'; 4 | import { IsMongoIdObject } from '../../common/mongo.validation'; 5 | 6 | export class SearchResultDto { 7 | @IsMongoIdObject() 8 | id: Types.ObjectId; 9 | 10 | @IsNotEmpty() 11 | title: string; 12 | 13 | @IsEnum(Category) 14 | category: Category; 15 | 16 | @IsUrl({ require_tld: false }) 17 | thumbnail: string; 18 | 19 | @IsNotEmpty() 20 | extra: string; 21 | 22 | constructor(props: SearchResultDto) { 23 | Object.assign(this, props); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/user/dto/user-info.dto.ts: -------------------------------------------------------------------------------- 1 | import { Types } from 'mongoose'; 2 | import { User } from '../schemas/user.schema'; 3 | import { IsNotEmpty, IsOptional, IsUrl } from 'class-validator'; 4 | import { IsMongoIdObject } from '../../common/mongo.validation'; 5 | import { copy } from '../../common/copier'; 6 | 7 | export class UserInfoDto { 8 | @IsMongoIdObject() 9 | readonly _id: Types.ObjectId; 10 | 11 | @IsNotEmpty() 12 | @IsOptional() 13 | readonly name?: string; 14 | 15 | @IsUrl({ require_tld: false }) 16 | @IsOptional() 17 | readonly profilePicUrl?: string; 18 | 19 | constructor(user: User) { 20 | Object.assign(this, copy(user, ['_id', 'name', 'profilePicUrl'])); 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/cache/cache.service.ts: -------------------------------------------------------------------------------- 1 | import { CACHE_MANAGER } from '@nestjs/cache-manager'; 2 | import { Inject, Injectable } from '@nestjs/common'; 3 | import { Cache } from 'cache-manager'; 4 | 5 | @Injectable() 6 | export class CacheService { 7 | constructor(@Inject(CACHE_MANAGER) private readonly cache: Cache) {} 8 | 9 | async getValue(key: string): Promise { 10 | return await this.cache.get(key); 11 | } 12 | 13 | async setValue(key: string, value: string): Promise { 14 | await this.cache.set(key, value); 15 | } 16 | 17 | async delete(key: string): Promise { 18 | await this.cache.del(key); 19 | } 20 | 21 | onModuleDestroy() { 22 | this.cache.disconnect(); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/topic/topic.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { Topic, TopicSchema } from './schemas/topic.schema'; 3 | import { MongooseModule } from '@nestjs/mongoose'; 4 | import { TopicController } from './topic.controller'; 5 | import { TopicService } from './topic.service'; 6 | import { TopicAdminController } from './topic-admin.controller'; 7 | import { TopicsController } from './topics.controller'; 8 | 9 | @Module({ 10 | imports: [ 11 | MongooseModule.forFeature([{ name: Topic.name, schema: TopicSchema }]), 12 | ], 13 | controllers: [TopicController, TopicAdminController, TopicsController], 14 | providers: [TopicService], 15 | exports: [TopicService], 16 | }) 17 | export class TopicModule {} 18 | -------------------------------------------------------------------------------- /src/bookmark/bookmark.module.ts: -------------------------------------------------------------------------------- 1 | import { Module, forwardRef } from '@nestjs/common'; 2 | import { Bookmark, BookmarkSchema } from './schemas/bookmark.schema'; 3 | import { MongooseModule } from '@nestjs/mongoose'; 4 | import { BookmarkController } from './bookmark.controller'; 5 | import { BookmarkService } from './bookmark.service'; 6 | import { ContentModule } from '../content/content.module'; 7 | 8 | @Module({ 9 | imports: [ 10 | MongooseModule.forFeature([ 11 | { name: Bookmark.name, schema: BookmarkSchema }, 12 | ]), 13 | forwardRef(() => ContentModule), 14 | ], 15 | controllers: [BookmarkController], 16 | providers: [BookmarkService], 17 | exports: [BookmarkService], 18 | }) 19 | export class BookmarkModule {} 20 | -------------------------------------------------------------------------------- /src/common/mongoid.transformer.ts: -------------------------------------------------------------------------------- 1 | import { 2 | PipeTransform, 3 | Injectable, 4 | BadRequestException, 5 | ArgumentMetadata, 6 | } from '@nestjs/common'; 7 | import { Types } from 'mongoose'; 8 | 9 | @Injectable() 10 | export class MongoIdTransformer implements PipeTransform { 11 | transform(value: any, metadata: ArgumentMetadata): any { 12 | if (typeof value !== 'string') return value; 13 | 14 | if (metadata.metatype?.name === 'ObjectId') { 15 | if (!Types.ObjectId.isValid(value)) { 16 | const key = metadata?.data ?? ''; 17 | throw new BadRequestException(`${key} must be a mongodb id`); 18 | } 19 | return new Types.ObjectId(value); 20 | } 21 | 22 | return value; 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/config/database.config.ts: -------------------------------------------------------------------------------- 1 | import { registerAs } from '@nestjs/config'; 2 | 3 | export const DatabaseConfigName = 'database'; 4 | 5 | export interface DatabaseConfig { 6 | name: string; 7 | host: string; 8 | port: number; 9 | user: string; 10 | password: string; 11 | minPoolSize: number; 12 | maxPoolSize: number; 13 | } 14 | 15 | export default registerAs(DatabaseConfigName, () => ({ 16 | name: process.env.DB_NAME || '', 17 | host: process.env.DB_HOST || '', 18 | port: process.env.DB_PORT || '', 19 | user: process.env.DB_USER || '', 20 | password: process.env.DB_USER_PWD || '', 21 | minPoolSize: parseInt(process.env.DB_MIN_POOL_SIZE || '5'), 22 | maxPoolSize: parseInt(process.env.DB_MAX_POOL_SIZE || '10'), 23 | })); 24 | -------------------------------------------------------------------------------- /src/mentor/mentor.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { Mentor, MentorSchema } from './schemas/mentor.schema'; 3 | import { MongooseModule } from '@nestjs/mongoose'; 4 | import { MentorController } from './mentor.controller'; 5 | import { MentorService } from './mentor.service'; 6 | import { MentorAdminController } from './mentor-admin.controller'; 7 | import { MentorsController } from './mentors.controller'; 8 | 9 | @Module({ 10 | imports: [ 11 | MongooseModule.forFeature([{ name: Mentor.name, schema: MentorSchema }]), 12 | ], 13 | controllers: [MentorController, MentorAdminController, MentorsController], 14 | providers: [MentorService], 15 | exports: [MentorService], 16 | }) 17 | export class MentorModule {} 18 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | parser: '@typescript-eslint/parser', 3 | parserOptions: { 4 | project: 'tsconfig.json', 5 | tsconfigRootDir: __dirname, 6 | sourceType: 'module', 7 | }, 8 | plugins: ['@typescript-eslint/eslint-plugin'], 9 | extends: [ 10 | 'plugin:@typescript-eslint/recommended', 11 | 'plugin:prettier/recommended', 12 | ], 13 | root: true, 14 | env: { 15 | node: true, 16 | jest: true, 17 | }, 18 | ignorePatterns: ['.eslintrc.js'], 19 | rules: { 20 | '@typescript-eslint/interface-name-prefix': 'off', 21 | '@typescript-eslint/explicit-function-return-type': 'off', 22 | '@typescript-eslint/explicit-module-boundary-types': 'off', 23 | '@typescript-eslint/no-explicit-any': 'off', 24 | }, 25 | }; 26 | -------------------------------------------------------------------------------- /src/topic/topic.controller.ts: -------------------------------------------------------------------------------- 1 | import { Controller, Get, NotFoundException, Param } from '@nestjs/common'; 2 | import { Types } from 'mongoose'; 3 | import { MongoIdTransformer } from '../common/mongoid.transformer'; 4 | import { TopicService } from './topic.service'; 5 | import { TopicInfoDto } from './dto/topic-info.dto'; 6 | 7 | @Controller('topic') 8 | export class TopicController { 9 | constructor(private readonly topicService: TopicService) {} 10 | 11 | @Get('id/:id') 12 | async findOne( 13 | @Param('id', MongoIdTransformer) id: Types.ObjectId, 14 | ): Promise { 15 | const topic = await this.topicService.findById(id); 16 | if (!topic) throw new NotFoundException('Topic Not Found'); 17 | return new TopicInfoDto(topic); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/message/message.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { InjectModel } from '@nestjs/mongoose'; 3 | import { Model } from 'mongoose'; 4 | import { Message } from './schemas/message.schema'; 5 | import { CreateMessageDto } from './dto/create-message.dto'; 6 | import { User } from '../user/schemas/user.schema'; 7 | 8 | @Injectable() 9 | export class MessageService { 10 | constructor( 11 | @InjectModel(Message.name) private readonly messageModel: Model, 12 | ) {} 13 | 14 | async create( 15 | user: User, 16 | createMessageDto: CreateMessageDto, 17 | ): Promise { 18 | const message = await this.messageModel.create({ 19 | ...createMessageDto, 20 | user: user, 21 | }); 22 | return message; 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/mentor/mentor.controller.ts: -------------------------------------------------------------------------------- 1 | import { Controller, Get, NotFoundException, Param } from '@nestjs/common'; 2 | import { Types } from 'mongoose'; 3 | import { MongoIdTransformer } from '../common/mongoid.transformer'; 4 | import { MentorService } from './mentor.service'; 5 | import { MentorInfoDto } from './dto/mentor-info.dto'; 6 | 7 | @Controller('mentor') 8 | export class MentorController { 9 | constructor(private readonly mentorService: MentorService) {} 10 | 11 | @Get('id/:id') 12 | async findOne( 13 | @Param('id', MongoIdTransformer) id: Types.ObjectId, 14 | ): Promise { 15 | const mentor = await this.mentorService.findById(id); 16 | if (!mentor) throw new NotFoundException('Mentor Not Found'); 17 | return new MentorInfoDto(mentor); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/files/file.factory.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { diskStorage } from 'multer'; 3 | import { 4 | MulterModuleOptions, 5 | MulterOptionsFactory, 6 | } from '@nestjs/platform-express'; 7 | import { FilesService } from './files.service'; 8 | 9 | @Injectable() 10 | export class FileDiskFactory implements MulterOptionsFactory { 11 | constructor(private readonly filesService: FilesService) {} 12 | 13 | createMulterOptions(): MulterModuleOptions { 14 | return { 15 | storage: diskStorage({ 16 | destination: this.filesService.getDiskPath(), 17 | filename: (_, file, callback) => { 18 | const fileName = this.filesService.getFileName(file); 19 | callback(null, fileName); 20 | }, 21 | }), 22 | }; 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/message/schemas/message.schema.ts: -------------------------------------------------------------------------------- 1 | import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose'; 2 | import mongoose, { HydratedDocument, Types } from 'mongoose'; 3 | import { User } from '../../user/schemas/user.schema'; 4 | 5 | export type MessageDocument = HydratedDocument; 6 | 7 | @Schema({ collection: 'messages', versionKey: false, timestamps: true }) 8 | export class Message { 9 | readonly _id: Types.ObjectId; 10 | 11 | @Prop({ required: true }) 12 | type: string; 13 | 14 | @Prop({ 15 | type: mongoose.Schema.Types.ObjectId, 16 | ref: User.name, 17 | required: true, 18 | }) 19 | user: User; 20 | 21 | @Prop({ required: true }) 22 | message: string; 23 | 24 | @Prop() 25 | deviceId?: string; 26 | } 27 | 28 | export const MessageSchema = SchemaFactory.createForClass(Message); 29 | -------------------------------------------------------------------------------- /src/topic/dto/create-topic.dto.ts: -------------------------------------------------------------------------------- 1 | import { 2 | IsOptional, 3 | IsUrl, 4 | Max, 5 | MaxLength, 6 | Min, 7 | MinLength, 8 | } from 'class-validator'; 9 | 10 | export class CreateTopicDto { 11 | @MinLength(3) 12 | @MaxLength(50) 13 | readonly name: string; 14 | 15 | @MinLength(3) 16 | @MaxLength(300) 17 | readonly title: string; 18 | 19 | @MinLength(3) 20 | @MaxLength(10000) 21 | readonly description: string; 22 | 23 | @IsUrl({ require_tld: false }) 24 | @MaxLength(300) 25 | readonly thumbnail: string; 26 | 27 | @IsUrl({ require_tld: false }) 28 | @MaxLength(300) 29 | readonly coverImgUrl: string; 30 | 31 | @IsOptional() 32 | @Min(0) 33 | @Max(1) 34 | readonly score: number; 35 | 36 | constructor(params: CreateTopicDto) { 37 | Object.assign(this, params); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/auth/schemas/role.schema.ts: -------------------------------------------------------------------------------- 1 | import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose'; 2 | import { HydratedDocument, Types } from 'mongoose'; 3 | 4 | export type RoleDocument = HydratedDocument; 5 | 6 | export enum RoleCode { 7 | VIEWER = 'VIEWER', 8 | ADMIN = 'ADMIN', 9 | MANAGER = 'MANAGER', 10 | } 11 | 12 | @Schema({ collection: 'roles', versionKey: false, timestamps: true }) 13 | export class Role { 14 | readonly _id: Types.ObjectId; 15 | 16 | @Prop({ 17 | type: String, 18 | required: true, 19 | unique: true, 20 | enum: Object.values(RoleCode), 21 | }) 22 | readonly code: RoleCode; 23 | 24 | @Prop({ default: true }) 25 | readonly status: boolean; 26 | } 27 | 28 | export const RoleSchema = SchemaFactory.createForClass(Role); 29 | 30 | RoleSchema.index({ code: 1, status: 1 }); 31 | -------------------------------------------------------------------------------- /test/app.e2e-spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { INestApplication } from '@nestjs/common'; 3 | import * as request from 'supertest'; 4 | import { AppModule } from '../src/app.module'; 5 | 6 | describe('AppController (e2e)', () => { 7 | let app: INestApplication; 8 | 9 | beforeEach(async () => { 10 | const module: TestingModule = await Test.createTestingModule({ 11 | imports: [AppModule], 12 | }).compile(); 13 | 14 | app = module.createNestApplication(); 15 | await app.init(); 16 | }); 17 | 18 | afterEach(async () => { 19 | await app.close(); 20 | }); 21 | 22 | it('should throw 404 when the endpoint is not defined', () => { 23 | return request(app.getHttpServer()) 24 | .get('/') 25 | .expect(404) 26 | .expect(/Cannot GET/); 27 | }); 28 | }); 29 | -------------------------------------------------------------------------------- /src/scrapper/dto/meta-content.dto.ts: -------------------------------------------------------------------------------- 1 | import { 2 | IsEnum, 3 | IsOptional, 4 | IsUrl, 5 | MaxLength, 6 | MinLength, 7 | } from 'class-validator'; 8 | import { Category } from '../../content/schemas/content.schema'; 9 | 10 | export class MetaContentDto { 11 | @IsEnum(Category) 12 | category: Category; 13 | 14 | @MinLength(2) 15 | @MaxLength(500) 16 | readonly title: string; 17 | 18 | @MinLength(2) 19 | @MaxLength(100) 20 | readonly subtitle: string; 21 | 22 | @IsOptional() 23 | @MinLength(2) 24 | @MaxLength(2000) 25 | readonly description: string; 26 | 27 | @IsUrl({ require_tld: false }) 28 | @MaxLength(300) 29 | readonly thumbnail: string; 30 | 31 | @MaxLength(300) 32 | readonly extra: string; 33 | 34 | constructor(metaContent: MetaContentDto) { 35 | Object.assign(this, metaContent); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /keys/public.pem.example: -------------------------------------------------------------------------------- 1 | -----BEGIN PUBLIC KEY----- 2 | MIICITANBgkqhkiG9w0BAQEFAAOCAg4AMIICCQKCAgBlwJaoLrrQOpNGfsH8xPnk 3 | iV4jVF6gsaS46JjBRFbdPAcMWg1hhDm2McYrcajmBleXhTBgdTlmr84LSD6/bAD5 4 | uVC0i/jUyq1i3zeMmwe8pwy1aprFgCb3DwnlF0WH0ZumRf+mAgAXEDRZOhtWeWKd 5 | W7XFNGlRdMegfRfog+ZXtQhRJZ2+8E7+RYi8AE4zTEgjakXaY0j8fNiQ6F8sY4Z3 6 | oIc/J5K2TTBgyVDX3sIWMjP3+Eq6wpU3/fd38LpDDhEf3KzKpDmtr+R9u9R0CzNi 7 | +JidRfETjYdjs/fGXHqxV94NYhRKJC/crtLNaZF3tN5JHttWV41KgD8MM1moY+5w 8 | BFSY3/wOIHG9eZVV/xrsHkoWHoJ4IAeh0tA3sklrN6iPx+1c9oIqSb7NS3hVhtFQ 9 | sPDqeq7r9q/PTYrYYQxXCIlXcXRq15MpCJlN1wsDQxiqZ/rPtkj7a81MI2N0X7Vh 10 | Va5RadTbh1OBCZXAfDWI2jRepuTFssFO4Y5uqQmyIFLeo4raqNtyMM2U9a50/TaV 11 | F8nLsR1SNB/U0KAKn7Z9T0aWyCwDt/Pbcro2DCBUQgQPH1FCYMRBbbSi/0dtGXIa 12 | 5PFLHW7jNiNrNKt2HD093avvSH00uKngLV/p8f3utdz7uNzFdO7G2Aif6gDvEOsl 13 | DwTfqL5oa9oocBiLsY4hpwIDAQAB 14 | -----END PUBLIC KEY----- -------------------------------------------------------------------------------- /src/cache/cache.factory.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { ConfigService } from '@nestjs/config'; 3 | import { CacheConfig, CacheConfigName } from '../config/cache.config'; 4 | import { CacheModuleOptions, CacheOptionsFactory } from '@nestjs/cache-manager'; 5 | import { createKeyv } from '@keyv/redis'; 6 | 7 | @Injectable() 8 | export class CacheConfigFactory implements CacheOptionsFactory { 9 | constructor(private readonly configService: ConfigService) {} 10 | 11 | async createCacheOptions(): Promise { 12 | const cacheConfig = 13 | this.configService.getOrThrow(CacheConfigName); 14 | const redisURL = `redis://:${cacheConfig.password}@${cacheConfig.host}:${cacheConfig.port}`; 15 | const keyv = createKeyv(redisURL); 16 | return { 17 | stores: keyv, 18 | }; 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/core/interceptors/response.transformer.ts: -------------------------------------------------------------------------------- 1 | import { 2 | CallHandler, 3 | ExecutionContext, 4 | Injectable, 5 | NestInterceptor, 6 | } from '@nestjs/common'; 7 | import { Observable, map } from 'rxjs'; 8 | import { DataResponse, MessageResponse, StatusCode } from '../http/response'; 9 | 10 | @Injectable() 11 | export class ResponseTransformer implements NestInterceptor { 12 | intercept(_: ExecutionContext, next: CallHandler): Observable { 13 | return next.handle().pipe( 14 | map((data) => { 15 | if (data instanceof MessageResponse) return data; 16 | if (data instanceof DataResponse) return data; 17 | if (typeof data == 'string') 18 | return new MessageResponse(StatusCode.SUCCESS, data); 19 | return new DataResponse(StatusCode.SUCCESS, 'success', data); 20 | }), 21 | ); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/subscription/subscription.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { 3 | Subscription, 4 | SubscriptionSchema, 5 | } from './schemas/subscription.schema'; 6 | import { MongooseModule } from '@nestjs/mongoose'; 7 | import { SubscriptionController } from './subscription.controller'; 8 | import { SubscriptionService } from './subscription.service'; 9 | import { MentorModule } from '../mentor/mentor.module'; 10 | import { TopicModule } from '../topic/topic.module'; 11 | 12 | @Module({ 13 | imports: [ 14 | MongooseModule.forFeature([ 15 | { name: Subscription.name, schema: SubscriptionSchema }, 16 | ]), 17 | MentorModule, 18 | TopicModule, 19 | ], 20 | controllers: [SubscriptionController], 21 | providers: [SubscriptionService], 22 | exports: [SubscriptionService], 23 | }) 24 | export class SubscriptionModule {} 25 | -------------------------------------------------------------------------------- /.resources/wimm-db-dump/contents.metadata.json: -------------------------------------------------------------------------------- 1 | {"indexes":[{"v":{"$numberInt":"2"},"key":{"_id":{"$numberInt":"1"}},"name":"_id_"},{"v":{"$numberInt":"2"},"key":{"topics":{"$numberInt":"1"}},"name":"topics_1","background":true},{"v":{"$numberInt":"2"},"key":{"mentors":{"$numberInt":"1"}},"name":"mentors_1","background":true},{"v":{"$numberInt":"2"},"key":{"likedBy":{"$numberInt":"1"}},"name":"likedBy_1","background":true},{"v":{"$numberInt":"2"},"key":{"createdBy":{"$numberInt":"1"}},"name":"createdBy_1","background":true},{"v":{"$numberInt":"2"},"key":{"_fts":"text","_ftsx":{"$numberInt":"1"}},"name":"title_text_subtitle_text","background":false,"weights":{"subtitle":{"$numberInt":"1"},"title":{"$numberInt":"3"}},"default_language":"english","language_override":"language","textIndexVersion":{"$numberInt":"3"}}],"uuid":"0add3321378f4362860ee2ce4f54e63f","collectionName":"contents","type":"collection"} -------------------------------------------------------------------------------- /src/mentor/dto/create-mentor.dto.ts: -------------------------------------------------------------------------------- 1 | import { 2 | IsOptional, 3 | IsUrl, 4 | Max, 5 | MaxLength, 6 | Min, 7 | MinLength, 8 | } from 'class-validator'; 9 | 10 | export class CreateMentorDto { 11 | @MinLength(3) 12 | @MaxLength(50) 13 | readonly name: string; 14 | 15 | @MinLength(3) 16 | @MaxLength(50) 17 | readonly occupation: string; 18 | 19 | @MinLength(3) 20 | @MaxLength(300) 21 | readonly title: string; 22 | 23 | @MinLength(3) 24 | @MaxLength(10000) 25 | readonly description: string; 26 | 27 | @IsUrl({ require_tld: false }) 28 | @MaxLength(300) 29 | readonly thumbnail: string; 30 | 31 | @IsUrl({ require_tld: false }) 32 | @MaxLength(300) 33 | readonly coverImgUrl: string; 34 | 35 | @IsOptional() 36 | @Min(0) 37 | @Max(1) 38 | readonly score: number; 39 | 40 | constructor(params: CreateMentorDto) { 41 | Object.assign(this, params); 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/common/mongo.validation.ts: -------------------------------------------------------------------------------- 1 | import { 2 | registerDecorator, 3 | ValidationArguments, 4 | ValidationOptions, 5 | } from 'class-validator'; 6 | import { Types } from 'mongoose'; 7 | 8 | export function IsMongoIdObject(validationOptions?: ValidationOptions) { 9 | return function (object: object, propertyName: string) { 10 | registerDecorator({ 11 | name: 'IsMongoIdObject', 12 | target: object.constructor, 13 | propertyName: propertyName, 14 | constraints: [], 15 | options: validationOptions, 16 | validator: { 17 | validate(value: any) { 18 | return Types.ObjectId.isValid(value); 19 | }, 20 | 21 | defaultMessage(validationArguments?: ValidationArguments) { 22 | const property = validationArguments?.property ?? ''; 23 | return `${property} should be a valid MongoId`; 24 | }, 25 | }, 26 | }); 27 | }; 28 | } 29 | -------------------------------------------------------------------------------- /src/auth/schemas/keystore.schema.ts: -------------------------------------------------------------------------------- 1 | import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose'; 2 | import mongoose, { HydratedDocument, Types } from 'mongoose'; 3 | import { User } from '../../user/schemas/user.schema'; 4 | 5 | export type KeystoreDocument = HydratedDocument; 6 | 7 | @Schema({ collection: 'keystores', versionKey: false, timestamps: true }) 8 | export class Keystore { 9 | readonly _id: Types.ObjectId; 10 | 11 | @Prop({ 12 | type: mongoose.Schema.Types.ObjectId, 13 | ref: User.name, 14 | required: true, 15 | }) 16 | client: User; 17 | 18 | @Prop({ required: true, trim: true }) 19 | primaryKey: string; 20 | 21 | @Prop({ required: true, trim: true }) 22 | secondaryKey: string; 23 | 24 | @Prop({ default: true }) 25 | status: boolean; 26 | } 27 | 28 | export const KeystoreSchema = SchemaFactory.createForClass(Keystore); 29 | 30 | KeystoreSchema.index({ client: 1, primaryKey: 1, secondaryKey: 1, status: 1 }); 31 | -------------------------------------------------------------------------------- /src/topic/dto/topic-info.dto.ts: -------------------------------------------------------------------------------- 1 | import { IsNotEmpty, IsOptional, IsUrl } from 'class-validator'; 2 | import { Types } from 'mongoose'; 3 | import { IsMongoIdObject } from '../../common/mongo.validation'; 4 | import { copy } from '../../common/copier'; 5 | import { Topic } from '../schemas/topic.schema'; 6 | 7 | export class TopicInfoDto { 8 | @IsMongoIdObject() 9 | _id: Types.ObjectId; 10 | 11 | @IsNotEmpty() 12 | name: string; 13 | 14 | @IsNotEmpty() 15 | title: string; 16 | 17 | @IsOptional() 18 | @IsNotEmpty() 19 | description: string; 20 | 21 | @IsUrl({ require_tld: false }) 22 | thumbnail: string; 23 | 24 | @IsUrl({ require_tld: false }) 25 | coverImgUrl: string; 26 | 27 | constructor(topic: Topic) { 28 | const props = copy(topic, [ 29 | '_id', 30 | 'name', 31 | 'thumbnail', 32 | 'title', 33 | 'description', 34 | 'coverImgUrl', 35 | ]); 36 | Object.assign(this, props); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/subscription/dto/subscription.dto.ts: -------------------------------------------------------------------------------- 1 | import { IsArray, IsOptional } from 'class-validator'; 2 | import { IsMongoIdObject } from '../../common/mongo.validation'; 3 | import { Transform } from 'class-transformer'; 4 | import { Types } from 'mongoose'; 5 | 6 | export class SubscriptionDto { 7 | @IsOptional() 8 | @IsArray() 9 | @IsMongoIdObject({ each: true }) 10 | @Transform(({ value }) => 11 | value.map((id: string) => 12 | Types.ObjectId.isValid(id) ? new Types.ObjectId(id) : null, 13 | ), 14 | ) 15 | readonly mentorIds: Types.ObjectId[] | undefined; 16 | 17 | @IsOptional() 18 | @IsArray() 19 | @IsMongoIdObject({ each: true }) 20 | @Transform(({ value }) => 21 | value.map((id: string) => 22 | Types.ObjectId.isValid(id) ? new Types.ObjectId(id) : null, 23 | ), 24 | ) 25 | readonly topicIds: Types.ObjectId[] | undefined; 26 | 27 | constructor(props: SubscriptionDto) { 28 | Object.assign(this, props); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /.github/workflows/docker_compose.yml: -------------------------------------------------------------------------------- 1 | name: Docker Compose CI 2 | 3 | on: 4 | push: 5 | branches: [ "main" ] 6 | pull_request: 7 | branches: [ "main" ] 8 | 9 | jobs: 10 | build: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - name: Checkout code 14 | uses: actions/checkout@v3 15 | - name: Create .env 16 | run: cp .env.example .env 17 | - name: Create .env.test 18 | run: cp .env.test.example .env.test 19 | - name: Create public.pem 20 | run: cp keys/public.pem.example keys/public.pem 21 | - name: Create private.pem 22 | run: cp keys/private.pem.example keys/private.pem 23 | - name: Build docker images 24 | run: docker compose build 25 | - name: Run docker images 26 | run: docker compose up -d 27 | - name: Run tests 28 | run: docker exec -t wimm-apis-tester npm run test 29 | - name: Clean up 30 | if: success() || failure() 31 | run: docker compose down --rmi all -v --remove-orphans -------------------------------------------------------------------------------- /src/topic/topics.controller.ts: -------------------------------------------------------------------------------- 1 | import { Controller, Get, Query } from '@nestjs/common'; 2 | import { TopicService } from './topic.service'; 3 | import { TopicInfoDto } from './dto/topic-info.dto'; 4 | import { PaginationDto } from '../common/pagination.dto'; 5 | 6 | @Controller('topics') 7 | export class TopicsController { 8 | constructor(private readonly topicService: TopicService) {} 9 | 10 | @Get('latest') 11 | async findLatest( 12 | @Query() paginationDto: PaginationDto, 13 | ): Promise { 14 | const topics = await this.topicService.findTopicsPaginated(paginationDto); 15 | return topics.map((topic) => new TopicInfoDto(topic)); 16 | } 17 | 18 | @Get('recommendation') 19 | async findRecomended( 20 | @Query() paginationDto: PaginationDto, 21 | ): Promise { 22 | const topics = 23 | await this.topicService.findRecommendedTopicsPaginated(paginationDto); 24 | return topics.map((topic) => new TopicInfoDto(topic)); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/user/user.controller.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Body, 3 | Controller, 4 | Get, 5 | NotFoundException, 6 | Put, 7 | Request, 8 | } from '@nestjs/common'; 9 | import { UserService } from './user.service'; 10 | import { ProtectedRequest } from '../core/http/request'; 11 | import { UserDto } from './dto/user.dto'; 12 | import { UpdateProfileDto } from './dto/upadte-profile.dto'; 13 | 14 | @Controller('profile') 15 | export class UserController { 16 | constructor(private readonly userService: UserService) {} 17 | @Get('my') 18 | async findMy(@Request() request: ProtectedRequest) { 19 | const profile = await this.userService.findPrivateProfile(request.user); 20 | if (!profile) throw new NotFoundException('Profile Not Found'); 21 | return new UserDto(profile); 22 | } 23 | 24 | @Put() 25 | async update( 26 | @Request() request: ProtectedRequest, 27 | @Body() updateProfileDto: UpdateProfileDto, 28 | ) { 29 | return await this.userService.updateProfile(request.user, updateProfileDto); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/mentor/mentors.controller.ts: -------------------------------------------------------------------------------- 1 | import { Controller, Get, Query } from '@nestjs/common'; 2 | import { MentorService } from './mentor.service'; 3 | import { MentorInfoDto } from './dto/mentor-info.dto'; 4 | import { PaginationDto } from '../common/pagination.dto'; 5 | 6 | @Controller('mentors') 7 | export class MentorsController { 8 | constructor(private readonly mentorService: MentorService) {} 9 | 10 | @Get('latest') 11 | async findLatest( 12 | @Query() paginationDto: PaginationDto, 13 | ): Promise { 14 | const mentors = 15 | await this.mentorService.findMentorsPaginated(paginationDto); 16 | return mentors.map((mentor) => new MentorInfoDto(mentor)); 17 | } 18 | 19 | @Get('recommendation') 20 | async findRecomended( 21 | @Query() paginationDto: PaginationDto, 22 | ): Promise { 23 | const mentors = 24 | await this.mentorService.findRecommendedMentorsPaginated(paginationDto); 25 | return mentors.map((mentor) => new MentorInfoDto(mentor)); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/mentor/dto/mentor-info.dto.ts: -------------------------------------------------------------------------------- 1 | import { IsNotEmpty, IsOptional, IsUrl } from 'class-validator'; 2 | import { Types } from 'mongoose'; 3 | import { IsMongoIdObject } from '../../common/mongo.validation'; 4 | import { Mentor } from '../schemas/mentor.schema'; 5 | import { copy } from '../../common/copier'; 6 | 7 | export class MentorInfoDto { 8 | @IsMongoIdObject() 9 | _id: Types.ObjectId; 10 | 11 | @IsNotEmpty() 12 | name: string; 13 | 14 | @IsUrl({ require_tld: false }) 15 | thumbnail: string; 16 | 17 | @IsNotEmpty() 18 | occupation: string; 19 | 20 | @IsNotEmpty() 21 | title: string; 22 | 23 | @IsOptional() 24 | @IsNotEmpty() 25 | description: string; 26 | 27 | @IsUrl({ require_tld: false }) 28 | coverImgUrl: string; 29 | 30 | constructor(mentor: Mentor) { 31 | const props = copy(mentor, [ 32 | '_id', 33 | 'name', 34 | 'thumbnail', 35 | 'title', 36 | 'occupation', 37 | 'description', 38 | 'coverImgUrl', 39 | ]); 40 | Object.assign(this, props); 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/auth/token/token.factory.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { JwtOptionsFactory, JwtModuleOptions } from '@nestjs/jwt'; 3 | import { ConfigService } from '@nestjs/config'; 4 | import { AuthKeyConfig, AuthKeyConfigName } from '../../config/authkey.config'; 5 | import { readFile } from 'fs/promises'; 6 | import { join } from 'path'; 7 | 8 | @Injectable() 9 | export class TokenFactory implements JwtOptionsFactory { 10 | constructor(private readonly configService: ConfigService) {} 11 | 12 | async createJwtOptions(): Promise { 13 | const keys = 14 | this.configService.getOrThrow(AuthKeyConfigName); 15 | 16 | const publicKey = await readFile( 17 | join(__dirname, '../../../', keys.publicKeyPath), 18 | 'utf8', 19 | ); 20 | const privateKey = await readFile( 21 | join(__dirname, '../../../', keys.privateKeyPath), 22 | 'utf8', 23 | ); 24 | 25 | return { 26 | publicKey, 27 | privateKey, 28 | signOptions: { 29 | algorithm: 'RS256', 30 | }, 31 | }; 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/bookmark/schemas/bookmark.schema.ts: -------------------------------------------------------------------------------- 1 | import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose'; 2 | import mongoose, { HydratedDocument, Types } from 'mongoose'; 3 | import { User } from '../../user/schemas/user.schema'; 4 | import { Content } from '../../content/schemas/content.schema'; 5 | 6 | export type BookmarkDocument = HydratedDocument; 7 | 8 | @Schema({ collection: 'bookmarks', versionKey: false, timestamps: true }) 9 | export class Bookmark { 10 | readonly _id: Types.ObjectId; 11 | 12 | @Prop({ 13 | type: mongoose.Schema.Types.ObjectId, 14 | ref: User.name, 15 | required: true, 16 | }) 17 | user: User; 18 | 19 | @Prop({ 20 | type: mongoose.Schema.Types.ObjectId, 21 | ref: Content.name, 22 | required: true, 23 | }) 24 | content: Content; 25 | 26 | @Prop({ default: true }) 27 | status: boolean; 28 | } 29 | 30 | export const BookmarkSchema = SchemaFactory.createForClass(Bookmark); 31 | 32 | BookmarkSchema.index({ user: 1, status: 1 }); 33 | BookmarkSchema.index({ content: 1, status: 1 }); 34 | BookmarkSchema.index({ user: 1, content: 1, status: 1 }); 35 | -------------------------------------------------------------------------------- /src/core/core.module.ts: -------------------------------------------------------------------------------- 1 | import { Module, ValidationPipe } from '@nestjs/common'; 2 | import { APP_FILTER, APP_INTERCEPTOR, APP_PIPE } from '@nestjs/core'; 3 | import { ResponseTransformer } from './interceptors/response.transformer'; 4 | import { ExpectionHandler } from './interceptors/exception.handler'; 5 | import { ResponseValidation } from './interceptors/response.validations'; 6 | import { ConfigModule } from '@nestjs/config'; 7 | import { WinstonLogger } from '../setup/winston.logger'; 8 | import { CoreController } from './core.controller'; 9 | 10 | @Module({ 11 | imports: [ConfigModule], 12 | providers: [ 13 | { provide: APP_INTERCEPTOR, useClass: ResponseTransformer }, 14 | { provide: APP_INTERCEPTOR, useClass: ResponseValidation }, 15 | { provide: APP_FILTER, useClass: ExpectionHandler }, 16 | { 17 | provide: APP_PIPE, 18 | useValue: new ValidationPipe({ 19 | transform: true, 20 | whitelist: true, 21 | forbidNonWhitelisted: true, 22 | }), 23 | }, 24 | WinstonLogger, 25 | ], 26 | controllers: [CoreController], 27 | }) 28 | export class CoreModule {} 29 | -------------------------------------------------------------------------------- /src/message/message.controller.ts: -------------------------------------------------------------------------------- 1 | import { Body, Controller, Post, Request } from '@nestjs/common'; 2 | import { MessageService } from './message.service'; 3 | import { CreateMessageDto } from './dto/create-message.dto'; 4 | import { Permissions } from '../auth/decorators/permissions.decorator'; 5 | import { Permission } from '../auth/schemas/apikey.schema'; 6 | import { ProtectedRequest } from '../core/http/request'; 7 | import { Roles } from '../auth/decorators/roles.decorator'; 8 | import { RoleCode } from '../auth/schemas/role.schema'; 9 | 10 | @Roles([RoleCode.VIEWER]) // Example: how to add roles on entire controller. 11 | @Permissions([Permission.GENERAL]) // Example: how to add api key specific route restrictions. 12 | @Controller('contact') 13 | export class MessageController { 14 | constructor(private readonly messageService: MessageService) {} 15 | 16 | @Post() 17 | async create( 18 | @Request() request: ProtectedRequest, 19 | @Body() createMessageDto: CreateMessageDto, 20 | ) { 21 | await this.messageService.create(request.user, createMessageDto); 22 | return 'Message received successfully!'; 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/auth/guards/roles.guard.ts: -------------------------------------------------------------------------------- 1 | import { 2 | CanActivate, 3 | ExecutionContext, 4 | ForbiddenException, 5 | Injectable, 6 | } from '@nestjs/common'; 7 | import { Reflector } from '@nestjs/core'; 8 | import { Roles } from '../decorators/roles.decorator'; 9 | import { ProtectedRequest } from '../../core/http/request'; 10 | 11 | @Injectable() 12 | export class RolesGuard implements CanActivate { 13 | constructor(private readonly reflector: Reflector) {} 14 | 15 | async canActivate(context: ExecutionContext): Promise { 16 | let roles = this.reflector.get(Roles, context.getHandler()); 17 | if (!roles) roles = this.reflector.get(Roles, context.getClass()); 18 | if (roles) { 19 | const request = context.switchToHttp().getRequest(); 20 | const user = request.user; 21 | if (!user) throw new ForbiddenException('Permission Denied'); 22 | 23 | const hasRole = () => 24 | user.roles.some((role) => !!roles.find((item) => item === role.code)); 25 | 26 | if (!hasRole()) throw new ForbiddenException('Permission Denied'); 27 | } 28 | 29 | return true; 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/files/files.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { ConfigService } from '@nestjs/config'; 3 | import { DiskConfig, DiskConfigName } from '../config/disk.config'; 4 | import { extname, resolve } from 'path'; 5 | 6 | @Injectable() 7 | export class FilesService { 8 | constructor(private readonly configService: ConfigService) {} 9 | 10 | getDiskPath() { 11 | const diskConfig = 12 | this.configService.getOrThrow(DiskConfigName); 13 | const diskPath = resolve(__dirname, '../..', diskConfig.path); 14 | return diskPath; 15 | } 16 | 17 | getFileName(file: Express.Multer.File) { 18 | const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1e9); 19 | const ext = extname(file.originalname).toLowerCase(); 20 | const name = file.originalname.replace(/\s/g, '-').replace(ext, ''); 21 | const fileName = `${name}_${uniqueSuffix}${ext}`; 22 | return fileName; 23 | } 24 | 25 | getImageCacheDuration() { 26 | const diskConfig = 27 | this.configService.getOrThrow(DiskConfigName); 28 | return diskConfig.imageCacheDuration; 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/user/dto/user.dto.ts: -------------------------------------------------------------------------------- 1 | import { Types } from 'mongoose'; 2 | import { User } from '../schemas/user.schema'; 3 | import { 4 | IsArray, 5 | IsEmail, 6 | IsNotEmpty, 7 | IsOptional, 8 | IsUrl, 9 | ValidateNested, 10 | } from 'class-validator'; 11 | import { IsMongoIdObject } from '../../common/mongo.validation'; 12 | import { RoleDto } from './role.dto'; 13 | 14 | export class UserDto { 15 | @IsMongoIdObject() 16 | readonly _id: Types.ObjectId; 17 | 18 | @IsEmail() 19 | readonly email: string; 20 | 21 | @IsNotEmpty() 22 | @IsOptional() 23 | readonly name?: string; 24 | 25 | @IsUrl({ require_tld: false }) 26 | @IsOptional() 27 | readonly profilePicUrl?: string; 28 | 29 | @IsNotEmpty() 30 | @IsOptional() 31 | readonly tagline?: string; 32 | 33 | @ValidateNested() 34 | @IsArray() 35 | readonly roles: RoleDto[]; 36 | 37 | constructor(user: User) { 38 | this._id = user._id; 39 | this.name = user.name; 40 | this.email = user.email; 41 | this.profilePicUrl = user.profilePicUrl; 42 | this.tagline = user.tagline; 43 | this.roles = user.roles.map((role) => new RoleDto(role)); 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/subscription/schemas/subscription.schema.ts: -------------------------------------------------------------------------------- 1 | import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose'; 2 | import mongoose, { HydratedDocument, Types } from 'mongoose'; 3 | import { User } from '../../user/schemas/user.schema'; 4 | import { Topic } from '../../topic/schemas/topic.schema'; 5 | import { Mentor } from '../../mentor/schemas/mentor.schema'; 6 | 7 | export type SubscriptionDocument = HydratedDocument; 8 | 9 | @Schema({ collection: 'subscriptions', versionKey: false, timestamps: true }) 10 | export class Subscription { 11 | readonly _id: Types.ObjectId; 12 | 13 | @Prop({ 14 | type: mongoose.Schema.Types.ObjectId, 15 | ref: User.name, 16 | required: true, 17 | unique: true, 18 | index: true, 19 | }) 20 | user: User; 21 | 22 | @Prop({ type: [{ type: mongoose.Schema.Types.ObjectId, ref: Topic.name }] }) 23 | topics: Topic[]; 24 | 25 | @Prop({ type: [{ type: mongoose.Schema.Types.ObjectId, ref: Mentor.name }] }) 26 | mentors: Mentor[]; 27 | 28 | @Prop({ default: true }) 29 | status: boolean; 30 | } 31 | 32 | export const SubscriptionSchema = SchemaFactory.createForClass(Subscription); 33 | 34 | SubscriptionSchema.index({ user: 1, status: 1 }); 35 | -------------------------------------------------------------------------------- /src/auth/schemas/apikey.schema.ts: -------------------------------------------------------------------------------- 1 | import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose'; 2 | import { HydratedDocument, Types } from 'mongoose'; 3 | 4 | export enum Permission { 5 | GENERAL = 'GENERAL', // All api end points are allowed 6 | XYZ_SERVICE = 'XYZ_SERVICE', 7 | } 8 | 9 | export type MessageDocument = HydratedDocument; 10 | 11 | @Schema({ collection: 'api_keys', versionKey: false, timestamps: true }) 12 | export class ApiKey { 13 | readonly _id: Types.ObjectId; 14 | 15 | @Prop({ trim: true, required: true, unique: true, maxlength: 1024 }) 16 | readonly key: string; 17 | 18 | @Prop({ required: true, min: 1, max: 100 }) 19 | readonly version: number; 20 | 21 | @Prop({ 22 | type: [{ type: String, required: true, enum: Object.values(Permission) }], 23 | required: true, 24 | }) 25 | readonly permissions: Permission[]; 26 | 27 | @Prop({ 28 | type: [{ type: String, required: true, trim: true, maxlength: 1000 }], 29 | required: true, 30 | }) 31 | readonly comments: string[]; 32 | 33 | @Prop({ default: true }) 34 | readonly status: boolean; 35 | } 36 | 37 | export const ApiKeySchema = SchemaFactory.createForClass(ApiKey); 38 | 39 | ApiKeySchema.index({ key: 1, status: 1 }); 40 | -------------------------------------------------------------------------------- /.resources/init-project.ts: -------------------------------------------------------------------------------- 1 | import { existsSync, readFileSync, writeFileSync } from 'fs'; 2 | import { join } from 'path'; 3 | 4 | const publicPem = join(__dirname, '../keys/public.pem'); 5 | const publicPemExample = join(__dirname, '../keys/public.pem.example'); 6 | 7 | const privatePem = join(__dirname, '../keys/private.pem'); 8 | const privatePemExample = join(__dirname, '../keys/private.pem.example'); 9 | 10 | const env = join(__dirname, '../.env'); 11 | const envExample = join(__dirname, '../.env.example'); 12 | 13 | const envTest = join(__dirname, '../.env.test'); 14 | const envTestExample = join(__dirname, '../.env.test.example'); 15 | 16 | if (!existsSync(publicPem)) { 17 | writeFileSync(publicPem, new Uint8Array(readFileSync(publicPemExample))); 18 | console.log('keys/public.pem created') 19 | } 20 | 21 | if (!existsSync(privatePem)) { 22 | writeFileSync(privatePem, new Uint8Array(readFileSync(privatePemExample))); 23 | console.log('keys/private.pem created'); 24 | } 25 | 26 | if (!existsSync(env)) { 27 | writeFileSync(env, new Uint8Array(readFileSync(envExample))); 28 | console.log('.env created'); 29 | } 30 | 31 | if (!existsSync(envTest)) { 32 | writeFileSync(envTest, new Uint8Array(readFileSync(envTestExample))); 33 | console.log('.env.test created'); 34 | } 35 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | # .env.example 2 | 3 | # Environment Name 4 | NODE_ENV=development 5 | 6 | # TimeZone 7 | TZ=UTC 8 | 9 | # Server listen to this port 10 | PORT=3000 11 | 12 | #Cors 13 | CORS_URL=* 14 | 15 | 16 | # Databse 17 | # on localmachine 18 | # DB_HOST=localhost 19 | # on docker 20 | DB_HOST=mongo 21 | 22 | DB_ADMIN=admin 23 | DB_ADMIN_PWD=changeit 24 | 25 | DB_PORT=27017 26 | 27 | DB_NAME=wimm-db 28 | DB_USER=wimm-db-user 29 | DB_USER_PWD=changeit 30 | 31 | DB_MIN_POOL_SIZE=2 32 | DB_MAX_POOL_SIZE=5 33 | 34 | # Redis 35 | # on localmachine 36 | # REDIS_HOST=localhost 37 | # on docker 38 | REDIS_HOST=redis 39 | REDIS_PORT=6379 40 | REDIS_PASSWORD=changeit 41 | REDIS_TTL=60 42 | 43 | # Log 44 | # Example '/home/node/logs' 45 | # DEFAUlT is this project's directory 46 | LOG_DIR=logs 47 | 48 | # Token Info 49 | # 2 DAYS: 172800 Sec 50 | ACCESS_TOKEN_VALIDITY_SEC=172800 51 | # 7 DAYS: 604800 Sec 52 | REFRESH_TOKEN_VALIDITY_SEC=604800 53 | TOKEN_ISSUER=api.dev.xyz.com 54 | TOKEN_AUDIENCE=xyz.com 55 | 56 | # Caching 57 | CONTENT_CACHE_DURATION_MILLIS=600000 58 | 59 | # Firebase 60 | FIREBASE_DB_URL= 61 | 62 | # Notification Center 63 | NOTIFICATION_DRY_RUN=true 64 | NOTIFICATION_VIEWS_INTERVAL=20 65 | 66 | # Local disk for storage 67 | DISK_STORAGE_PATH=disk 68 | # 1 Year 69 | IMAGE_CACHE_DURATION=31536000 70 | 71 | # Auth keys 72 | AUTH_PUBLIC_KEY_PATH=keys/public.pem 73 | AUTH_PRIVATE_KEY_PATH=keys/private.pem -------------------------------------------------------------------------------- /.env.test.example: -------------------------------------------------------------------------------- 1 | # .env.test.example 2 | 3 | # Environment Name 4 | NODE_ENV=test 5 | 6 | # TimeZone 7 | TZ=UTC 8 | 9 | # Server listen to this port 10 | PORT=3001 11 | 12 | #Cors 13 | CORS_URL=* 14 | 15 | 16 | # Databse 17 | # on localmachine 18 | # DB_HOST=localhost 19 | # on docker 20 | DB_HOST=mongo 21 | 22 | DB_ADMIN=admin 23 | DB_ADMIN_PWD=changeit 24 | 25 | DB_PORT=27017 26 | 27 | DB_NAME=wimm-test-db 28 | DB_USER=wimm-test-db-user 29 | DB_USER_PWD=changeit 30 | 31 | DB_MIN_POOL_SIZE=2 32 | DB_MAX_POOL_SIZE=5 33 | 34 | # Redis 35 | # on localmachine 36 | # REDIS_HOST=localhost 37 | # on docker 38 | REDIS_HOST=redis 39 | REDIS_PORT=6379 40 | REDIS_PASSWORD=changeit 41 | REDIS_TTL=60 42 | 43 | # Log 44 | # Example '/home/node/logs' 45 | # DEFAUlT is this project's directory 46 | LOG_DIR=logs 47 | 48 | # Token Info 49 | # 2 DAYS: 172800 Sec 50 | ACCESS_TOKEN_VALIDITY_SEC=172800 51 | # 7 DAYS: 604800 Sec 52 | REFRESH_TOKEN_VALIDITY_SEC=604800 53 | TOKEN_ISSUER=api.dev.xyz.com 54 | TOKEN_AUDIENCE=xyz.com 55 | 56 | # Caching 57 | CONTENT_CACHE_DURATION_MILLIS=600000 58 | 59 | # Firebase 60 | FIREBASE_DB_URL= 61 | 62 | # Notification Center 63 | NOTIFICATION_DRY_RUN=true 64 | NOTIFICATION_VIEWS_INTERVAL=20 65 | 66 | # Local disk for storage 67 | DISK_STORAGE_PATH=disk 68 | # 1 Year 69 | IMAGE_CACHE_DURATION=31536000 70 | 71 | # Auth keys 72 | AUTH_PUBLIC_KEY_PATH=keys/public.pem 73 | AUTH_PRIVATE_KEY_PATH=keys/private.pem -------------------------------------------------------------------------------- /src/content/content.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { Content, ContentSchema } from './schemas/content.schema'; 3 | import { MongooseModule } from '@nestjs/mongoose'; 4 | import { ContentController } from './content.controller'; 5 | import { ContentService } from './content.service'; 6 | import { ContentAdminController } from './content-admin.controller'; 7 | import { TopicModule } from '../topic/topic.module'; 8 | import { MentorModule } from '../mentor/mentor.module'; 9 | import { ContentPrivateController } from './content-private.controller'; 10 | import { ContentsController } from './contents.controller'; 11 | import { ContentsService } from './contents.service'; 12 | import { SubscriptionModule } from '../subscription/subscription.module'; 13 | import { RedisCacheModule } from '../cache/redis-cache.module'; 14 | import { BookmarkModule } from '../bookmark/bookmark.module'; 15 | 16 | @Module({ 17 | imports: [ 18 | MongooseModule.forFeature([{ name: Content.name, schema: ContentSchema }]), 19 | TopicModule, 20 | MentorModule, 21 | SubscriptionModule, 22 | RedisCacheModule, 23 | BookmarkModule, 24 | ], 25 | controllers: [ 26 | ContentController, 27 | ContentAdminController, 28 | ContentPrivateController, 29 | ContentsController, 30 | ], 31 | providers: [ContentService, ContentsService], 32 | exports: [ContentService, ContentsService], 33 | }) 34 | export class ContentModule {} 35 | -------------------------------------------------------------------------------- /src/scrapper/scrapper.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import axios from 'axios'; 3 | import * as cheerio from 'cheerio'; 4 | import { Category as ContentCategory } from '../content/schemas/content.schema'; 5 | import { MetaContentDto } from './dto/meta-content.dto'; 6 | 7 | @Injectable() 8 | export class ScrapperService { 9 | async scrape(url: string) { 10 | const response = await axios.get(url); 11 | const $ = cheerio.load(response.data); 12 | 13 | const website = new URL(url).hostname; 14 | 15 | const title = $('title').text(); 16 | const description = $('meta[name="description"]').attr('content'); 17 | const author = $('meta[name="author"]').attr('content'); 18 | const thumbnail = $('meta[property="og:image"]').attr('content'); 19 | const publisher = $('meta[property="og:site_name"]').attr('content'); 20 | 21 | let category = ContentCategory.ARTICLE; 22 | 23 | if (website.includes('youtu.be') || website.includes('youtube')) { 24 | category = ContentCategory.YOUTUBE; 25 | } 26 | 27 | const data: MetaContentDto = new MetaContentDto({ 28 | category: category, 29 | title: title, 30 | subtitle: author ? author : publisher ? publisher : website, 31 | description: description ? description : title, 32 | thumbnail: thumbnail ? thumbnail : 'http://localhost/dummy.png', 33 | extra: url, 34 | }); 35 | 36 | return data; 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/bookmark/bookmark.controller.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Body, 3 | Controller, 4 | Delete, 5 | InternalServerErrorException, 6 | Param, 7 | Post, 8 | Request, 9 | } from '@nestjs/common'; 10 | import { BookmarkService } from './bookmark.service'; 11 | import { ProtectedRequest } from '../core/http/request'; 12 | import { MongoIdDto } from '../common/mongoid.dto'; 13 | import { MongoIdTransformer } from '../common/mongoid.transformer'; 14 | import { Types } from 'mongoose'; 15 | 16 | @Controller('content/bookmark') 17 | export class BookmarkController { 18 | constructor(private readonly bookmarkService: BookmarkService) {} 19 | 20 | @Post() 21 | async create( 22 | @Body() mongoIdDto: MongoIdDto, 23 | @Request() request: ProtectedRequest, 24 | ) { 25 | const bookmark = await this.bookmarkService.create( 26 | request.user, 27 | mongoIdDto.id, 28 | ); 29 | if (!bookmark) 30 | throw new InternalServerErrorException('Not able to create bookmark'); 31 | return 'Content bookmarked successfully'; 32 | } 33 | 34 | @Delete('id/:id') 35 | async delete( 36 | @Param('id', MongoIdTransformer) id: Types.ObjectId, 37 | @Request() request: ProtectedRequest, 38 | ): Promise { 39 | const content = await this.bookmarkService.delete(request.user, id); 40 | if (!content) 41 | throw new InternalServerErrorException('Not able to delete bookmark'); 42 | return 'Bookmark deleted successfully'; 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/topic/topic-admin.controller.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Body, 3 | Controller, 4 | Delete, 5 | Param, 6 | Post, 7 | Put, 8 | Request, 9 | } from '@nestjs/common'; 10 | import { TopicService } from './topic.service'; 11 | import { CreateTopicDto } from './dto/create-topic.dto'; 12 | import { Roles } from '../auth/decorators/roles.decorator'; 13 | import { RoleCode } from '../auth/schemas/role.schema'; 14 | import { ProtectedRequest } from '../core/http/request'; 15 | import { UpdateTopicDto } from './dto/update-topic.dto'; 16 | import { Types } from 'mongoose'; 17 | import { MongoIdTransformer } from '../common/mongoid.transformer'; 18 | 19 | @Roles([RoleCode.ADMIN]) 20 | @Controller('topic/admin') 21 | export class TopicAdminController { 22 | constructor(private readonly topicService: TopicService) {} 23 | 24 | @Post() 25 | async create( 26 | @Request() request: ProtectedRequest, 27 | @Body() createTopicDto: CreateTopicDto, 28 | ) { 29 | return await this.topicService.create(request.user, createTopicDto); 30 | } 31 | 32 | @Put('id/:id') 33 | async update( 34 | @Param('id', MongoIdTransformer) id: Types.ObjectId, 35 | @Request() request: ProtectedRequest, 36 | @Body() updateTopicDto: UpdateTopicDto, 37 | ) { 38 | return await this.topicService.update(request.user, id, updateTopicDto); 39 | } 40 | 41 | @Delete('id/:id') 42 | async delete(@Param('id', MongoIdTransformer) id: Types.ObjectId) { 43 | return await this.topicService.delete(id); 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/mentor/mentor-admin.controller.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Body, 3 | Controller, 4 | Delete, 5 | Param, 6 | Post, 7 | Put, 8 | Request, 9 | } from '@nestjs/common'; 10 | import { MentorService } from './mentor.service'; 11 | import { CreateMentorDto } from './dto/create-mentor.dto'; 12 | import { Roles } from '../auth/decorators/roles.decorator'; 13 | import { RoleCode } from '../auth/schemas/role.schema'; 14 | import { ProtectedRequest } from '../core/http/request'; 15 | import { UpdateMentorDto } from './dto/update-mentor.dto'; 16 | import { Types } from 'mongoose'; 17 | import { MongoIdTransformer } from '../common/mongoid.transformer'; 18 | 19 | @Roles([RoleCode.ADMIN]) 20 | @Controller('mentor/admin') 21 | export class MentorAdminController { 22 | constructor(private readonly mentorService: MentorService) {} 23 | 24 | @Post() 25 | async create( 26 | @Request() request: ProtectedRequest, 27 | @Body() createMentorDto: CreateMentorDto, 28 | ) { 29 | return await this.mentorService.create(request.user, createMentorDto); 30 | } 31 | 32 | @Put('id/:id') 33 | async update( 34 | @Param('id', MongoIdTransformer) id: Types.ObjectId, 35 | @Request() request: ProtectedRequest, 36 | @Body() updateMentorDto: UpdateMentorDto, 37 | ) { 38 | return await this.mentorService.update(request.user, id, updateMentorDto); 39 | } 40 | 41 | @Delete('id/:id') 42 | async delete(@Param('id', MongoIdTransformer) id: Types.ObjectId) { 43 | return await this.mentorService.delete(id); 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/message/message.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test } from '@nestjs/testing'; 2 | import { MessageService } from './message.service'; 3 | import { User } from '../user/schemas/user.schema'; 4 | import { CreateMessageDto } from './dto/create-message.dto'; 5 | import { getModelToken } from '@nestjs/mongoose'; 6 | import { Message } from './schemas/message.schema'; 7 | 8 | describe('MessageService', () => { 9 | let messageService: MessageService; 10 | 11 | const mockMessage = { message: 'message' } as Message; 12 | 13 | beforeEach(async () => { 14 | const moduleRef = await Test.createTestingModule({ 15 | providers: [ 16 | MessageService, 17 | { 18 | provide: getModelToken(Message.name), 19 | useValue: { 20 | create: jest.fn().mockReturnValue(mockMessage), 21 | }, 22 | }, 23 | ], 24 | }).compile(); 25 | 26 | messageService = moduleRef.get(MessageService); 27 | }); 28 | 29 | describe('create', () => { 30 | const mockUser = {} as User; 31 | const mockCreateMessageDto = { message: 'message' } as CreateMessageDto; 32 | it('should create a Message when CreateMessageDto is sent', async () => { 33 | jest.spyOn(messageService, 'create'); 34 | 35 | expect( 36 | messageService.create(mockUser, mockCreateMessageDto), 37 | ).resolves.toStrictEqual(mockCreateMessageDto); 38 | 39 | expect(messageService.create).toHaveBeenCalledWith( 40 | mockUser, 41 | mockCreateMessageDto, 42 | ); 43 | }); 44 | }); 45 | }); 46 | -------------------------------------------------------------------------------- /src/setup/database.factory.ts: -------------------------------------------------------------------------------- 1 | import { Injectable, Logger } from '@nestjs/common'; 2 | import { 3 | MongooseOptionsFactory, 4 | MongooseModuleOptions, 5 | } from '@nestjs/mongoose'; 6 | import { ConfigService } from '@nestjs/config'; 7 | import { DatabaseConfig, DatabaseConfigName } from '../config/database.config'; 8 | import mongoose from 'mongoose'; 9 | import { ServerConfig, ServerConfigName } from '../config/server.config'; 10 | 11 | @Injectable() 12 | export class DatabaseFactory implements MongooseOptionsFactory { 13 | constructor(private readonly configService: ConfigService) {} 14 | 15 | createMongooseOptions(): MongooseModuleOptions { 16 | const dbConfig = 17 | this.configService.getOrThrow(DatabaseConfigName); 18 | 19 | const { user, host, port, name, minPoolSize, maxPoolSize } = dbConfig; 20 | 21 | const password = encodeURIComponent(dbConfig.password); 22 | 23 | const uri = `mongodb://${user}:${password}@${host}:${port}/${name}`; 24 | 25 | const serverConfig = 26 | this.configService.getOrThrow(ServerConfigName); 27 | if (serverConfig.nodeEnv == 'development') mongoose.set({ debug: true }); 28 | 29 | Logger.debug('Database URI:' + uri); 30 | 31 | return { 32 | uri: uri, 33 | autoIndex: true, 34 | minPoolSize: minPoolSize, 35 | maxPoolSize: maxPoolSize, 36 | connectTimeoutMS: 60000, // Give up initial connection after 10 seconds 37 | socketTimeoutMS: 45000, // Close sockets after 45 seconds of inactivity, 38 | }; 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/topic/schemas/topic.schema.ts: -------------------------------------------------------------------------------- 1 | import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose'; 2 | import mongoose, { HydratedDocument, Types } from 'mongoose'; 3 | import { User } from '../../user/schemas/user.schema'; 4 | 5 | export type TopicDocument = HydratedDocument; 6 | 7 | @Schema({ collection: 'topics', versionKey: false, timestamps: true }) 8 | export class Topic { 9 | readonly _id: Types.ObjectId; 10 | 11 | @Prop({ required: true, maxlength: 50, trim: true }) 12 | name: string; 13 | 14 | @Prop({ required: true, maxlength: 300, trim: true }) 15 | title: string; 16 | 17 | @Prop({ required: true, maxlength: 300, trim: true }) 18 | thumbnail: string; 19 | 20 | @Prop({ required: true, maxlength: 10000, trim: true }) 21 | description: string; 22 | 23 | @Prop({ required: true, maxlength: 300, trim: true }) 24 | coverImgUrl: string; 25 | 26 | @Prop({ default: 0.01, max: 1, min: 0 }) 27 | score: number; 28 | 29 | @Prop({ 30 | type: mongoose.Schema.Types.ObjectId, 31 | ref: User.name, 32 | required: true, 33 | }) 34 | createdBy: User; 35 | 36 | @Prop({ 37 | type: mongoose.Schema.Types.ObjectId, 38 | ref: User.name, 39 | required: true, 40 | }) 41 | updatedBy: User; 42 | 43 | @Prop({ default: true }) 44 | status: boolean; 45 | } 46 | 47 | export const TopicSchema = SchemaFactory.createForClass(Topic); 48 | 49 | TopicSchema.index( 50 | { name: 'text', title: 'text' }, 51 | { weights: { name: 3, title: 1 }, background: false }, 52 | ); 53 | 54 | TopicSchema.index({ _id: 1, status: 1 }); 55 | -------------------------------------------------------------------------------- /src/auth/guards/apikey.guard.ts: -------------------------------------------------------------------------------- 1 | import { 2 | CanActivate, 3 | ExecutionContext, 4 | ForbiddenException, 5 | Injectable, 6 | } from '@nestjs/common'; 7 | import { HeaderName } from '../../core/http/header'; 8 | import { Reflector } from '@nestjs/core'; 9 | import { Permissions } from '../decorators/permissions.decorator'; 10 | import { PublicRequest } from '../../core/http/request'; 11 | import { Permission } from '../../auth/schemas/apikey.schema'; 12 | import { AuthService } from '../auth.service'; 13 | 14 | @Injectable() 15 | export class ApiKeyGuard implements CanActivate { 16 | constructor( 17 | private readonly authService: AuthService, 18 | private readonly reflector: Reflector, 19 | ) {} 20 | 21 | async canActivate(context: ExecutionContext): Promise { 22 | const permissions = this.reflector.get(Permissions, context.getClass()) ?? [ 23 | Permission.GENERAL, 24 | ]; 25 | if (!permissions) throw new ForbiddenException(); 26 | 27 | const request = context.switchToHttp().getRequest(); 28 | 29 | const key = request.headers[HeaderName.API_KEY]?.toString(); 30 | if (!key) throw new ForbiddenException(); 31 | 32 | const apiKey = await this.authService.findApiKey(key); 33 | if (!apiKey) throw new ForbiddenException(); 34 | 35 | request.apiKey = apiKey; 36 | 37 | for (const askedPermission of permissions) { 38 | for (const allowedPemission of apiKey.permissions) { 39 | if (allowedPemission === askedPermission) return true; 40 | } 41 | } 42 | 43 | throw new ForbiddenException(); 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/auth/auth.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { APP_GUARD } from '@nestjs/core'; 3 | import { JwtModule } from '@nestjs/jwt'; 4 | import { AuthController } from './auth.controller'; 5 | import { AuthGuard } from './guards/auth.guard'; 6 | import { AuthService } from './auth.service'; 7 | import { TokenFactory } from './token/token.factory'; 8 | import { ConfigModule } from '@nestjs/config'; 9 | import { MongooseModule } from '@nestjs/mongoose'; 10 | import { Keystore, KeystoreSchema } from './schemas/keystore.schema'; 11 | import { UserModule } from '../user/user.module'; 12 | import { Role, RoleSchema } from './schemas/role.schema'; 13 | import { RolesGuard } from './guards/roles.guard'; 14 | import { ApiKeyGuard } from './guards/apikey.guard'; 15 | import { ApiKey, ApiKeySchema } from './schemas/apikey.schema'; 16 | 17 | @Module({ 18 | imports: [ 19 | ConfigModule, 20 | JwtModule.registerAsync({ 21 | imports: [ConfigModule], 22 | useClass: TokenFactory, 23 | }), 24 | MongooseModule.forFeature([{ name: ApiKey.name, schema: ApiKeySchema }]), 25 | MongooseModule.forFeature([ 26 | { name: Keystore.name, schema: KeystoreSchema }, 27 | ]), 28 | MongooseModule.forFeature([{ name: Role.name, schema: RoleSchema }]), 29 | UserModule, 30 | ], 31 | providers: [ 32 | { provide: APP_GUARD, useClass: ApiKeyGuard }, 33 | { provide: APP_GUARD, useClass: AuthGuard }, 34 | { provide: APP_GUARD, useClass: RolesGuard }, 35 | AuthService, 36 | ], 37 | controllers: [AuthController], 38 | exports: [AuthService], 39 | }) 40 | export class AuthModule {} 41 | -------------------------------------------------------------------------------- /src/content/dto/create-content.dto.ts: -------------------------------------------------------------------------------- 1 | import { 2 | IsArray, 3 | IsEnum, 4 | IsOptional, 5 | IsUrl, 6 | Max, 7 | MaxLength, 8 | Min, 9 | MinLength, 10 | } from 'class-validator'; 11 | import { Category } from '../schemas/content.schema'; 12 | import { Types } from 'mongoose'; 13 | import { IsMongoIdObject } from '../../common/mongo.validation'; 14 | import { Transform } from 'class-transformer'; 15 | 16 | export class CreateContentDto { 17 | @IsEnum(Category) 18 | category: Category; 19 | 20 | @MinLength(3) 21 | @MaxLength(500) 22 | readonly title: string; 23 | 24 | @MinLength(3) 25 | @MaxLength(100) 26 | readonly subtitle: string; 27 | 28 | @IsOptional() 29 | @MinLength(3) 30 | @MaxLength(2000) 31 | readonly description: string; 32 | 33 | @IsUrl({ require_tld: false }) 34 | @MaxLength(300) 35 | readonly thumbnail: string; 36 | 37 | @MaxLength(300) 38 | readonly extra: string; 39 | 40 | @IsOptional() 41 | @IsArray() 42 | @IsMongoIdObject({ each: true }) 43 | @Transform(({ value }) => 44 | value.map((id: string) => 45 | Types.ObjectId.isValid(id) ? new Types.ObjectId(id) : null, 46 | ), 47 | ) 48 | readonly topics: Types.ObjectId[]; 49 | 50 | @IsOptional() 51 | @IsArray() 52 | @IsMongoIdObject({ each: true }) 53 | @Transform(({ value }) => 54 | value.map((id: string) => 55 | Types.ObjectId.isValid(id) ? new Types.ObjectId(id) : null, 56 | ), 57 | ) 58 | readonly mentors: Types.ObjectId[]; 59 | 60 | @IsOptional() 61 | @Min(0) 62 | @Max(1) 63 | readonly score: number; 64 | 65 | constructor(params: CreateContentDto) { 66 | Object.assign(this, params); 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /src/mentor/schemas/mentor.schema.ts: -------------------------------------------------------------------------------- 1 | import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose'; 2 | import mongoose, { HydratedDocument, Types } from 'mongoose'; 3 | import { User } from '../../user/schemas/user.schema'; 4 | 5 | export type MentorDocument = HydratedDocument; 6 | 7 | @Schema({ collection: 'mentors', versionKey: false, timestamps: true }) 8 | export class Mentor { 9 | readonly _id: Types.ObjectId; 10 | 11 | @Prop({ required: true, maxlength: 50, trim: true }) 12 | name: string; 13 | 14 | @Prop({ required: true, maxlength: 300, trim: true }) 15 | title: string; 16 | 17 | @Prop({ required: true, maxlength: 300, trim: true }) 18 | thumbnail: string; 19 | 20 | @Prop({ required: true, maxlength: 50, trim: true }) 21 | occupation: string; 22 | 23 | @Prop({ required: true, maxlength: 10000, trim: true }) 24 | description: string; 25 | 26 | @Prop({ required: true, maxlength: 300, trim: true }) 27 | coverImgUrl: string; 28 | 29 | @Prop({ default: 0.01, max: 1, min: 0 }) 30 | score: number; 31 | 32 | @Prop({ 33 | type: mongoose.Schema.Types.ObjectId, 34 | ref: User.name, 35 | required: true, 36 | }) 37 | createdBy: User; 38 | 39 | @Prop({ 40 | type: mongoose.Schema.Types.ObjectId, 41 | ref: User.name, 42 | required: true, 43 | }) 44 | updatedBy: User; 45 | 46 | @Prop({ default: true }) 47 | status: boolean; 48 | } 49 | 50 | export const MentorSchema = SchemaFactory.createForClass(Mentor); 51 | 52 | MentorSchema.index( 53 | { name: 'text', occupation: 'text', title: 'text' }, 54 | { weights: { name: 5, occupation: 1, title: 2 }, background: false }, 55 | ); 56 | 57 | MentorSchema.index({ _id: 1, status: 1 }); 58 | -------------------------------------------------------------------------------- /src/core/interceptors/response.validations.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Injectable, 3 | NestInterceptor, 4 | ExecutionContext, 5 | CallHandler, 6 | InternalServerErrorException, 7 | } from '@nestjs/common'; 8 | import { Observable } from 'rxjs'; 9 | import { map } from 'rxjs/operators'; 10 | import { ValidationError, validateSync } from 'class-validator'; 11 | 12 | @Injectable() 13 | export class ResponseValidation implements NestInterceptor { 14 | intercept(_: ExecutionContext, next: CallHandler): Observable { 15 | return next.handle().pipe( 16 | map((data) => { 17 | if (Array.isArray(data)) { 18 | data.forEach((item) => { 19 | if (item instanceof Object) this.validate(item); 20 | }); 21 | } else if (data instanceof Object) { 22 | this.validate(data); 23 | } 24 | return data; 25 | }), 26 | ); 27 | } 28 | 29 | private validate(data: any) { 30 | const errors = validateSync(data); 31 | if (errors.length > 0) { 32 | const messages = this.extractErrorMessages(errors); 33 | throw new InternalServerErrorException([ 34 | 'Response validation failed', 35 | ...messages, 36 | ]); 37 | } 38 | } 39 | 40 | private extractErrorMessages( 41 | errors: ValidationError[], 42 | messages: string[] = [], 43 | ): string[] { 44 | for (const error of errors) { 45 | if (error) { 46 | if (error.children && error.children.length > 0) 47 | this.extractErrorMessages(error.children, messages); 48 | const constraints = error.constraints; 49 | if (constraints) messages.push(Object.values(constraints).join(', ')); 50 | } 51 | } 52 | return messages; 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/user/schemas/user.schema.ts: -------------------------------------------------------------------------------- 1 | import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose'; 2 | import mongoose, { HydratedDocument, Types } from 'mongoose'; 3 | import { Role } from '../../auth/schemas/role.schema'; 4 | 5 | export type MessageDocument = HydratedDocument; 6 | 7 | @Schema({ collection: 'users', versionKey: false, timestamps: true }) 8 | export class User { 9 | readonly _id: Types.ObjectId; 10 | 11 | @Prop({ trim: true, maxlength: 200 }) 12 | name?: string; 13 | 14 | @Prop({ select: false, trim: true, maxlength: 200 }) 15 | deviceId?: string; 16 | 17 | @Prop({ unique: true, required: true, trim: true, select: false }) 18 | email: string; 19 | 20 | @Prop({ 21 | select: false, 22 | required: true, 23 | trim: true, 24 | minlength: 6, 25 | maxlength: 100, 26 | }) 27 | password?: string; 28 | 29 | @Prop({ select: false, trim: true, maxlength: 2000 }) 30 | firebaseToken?: string; 31 | 32 | @Prop({ select: false, trim: true, maxlength: 200 }) 33 | googleId?: string; 34 | 35 | @Prop({ select: false, trim: true, maxlength: 200 }) 36 | facebookId?: string; 37 | 38 | @Prop({ trim: true, maxlength: 500 }) 39 | profilePicUrl?: string; 40 | 41 | @Prop({ trim: true, maxlength: 500 }) 42 | googleProfilePicUrl?: string; 43 | 44 | @Prop({ trim: true, maxlength: 500 }) 45 | facebookProfilePicUrl?: string; 46 | 47 | @Prop({ trim: true, maxlength: 500 }) 48 | tagline?: string; 49 | 50 | @Prop({ type: [{ type: mongoose.Schema.Types.ObjectId, ref: Role.name }] }) 51 | roles: Role[]; 52 | 53 | @Prop({ default: false }) 54 | verified: boolean; 55 | 56 | @Prop({ default: true }) 57 | readonly status: boolean; 58 | } 59 | 60 | export const UserSchema = SchemaFactory.createForClass(User); 61 | 62 | UserSchema.index({ _id: 1, status: 1 }); 63 | UserSchema.index({ email: 1, status: 1 }); 64 | -------------------------------------------------------------------------------- /src/files/files.controller.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Controller, 3 | FileTypeValidator, 4 | Get, 5 | MaxFileSizeValidator, 6 | NotFoundException, 7 | Param, 8 | ParseFilePipe, 9 | Post, 10 | Request, 11 | Res, 12 | UploadedFile, 13 | UseInterceptors, 14 | } from '@nestjs/common'; 15 | import { FileInterceptor } from '@nestjs/platform-express'; 16 | import { Express, Response } from 'express'; 17 | import { Roles } from '../auth/decorators/roles.decorator'; 18 | import { RoleCode } from '../auth/schemas/role.schema'; 19 | import { ProtectedRequest } from '../core/http/request'; 20 | import { existsSync } from 'fs'; 21 | import { join } from 'path'; 22 | import { FilesService } from './files.service'; 23 | 24 | @Controller() 25 | export class FilesController { 26 | constructor(private readonly filesService: FilesService) {} 27 | 28 | @Get('assets/image/:image') 29 | async getImageFile(@Param('image') image: string, @Res() response: Response) { 30 | const diskPath = this.filesService.getDiskPath(); 31 | const filepath = join(diskPath, image); 32 | 33 | const exists = existsSync(filepath); 34 | if (!exists) throw new NotFoundException(`${image} not found`); 35 | 36 | const cacheDuration = this.filesService.getImageCacheDuration(); 37 | 38 | response.set('Cache-Control', `private, max-age=${cacheDuration}`); 39 | response.sendFile(filepath); 40 | } 41 | 42 | @Roles([RoleCode.ADMIN]) 43 | @UseInterceptors(FileInterceptor('image')) 44 | @Post('assets/upload/image') 45 | async uploadImageFile( 46 | @Request() request: ProtectedRequest, 47 | @UploadedFile( 48 | new ParseFilePipe({ 49 | validators: [ 50 | new MaxFileSizeValidator({ maxSize: 5 * 1024 * 1024 }), 51 | new FileTypeValidator({ fileType: /(jpe?g|png)$/i }), 52 | ], 53 | }), 54 | ) 55 | file: Express.Multer.File, 56 | ) { 57 | const baseUrl = request.protocol + '://' + request.get('host'); 58 | return `${baseUrl}/assets/image/${file.filename}`; 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /src/content/dto/content-info.dto.ts: -------------------------------------------------------------------------------- 1 | import { 2 | IsBoolean, 3 | IsNotEmpty, 4 | IsNumber, 5 | IsOptional, 6 | IsUrl, 7 | Max, 8 | Min, 9 | ValidateNested, 10 | } from 'class-validator'; 11 | import { Types } from 'mongoose'; 12 | import { IsMongoIdObject } from '../../common/mongo.validation'; 13 | import { copy } from '../../common/copier'; 14 | import { Category, Content } from '../schemas/content.schema'; 15 | import { UserInfoDto } from '../../user/dto/user-info.dto'; 16 | 17 | export class ContentInfoDto { 18 | @IsMongoIdObject() 19 | _id: Types.ObjectId; 20 | 21 | @IsNotEmpty() 22 | category: Category; 23 | 24 | @IsNotEmpty() 25 | title: string; 26 | 27 | @IsNotEmpty() 28 | subtitle: string; 29 | 30 | @IsOptional() 31 | @IsNotEmpty() 32 | description?: string; 33 | 34 | @IsUrl({ require_tld: false }) 35 | thumbnail: string; 36 | 37 | @IsNotEmpty() 38 | extra: string; 39 | 40 | @IsNumber() 41 | @Min(0) 42 | likes: number; 43 | 44 | @IsNumber() 45 | @Min(0) 46 | views: number; 47 | 48 | @IsNumber() 49 | @Min(0) 50 | shares: number; 51 | 52 | @IsNumber() 53 | @Min(0) 54 | @Max(1) 55 | score: number; 56 | 57 | @IsOptional() 58 | @IsBoolean() 59 | liked: boolean; 60 | 61 | @IsOptional() 62 | @IsBoolean() 63 | submit?: boolean; 64 | 65 | @IsOptional() 66 | @IsBoolean() 67 | private?: boolean; 68 | 69 | @ValidateNested() 70 | createdBy: UserInfoDto; 71 | 72 | constructor(content: Content, liked: boolean | undefined = undefined) { 73 | const props = copy(content, [ 74 | '_id', 75 | 'category', 76 | 'thumbnail', 77 | 'title', 78 | 'subtitle', 79 | 'description', 80 | 'thumbnail', 81 | 'extra', 82 | 'likes', 83 | 'views', 84 | 'shares', 85 | 'submit', 86 | 'private', 87 | 'score', 88 | ]); 89 | Object.assign(this, props); 90 | this.createdBy = new UserInfoDto(content.createdBy); 91 | if (liked !== undefined) this.liked = liked; 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /src/auth/guards/auth.guard.ts: -------------------------------------------------------------------------------- 1 | import { 2 | CanActivate, 3 | ExecutionContext, 4 | Injectable, 5 | UnauthorizedException, 6 | } from '@nestjs/common'; 7 | import { Reflector } from '@nestjs/core'; 8 | import { Request } from 'express'; 9 | import { IS_PUBLIC_KEY } from '../decorators/public.decorator'; 10 | import { ProtectedRequest } from '../../core/http/request'; 11 | import { Types } from 'mongoose'; 12 | import { AuthService } from '../auth.service'; 13 | import { UserService } from '../../user/user.service'; 14 | 15 | @Injectable() 16 | export class AuthGuard implements CanActivate { 17 | constructor( 18 | private readonly authService: AuthService, 19 | private readonly reflector: Reflector, 20 | private readonly userService: UserService, 21 | ) {} 22 | 23 | async canActivate(context: ExecutionContext): Promise { 24 | const isPublic = this.reflector.getAllAndOverride(IS_PUBLIC_KEY, [ 25 | context.getHandler(), 26 | context.getClass(), 27 | ]); 28 | if (isPublic) return true; 29 | 30 | const request = context.switchToHttp().getRequest(); 31 | const token = this.extractTokenFromHeader(request); 32 | if (!token) throw new UnauthorizedException(); 33 | 34 | const payload = await this.authService.verifyToken(token); 35 | const valid = this.authService.validatePayload(payload); 36 | if (!valid) throw new UnauthorizedException('Invalid Access Token'); 37 | 38 | const user = await this.userService.findUserById( 39 | new Types.ObjectId(payload.sub), 40 | ); 41 | if (!user) throw new UnauthorizedException('User not registered'); 42 | 43 | const keystore = await this.authService.findKeystore(user, payload.prm); 44 | if (!keystore) throw new UnauthorizedException('Invalid Access Token'); 45 | 46 | request.user = user; 47 | request.keystore = keystore; 48 | 49 | return true; 50 | } 51 | 52 | private extractTokenFromHeader(request: Request): string | undefined { 53 | const [type, token] = request.headers.authorization?.split(' ') ?? []; 54 | return type === 'Bearer' ? token : undefined; 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/auth/auth.controller.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Body, 3 | Controller, 4 | Delete, 5 | HttpCode, 6 | HttpStatus, 7 | Post, 8 | Request, 9 | UnauthorizedException, 10 | } from '@nestjs/common'; 11 | import { AuthService } from './auth.service'; 12 | import { Public } from './decorators/public.decorator'; 13 | import { SignInBasicDto } from './dto/signin-basic.dto'; 14 | import { ProtectedRequest } from '../core/http/request'; 15 | import { TokenRefreshDto } from './dto/token-refresh.dto'; 16 | import { UserAuthDto } from './dto/user-auth.dto'; 17 | import { UserTokensDto } from './dto/user-tokens.dto'; 18 | import { SignUpBasicDto } from './dto/signup-basic.dto'; 19 | 20 | @Controller('auth') 21 | export class AuthController { 22 | constructor(private authService: AuthService) {} 23 | 24 | @Public() 25 | @HttpCode(HttpStatus.OK) 26 | @Post('signup/basic') 27 | async signUpBasic( 28 | @Body() signUpBasicDto: SignUpBasicDto, 29 | ): Promise { 30 | const { user, tokens } = await this.authService.signUpBasic(signUpBasicDto); 31 | return new UserAuthDto(user, tokens); 32 | } 33 | 34 | @Public() 35 | @HttpCode(HttpStatus.OK) 36 | @Post('login/basic') 37 | async signInBasic( 38 | @Body() signInBasicDto: SignInBasicDto, 39 | ): Promise { 40 | const { user, tokens } = await this.authService.signInBasic(signInBasicDto); 41 | return new UserAuthDto(user, tokens); 42 | } 43 | 44 | @Delete('logout') 45 | async signOut(@Request() request: ProtectedRequest): Promise { 46 | await this.authService.signOut(request.keystore); 47 | return 'Logout sucess'; 48 | } 49 | 50 | @Public() 51 | @Post('token/refresh') 52 | async tokenRefresh( 53 | @Request() request: ProtectedRequest, 54 | @Body() tokenRefreshDto: TokenRefreshDto, 55 | ): Promise { 56 | const [type, token] = request.headers.authorization?.split(' ') ?? []; 57 | if (type !== 'Bearer' || token === undefined) 58 | throw new UnauthorizedException(); 59 | 60 | const { tokens } = await this.authService.refreshToken( 61 | tokenRefreshDto, 62 | token, 63 | ); 64 | return tokens; 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /src/core/interceptors/response.transformer.spec.ts: -------------------------------------------------------------------------------- 1 | import { CallHandler, ExecutionContext } from '@nestjs/common'; 2 | import { DataResponse, MessageResponse, StatusCode } from '../http/response'; 3 | import { lastValueFrom, of } from 'rxjs'; 4 | import { ResponseTransformer } from './response.transformer'; 5 | 6 | describe('ResponseTransformerInterceptor', () => { 7 | let interceptor: ResponseTransformer; 8 | let context: ExecutionContext; 9 | let next: CallHandler; 10 | 11 | beforeEach(() => { 12 | interceptor = new ResponseTransformer(); 13 | context = {} as ExecutionContext; 14 | next = { 15 | handle: jest.fn(), 16 | } as CallHandler; 17 | }); 18 | it('should transform MessageResponse', async () => { 19 | const messageResponse = new MessageResponse(StatusCode.SUCCESS, 'Hello'); 20 | jest.spyOn(next, 'handle').mockReturnValue(of(messageResponse)); 21 | 22 | const result = await lastValueFrom(interceptor.intercept(context, next)); 23 | 24 | expect(result).toBe(messageResponse); 25 | }); 26 | 27 | it('should transform DataResponse', async () => { 28 | const dataResponse = new DataResponse(StatusCode.SUCCESS, 'success', { 29 | key: 'value', 30 | }); 31 | jest.spyOn(next, 'handle').mockReturnValue(of(dataResponse)); 32 | 33 | const result = await lastValueFrom(interceptor.intercept(context, next)); 34 | 35 | expect(result).toBe(dataResponse); 36 | }); 37 | 38 | it('should transform string to MessageResponse', async () => { 39 | const plainString = 'Hello, world!'; 40 | jest.spyOn(next, 'handle').mockReturnValue(of(plainString)); 41 | 42 | const result = await lastValueFrom(interceptor.intercept(context, next)); 43 | 44 | expect(result).toEqual( 45 | new MessageResponse(StatusCode.SUCCESS, plainString), 46 | ); 47 | }); 48 | 49 | it('should transform other types to DataResponse', async () => { 50 | const complexObject = { key: 'value' }; 51 | jest.spyOn(next, 'handle').mockReturnValue(of(complexObject)); 52 | 53 | const result = await lastValueFrom(interceptor.intercept(context, next)); 54 | 55 | expect(result).toEqual( 56 | new DataResponse(StatusCode.SUCCESS, 'success', complexObject), 57 | ); 58 | }); 59 | }); 60 | -------------------------------------------------------------------------------- /src/app.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { MongooseModule } from '@nestjs/mongoose'; 3 | import { ConfigModule } from '@nestjs/config'; 4 | import serverConfig from './config/server.config'; 5 | import { MessageModule } from './message/message.module'; 6 | import { DatabaseFactory } from './setup/database.factory'; 7 | import databaseConfig from './config/database.config'; 8 | import { CoreModule } from './core/core.module'; 9 | import authkeyConfig from './config/authkey.config'; 10 | import tokenConfig from './config/token.config'; 11 | import { AuthModule } from './auth/auth.module'; 12 | import diskConfig from './config/disk.config'; 13 | import { FilesModule } from './files/files.module'; 14 | import { WinstonLogger } from './setup/winston.logger'; 15 | import { ScrapperModule } from './scrapper/scrapper.module'; 16 | import { MentorModule } from './mentor/mentor.module'; 17 | import { TopicModule } from './topic/topic.module'; 18 | import { SubscriptionModule } from './subscription/subscription.module'; 19 | import { ContentModule } from './content/content.module'; 20 | import { BookmarkModule } from './bookmark/bookmark.module'; 21 | import { SearchModule } from './search/search.module'; 22 | import { RedisCacheModule } from './cache/redis-cache.module'; 23 | import cacheConfig from './config/cache.config'; 24 | 25 | @Module({ 26 | imports: [ 27 | ConfigModule.forRoot({ 28 | load: [ 29 | serverConfig, 30 | databaseConfig, 31 | cacheConfig, 32 | authkeyConfig, 33 | tokenConfig, 34 | diskConfig, 35 | ], 36 | cache: true, 37 | envFilePath: getEnvFilePath(), 38 | }), 39 | MongooseModule.forRootAsync({ 40 | imports: [ConfigModule], 41 | useClass: DatabaseFactory, 42 | }), 43 | RedisCacheModule, 44 | CoreModule, 45 | AuthModule, 46 | MessageModule, 47 | FilesModule, 48 | ScrapperModule, 49 | MentorModule, 50 | TopicModule, 51 | SubscriptionModule, 52 | ContentModule, 53 | BookmarkModule, 54 | SearchModule, 55 | ], 56 | providers: [ 57 | { 58 | provide: 'Logger', 59 | useClass: WinstonLogger, 60 | }, 61 | ], 62 | }) 63 | export class AppModule {} 64 | 65 | function getEnvFilePath() { 66 | return process.env.NODE_ENV === 'test' ? '.env.test' : '.env'; 67 | } 68 | -------------------------------------------------------------------------------- /src/setup/winston.logger.ts: -------------------------------------------------------------------------------- 1 | import { Injectable, LoggerService } from '@nestjs/common'; 2 | import { ConfigService } from '@nestjs/config'; 3 | import * as winston from 'winston'; 4 | import * as DailyRotateFile from 'winston-daily-rotate-file'; 5 | import { ServerConfig, ServerConfigName } from '../config/server.config'; 6 | import { resolve } from 'path'; 7 | 8 | @Injectable() 9 | export class WinstonLogger implements LoggerService { 10 | private readonly logger: winston.Logger; 11 | 12 | constructor(private readonly configService: ConfigService) { 13 | const serverConfig = 14 | this.configService.getOrThrow(ServerConfigName); 15 | const logsPath = resolve(__dirname, '../..', serverConfig.logDirectory); 16 | 17 | const logLevel = serverConfig.nodeEnv === 'development' ? 'warn' : 'error'; 18 | 19 | const dailyRotateFile = new DailyRotateFile({ 20 | level: logLevel, 21 | dirname: logsPath, 22 | filename: '%DATE%.log', 23 | datePattern: 'YYYY-MM-DD', 24 | zippedArchive: true, 25 | handleExceptions: true, 26 | maxSize: '20m', 27 | maxFiles: '14d', 28 | }); 29 | 30 | this.logger = winston.createLogger({ 31 | level: logLevel, 32 | format: winston.format.combine( 33 | winston.format.timestamp(), 34 | winston.format.errors({ stack: true }), 35 | winston.format.prettyPrint(), 36 | ), 37 | transports: [ 38 | new winston.transports.Console({ 39 | level: logLevel, 40 | format: winston.format.combine( 41 | winston.format.errors({ stack: true }), 42 | winston.format.prettyPrint(), 43 | ), 44 | }), 45 | dailyRotateFile, 46 | ], 47 | exitOnError: false, // do not exit on handled exceptions 48 | }); 49 | } 50 | 51 | log(message: string) { 52 | this.logger.info(message); 53 | } 54 | 55 | error(message: string, trace?: string) { 56 | if (trace) { 57 | this.logger.error(`${message}\n${trace}`, trace); 58 | } else { 59 | this.logger.error(message); 60 | } 61 | } 62 | 63 | warn(message: string) { 64 | this.logger.warn(message); 65 | } 66 | 67 | debug(message: string) { 68 | this.logger.debug(message); 69 | } 70 | 71 | verbose(message: string) { 72 | this.logger.verbose(message); 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /src/content/contents.controller.ts: -------------------------------------------------------------------------------- 1 | import { Controller, Get, Param, Query, Request } from '@nestjs/common'; 2 | import { ContentsService } from './contents.service'; 3 | import { ProtectedRequest } from '../core/http/request'; 4 | import { MongoIdTransformer } from '../common/mongoid.transformer'; 5 | import { Types } from 'mongoose'; 6 | import { ContentInfoDto } from './dto/content-info.dto'; 7 | import { PaginationDto } from '../common/pagination.dto'; 8 | import { PaginationRotatedDto } from './dto/pagination-rotated.dto'; 9 | 10 | @Controller('contents') 11 | export class ContentsController { 12 | constructor(private readonly contentsService: ContentsService) {} 13 | 14 | @Get('content/:id/similar') 15 | async findSimilarContents( 16 | @Param('id', MongoIdTransformer) contentId: Types.ObjectId, 17 | @Query() paginationDto: PaginationDto, 18 | @Request() request: ProtectedRequest, 19 | ): Promise { 20 | return await this.contentsService.findSimilarContents( 21 | request.user, 22 | contentId, 23 | paginationDto, 24 | ); 25 | } 26 | 27 | @Get('mentor/:id') 28 | async findMentorContents( 29 | @Param('id', MongoIdTransformer) mentorId: Types.ObjectId, 30 | @Query() paginationDto: PaginationDto, 31 | ): Promise { 32 | return await this.contentsService.findMentorContents( 33 | mentorId, 34 | paginationDto, 35 | ); 36 | } 37 | 38 | @Get('topic/:id') 39 | async findTopicContents( 40 | @Param('id', MongoIdTransformer) topicId: Types.ObjectId, 41 | @Query() paginationDto: PaginationDto, 42 | ): Promise { 43 | return await this.contentsService.findTopicContents(topicId, paginationDto); 44 | } 45 | 46 | @Get('rotated') 47 | async findRotatedContents( 48 | @Query() paginationRotatedDto: PaginationRotatedDto, 49 | @Request() request: ProtectedRequest, 50 | ): Promise { 51 | return await this.contentsService.findRotatedContents( 52 | request.user, 53 | paginationRotatedDto, 54 | ); 55 | } 56 | 57 | @Get('my/box') 58 | async findMyBoxContents( 59 | @Query() paginationDto: PaginationDto, 60 | @Request() request: ProtectedRequest, 61 | ): Promise { 62 | return await this.contentsService.myboxContents( 63 | request.user, 64 | paginationDto, 65 | ); 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /src/content/content.controller.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Body, 3 | Controller, 4 | Get, 5 | Param, 6 | Post, 7 | Request, 8 | UseInterceptors, 9 | } from '@nestjs/common'; 10 | import { ContentService } from './content.service'; 11 | import { ProtectedRequest } from '../core/http/request'; 12 | import { MongoIdTransformer } from '../common/mongoid.transformer'; 13 | import { Types } from 'mongoose'; 14 | import { ContentInfoDto } from './dto/content-info.dto'; 15 | import { MongoIdDto } from '../common/mongoid.dto'; 16 | import { CacheInterceptor } from '@nestjs/cache-manager'; 17 | 18 | @Controller('content') 19 | export class ContentController { 20 | constructor(private readonly contentService: ContentService) {} 21 | 22 | @UseInterceptors(CacheInterceptor) 23 | @Get('id/:id') 24 | async findOne( 25 | @Param('id', MongoIdTransformer) id: Types.ObjectId, 26 | @Request() request: ProtectedRequest, 27 | ): Promise { 28 | return await this.contentService.findOne(id, request.user); 29 | } 30 | 31 | @Post('mark/view') 32 | async markView(@Body() mongoIdDto: MongoIdDto): Promise { 33 | const marked = await this.contentService.markView(mongoIdDto.id); 34 | if (!marked) return 'Content view marked failure.'; 35 | return 'Content view marked successfully.'; 36 | } 37 | 38 | @Post('mark/like') 39 | async markLike( 40 | @Body() mongoIdDto: MongoIdDto, 41 | @Request() request: ProtectedRequest, 42 | ): Promise { 43 | const marked = await this.contentService.markLike( 44 | mongoIdDto.id, 45 | request.user, 46 | ); 47 | if (!marked) return 'Content like marked failure.'; 48 | return 'Content liked successfully.'; 49 | } 50 | 51 | @Post('mark/unlike') 52 | async removeLike( 53 | @Body() mongoIdDto: MongoIdDto, 54 | @Request() request: ProtectedRequest, 55 | ): Promise { 56 | const marked = await this.contentService.removeLike( 57 | mongoIdDto.id, 58 | request.user, 59 | ); 60 | if (!marked) return 'Content like removed failure.'; 61 | return 'Content like removed successfully.'; 62 | } 63 | 64 | @Post('mark/share') 65 | async markShare(@Body() mongoIdDto: MongoIdDto): Promise { 66 | const marked = await this.contentService.markShare(mongoIdDto.id); 67 | if (!marked) return 'Content share marked failure.'; 68 | return 'Content share marked successfully.'; 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to WhereIsMyMotivation Backend 2 | 3 | Welcome to WhereIsMyMotivation Backend! We appreciate your interest in contributing. Whether you're reporting bugs, suggesting improvements, or contributing code, your collaboration makes this project better for everyone. 4 | 5 | Before you start contributing, please take a moment to review this document for guidelines on how to contribute to this project. 6 | 7 | ## Code of Conduct 8 | 9 | Please note that this project adheres to a [Code of Conduct](CODE_OF_CONDUCT.md). By participating in this project, you agree to abide by its terms. 10 | 11 | ## How to Contribute 12 | 13 | ### Reporting Bugs 14 | 15 | If you find a bug, please help us by providing a detailed bug report. Use the [GitHub Issues](https://github.com/janishar/wimm-node-app/issues) section to report bugs. Ensure your bug report includes: 16 | 17 | - A clear and descriptive title. 18 | - A detailed description of the bug, including steps to reproduce. 19 | - Your operating system and relevant environment information. 20 | 21 | ### Suggesting Enhancements 22 | 23 | To suggest enhancements or new features, please use the [GitHub Issues](https://github.com/janishar/wimm-node-app/issues) section. Provide a clear and concise description of your suggestion, including any relevant use cases or scenarios. 24 | 25 | ### Pull Requests 26 | 27 | 1. Fork the repository and create your branch from `main`. 28 | 29 | ```bash 30 | git clone https://github.com/janishar/wimm-node-app.git --recursive 31 | git checkout -b your-feature-branch 32 | ``` 33 | 34 | 2. Make your changes and ensure that your code adheres to the project's coding standards. 35 | 36 | 3. Test your changes thoroughly. 37 | 38 | 4. Commit your changes with a clear and concise commit message. 39 | 40 | ```bash 41 | git add . 42 | git commit -m "Add your concise commit message here" 43 | ``` 44 | 45 | 5. Push to your forked repository. 46 | 47 | ```bash 48 | git push origin your-feature-branch 49 | ``` 50 | 51 | 6. Open a pull request with a detailed description of your changes. 52 | 53 | ### Coding Standards 54 | 55 | Ensure that your code adheres to the coding standards used in this project. If there are specific guidelines, mention them here. 56 | 57 | ## License 58 | 59 | By contributing to this project, you agree that your contributions will be licensed under the [project's license](LICENSE.md). 60 | 61 | Thank you for contributing to WhereIsMyMotivation Backend! 62 | -------------------------------------------------------------------------------- /src/auth/auth.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test } from '@nestjs/testing'; 2 | import { AuthService } from './auth.service'; 3 | import { ApiKey } from './schemas/apikey.schema'; 4 | import { getModelToken } from '@nestjs/mongoose'; 5 | import { Keystore } from './schemas/keystore.schema'; 6 | import { Role } from './schemas/role.schema'; 7 | import { JwtService } from '@nestjs/jwt'; 8 | import { UserService } from '../user/user.service'; 9 | import { ConfigService } from '@nestjs/config'; 10 | 11 | describe('AuthService', () => { 12 | const validKey = 'api_key'; 13 | const expectedResult = { 14 | key: 'api_key', 15 | status: true, 16 | }; 17 | 18 | const findOneMockFn = jest.fn(({ key }) => ({ 19 | lean: jest.fn(() => ({ 20 | exec: jest.fn(() => (key === validKey ? expectedResult : null)), 21 | })), 22 | })); 23 | 24 | let keyService: AuthService; 25 | 26 | beforeEach(async () => { 27 | findOneMockFn.mockClear(); 28 | const module = await Test.createTestingModule({ 29 | providers: [ 30 | AuthService, 31 | { 32 | provide: getModelToken(ApiKey.name), 33 | useValue: { 34 | findOne: findOneMockFn, 35 | }, 36 | }, 37 | { 38 | provide: getModelToken(Keystore.name), 39 | useValue: {}, 40 | }, 41 | { 42 | provide: getModelToken(Role.name), 43 | useValue: {}, 44 | }, 45 | { 46 | provide: getModelToken(Role.name), 47 | useValue: {}, 48 | }, 49 | { 50 | provide: JwtService, 51 | useValue: {}, 52 | }, 53 | { 54 | provide: UserService, 55 | useValue: {}, 56 | }, 57 | { 58 | provide: ConfigService, 59 | useValue: {}, 60 | }, 61 | ], 62 | }).compile(); 63 | keyService = module.get(AuthService); 64 | }); 65 | 66 | it('should return the API key if correct key is sent', async () => { 67 | const result = await keyService.findApiKey(validKey); 68 | expect(result).toEqual(expectedResult); 69 | expect(findOneMockFn).toHaveBeenCalledWith({ 70 | key: validKey, 71 | status: true, 72 | }); 73 | }); 74 | 75 | it('should return null if wrong key is sent', async () => { 76 | const wrongKey = 'api_key_1'; 77 | const result = await keyService.findApiKey(wrongKey); 78 | expect(result).toBeNull(); 79 | expect(findOneMockFn).toHaveBeenCalledWith({ 80 | key: wrongKey, 81 | status: true, 82 | }); 83 | }); 84 | }); 85 | -------------------------------------------------------------------------------- /src/content/content-private.controller.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Body, 3 | Controller, 4 | Delete, 5 | InternalServerErrorException, 6 | Param, 7 | Post, 8 | Put, 9 | Request, 10 | } from '@nestjs/common'; 11 | import { ContentService } from './content.service'; 12 | import { ProtectedRequest } from '../core/http/request'; 13 | import { ContentInfoDto } from './dto/content-info.dto'; 14 | import { CreatePrivateContentDto } from './dto/create-private-content.dto'; 15 | import { MongoIdDto } from '../common/mongoid.dto'; 16 | import { Types } from 'mongoose'; 17 | import { MongoIdTransformer } from '../common/mongoid.transformer'; 18 | 19 | @Controller('content/private') 20 | export class ContentPrivateController { 21 | constructor(private readonly contentService: ContentService) {} 22 | 23 | @Post() 24 | async create( 25 | @Body() createPrivateContentDto: CreatePrivateContentDto, 26 | @Request() request: ProtectedRequest, 27 | ): Promise { 28 | const content = this.contentService.createPrivateContent( 29 | createPrivateContentDto, 30 | request.user, 31 | ); 32 | if (!content) throw new InternalServerErrorException(); 33 | return content; 34 | } 35 | 36 | @Put('submit') 37 | async submit( 38 | @Body() mongoIdDto: MongoIdDto, 39 | @Request() request: ProtectedRequest, 40 | ): Promise { 41 | const content = await this.contentService.submitPrivateContent( 42 | request.user, 43 | mongoIdDto.id, 44 | ); 45 | if (!content) throw new InternalServerErrorException('Not able to submit'); 46 | return 'Content submitted successfully'; 47 | } 48 | 49 | @Put('unsubmit') 50 | async unsubmit( 51 | @Body() mongoIdDto: MongoIdDto, 52 | @Request() request: ProtectedRequest, 53 | ): Promise { 54 | const content = await this.contentService.unsubmitPrivateContent( 55 | request.user, 56 | mongoIdDto.id, 57 | ); 58 | if (!content) 59 | throw new InternalServerErrorException('Not able to remove submission'); 60 | return 'Content submission removed successfully'; 61 | } 62 | 63 | @Delete('id/:id') 64 | async delete( 65 | @Param('id', MongoIdTransformer) id: Types.ObjectId, 66 | @Request() request: ProtectedRequest, 67 | ): Promise { 68 | const content = await this.contentService.deletePrivateContent( 69 | request.user, 70 | id, 71 | ); 72 | if (!content) 73 | throw new InternalServerErrorException('Not able to delete content'); 74 | return 'Content deleted successfully'; 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /src/core/interceptors/response.validations.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test } from '@nestjs/testing'; 2 | import { 3 | ExecutionContext, 4 | InternalServerErrorException, 5 | CallHandler, 6 | } from '@nestjs/common'; 7 | import { Observable, of, lastValueFrom } from 'rxjs'; 8 | import { ResponseValidation } from './response.validations'; 9 | import * as classValidator from 'class-validator'; 10 | 11 | class MockCallHandler implements CallHandler { 12 | handle(): Observable { 13 | return of({}); 14 | } 15 | } 16 | 17 | jest.mock('class-validator', () => ({ 18 | ...jest.requireActual('class-validator'), 19 | validateSync: jest.fn(), 20 | })); 21 | 22 | describe('ResponseValidationInterceptor', () => { 23 | let interceptor: ResponseValidation; 24 | const context = {} as ExecutionContext; 25 | const mockCallHandler = new MockCallHandler(); 26 | 27 | beforeEach(async () => { 28 | jest.clearAllMocks(); 29 | const module = await Test.createTestingModule({ 30 | providers: [ResponseValidation], 31 | }).compile(); 32 | 33 | interceptor = module.get(ResponseValidation); 34 | }); 35 | 36 | it('should not throw an exception for a valid response', async () => { 37 | jest.spyOn(mockCallHandler, 'handle').mockReturnValue(of({})); 38 | 39 | (classValidator.validateSync as jest.Mock).mockReturnValue([]); 40 | 41 | await expect( 42 | lastValueFrom(interceptor.intercept(context, mockCallHandler)), 43 | ).resolves.not.toThrow(); 44 | 45 | expect(classValidator.validateSync).toHaveBeenCalled(); 46 | }); 47 | 48 | it('should throw InternalServerErrorException for an invalid response', async () => { 49 | const validationError = { 50 | constraints: { exampleConstraint: 'Error message' }, 51 | }; 52 | 53 | jest.spyOn(mockCallHandler, 'handle').mockReturnValue(of({})); 54 | 55 | (classValidator.validateSync as jest.Mock).mockReturnValue([ 56 | validationError, 57 | ]); 58 | 59 | await expect( 60 | lastValueFrom(interceptor.intercept(context, mockCallHandler)), 61 | ).rejects.toThrow(InternalServerErrorException); 62 | 63 | expect(classValidator.validateSync).toHaveBeenCalled(); 64 | }); 65 | 66 | it('should extract error messages correctly', () => { 67 | const errors = [ 68 | { 69 | property: 'property1', 70 | constraints: { exampleConstraint1: 'Error message 1' }, 71 | } as classValidator.ValidationError, 72 | { 73 | property: 'property1', 74 | constraints: { exampleConstraint2: 'Error message 2' }, 75 | } as classValidator.ValidationError, 76 | ]; 77 | 78 | const result = interceptor['extractErrorMessages'](errors, []); 79 | 80 | expect(result).toEqual(['Error message 1', 'Error message 2']); 81 | }); 82 | }); 83 | -------------------------------------------------------------------------------- /src/core/interceptors/exception.handler.ts: -------------------------------------------------------------------------------- 1 | // exception.filter.ts 2 | import { 3 | ExceptionFilter, 4 | Catch, 5 | ArgumentsHost, 6 | HttpException, 7 | HttpStatus, 8 | InternalServerErrorException, 9 | UnauthorizedException, 10 | } from '@nestjs/common'; 11 | import { TokenExpiredError } from '@nestjs/jwt'; 12 | import { Request, Response } from 'express'; 13 | import { StatusCode } from '../http/response'; 14 | import { isArray } from 'class-validator'; 15 | import { ConfigService } from '@nestjs/config'; 16 | import { ServerConfig, ServerConfigName } from '../../config/server.config'; 17 | import { WinstonLogger } from '../../setup/winston.logger'; 18 | 19 | @Catch() 20 | export class ExpectionHandler implements ExceptionFilter { 21 | constructor( 22 | private readonly configService: ConfigService, 23 | private readonly logger: WinstonLogger, 24 | ) {} 25 | 26 | catch(exception: any, host: ArgumentsHost) { 27 | const ctx = host.switchToHttp(); 28 | const response = ctx.getResponse(); 29 | const request = ctx.getRequest(); 30 | 31 | let status = HttpStatus.INTERNAL_SERVER_ERROR; 32 | let statusCode = StatusCode.FAILURE; 33 | let message: string = 'Something went wrong'; 34 | let errors: any[] | undefined = undefined; 35 | 36 | if (exception instanceof HttpException) { 37 | status = exception.getStatus(); 38 | const body = exception.getResponse(); 39 | if (typeof body === 'string') { 40 | message = body; 41 | } else if ('message' in body) { 42 | if (typeof body.message === 'string') { 43 | message = body.message; 44 | } else if (isArray(body.message) && body.message.length > 0) { 45 | message = body.message[0]; 46 | errors = body.message; 47 | } 48 | } 49 | if (exception instanceof InternalServerErrorException) { 50 | this.logger.error(exception.message, exception.stack); 51 | } 52 | 53 | if (exception instanceof UnauthorizedException) { 54 | if (message.toLowerCase().includes('invalid access token')) { 55 | statusCode = StatusCode.INVALID_ACCESS_TOKEN; 56 | response.appendHeader('instruction', 'logout'); 57 | } 58 | } 59 | } else if (exception instanceof TokenExpiredError) { 60 | status = HttpStatus.UNAUTHORIZED; 61 | statusCode = StatusCode.INVALID_ACCESS_TOKEN; 62 | response.appendHeader('instruction', 'refresh_token'); 63 | message = 'Token Expired'; 64 | } else { 65 | const serverConfig = 66 | this.configService.getOrThrow(ServerConfigName); 67 | if (serverConfig.nodeEnv === 'development') message = exception.message; 68 | this.logger.error(exception.message, exception.stack); 69 | } 70 | 71 | response.status(status).json({ 72 | statusCode: statusCode, 73 | message: message, 74 | errors: errors, 75 | url: request.url, 76 | }); 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /src/user/user.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable, InternalServerErrorException } from '@nestjs/common'; 2 | import { InjectModel } from '@nestjs/mongoose'; 3 | import { Model, Types } from 'mongoose'; 4 | import { User } from './schemas/user.schema'; 5 | import { UpdateProfileDto } from './dto/upadte-profile.dto'; 6 | import { UserDto } from './dto/user.dto'; 7 | 8 | @Injectable() 9 | export class UserService { 10 | constructor( 11 | @InjectModel(User.name) private readonly userModel: Model, 12 | ) {} 13 | 14 | readonly USER_CRITICAL_DETAIL = 15 | '+email +password +roles +googleId +facebookId'; 16 | 17 | async create(user: Omit): Promise { 18 | const created = await this.userModel.create(user); 19 | return { ...created.toObject(), roles: user.roles }; 20 | } 21 | 22 | async updateProfile(user: User, updateProfileDto: UpdateProfileDto) { 23 | const something = 24 | updateProfileDto.name || 25 | updateProfileDto.profilePicUrl || 26 | updateProfileDto.tagline || 27 | updateProfileDto.firebaseToken; 28 | 29 | if (!something) return new UserDto(user); 30 | 31 | const updated = await this.updateUserInfo({ 32 | _id: user._id, 33 | ...updateProfileDto, 34 | }); 35 | 36 | if (!updated) throw new InternalServerErrorException(); 37 | 38 | return new UserDto(updated); 39 | } 40 | 41 | async findUserById(id: Types.ObjectId) { 42 | return this.userModel 43 | .findOne({ _id: id, status: true }) 44 | .select(this.USER_CRITICAL_DETAIL) 45 | .populate({ 46 | path: 'roles', 47 | match: { status: true }, 48 | }) 49 | .lean() 50 | .exec(); 51 | } 52 | 53 | async findByEmail(email: string) { 54 | return this.userModel 55 | .findOne({ email: email }) 56 | .select(this.USER_CRITICAL_DETAIL) 57 | .populate({ 58 | path: 'roles', 59 | match: { status: true }, 60 | }) 61 | .lean() 62 | .exec(); 63 | } 64 | 65 | async findPrivateProfile(user: User) { 66 | return this.userModel 67 | .findOne({ _id: user._id, status: true }) 68 | .select('+email') 69 | .populate({ 70 | path: 'roles', 71 | match: { status: true }, 72 | select: { code: 1 }, 73 | }) 74 | .lean() 75 | .exec(); 76 | } 77 | 78 | async updateUserInfo(user: Partial) { 79 | return this.userModel 80 | .findByIdAndUpdate({ _id: user._id, status: true }, { $set: user }) 81 | .select('+email') 82 | .populate({ 83 | path: 'roles', 84 | match: { status: true }, 85 | select: { code: 1 }, 86 | }) 87 | .lean() 88 | .exec(); 89 | } 90 | 91 | async delete(user: User) { 92 | return this.userModel.findByIdAndDelete(user._id); 93 | } 94 | 95 | async deactivate(user: User): Promise { 96 | return this.userModel.findByIdAndUpdate(user._id, { status: false }); 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /src/content/schemas/content.schema.ts: -------------------------------------------------------------------------------- 1 | import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose'; 2 | import mongoose, { HydratedDocument, Types } from 'mongoose'; 3 | import { User } from '../../user/schemas/user.schema'; 4 | import { Topic } from '../../topic/schemas/topic.schema'; 5 | import { Mentor } from '../../mentor/schemas/mentor.schema'; 6 | 7 | export enum Category { 8 | AUDIO = 'AUDIO', 9 | VIDEO = 'VIDEO', 10 | IMAGE = 'IMAGE', 11 | YOUTUBE = 'YOUTUBE', 12 | ARTICLE = 'ARTICLE', 13 | QUOTE = 'QUOTE', 14 | MENTOR_INFO = 'MENTOR_INFO', 15 | TOPIC_INFO = 'TOPIC_INFO', 16 | } 17 | 18 | export type ContentDocument = HydratedDocument; 19 | 20 | @Schema({ collection: 'contents', versionKey: false, timestamps: true }) 21 | export class Content { 22 | readonly _id: Types.ObjectId; 23 | 24 | @Prop({ required: true, enum: Object.values(Category) }) 25 | category: Category; 26 | 27 | @Prop({ required: true, maxlength: 500, trim: true }) 28 | title: string; 29 | 30 | @Prop({ required: true, maxlength: 100, trim: true }) 31 | subtitle: string; 32 | 33 | @Prop({ required: false, select: false, maxlength: 2000, trim: true }) 34 | description?: string; 35 | 36 | @Prop({ required: true, maxlength: 300, trim: true }) 37 | thumbnail: string; 38 | 39 | @Prop({ required: true, maxlength: 300, trim: true }) 40 | extra: string; 41 | 42 | @Prop({ 43 | type: [{ type: mongoose.Schema.Types.ObjectId, ref: Topic.name }], 44 | }) 45 | topics: Topic[]; 46 | 47 | @Prop({ 48 | type: [{ type: mongoose.Schema.Types.ObjectId, ref: Mentor.name }], 49 | }) 50 | mentors: Mentor[]; 51 | 52 | @Prop({ 53 | type: [ 54 | { type: mongoose.Schema.Types.ObjectId, ref: User.name, select: false }, 55 | ], 56 | }) 57 | likedBy?: User[]; 58 | 59 | @Prop({ default: 0, min: 0 }) 60 | likes: number; 61 | 62 | @Prop({ default: 0, min: 0 }) 63 | views: number; 64 | 65 | @Prop({ default: 0, min: 0 }) 66 | shares: number; 67 | 68 | @Prop({ default: false }) 69 | general: boolean; 70 | 71 | @Prop({ default: 0.01, max: 1, min: 0 }) 72 | score: number; 73 | 74 | @Prop({ default: true }) 75 | private: boolean; 76 | 77 | @Prop({ default: false, select: false }) 78 | submit?: boolean; 79 | 80 | @Prop({ 81 | type: mongoose.Schema.Types.ObjectId, 82 | ref: User.name, 83 | required: true, 84 | }) 85 | createdBy: User; 86 | 87 | @Prop({ 88 | type: mongoose.Schema.Types.ObjectId, 89 | ref: User.name, 90 | required: true, 91 | select: false, 92 | }) 93 | updatedBy?: User; 94 | 95 | @Prop({ default: true }) 96 | status: boolean; 97 | } 98 | 99 | export const ContentSchema = SchemaFactory.createForClass(Content); 100 | 101 | ContentSchema.index( 102 | { title: 'text', subtitle: 'text' }, 103 | { weights: { title: 3, subtitle: 1 }, background: false }, 104 | ); 105 | 106 | ContentSchema.index({ _id: 1, status: 1 }); 107 | ContentSchema.index({ createdBy: 1, status: 1 }); 108 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "wimm-apis", 3 | "version": "1.0.0", 4 | "description": "backend project", 5 | "author": "fifocode", 6 | "private": true, 7 | "license": "Apache-2.0", 8 | "scripts": { 9 | "postinstall": "ts-node .resources/init-project.ts", 10 | "build": "nest build", 11 | "format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"", 12 | "start": "nest start", 13 | "start:dev": "nest start --watch", 14 | "start:debug": "nest start --debug --watch", 15 | "start:prod": "node dist/main", 16 | "lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix", 17 | "test": "NODE_ENV=test jest --runInBand", 18 | "test:watch": "NODE_ENV=test jest --watch", 19 | "test:cov": "NODE_ENV=test jest --coverage --runInBand", 20 | "test:debug": "NODE_ENV=test node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest", 21 | "test:unit": "NODE_ENV=test jest '.*\\.spec\\.ts$'", 22 | "test:e2e": "NODE_ENV=test jest '.*\\.e2e-spec\\.ts$' --runInBand" 23 | }, 24 | "dependencies": { 25 | "@keyv/redis": "^4.4.0", 26 | "@nestjs/cache-manager": "^3.0.1", 27 | "@nestjs/common": "^11.1.1", 28 | "@nestjs/config": "^4.0.2", 29 | "@nestjs/core": "^11.1.1", 30 | "@nestjs/jwt": "^11.0.0", 31 | "@nestjs/mapped-types": "^2.1.0", 32 | "@nestjs/mongoose": "^11.0.3", 33 | "@nestjs/platform-express": "^11.1.1", 34 | "axios": "^1.9.0", 35 | "bcrypt": "^6.0.0", 36 | "cache-manager": "^6.4.3", 37 | "cheerio": "^1.0.0", 38 | "class-transformer": "^0.5.1", 39 | "class-validator": "^0.14.2", 40 | "mongoose": "^8.15.0", 41 | "redis": "^5.1.0", 42 | "reflect-metadata": "^0.2.2", 43 | "rxjs": "^7.8.2", 44 | "winston": "^3.17.0", 45 | "winston-daily-rotate-file": "^5.0.0" 46 | }, 47 | "devDependencies": { 48 | "@nestjs/cli": "^11.0.7", 49 | "@nestjs/schematics": "^11.0.5", 50 | "@nestjs/testing": "^11.1.1", 51 | "@types/bcrypt": "^5.0.2", 52 | "@types/cheerio": "^1.0.0", 53 | "@types/express": "^5.0.2", 54 | "@types/jest": "^29.5.14", 55 | "@types/multer": "^1.4.12", 56 | "@types/node": "^22.15.21", 57 | "@types/supertest": "^6.0.3", 58 | "@typescript-eslint/eslint-plugin": "^8.32.1", 59 | "@typescript-eslint/parser": "^8.32.1", 60 | "eslint": "^9.27.0", 61 | "eslint-config-prettier": "^10.1.5", 62 | "eslint-plugin-prettier": "^5.4.0", 63 | "jest": "^29.7.0", 64 | "prettier": "^3.5.3", 65 | "rimraf": "^6.0.1", 66 | "source-map-support": "^0.5.21", 67 | "supertest": "^7.1.1", 68 | "ts-jest": "^29.3.4", 69 | "ts-loader": "^9.5.2", 70 | "ts-node": "^10.9.2", 71 | "tsconfig-paths": "^4.2.0", 72 | "typescript": "^5.8.3" 73 | }, 74 | "jest": { 75 | "moduleFileExtensions": [ 76 | "js", 77 | "json", 78 | "ts" 79 | ], 80 | "rootDir": ".", 81 | "testRegex": ".*\\.(spec|e2e-spec)\\.ts$", 82 | "preset": "ts-jest", 83 | "roots": [ 84 | "/src", 85 | "/test" 86 | ], 87 | "collectCoverageFrom": [ 88 | "/src/**/*.(t|j)s" 89 | ], 90 | "coverageDirectory": "coverage", 91 | "testEnvironment": "node" 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Code of Conduct for WhereIsMyMotivation Backend 2 | 3 | ## Introduction 4 | 5 | We, the contributors and maintainers of the WhereIsMyMotivation Backend, pledge to foster an open and welcoming environment. We aim to make participation in our project a positive and inclusive experience for everyone, regardless of background or identity. 6 | 7 | This Code of Conduct outlines our expectations for all those who participate in our community, as well as the consequences for unacceptable behavior. 8 | 9 | We invite all contributors, users, and other participants to help us realize a safe and positive community experience. 10 | 11 | ## Our Standards 12 | 13 | Examples of behavior that contributes to creating a positive environment include: 14 | 15 | - **Respectful and Inclusive Language:** Be respectful of differing opinions, experiences, and viewpoints. Use inclusive language and avoid offensive comments or personal attacks. 16 | 17 | - **Collaboration:** Work collaboratively with others, embracing constructive criticism and differing perspectives. 18 | 19 | - **Empathy:** Be empathetic towards other community members, understanding and valuing diverse experiences. 20 | 21 | - **Open-Mindedness:** Be open to learning from others and willing to adapt and grow. 22 | 23 | Examples of unacceptable behavior include: 24 | 25 | - **Harassment:** Any form of harassment, discrimination, or unwelcome conduct based on factors such as race, gender, sexual orientation, disability, or any other protected status. 26 | 27 | - **Intimidation:** Deliberate intimidation, stalking, or following, online or offline. 28 | 29 | - **Unsolicited Advice:** Unwelcome advice or criticism, especially if it's repetitive or not constructive. 30 | 31 | - **Disruptive Behavior:** Any disruptive behavior that interferes with a positive and inclusive community experience. 32 | 33 | ## Responsibilities 34 | 35 | Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned with this Code of Conduct. Project maintainers have the final say on all matters. 36 | 37 | ## Enforcement 38 | 39 | Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting the project team at [janishar.ali@gmail.com]. All complaints will be reviewed and investigated and will result in a response that is deemed necessary and appropriate to the circumstances. The project team is obligated to maintain confidentiality regarding the reporter of an incident. 40 | 41 | Project maintainers who do not follow or enforce the Code of Conduct may be temporarily or permanently removed from the project team. 42 | 43 | ## Attribution 44 | 45 | This Code of Conduct is adapted from the [Contributor Covenant](https://www.contributor-covenant.org), version 2.0, available at https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. 46 | 47 | ## Conclusion 48 | 49 | By participating in our project, you agree to uphold this Code of Conduct. We expect all contributors to adhere to these guidelines in all project spaces, including GitHub repositories, social media, and community events. 50 | 51 | Thank you for helping make this project a welcoming, positive, and inclusive space for everyone. 52 | -------------------------------------------------------------------------------- /src/auth/guards/roles.guard.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test } from '@nestjs/testing'; 2 | import { RolesGuard } from './roles.guard'; 3 | import { Reflector } from '@nestjs/core'; 4 | import { User } from '../../user/schemas/user.schema'; 5 | import { ExecutionContext, ForbiddenException } from '@nestjs/common'; 6 | import { Role, RoleCode } from '../schemas/role.schema'; 7 | 8 | describe('RoleGuard', () => { 9 | let roleGuard: RolesGuard; 10 | 11 | const user = { roles: [] as Role[] } as User; 12 | const viewer = { roles: [{ code: RoleCode.VIEWER } as Role] } as User; 13 | 14 | const reflectorGetMock = jest.fn(); 15 | const requestMock = jest.fn(); 16 | 17 | const context = { 18 | getHandler: () => ({}), 19 | getClass: () => ({}), 20 | switchToHttp: () => ({ 21 | getRequest: () => requestMock(), 22 | }), 23 | } as ExecutionContext; 24 | 25 | beforeEach(async () => { 26 | jest.clearAllMocks(); 27 | const module = await Test.createTestingModule({ 28 | providers: [ 29 | RolesGuard, 30 | { 31 | provide: Reflector, 32 | useValue: { 33 | get: reflectorGetMock, 34 | }, 35 | }, 36 | ], 37 | }).compile(); 38 | 39 | roleGuard = module.get(RolesGuard); 40 | }); 41 | 42 | it('should pass if role is not provided', async () => { 43 | reflectorGetMock.mockReturnValue(null); 44 | const pass = await roleGuard.canActivate(context); 45 | expect(pass).toBe(true); 46 | expect(reflectorGetMock).toHaveBeenCalledTimes(2); 47 | expect(requestMock).not.toHaveBeenCalled(); 48 | }); 49 | 50 | it('should throw ForbiddenException if user is null', async () => { 51 | reflectorGetMock.mockReturnValue([RoleCode.VIEWER]); 52 | requestMock.mockReturnValue({ user: null }); 53 | await expect(roleGuard.canActivate(context)).rejects.toBeInstanceOf( 54 | ForbiddenException, 55 | ); 56 | expect(reflectorGetMock).toHaveBeenCalled(); 57 | expect(requestMock).toHaveBeenCalledTimes(1); 58 | }); 59 | 60 | it('should throw ForbiddenException if user does not have no role', async () => { 61 | requestMock.mockReturnValue({ user: user }); 62 | reflectorGetMock.mockReturnValue([RoleCode.VIEWER]); 63 | await expect(roleGuard.canActivate(context)).rejects.toBeInstanceOf( 64 | ForbiddenException, 65 | ); 66 | expect(reflectorGetMock).toHaveBeenCalled(); 67 | expect(requestMock).toHaveBeenCalledTimes(1); 68 | }); 69 | 70 | it('should throw ForbiddenException if user does not have allowed role', async () => { 71 | requestMock.mockReturnValue({ user: viewer }); 72 | reflectorGetMock.mockReturnValue([RoleCode.ADMIN]); 73 | await expect(roleGuard.canActivate(context)).rejects.toBeInstanceOf( 74 | ForbiddenException, 75 | ); 76 | expect(reflectorGetMock).toHaveBeenCalled(); 77 | expect(requestMock).toHaveBeenCalledTimes(1); 78 | }); 79 | 80 | it('should pass if user has allowed role', async () => { 81 | requestMock.mockReturnValue({ user: viewer }); 82 | reflectorGetMock.mockReturnValue([RoleCode.VIEWER]); 83 | const pass = await roleGuard.canActivate(context); 84 | expect(pass).toBe(true); 85 | expect(reflectorGetMock).toHaveBeenCalled(); 86 | expect(requestMock).toHaveBeenCalledTimes(1); 87 | }); 88 | }); 89 | -------------------------------------------------------------------------------- /keys/private.pem.example: -------------------------------------------------------------------------------- 1 | -----BEGIN RSA PRIVATE KEY----- 2 | MIIJKAIBAAKCAgBlwJaoLrrQOpNGfsH8xPnkiV4jVF6gsaS46JjBRFbdPAcMWg1h 3 | hDm2McYrcajmBleXhTBgdTlmr84LSD6/bAD5uVC0i/jUyq1i3zeMmwe8pwy1aprF 4 | gCb3DwnlF0WH0ZumRf+mAgAXEDRZOhtWeWKdW7XFNGlRdMegfRfog+ZXtQhRJZ2+ 5 | 8E7+RYi8AE4zTEgjakXaY0j8fNiQ6F8sY4Z3oIc/J5K2TTBgyVDX3sIWMjP3+Eq6 6 | wpU3/fd38LpDDhEf3KzKpDmtr+R9u9R0CzNi+JidRfETjYdjs/fGXHqxV94NYhRK 7 | JC/crtLNaZF3tN5JHttWV41KgD8MM1moY+5wBFSY3/wOIHG9eZVV/xrsHkoWHoJ4 8 | IAeh0tA3sklrN6iPx+1c9oIqSb7NS3hVhtFQsPDqeq7r9q/PTYrYYQxXCIlXcXRq 9 | 15MpCJlN1wsDQxiqZ/rPtkj7a81MI2N0X7VhVa5RadTbh1OBCZXAfDWI2jRepuTF 10 | ssFO4Y5uqQmyIFLeo4raqNtyMM2U9a50/TaVF8nLsR1SNB/U0KAKn7Z9T0aWyCwD 11 | t/Pbcro2DCBUQgQPH1FCYMRBbbSi/0dtGXIa5PFLHW7jNiNrNKt2HD093avvSH00 12 | uKngLV/p8f3utdz7uNzFdO7G2Aif6gDvEOslDwTfqL5oa9oocBiLsY4hpwIDAQAB 13 | AoICAGCjV7LlptGLQELMsqrB3WA7QuglAjZ2YS6o4iN0J1e44izN/jVmonUysrai 14 | HTpSPbCPc5G448puIC/A7q9ZAVgrs8W1xp862w+zVou41dFLiptmYxY+j0NHyisF 15 | PikvXze89X+SGMnOvxkbQcmQQaOX7XwdOgV99vaquCFB3GviaMLBCh/FFhESszly 16 | xNKdmUnhbfRrJW9/lyORPorm/1fwY2MMz59Kki7qJFjRnN390jOpYjgcBCKIlxcX 17 | z59N5d+v84AGFy2ph0YPNUj9NlHCLxf3zG24liSQUTvYq0JxZFirlP+RfM+ITnd+ 18 | 0wSxCAjEQKaxQmTGhpqsznNKbbJHeGKQoCqwAj+QX0/gthIsKNc3YvP3VcjPPHBG 19 | cqGSXpJ1V2syECoG+pdOvUfEkwSh15mgKcXkQbRRvIJPkFDnSz7tmhW6ls5AWxQA 20 | FzrLwrmhO2+pb/1S/ru2AJYZ65sfQ8dcPpkY7qwX1TwibBdFE3BcuRd9Y0IaSLKA 21 | GG6YDBPQ5AemTc1pgDEHUF6KX2RaKadEThiIGSvwG8VRMDY4i1xrBPUsRQWQaa2E 22 | YA3qRopna3JLiO0IvecrI5QFnG63LIzJfE0z2ehtwqBMK304lkcBXcActLxXsBMf 23 | 0hkB4svM9LUKtAvIdRyRaYFpZ+IQi4aRXdKapqx2k7Cu354ZAoIBAQDDno7SEg19 24 | 8AS//LPTYO1OgyiSCALfr7WKxk07qhMBqJ9xRwkECiGHLLESxwC9L8mlxYNMf+3Y 25 | Hvloxo3QMvxWfqRs4URDOQBUnmqkNsprf1MyH3RKHVmIifMafCi4npKBAXgYkanf 26 | 4D9CLJlcjGzG690T3KTN++U1oWNIkOUf1kS/XKoG/yS3TXepoPG8kH9QiGKMIYGd 27 | g4M/nre4zDyFGvF4RVkcBkUq3cTmaQr+s1BjFKu0vQSSrdirPXHfQffqmxN90s0M 28 | +zNr1M0jz22M6FtjeTzYh7ObfO4yTqHyh0o97gSl2TNeNdAzxxufJLECp7tEnJCR 29 | kQ5FjEfSoaArAoIBAQCFKNjSn5k+YdtDPlqhdoYEwFZl9dG6nxFKD68myovpBZts 30 | c+jdkMoadP90Usoqp8t0S2ycRKSOdpv2IMlPHBrcTjMR5X+BkPn/A7/VxPBdTsBb 31 | bqH52GJHCsag5zZ8Tb1kfebLnATlLHP7smVMy3U2usC5LEvrw54WouO3hZ4uwiJ0 32 | 48fy9QSu2+AdjPGUDxSkEcxB8f5r/FFlioZUfTSskUWYSFsE6dldWJDbj9my57LP 33 | 572H5khgkJgF6DOLbKYIiJFVjFKzxBJK5qJlDDiPoLbX7eFE4k8AXbFiNa6G6re8 34 | qcFEk9YceBJ7mJR3x2KQAvlsip+0krgAllx66sp1AoIBAQCdHtq6EjPyBnnaNr+9 35 | NSoGGMTaXkLopbncKCDanmU7vNOr6ZFtRDHf396uJVcLnbmKQ5WNOWexYleTd39q 36 | TbluQ7QPm/P3Rm5kiQVAtp1pMFCDfs4pxV8nkx3HFRikCQHFSofKvBzEq9pDbY0U 37 | z9mDAOmGTX+1zvAwXltfaroOE00OowASNJaqADfv8EyZ1znbVmBJC5SPDpW54OLS 38 | NIEbrCNtJc5H+yVNI+lF4/Mn1qDnpC+yMOveYHIaQHUZDGRzdHF4a3u1RILtxnOq 39 | oq3sMqlMwZFEneJ+Lh8oGw5qvY29JlNnXoiqz/BV1kPyVEc0ycJZfqODuA3NgLoj 40 | jRwXAoIBADaX0ZVA074sto1yIgwyoK2QAZwZ1Vuy3Y7EsWWxAv2NS/SB7QXx17pT 41 | H2yciMBGPPa3+ZZz2heCb1zQglhJyIVsFioPMmB3hNdVvS+yZ22J3PlfeU8KtPg1 42 | ZcwQH1mFMdHigF4X4DXpLMATms6KV8sRc/Q3QgUuFUFolP3n7Tt0YlYUEST96Wab 43 | RN44q86tBCRkG58pzMqPDvwXeA1pq8/YW3UcrRxtl61ao0ExT+q2bawpcZ4m/qOA 44 | hKNfWMqfx86V1ygAuON0zp8gwZG2GfZgyLXslD/+nK8kupXuNligIKZb3p30EoNn 45 | gwRjQPN+rl0mqwYiK2oIASJUgyPt4QUCggEBAKoqOavlfWGsiRzeYsVgDKyIm0eW 46 | YC0KMj4aDaHjl2Lzk7gPympsBwGWNNJ+fqFIwyFJxwkX1KQlHnTHbweLFueJUpcP 47 | Ao99E4i4aGDVLshv5jGHpJInPjhwNrHykVChVo4e90n6QBFZ5Hfv9cg4P8wwDB0a 48 | r45Q7N8Uc7Z86otf5E1xREVaqLOhCRCm0E9PjUB29YBQIxt5CxVEr/KMvrBqxZEb 49 | 9QOgGcK/YatHslYE3/3DiijBz2ZIpBEzJlSb5YXFSX6bHYnlIcesK98nXQ5px7J9 50 | JiknuyM5zuwPPwkAS6ztDjMRugVPPXlSmh/cCFNzrxLuPN4nkoNcx/vanJA= 51 | -----END RSA PRIVATE KEY----- -------------------------------------------------------------------------------- /src/content/content-admin.controller.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Body, 3 | Controller, 4 | Delete, 5 | Get, 6 | InternalServerErrorException, 7 | NotFoundException, 8 | Param, 9 | Post, 10 | Put, 11 | Request, 12 | } from '@nestjs/common'; 13 | import { ContentService } from './content.service'; 14 | import { Roles } from '../auth/decorators/roles.decorator'; 15 | import { RoleCode } from '../auth/schemas/role.schema'; 16 | import { MongoIdTransformer } from '../common/mongoid.transformer'; 17 | import { Types } from 'mongoose'; 18 | import { Content } from './schemas/content.schema'; 19 | import { CreateContentDto } from './dto/create-content.dto'; 20 | import { ProtectedRequest } from '../core/http/request'; 21 | import { UpdateContentDto } from './dto/update-content.dto'; 22 | import { MongoIdDto } from '../common/mongoid.dto'; 23 | 24 | @Roles([RoleCode.ADMIN]) 25 | @Controller('content/admin') 26 | export class ContentAdminController { 27 | constructor(private readonly contentService: ContentService) {} 28 | 29 | @Get('id/:id') 30 | async findOne( 31 | @Param('id', MongoIdTransformer) id: Types.ObjectId, 32 | ): Promise { 33 | const content = await this.contentService.findById(id); 34 | if (!content) throw new NotFoundException(); 35 | return content; 36 | } 37 | 38 | @Post() 39 | async create( 40 | @Body() createContentDto: CreateContentDto, 41 | @Request() request: ProtectedRequest, 42 | ): Promise { 43 | const content = this.contentService.createContent( 44 | createContentDto, 45 | request.user, 46 | ); 47 | if (!content) throw new InternalServerErrorException(); 48 | return content; 49 | } 50 | 51 | @Put('id/:id') 52 | async update( 53 | @Param('id', MongoIdTransformer) id: Types.ObjectId, 54 | @Request() request: ProtectedRequest, 55 | @Body() updateContentDto: UpdateContentDto, 56 | ): Promise { 57 | const content = await this.contentService.updateContent( 58 | request.user, 59 | id, 60 | updateContentDto, 61 | ); 62 | if (!content) throw new InternalServerErrorException(); 63 | return content; 64 | } 65 | 66 | @Delete('id/:id') 67 | async delete(@Param('id', MongoIdTransformer) id: Types.ObjectId) { 68 | const content = await this.contentService.delete(id); 69 | if (!content) throw new InternalServerErrorException(); 70 | return content; 71 | } 72 | 73 | @Put('publish/general') 74 | async publish( 75 | @Body() mongoIdDto: MongoIdDto, 76 | @Request() request: ProtectedRequest, 77 | ): Promise { 78 | const content = await this.contentService.publishContent( 79 | request.user, 80 | mongoIdDto.id, 81 | ); 82 | if (!content) throw new InternalServerErrorException('Not able to publish'); 83 | return 'Content published successfully'; 84 | } 85 | 86 | @Put('unpublish/general') 87 | async unpublish( 88 | @Body() mongoIdDto: MongoIdDto, 89 | @Request() request: ProtectedRequest, 90 | ): Promise { 91 | const content = await this.contentService.unpublishContent( 92 | request.user, 93 | mongoIdDto.id, 94 | ); 95 | if (!content) 96 | throw new InternalServerErrorException('Not able to unpublish'); 97 | return 'Content publication removed'; 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /src/bookmark/bookmark.service.ts: -------------------------------------------------------------------------------- 1 | import { 2 | BadRequestException, 3 | ForbiddenException, 4 | Injectable, 5 | NotFoundException, 6 | } from '@nestjs/common'; 7 | import { InjectModel } from '@nestjs/mongoose'; 8 | import { Model, Types } from 'mongoose'; 9 | import { Bookmark } from './schemas/bookmark.schema'; 10 | import { User } from '../user/schemas/user.schema'; 11 | import { ContentService } from '../content/content.service'; 12 | import { Content } from '../content/schemas/content.schema'; 13 | 14 | @Injectable() 15 | export class BookmarkService { 16 | constructor( 17 | @InjectModel(Bookmark.name) private readonly bookmarkModel: Model, 18 | private readonly contentService: ContentService, 19 | ) {} 20 | 21 | async create( 22 | user: User, 23 | contentId: Types.ObjectId, 24 | ): Promise { 25 | const content = await this.contentService.findById(contentId); 26 | if (!content) throw new NotFoundException('Content Not Found'); 27 | 28 | if (content.private) throw new ForbiddenException('Content Not Avilable'); 29 | 30 | const bookmark = await this.findBookmark(user, content); 31 | if (bookmark) throw new BadRequestException('Bookmark already exists'); 32 | 33 | return await this.bookmarkModel.create({ 34 | user: user._id, 35 | content: content._id, 36 | }); 37 | } 38 | 39 | async delete( 40 | user: User, 41 | contentId: Types.ObjectId, 42 | ): Promise { 43 | const content = await this.contentService.findById(contentId); 44 | if (!content) throw new NotFoundException('Content Not Found'); 45 | 46 | const bookmark = await this.findBookmark(user, content); 47 | if (!bookmark) throw new BadRequestException('Bookmark Not Found'); 48 | 49 | return await this.remove(bookmark); 50 | } 51 | 52 | async findById(id: Types.ObjectId): Promise { 53 | return this.bookmarkModel 54 | .findOne({ _id: id, status: true }) 55 | .populate('user', 'status') 56 | .populate('content', 'status private') 57 | .lean() 58 | .exec(); 59 | } 60 | 61 | async update(bookmark: Bookmark): Promise { 62 | return this.bookmarkModel 63 | .findByIdAndUpdate(bookmark._id, bookmark, { new: true }) 64 | .lean() 65 | .exec(); 66 | } 67 | 68 | async findBookmark(user: User, content: Content): Promise { 69 | return this.bookmarkModel 70 | .findOne({ 71 | user: user._id, 72 | content: content._id, 73 | status: true, 74 | }) 75 | .populate('user', 'status') 76 | .populate('content', 'status private') 77 | .lean() 78 | .exec(); 79 | } 80 | 81 | async findBookmarks(user: User): Promise { 82 | return this.bookmarkModel 83 | .find({ user: user._id, status: true }) 84 | .sort({ createdAt: -1 }) 85 | .lean() 86 | .exec(); 87 | } 88 | 89 | async remove(bookmark: Bookmark): Promise { 90 | return this.bookmarkModel 91 | .updateOne( 92 | { _id: bookmark._id }, 93 | { $set: { status: false } }, 94 | { new: true }, 95 | ) 96 | .lean() 97 | .exec(); 98 | } 99 | 100 | async deleteUserBookmarks(user: User) { 101 | return this.bookmarkModel.deleteMany({ user: user }).lean().exec(); 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /test/app-auth.e2e-spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { INestApplication } from '@nestjs/common'; 3 | import * as request from 'supertest'; 4 | import { AppModule } from '../src/app.module'; 5 | import { StatusCode } from '../src/core/http/response'; 6 | import { ApiKey, Permission } from '../src/auth/schemas/apikey.schema'; 7 | import { UserService } from '../src/user/user.service'; 8 | import { AuthService } from '../src/auth/auth.service'; 9 | import { Role, RoleCode } from '../src/auth/schemas/role.schema'; 10 | 11 | describe('AppController - AUTH (e2e)', () => { 12 | let app: INestApplication; 13 | let userService: UserService; 14 | let authService: AuthService; 15 | let apiKey: ApiKey; 16 | let role: Role; 17 | 18 | beforeEach(async () => { 19 | const module: TestingModule = await Test.createTestingModule({ 20 | imports: [AppModule], 21 | }).compile(); 22 | 23 | userService = module.get(UserService); 24 | authService = module.get(AuthService); 25 | app = module.createNestApplication(); 26 | 27 | apiKey = await authService.createApiKey({ 28 | key: 'test_api_key', 29 | version: 1, 30 | permissions: [Permission.GENERAL], 31 | comments: ['For testing'], 32 | }); 33 | 34 | role = await authService.createRole({ 35 | code: RoleCode.VIEWER, 36 | }); 37 | 38 | await app.init(); 39 | }); 40 | 41 | afterEach(async () => { 42 | await authService.deleteApiKey(apiKey); 43 | await authService.deleteRole(role); 44 | await app.close(); 45 | }); 46 | 47 | it('should throw 401 when Authorization is not provided', () => { 48 | return request(app.getHttpServer()) 49 | .get('/mentors/latest?pageNumber=1&pageItemCount=10') 50 | .set('Content-Type', 'application/json') 51 | .set('x-api-key', apiKey.key) 52 | .expect(401) 53 | .expect((response) => { 54 | expect(response.body.statusCode).toEqual(StatusCode.FAILURE); 55 | expect(response.body.message).toEqual('Unauthorized'); 56 | }); 57 | }); 58 | 59 | it('should throw 401 when invalid auth token is sent', () => { 60 | return request(app.getHttpServer()) 61 | .get('/mentors/latest?pageNumber=1&pageItemCount=10') 62 | .set('Content-Type', 'application/json') 63 | .set('x-api-key', apiKey.key) 64 | .set('Authorization', 'Bearer wrong_access_token') 65 | .expect(401) 66 | .expect((response) => { 67 | expect(response.body.statusCode).toEqual( 68 | StatusCode.INVALID_ACCESS_TOKEN, 69 | ); 70 | expect(response.body.message).toEqual('Invalid Access Token'); 71 | }); 72 | }); 73 | 74 | it('should send 200 when corrent auth token is sent', async () => { 75 | const signUp = await authService.signUpBasic({ 76 | email: 'test@test.com', 77 | password: 'test123', 78 | name: 'test', 79 | }); 80 | 81 | try { 82 | await request(app.getHttpServer()) 83 | .get('/mentors/latest?pageNumber=1&pageItemCount=10') 84 | .set('Content-Type', 'application/json') 85 | .set('x-api-key', apiKey.key) 86 | .set('Authorization', `Bearer ${signUp.tokens.accessToken}`) 87 | .expect(200) 88 | .expect((response) => { 89 | expect(response.body.statusCode).toEqual(StatusCode.SUCCESS); 90 | }); 91 | } finally { 92 | await userService.delete(signUp.user); 93 | await authService.signOutFromEverywhere(signUp.user); 94 | } 95 | }); 96 | }); 97 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | tester: 3 | # This defines the configuration options, including the context and dockerfile, 4 | # that will be applied when Compose builds the application image. 5 | build: 6 | # This defines the build context for the image build — in this case, the current project directory. 7 | context: . 8 | # This specifies the Dockerfile in your current project directory as the file 9 | dockerfile: Dockerfile.test 10 | image: wimm-apis-tester 11 | container_name: wimm-apis-tester 12 | env_file: .env.test 13 | depends_on: 14 | - mongo 15 | - redis 16 | app: 17 | # This defines the configuration options, including the context and dockerfile, 18 | # that will be applied when Compose builds the application image. 19 | build: 20 | # This defines the build context for the image build — in this case, the current project directory. 21 | context: . 22 | # This specifies the Dockerfile in your current project directory as the file 23 | dockerfile: Dockerfile 24 | image: wimm-apis-app 25 | container_name: wimm-apis-app 26 | # This defines the restart policy. The default is no, 27 | # but we have set the container to restart unless it is stopped. 28 | restart: unless-stopped 29 | env_file: .env 30 | ports: 31 | # This maps port from .env on the host to same port number on the container. 32 | - '${PORT}:${PORT}' 33 | depends_on: 34 | - mongo 35 | - redis 36 | 37 | mongo: 38 | # To create this service, Compose will pull the mongo 39 | image: mongo:8.0.9 40 | container_name: wimm-apis-mongo 41 | restart: unless-stopped 42 | # This tells Compose that we would like to add environment variables 43 | # from a file called .env, located in our build context. 44 | env_file: .env 45 | environment: 46 | # MONGO_INITDB_ROOT_USERNAME and MONGO_INITDB_ROOT_PASSWORD together create 47 | # a root user in the admin authentication database and ensure that authentication is enabled 48 | # when the container starts. We have set MONGO_INITDB_ROOT_USERNAME and MONGO_INITDB_ROOT_PASSWORD 49 | # using the values from our .env file, which we pass to the db service using the env_file option. 50 | - MONGO_INITDB_ROOT_USERNAME=${DB_ADMIN} 51 | - MONGO_INITDB_ROOT_PASSWORD=${DB_ADMIN_PWD} 52 | - MONGO_INITDB_DATABASE=${DB_NAME} 53 | ports: 54 | - '${DB_PORT}:27017' 55 | command: mongod --bind_ip_all 56 | volumes: 57 | - ./.resources/init-mongo.js:/docker-entrypoint-initdb.d/init-mongo.js:ro 58 | - ./.resources/restore-mongo.sh:/docker-entrypoint-initdb.d/restore-mongo.sh:ro 59 | - ./.resources/wimm-db-dump:/db-dump 60 | # The named volume dbdata will persist the data stored in Mongo’s default data directory, /data/db. 61 | # This will ensure that you don’t lose data in cases where you stop or remove containers. 62 | - dbdata:/data/db 63 | 64 | redis: 65 | image: redis:8.0.0 66 | container_name: wimm-apis-redis 67 | restart: unless-stopped 68 | env_file: .env 69 | ports: 70 | - '${REDIS_PORT}:6379' 71 | command: redis-server --bind localhost --bind 0.0.0.0 --save 20 1 --loglevel warning --requirepass ${REDIS_PASSWORD} 72 | volumes: 73 | - cache:/data/cache 74 | 75 | # Our top-level volumes key defines the volumes dbdata. 76 | # When Docker creates volumes, the contents of the volume are stored in a part of the host filesystem, /var/lib/docker/volumes/, that’s managed by Docker. 77 | # The contents of each volume are stored in a directory under /var/lib/docker/volumes/ and get mounted to any container that uses the volume. 78 | # In this way, the data that our users will create will persist in the dbdata volume even if we remove and recreate the db container. 79 | volumes: 80 | dbdata: 81 | cache: 82 | driver: local 83 | -------------------------------------------------------------------------------- /src/auth/guards/apikey.guard.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { ApiKeyGuard } from './apikey.guard'; 3 | import { Reflector } from '@nestjs/core'; 4 | import { ExecutionContext, ForbiddenException } from '@nestjs/common'; 5 | import { ApiKey, Permission } from '../../auth/schemas/apikey.schema'; 6 | import { AuthService } from '../auth.service'; 7 | import { HeaderName } from '../../core/http/header'; 8 | 9 | describe('ApiKeyGuard', () => { 10 | let apiKeyGuard: ApiKeyGuard; 11 | 12 | const generalKey = 'general'; 13 | const serviceKey = 'service'; 14 | 15 | const generalApikey = { 16 | key: generalKey, 17 | permissions: [Permission.GENERAL], 18 | } as ApiKey; 19 | 20 | const serviceApikey = { 21 | key: serviceKey, 22 | permissions: [Permission.XYZ_SERVICE], 23 | } as ApiKey; 24 | 25 | const findApiKeyMock = jest.fn(); 26 | const reflectorGetMock = jest.fn(); 27 | 28 | beforeEach(async () => { 29 | jest.clearAllMocks(); 30 | const module: TestingModule = await Test.createTestingModule({ 31 | providers: [ 32 | ApiKeyGuard, 33 | { provide: AuthService, useValue: { findApiKey: findApiKeyMock } }, 34 | { provide: Reflector, useValue: { get: reflectorGetMock } }, 35 | ], 36 | }).compile(); 37 | 38 | apiKeyGuard = module.get(ApiKeyGuard); 39 | }); 40 | 41 | it('should throw ForbiddenException if API key is not sent', async () => { 42 | await expect( 43 | apiKeyGuard.canActivate(getExecutionContext()), 44 | ).rejects.toBeInstanceOf(ForbiddenException); 45 | 46 | expect(reflectorGetMock).toHaveBeenCalledTimes(1); 47 | expect(findApiKeyMock).not.toHaveBeenCalled(); 48 | }); 49 | 50 | it('should throw ForbiddenException if wrong API key is sent', async () => { 51 | const currentKey = 'wrong'; 52 | findApiKeyMock.mockReturnValue(null); 53 | 54 | await expect( 55 | apiKeyGuard.canActivate(getExecutionContext(currentKey)), 56 | ).rejects.toBeInstanceOf(ForbiddenException); 57 | 58 | expect(reflectorGetMock).toHaveBeenCalledTimes(1); 59 | expect(findApiKeyMock).toHaveBeenCalledWith(currentKey); 60 | }); 61 | 62 | it('should throw ForbiddenException if API key does not have permission', async () => { 63 | reflectorGetMock.mockReturnValue([Permission.GENERAL]); 64 | findApiKeyMock.mockReturnValue(serviceApikey); 65 | 66 | await expect( 67 | apiKeyGuard.canActivate(getExecutionContext(serviceKey)), 68 | ).rejects.toBeInstanceOf(ForbiddenException); 69 | 70 | expect(reflectorGetMock).toHaveBeenCalledTimes(1); 71 | expect(findApiKeyMock).toHaveBeenCalledWith(serviceKey); 72 | }); 73 | 74 | it('should pass if API key has general permission', async () => { 75 | reflectorGetMock.mockReturnValue([Permission.GENERAL]); 76 | findApiKeyMock.mockReturnValue(generalApikey); 77 | 78 | const pass = await apiKeyGuard.canActivate(getExecutionContext(generalKey)); 79 | 80 | expect(pass).toBe(true); 81 | expect(reflectorGetMock).toHaveBeenCalledTimes(1); 82 | expect(findApiKeyMock).toHaveBeenCalledWith(generalKey); 83 | }); 84 | 85 | it('should pass if API key has necessary permission', async () => { 86 | reflectorGetMock.mockReturnValue([Permission.XYZ_SERVICE]); 87 | findApiKeyMock.mockReturnValue(serviceApikey); 88 | 89 | const pass = await apiKeyGuard.canActivate(getExecutionContext(serviceKey)); 90 | 91 | expect(pass).toBe(true); 92 | expect(reflectorGetMock).toHaveBeenCalledTimes(1); 93 | expect(findApiKeyMock).toHaveBeenCalledWith(serviceKey); 94 | }); 95 | }); 96 | 97 | function getExecutionContext(key?: string) { 98 | const context = { 99 | getClass: () => ({}), 100 | switchToHttp: () => ({ 101 | getRequest: () => ({ 102 | headers: { [HeaderName.API_KEY]: key }, 103 | }), 104 | }), 105 | } as ExecutionContext; 106 | return context; 107 | } 108 | -------------------------------------------------------------------------------- /src/subscription/subscription.controller.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Body, 3 | Controller, 4 | Get, 5 | InternalServerErrorException, 6 | NotFoundException, 7 | Param, 8 | Post, 9 | Query, 10 | Request, 11 | } from '@nestjs/common'; 12 | import { SubscriptionService } from './subscription.service'; 13 | import { SubscriptionDto } from './dto/subscription.dto'; 14 | import { ProtectedRequest } from '../core/http/request'; 15 | import { MentorInfoDto } from '../mentor/dto/mentor-info.dto'; 16 | import { TopicInfoDto } from '../topic/dto/topic-info.dto'; 17 | import { MongoIdTransformer } from '../common/mongoid.transformer'; 18 | import { Types } from 'mongoose'; 19 | import { Category } from '../content/schemas/content.schema'; 20 | import { SubscriptionInfoDto } from './dto/subscription-info.dto'; 21 | import { PaginationDto } from '../common/pagination.dto'; 22 | 23 | @Controller('subscription') 24 | export class SubscriptionController { 25 | constructor(private readonly subscriptionService: SubscriptionService) {} 26 | 27 | @Post('subscribe') 28 | async subscribe( 29 | @Request() request: ProtectedRequest, 30 | @Body() subscriptionDto: SubscriptionDto, 31 | ): Promise { 32 | const subscription = await this.subscriptionService.processSubscription( 33 | request.user, 34 | subscriptionDto, 35 | 'SUBSCRIBE', 36 | ); 37 | if (!subscription) throw new InternalServerErrorException(); 38 | return 'Followed Successfully'; 39 | } 40 | 41 | @Post('unsubscribe') 42 | async unsubscribe( 43 | @Request() request: ProtectedRequest, 44 | @Body() subscriptionDto: SubscriptionDto, 45 | ): Promise { 46 | const subscription = await this.subscriptionService.processSubscription( 47 | request.user, 48 | subscriptionDto, 49 | 'UNSUBSCRIBE', 50 | ); 51 | if (!subscription) throw new InternalServerErrorException(); 52 | return 'Unfollowed Successfully'; 53 | } 54 | 55 | @Get('mentors') 56 | async subscriptionMentors(@Request() request: ProtectedRequest) { 57 | const subscription = await this.subscriptionService.findSubscribedMentors( 58 | request.user, 59 | ); 60 | if (!subscription) 61 | throw new NotFoundException('You have not subscribed to any Mentor'); 62 | 63 | return subscription.mentors.map((m) => new MentorInfoDto(m)); 64 | } 65 | 66 | @Get('topics') 67 | async subscriptionTopics(@Request() request: ProtectedRequest) { 68 | const subscription = await this.subscriptionService.findSubscribedTopics( 69 | request.user, 70 | ); 71 | if (!subscription) 72 | throw new NotFoundException('You have not subscribed to any Topic'); 73 | 74 | return subscription.topics.map((t) => new TopicInfoDto(t)); 75 | } 76 | 77 | @Get('info/mentor/:id') 78 | async mentorInfo( 79 | @Param('id', MongoIdTransformer) mentorId: Types.ObjectId, 80 | @Request() request: ProtectedRequest, 81 | ) { 82 | const exits = await this.subscriptionService.mentorSubscriptionExists( 83 | request.user, 84 | mentorId, 85 | ); 86 | 87 | return new SubscriptionInfoDto({ 88 | itemId: mentorId, 89 | category: Category.MENTOR_INFO, 90 | subscribed: exits, 91 | }); 92 | } 93 | 94 | @Get('info/topic/:id') 95 | async topicInfo( 96 | @Param('id', MongoIdTransformer) topicId: Types.ObjectId, 97 | @Request() request: ProtectedRequest, 98 | ) { 99 | const exits = await this.subscriptionService.topicSubscriptionExists( 100 | request.user, 101 | topicId, 102 | ); 103 | 104 | return new SubscriptionInfoDto({ 105 | itemId: topicId, 106 | category: Category.TOPIC_INFO, 107 | subscribed: exits, 108 | }); 109 | } 110 | 111 | @Get('recommendation/mentors') 112 | async recommendedMentors( 113 | @Query() paginationDto: PaginationDto, 114 | @Request() request: ProtectedRequest, 115 | ) { 116 | return this.subscriptionService.findRecommendedMentors( 117 | request.user, 118 | paginationDto, 119 | ); 120 | } 121 | 122 | @Get('recommendation/topics') 123 | async recommendedTopics( 124 | @Query() paginationDto: PaginationDto, 125 | @Request() request: ProtectedRequest, 126 | ) { 127 | return this.subscriptionService.findRecommendedTopics( 128 | request.user, 129 | paginationDto, 130 | ); 131 | } 132 | } 133 | -------------------------------------------------------------------------------- /src/topic/topic.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable, NotFoundException } from '@nestjs/common'; 2 | import { InjectModel } from '@nestjs/mongoose'; 3 | import { Model, Types } from 'mongoose'; 4 | import { Topic } from './schemas/topic.schema'; 5 | import { User } from '../user/schemas/user.schema'; 6 | import { CreateTopicDto } from './dto/create-topic.dto'; 7 | import { UpdateTopicDto } from './dto/update-topic.dto'; 8 | import { PaginationDto } from '../common/pagination.dto'; 9 | 10 | @Injectable() 11 | export class TopicService { 12 | constructor( 13 | @InjectModel(Topic.name) private readonly topicModel: Model, 14 | ) {} 15 | 16 | INFO_PARAMETERS = '-description -status'; 17 | 18 | async create(admin: User, createTopicDto: CreateTopicDto): Promise { 19 | const created = await this.topicModel.create({ 20 | ...createTopicDto, 21 | createdBy: admin, 22 | updatedBy: admin, 23 | }); 24 | return created.toObject(); 25 | } 26 | 27 | async update( 28 | admin: User, 29 | topicId: Types.ObjectId, 30 | updateTopicDto: UpdateTopicDto, 31 | ): Promise { 32 | const topic = await this.findById(topicId); 33 | if (!topic) throw new NotFoundException('Topic not found'); 34 | 35 | return this.topicModel 36 | .findByIdAndUpdate( 37 | topic._id, 38 | { 39 | ...updateTopicDto, 40 | updatedBy: admin, 41 | }, 42 | { new: true }, 43 | ) 44 | .lean() 45 | .exec(); 46 | } 47 | 48 | async delete(topicId: Types.ObjectId): Promise { 49 | const topic = await this.findById(topicId); 50 | if (!topic) throw new NotFoundException('Topic not found'); 51 | return this.topicModel 52 | .findByIdAndUpdate(topic._id, { $set: { status: false } }, { new: true }) 53 | .lean() 54 | .exec(); 55 | } 56 | 57 | async deleteFromDb(topic: Topic) { 58 | return this.topicModel.findByIdAndDelete(topic._id); 59 | } 60 | 61 | async exists(id: Types.ObjectId): Promise { 62 | const exists = await this.topicModel.exists(id); 63 | return exists != null; 64 | } 65 | 66 | async findById(id: Types.ObjectId): Promise { 67 | return this.topicModel.findOne({ _id: id, status: true }).lean().exec(); 68 | } 69 | 70 | async findByIds(ids: Types.ObjectId[]): Promise { 71 | return this.topicModel 72 | .find({ _id: { $in: ids }, status: true }) 73 | .select(this.INFO_PARAMETERS) 74 | .lean() 75 | .exec(); 76 | } 77 | 78 | async findTopicsPaginated(paginationDto: PaginationDto): Promise { 79 | return this.topicModel 80 | .find({ status: true }) 81 | .skip(paginationDto.pageItemCount * (paginationDto.pageNumber - 1)) 82 | .limit(paginationDto.pageItemCount) 83 | .select(this.INFO_PARAMETERS) 84 | .sort({ updatedAt: -1 }) 85 | .lean() 86 | .exec(); 87 | } 88 | 89 | async search(query: string, limit: number): Promise { 90 | return this.topicModel 91 | .find({ 92 | $text: { $search: query, $caseSensitive: false }, 93 | status: true, 94 | }) 95 | .select(this.INFO_PARAMETERS) 96 | .limit(limit) 97 | .lean() 98 | .exec(); 99 | } 100 | 101 | async searchLike(query: string, limit: number): Promise { 102 | return this.topicModel 103 | .find() 104 | .and([ 105 | { status: true }, 106 | { 107 | $or: [ 108 | { name: { $regex: `.*${query}.*`, $options: 'i' } }, 109 | { title: { $regex: `.*${query}.*`, $options: 'i' } }, 110 | ], 111 | }, 112 | ]) 113 | .select(this.INFO_PARAMETERS) 114 | .limit(limit) 115 | .lean() 116 | .exec(); 117 | } 118 | 119 | async findRecommendedTopics(limit: number): Promise { 120 | return this.topicModel 121 | .find({ status: true }) 122 | .limit(limit) 123 | .select(this.INFO_PARAMETERS) 124 | .sort({ score: -1 }) 125 | .lean() 126 | .exec(); 127 | } 128 | 129 | async findRecommendedTopicsPaginated( 130 | paginationDto: PaginationDto, 131 | ): Promise { 132 | return this.topicModel 133 | .find({ status: true }) 134 | .skip(paginationDto.pageItemCount * (paginationDto.pageNumber - 1)) 135 | .limit(paginationDto.pageItemCount) 136 | .select(this.INFO_PARAMETERS) 137 | .sort({ score: -1 }) 138 | .lean() 139 | .exec(); 140 | } 141 | } 142 | -------------------------------------------------------------------------------- /test/app-apikey.e2e-spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { INestApplication } from '@nestjs/common'; 3 | import * as request from 'supertest'; 4 | import { AppModule } from '../src/app.module'; 5 | import { StatusCode } from '../src/core/http/response'; 6 | import { Permission } from '../src/auth/schemas/apikey.schema'; 7 | import { AuthService } from '../src/auth/auth.service'; 8 | 9 | describe('AppController - API KEY (e2e)', () => { 10 | let app: INestApplication; 11 | let authService: AuthService; 12 | 13 | beforeEach(async () => { 14 | const module: TestingModule = await Test.createTestingModule({ 15 | imports: [AppModule], 16 | }).compile(); 17 | 18 | authService = module.get(AuthService); 19 | app = module.createNestApplication(); 20 | 21 | await app.init(); 22 | }); 23 | 24 | afterEach(async () => { 25 | await app.close(); 26 | }); 27 | 28 | it('should throw 403 when x-api-key is not provided', () => { 29 | return request(app.getHttpServer()) 30 | .get('/mentors/latest?pageNumber=1&pageItemCount=10') 31 | .expect(403) 32 | .expect((response) => { 33 | expect(response.body.statusCode).toEqual(StatusCode.FAILURE); 34 | expect(response.body.message).toEqual('Forbidden'); 35 | }); 36 | }); 37 | 38 | it('should throw 403 when wrong x-api-key is provided', async () => { 39 | const apiKey = await authService.createApiKey({ 40 | key: 'test_api_key', 41 | version: 1, 42 | permissions: [Permission.GENERAL], 43 | comments: ['For testing'], 44 | }); 45 | 46 | try { 47 | await request(app.getHttpServer()) 48 | .get('/mentors/latest?pageNumber=1&pageItemCount=10') 49 | .set('Content-Type', 'application/json') 50 | .set('x-api-key', 'wrong_api_key') 51 | .expect(403) 52 | .expect((response) => { 53 | expect(response.body.statusCode).toEqual(StatusCode.FAILURE); 54 | expect(response.body.message).toEqual('Forbidden'); 55 | }); 56 | } finally { 57 | await authService.deleteApiKey(apiKey); 58 | } 59 | }); 60 | 61 | it('should throw 403 when x-api-key does not have right permissions', async () => { 62 | const apiKey = await authService.createApiKey({ 63 | key: 'test_api_key_1', 64 | version: 1, 65 | permissions: [Permission.XYZ_SERVICE], 66 | comments: ['For testing'], 67 | }); 68 | 69 | try { 70 | await request(app.getHttpServer()) 71 | .get('/mentors/latest?pageNumber=1&pageItemCount=10') 72 | .set('Content-Type', 'application/json') 73 | .set('x-api-key', apiKey.key) 74 | .expect(403) 75 | .expect((response) => { 76 | expect(response.body.statusCode).toEqual(StatusCode.FAILURE); 77 | expect(response.body.message).toEqual('Forbidden'); 78 | }); 79 | } finally { 80 | await authService.deleteApiKey(apiKey); 81 | } 82 | }); 83 | 84 | it('should throw 401 when correct x-api-key is provided', async () => { 85 | const apiKey = await authService.createApiKey({ 86 | key: 'test_api_key_2', 87 | version: 1, 88 | permissions: [Permission.GENERAL], 89 | comments: ['For testing'], 90 | }); 91 | 92 | try { 93 | await request(app.getHttpServer()) 94 | .get('/mentors/latest?pageNumber=1&pageItemCount=10') 95 | .set('Content-Type', 'application/json') 96 | .set('x-api-key', apiKey.key) 97 | .expect(401) 98 | .expect((response) => { 99 | expect(response.body.statusCode).toEqual(StatusCode.FAILURE); 100 | expect(response.body.message).toEqual('Unauthorized'); 101 | }); 102 | } finally { 103 | await authService.deleteApiKey(apiKey); 104 | } 105 | }); 106 | 107 | it('should send 200 when correct x-api-key is provided for heartbeat', async () => { 108 | const apiKey = await authService.createApiKey({ 109 | key: 'test_api_key_3', 110 | version: 1, 111 | permissions: [Permission.GENERAL], 112 | comments: ['For testing'], 113 | }); 114 | 115 | try { 116 | await request(app.getHttpServer()) 117 | .get('/heartbeat') 118 | .set('Content-Type', 'application/json') 119 | .set('x-api-key', apiKey.key) 120 | .expect(200) 121 | .expect((response) => { 122 | expect(response.body.statusCode).toEqual(StatusCode.SUCCESS); 123 | expect(response.body.message).toEqual('alive'); 124 | }); 125 | } finally { 126 | await authService.deleteApiKey(apiKey); 127 | } 128 | }); 129 | }); 130 | -------------------------------------------------------------------------------- /src/mentor/mentor.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable, NotFoundException } from '@nestjs/common'; 2 | import { InjectModel } from '@nestjs/mongoose'; 3 | import { Model, Types } from 'mongoose'; 4 | import { Mentor } from './schemas/mentor.schema'; 5 | import { User } from '../user/schemas/user.schema'; 6 | import { CreateMentorDto } from './dto/create-mentor.dto'; 7 | import { UpdateMentorDto } from './dto/update-mentor.dto'; 8 | import { PaginationDto } from '../common/pagination.dto'; 9 | 10 | @Injectable() 11 | export class MentorService { 12 | constructor( 13 | @InjectModel(Mentor.name) private readonly mentorModel: Model, 14 | ) {} 15 | 16 | INFO_PARAMETERS = '-description -status'; 17 | 18 | async create(admin: User, createMentorDto: CreateMentorDto): Promise { 19 | const created = await this.mentorModel.create({ 20 | ...createMentorDto, 21 | createdBy: admin, 22 | updatedBy: admin, 23 | }); 24 | return created.toObject(); 25 | } 26 | 27 | async update( 28 | admin: User, 29 | mentorId: Types.ObjectId, 30 | updateMentorDto: UpdateMentorDto, 31 | ): Promise { 32 | const mentor = await this.findById(mentorId); 33 | if (!mentor) throw new NotFoundException('Mentor not found'); 34 | 35 | return this.mentorModel 36 | .findByIdAndUpdate( 37 | mentor._id, 38 | { 39 | ...updateMentorDto, 40 | updatedBy: admin, 41 | }, 42 | { new: true }, 43 | ) 44 | .lean() 45 | .exec(); 46 | } 47 | 48 | async delete(mentorId: Types.ObjectId): Promise { 49 | const mentor = await this.findById(mentorId); 50 | if (!mentor) throw new NotFoundException('Mentor not found'); 51 | return this.mentorModel 52 | .findByIdAndUpdate(mentor._id, { $set: { status: false } }, { new: true }) 53 | .lean() 54 | .exec(); 55 | } 56 | 57 | async deleteFromDb(mentor: Mentor) { 58 | return this.mentorModel.findByIdAndDelete(mentor._id); 59 | } 60 | 61 | async exists(id: Types.ObjectId): Promise { 62 | const exists = await this.mentorModel.exists(id); 63 | return exists != null; 64 | } 65 | 66 | async findById(id: Types.ObjectId): Promise { 67 | return this.mentorModel.findOne({ _id: id, status: true }).lean().exec(); 68 | } 69 | 70 | async findByIds(ids: Types.ObjectId[]): Promise { 71 | return this.mentorModel 72 | .find({ _id: { $in: ids }, status: true }) 73 | .select(this.INFO_PARAMETERS) 74 | .lean() 75 | .exec(); 76 | } 77 | 78 | async findMentorsPaginated(paginationDto: PaginationDto): Promise { 79 | return this.mentorModel 80 | .find({ status: true }) 81 | .skip(paginationDto.pageItemCount * (paginationDto.pageNumber - 1)) 82 | .limit(paginationDto.pageItemCount) 83 | .select(this.INFO_PARAMETERS) 84 | .sort({ updatedAt: -1 }) 85 | .lean() 86 | .exec(); 87 | } 88 | 89 | async search(query: string, limit: number): Promise { 90 | return this.mentorModel 91 | .find({ 92 | $text: { $search: query, $caseSensitive: false }, 93 | status: true, 94 | }) 95 | .select(this.INFO_PARAMETERS) 96 | .limit(limit) 97 | .lean() 98 | .exec(); 99 | } 100 | 101 | async searchLike(query: string, limit: number): Promise { 102 | return this.mentorModel 103 | .find() 104 | .and([ 105 | { status: true }, 106 | { 107 | $or: [ 108 | { name: { $regex: `.*${query}.*`, $options: 'i' } }, 109 | { occupation: { $regex: `.*${query}.*`, $options: 'i' } }, 110 | { title: { $regex: `.*${query}.*`, $options: 'i' } }, 111 | ], 112 | }, 113 | ]) 114 | .select(this.INFO_PARAMETERS) 115 | .limit(limit) 116 | .lean() 117 | .exec(); 118 | } 119 | 120 | async findRecommendedMentors(limit: number): Promise { 121 | return this.mentorModel 122 | .find({ status: true }) 123 | .limit(limit) 124 | .select(this.INFO_PARAMETERS) 125 | .sort({ score: -1 }) 126 | .lean() 127 | .exec(); 128 | } 129 | 130 | async findRecommendedMentorsPaginated( 131 | paginationDto: PaginationDto, 132 | ): Promise { 133 | return this.mentorModel 134 | .find({ status: true }) 135 | .skip(paginationDto.pageItemCount * (paginationDto.pageNumber - 1)) 136 | .limit(paginationDto.pageItemCount) 137 | .select(this.INFO_PARAMETERS) 138 | .sort({ score: -1 }) 139 | .lean() 140 | .exec(); 141 | } 142 | } 143 | -------------------------------------------------------------------------------- /test/subscription.e2e-spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { INestApplication } from '@nestjs/common'; 3 | import * as request from 'supertest'; 4 | import { AppModule } from '../src/app.module'; 5 | import { StatusCode } from '../src/core/http/response'; 6 | import { ApiKey, Permission } from '../src/auth/schemas/apikey.schema'; 7 | import { UserService } from '../src/user/user.service'; 8 | import { AuthService } from '../src/auth/auth.service'; 9 | import { Role, RoleCode } from '../src/auth/schemas/role.schema'; 10 | import { TopicService } from '../src/topic/topic.service'; 11 | import { UserAuthDto } from '../src/auth/dto/user-auth.dto'; 12 | import { User } from '../src/user/schemas/user.schema'; 13 | import { MentorService } from '../src/mentor/mentor.service'; 14 | import { CreateTopicDto } from '../src/topic/dto/create-topic.dto'; 15 | import { CreateMentorDto } from '../src/mentor/dto/create-mentor.dto'; 16 | import { SubscriptionService } from '../src/subscription/subscription.service'; 17 | import { Topic } from '../src/topic/schemas/topic.schema'; 18 | import { Mentor } from '../src/mentor/schemas/mentor.schema'; 19 | import { SubscriptionDto } from '../src/subscription/dto/subscription.dto'; 20 | 21 | describe('Subscription Controller - (e2e)', () => { 22 | let app: INestApplication; 23 | let userService: UserService; 24 | let authService: AuthService; 25 | let topicService: TopicService; 26 | let mentorService: MentorService; 27 | let subscriptionService: SubscriptionService; 28 | 29 | let userAuthDto: UserAuthDto; 30 | let apiKey: ApiKey; 31 | let role: Role; 32 | let topic: Topic; 33 | let mentor: Mentor; 34 | 35 | beforeEach(async () => { 36 | const module: TestingModule = await Test.createTestingModule({ 37 | imports: [AppModule], 38 | }).compile(); 39 | 40 | userService = module.get(UserService); 41 | authService = module.get(AuthService); 42 | topicService = module.get(TopicService); 43 | mentorService = module.get(MentorService); 44 | subscriptionService = module.get(SubscriptionService); 45 | 46 | app = module.createNestApplication(); 47 | 48 | apiKey = await authService.createApiKey({ 49 | key: 'test_api_key', 50 | version: 1, 51 | permissions: [Permission.GENERAL], 52 | comments: ['For testing'], 53 | }); 54 | 55 | role = await authService.createRole({ 56 | code: RoleCode.VIEWER, 57 | }); 58 | 59 | userAuthDto = await authService.signUpBasic({ 60 | email: 'test@test.com', 61 | password: 'test123', 62 | name: 'test', 63 | }); 64 | 65 | const createTopicDto = new CreateTopicDto({ 66 | name: 'topic_name', 67 | title: 'topic_title', 68 | description: 'topic_description', 69 | thumbnail: 'https://topic.com/thumbnail.png', 70 | coverImgUrl: 'https://topic.com/coverImgUrl.png', 71 | score: 0.1, 72 | }); 73 | 74 | topic = await topicService.create(userAuthDto.user as User, createTopicDto); 75 | 76 | const createMentorDto = new CreateMentorDto({ 77 | name: 'mentor_name', 78 | occupation: 'mentor_occupation', 79 | title: 'mentor_title', 80 | description: 'mentor_description', 81 | thumbnail: 'https://mentor.com/thumbnail.png', 82 | coverImgUrl: 'https://mentor.com/coverImgUrl.png', 83 | score: 0.1, 84 | }); 85 | 86 | mentor = await mentorService.create( 87 | userAuthDto.user as User, 88 | createMentorDto, 89 | ); 90 | 91 | await app.init(); 92 | }); 93 | 94 | afterEach(async () => { 95 | await authService.deleteApiKey(apiKey); 96 | await authService.deleteRole(role); 97 | await userService.delete(userAuthDto.user as User); 98 | await topicService.deleteFromDb(topic); 99 | await mentorService.deleteFromDb(mentor); 100 | await subscriptionService.deleteUserSubscription(userAuthDto.user as User); 101 | await authService.signOutFromEverywhere(userAuthDto.user as User); 102 | await app.close(); 103 | }); 104 | 105 | it('should follow mentor and topic successfully', async () => { 106 | const subscriptionDto = new SubscriptionDto({ 107 | mentorIds: [mentor._id], 108 | topicIds: [topic._id], 109 | }); 110 | 111 | await request(app.getHttpServer()) 112 | .post('/subscription/subscribe') 113 | .set('Content-Type', 'application/json') 114 | .set('x-api-key', apiKey.key) 115 | .set('Authorization', `Bearer ${userAuthDto.tokens.accessToken}`) 116 | .send(subscriptionDto) 117 | .expect(201) 118 | .expect(async (response) => { 119 | expect(response.body.statusCode).toEqual(StatusCode.SUCCESS); 120 | expect(response.body.message).toEqual('Followed Successfully'); 121 | }); 122 | }); 123 | }); 124 | -------------------------------------------------------------------------------- /src/search/search.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { SearchResultDto } from './dto/search-result.dto'; 3 | import { Category, Content } from '../content/schemas/content.schema'; 4 | import { Mentor } from '../mentor/schemas/mentor.schema'; 5 | import { Topic } from '../topic/schemas/topic.schema'; 6 | import { MentorService } from '../mentor/mentor.service'; 7 | import { TopicService } from '../topic/topic.service'; 8 | import { ContentsService } from '../content/contents.service'; 9 | import { SearchQueryDto } from './dto/search-query.dto'; 10 | 11 | @Injectable() 12 | export class SearchService { 13 | constructor( 14 | private readonly mentorService: MentorService, 15 | private readonly topicService: TopicService, 16 | private readonly contentsService: ContentsService, 17 | ) {} 18 | 19 | async makeSearch(searchQueryDto: SearchQueryDto): Promise { 20 | let data: SearchResultDto[] = []; 21 | if (searchQueryDto.filter) { 22 | switch (searchQueryDto.filter) { 23 | case Category.MENTOR_INFO: { 24 | data = await this.searchMentors(searchQueryDto.query); 25 | break; 26 | } 27 | case Category.TOPIC_INFO: { 28 | data = await this.searchTopics(searchQueryDto.query); 29 | break; 30 | } 31 | } 32 | } else { 33 | data = await this.search(searchQueryDto.query); 34 | } 35 | 36 | return data; 37 | } 38 | 39 | async searchMentors(query: string) { 40 | const searches = [this.mentorService.search(query, 10)]; 41 | if (query.length >= 3) 42 | searches.push(this.mentorService.searchLike(query, 10)); 43 | 44 | const results = await Promise.all(searches); 45 | const mentors: Mentor[] = []; 46 | 47 | for (const result of results) { 48 | for (const entry of result) { 49 | const found = mentors.find((m) => m._id.equals(entry._id)); 50 | if (!found) mentors.push(entry); 51 | } 52 | } 53 | return mentors.map((mentor) => this.mentorToSearchDto(mentor)); 54 | } 55 | 56 | async searchTopics(query: string) { 57 | const searches = [this.topicService.search(query, 10)]; 58 | if (query.length >= 3) 59 | searches.push(this.topicService.searchLike(query, 10)); 60 | 61 | const results = await Promise.all(searches); 62 | const topics: Topic[] = []; 63 | 64 | for (const result of results) { 65 | for (const entry of result) { 66 | const found = topics.find((t) => t._id.equals(entry._id)); 67 | if (!found) topics.push(entry); 68 | } 69 | } 70 | return topics.map((t) => this.topicToSearchDto(t)); 71 | } 72 | 73 | async search(query: string) { 74 | const searches: SearchResultDto[] = []; 75 | 76 | const contents = await this.contentsService.search(query, 5); 77 | const mentors = await this.mentorService.search(query, 5); 78 | const topics = await this.topicService.search(query, 5); 79 | 80 | searches.push(...contents.map((c) => this.contentToSearchDto(c))); 81 | searches.push(...mentors.map((m) => this.mentorToSearchDto(m))); 82 | searches.push(...topics.map((t) => this.topicToSearchDto(t))); 83 | 84 | if (query.length >= 3) { 85 | const similarContents = await this.contentsService.searchLike(query, 3); 86 | const similarMentors = await this.mentorService.searchLike(query, 3); 87 | const similarTopics = await this.topicService.searchLike(query, 3); 88 | 89 | searches.push(...similarContents.map((c) => this.contentToSearchDto(c))); 90 | searches.push(...similarMentors.map((m) => this.mentorToSearchDto(m))); 91 | searches.push(...similarTopics.map((t) => this.topicToSearchDto(t))); 92 | } 93 | 94 | const data: SearchResultDto[] = []; 95 | 96 | for (const entry of searches) { 97 | const found = data.find((s) => s.id.equals(entry.id)); 98 | if (!found) data.push(entry); 99 | } 100 | 101 | return data; 102 | } 103 | 104 | mentorToSearchDto(mentor: Mentor) { 105 | return new SearchResultDto({ 106 | id: mentor._id, 107 | title: mentor.name, 108 | category: Category.MENTOR_INFO, 109 | thumbnail: mentor.thumbnail, 110 | extra: mentor.occupation, 111 | }); 112 | } 113 | 114 | topicToSearchDto(topic: Topic) { 115 | return new SearchResultDto({ 116 | id: topic._id, 117 | title: topic.name, 118 | category: Category.TOPIC_INFO, 119 | thumbnail: topic.thumbnail, 120 | extra: topic.title, 121 | }); 122 | } 123 | 124 | contentToSearchDto(content: Content) { 125 | return new SearchResultDto({ 126 | id: content._id, 127 | title: content.title, 128 | category: content.category, 129 | thumbnail: content.thumbnail, 130 | extra: content.extra, 131 | }); 132 | } 133 | } 134 | -------------------------------------------------------------------------------- /test/content-cache.e2e-spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { INestApplication } from '@nestjs/common'; 3 | import * as request from 'supertest'; 4 | import { AppModule } from '../src/app.module'; 5 | import { StatusCode } from '../src/core/http/response'; 6 | import { ApiKey, Permission } from '../src/auth/schemas/apikey.schema'; 7 | import { UserService } from '../src/user/user.service'; 8 | import { AuthService } from '../src/auth/auth.service'; 9 | import { Role, RoleCode } from '../src/auth/schemas/role.schema'; 10 | import { UserAuthDto } from '../src/auth/dto/user-auth.dto'; 11 | import { User } from '../src/user/schemas/user.schema'; 12 | import { ContentService } from '../src/content/content.service'; 13 | import { CreateContentDto } from '../src/content/dto/create-content.dto'; 14 | import { Category } from '../src/content/schemas/content.schema'; 15 | import { ContentInfoDto } from '../src/content/dto/content-info.dto'; 16 | import { CacheService } from '../src/cache/cache.service'; 17 | 18 | describe('Content Controller - Cache (e2e)', () => { 19 | let app: INestApplication; 20 | let userService: UserService; 21 | let authService: AuthService; 22 | let contentService: ContentService; 23 | let cacheService: CacheService; 24 | 25 | let userAuthDto: UserAuthDto; 26 | let apiKey: ApiKey; 27 | let role: Role; 28 | 29 | beforeEach(async () => { 30 | const module: TestingModule = await Test.createTestingModule({ 31 | imports: [AppModule], 32 | }).compile(); 33 | 34 | userService = module.get(UserService); 35 | authService = module.get(AuthService); 36 | contentService = module.get(ContentService); 37 | cacheService = module.get(CacheService); 38 | 39 | app = module.createNestApplication(); 40 | 41 | apiKey = await authService.createApiKey({ 42 | key: 'test_api_key', 43 | version: 1, 44 | permissions: [Permission.GENERAL], 45 | comments: ['For testing'], 46 | }); 47 | 48 | role = await authService.createRole({ 49 | code: RoleCode.VIEWER, 50 | }); 51 | 52 | userAuthDto = await authService.signUpBasic({ 53 | email: 'test@test.com', 54 | password: 'test123', 55 | name: 'test', 56 | profilePicUrl: 'https://user.com/profilePicUrl.png', 57 | }); 58 | 59 | await app.init(); 60 | }); 61 | 62 | afterEach(async () => { 63 | await authService.deleteApiKey(apiKey); 64 | await authService.deleteRole(role); 65 | await userService.delete(userAuthDto.user as User); 66 | await authService.signOutFromEverywhere(userAuthDto.user as User); 67 | await app.close(); 68 | }); 69 | 70 | it('should sent the content with id', async () => { 71 | const user = userAuthDto.user as User; 72 | const createContentDto = new CreateContentDto({ 73 | category: Category.YOUTUBE, 74 | title: 'content_title', 75 | subtitle: 'content_subtitle', 76 | description: 'content_description', 77 | thumbnail: 'https://content.com/thumbnail.png', 78 | extra: 'https://content.com/coverImgUrl.png', 79 | topics: [], 80 | mentors: [], 81 | score: 0.1, 82 | }); 83 | 84 | const content = await contentService.createContent(createContentDto, user); 85 | 86 | await contentService.publishContent(user, content._id); 87 | contentService.statsBoostUp(content); 88 | 89 | content.createdBy = user; 90 | 91 | const contentInfoDto = new ContentInfoDto(content, false); 92 | delete contentInfoDto.private; 93 | delete contentInfoDto.description; 94 | 95 | const createdBy = { 96 | _id: contentInfoDto.createdBy._id.toHexString(), 97 | name: contentInfoDto.createdBy.name, 98 | profilePicUrl: contentInfoDto.createdBy.profilePicUrl, 99 | }; 100 | 101 | const data = { 102 | ...contentInfoDto, 103 | _id: contentInfoDto._id.toHexString(), 104 | createdBy: createdBy, 105 | }; 106 | 107 | delete data.submit; 108 | 109 | try { 110 | await request(app.getHttpServer()) 111 | .get(`/content/id/${data._id}`) 112 | .set('Content-Type', 'application/json') 113 | .set('x-api-key', apiKey.key) 114 | .set('Authorization', `Bearer ${userAuthDto.tokens.accessToken}`) 115 | .expect(200) 116 | .expect((response) => { 117 | expect(response.body.statusCode).toEqual(StatusCode.SUCCESS); 118 | expect(response.body.data).not.toBeNull(); 119 | expect(response.body.data).toEqual(data); 120 | }); 121 | const cached = await cacheService.getValue( 122 | `/content/id/${data._id}`, 123 | ); 124 | expect(JSON.stringify(cached)).toEqual(JSON.stringify(data)); 125 | } finally { 126 | await cacheService.delete(`/content/id/${data._id}`); 127 | await contentService.deleteFromDb(content); 128 | } 129 | }); 130 | }); 131 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # NestJS Backend Architecture Project 2 | 3 | [![Docker Compose CI](https://github.com/fifocode/wimm-apis/actions/workflows/docker_compose.yml/badge.svg)](https://github.com/fifocode/wimm-apis/actions/workflows/docker_compose.yml) 4 | 5 | ## WhereIsMyMotivation 6 | 7 | ![Cover](.resources/documentations/assets/cover.jpg) 8 | 9 | This is a complete production ready project to learn modern techniques and approaches to build a performant and secure backend API services. It is designed for web apps, mobile apps, and other API services. 10 | 11 | ## Framework 12 | - NestJS 13 | - Express Node 14 | - Typescript 15 | - Mongoose 16 | - Mongodb 17 | - Redis 18 | - JsonWebToken 19 | - Jest 20 | - Docker 21 | - Multer 22 | 23 | ## Highlights 24 | - API key support 25 | - Token based Authentication 26 | - Role based Authorization 27 | - Database dump auto setup 28 | - vscode template support 29 | - Unit Tests 30 | - Integration Tests 31 | - 75% plus Test Coverage 32 | - Clean and modular codebase 33 | 34 | ## Notes 35 | A few things are added to aid the learning process 36 | - disk submodule is added to provide images 37 | - database dump is added to seed the database 38 | 39 | # About Project 40 | WhereIsMyMotivation is a concept where you see videos and quotes that can inspire you everyday. You will get information on great personalities and make them your perceived mentors. You can also subscribe to topics of your interests. 41 | 42 | You can track your happiness level and write down daily journals. You can also share things of interest from web to store in your motivation box. 43 | 44 | Using this app can bring a little bit of happiness and energy to live an inspired life. 45 | 46 | ## Read the Article to understand this project 47 | [Mastering NestJS — Building an Effective REST API Backend](https://fifocode.com/article/mastering-nestjs-building-an-effective-rest-api-backend) 48 | 49 | ## Android App using this backend 50 | ![Screenshot-Light](.resources/documentations/assets/display-light.png) 51 | 52 | The Android App using this backend is also open-sourced. You can find the project here: [Modern Android Development - WhereIsMyMotivation](https://github.com/fifocode/wimm-android-app) 53 | 54 | ## Request-Response Flow 55 | 56 | ![Request-Response-Design](.resources/documentations/assets/request-flow.svg) 57 | 58 | # Installation Instruction 59 | vscode is the recommended editor - dark theme 60 | 61 | ### Get the repo 62 | ```bash 63 | # clone repository recursively 64 | git clone https://github.com/fifocode/wimm-apis.git --recursive 65 | ``` 66 | 67 | ### Install libraries 68 | ```bash 69 | $ npm install 70 | ``` 71 | 72 | ### Run Docker Compose 73 | - Install Docker and Docker Compose. [Find Instructions Here](https://docs.docker.com/install/). 74 | 75 | ```bash 76 | # install and start docker containers 77 | docker-compose up --build 78 | ``` 79 | - You will be able to access the api from http://localhost:3000 80 | 81 | ### Run Tests 82 | ```bash 83 | docker exec -t wimm-apis-tester npm run test:cov 84 | ``` 85 | If having any issue 86 | - Make sure 3000 port is not occupied else change PORT in **.env** file. 87 | - Make sure 27017 port is not occupied else change DB_PORT in **.env** file. 88 | - Make sure 6379 port is not occupied else change REDIS_PORT in **.env** file. 89 | 90 | ## Run on the local machine 91 | Change the following hosts in the **.env** and **.env.test** 92 | - DB_HOST=localhost 93 | - REDIS_HOST=localhost 94 | 95 | Best way to run this project is to use the vscode `Run and Debug` button. Scripts are available for debugging and template generation on vscode. 96 | 97 | ```bash 98 | $ npm install 99 | ``` 100 | 101 | ### Running the app 102 | 103 | ```bash 104 | # development 105 | $ npm run start 106 | 107 | # watch mode 108 | $ npm run start:dev 109 | 110 | # production mode 111 | $ npm run start:prod 112 | ``` 113 | 114 | ### Test 115 | 116 | ```bash 117 | # unit tests 118 | $ npm run test 119 | 120 | # e2e tests 121 | $ npm run test:e2e 122 | 123 | # test coverage 124 | $ npm run test:cov 125 | ``` 126 | 127 | ## API DOC 128 | [![API Documentation](https://img.shields.io/badge/API%20Documentation-View%20Here-blue?style=for-the-badge)](https://documenter.getpostman.com/view/1552895/2s9YymH5MR) 129 | 130 | ## Minimalistic version of this project 131 | If you want to use plain Express.js to create the same project explore the Minimalistic version of this project 132 | 133 | [Minimalistic Backend Development - WhereIsMyMotivation](https://github.com/fifocode/wimm-apis-minimalistic) 134 | 135 | ## Find this project useful ? :heart: 136 | * Support it by clicking the :star: button on the upper right of this page. :v: 137 | 138 | ## More on YouTube channel - fifocode 139 | Subscribe to the YouTube channel `fifocode` for understanding the concepts used in this project: 140 | 141 | [![YouTube](https://img.shields.io/badge/YouTube-Subscribe-red?style=for-the-badge&logo=youtube&logoColor=white)](https://www.youtube.com/@fifocode) 142 | 143 | ## Contribution 144 | Please feel free to fork it and open a PR. 145 | 146 | -------------------------------------------------------------------------------- /src/core/interceptors/exception.handler.spec.ts: -------------------------------------------------------------------------------- 1 | import { ConfigService } from '@nestjs/config'; 2 | import { WinstonLogger } from '../../setup/winston.logger'; 3 | import { ExpectionHandler } from './exception.handler'; 4 | import { Test } from '@nestjs/testing'; 5 | import { 6 | ArgumentsHost, 7 | BadRequestException, 8 | HttpStatus, 9 | InternalServerErrorException, 10 | UnauthorizedException, 11 | } from '@nestjs/common'; 12 | import { TokenExpiredError } from '@nestjs/jwt'; 13 | import { HttpArgumentsHost } from '@nestjs/common/interfaces'; 14 | import { StatusCode } from '../http/response'; 15 | 16 | describe('ExpectionHandler', () => { 17 | let exceptionHandler: ExpectionHandler; 18 | 19 | const mockSetStatus = jest.fn(() => ({ json: mockSetJson })); 20 | const mockSetJson = jest.fn(); 21 | const mockAppendHeader = jest.fn(); 22 | const mockServiceConfig = jest.fn(); 23 | 24 | const hostMock: ArgumentsHost = { 25 | switchToHttp: () => 26 | ({ 27 | getResponse: () => 28 | ({ 29 | appendHeader: mockAppendHeader, 30 | status: mockSetStatus, 31 | }) as any, 32 | getRequest: () => ({ url: 'test' }), 33 | }) as HttpArgumentsHost, 34 | getArgs: () => ({}) as any, 35 | getArgByIndex: () => ({}) as any, 36 | switchToRpc: () => ({}) as any, 37 | switchToWs: () => ({}) as any, 38 | getType: () => ({}) as any, 39 | }; 40 | 41 | beforeEach(async () => { 42 | jest.clearAllMocks(); 43 | 44 | const module = await Test.createTestingModule({ 45 | providers: [ 46 | ExpectionHandler, 47 | { 48 | provide: ConfigService, 49 | useValue: { getOrThrow: mockServiceConfig }, 50 | }, 51 | { provide: WinstonLogger, useValue: { error: jest.fn() } }, 52 | ], 53 | }).compile(); 54 | 55 | exceptionHandler = module.get(ExpectionHandler); 56 | mockServiceConfig.mockReturnValue({ nodeEnv: 'development' }); 57 | }); 58 | 59 | it('should set token expired data on TokenExpiredError', () => { 60 | const exception = new TokenExpiredError('Token is expired', new Date()); 61 | exceptionHandler.catch(exception, hostMock); 62 | 63 | expect(mockSetStatus).toHaveBeenCalledWith(HttpStatus.UNAUTHORIZED); 64 | 65 | expect(mockSetJson).toHaveBeenCalledWith({ 66 | statusCode: StatusCode.INVALID_ACCESS_TOKEN, 67 | message: 'Token Expired', 68 | url: 'test', 69 | }); 70 | 71 | expect(mockAppendHeader).toHaveBeenCalledWith( 72 | 'instruction', 73 | 'refresh_token', 74 | ); 75 | expect(exceptionHandler['logger'].error).not.toHaveBeenCalled(); 76 | }); 77 | 78 | it('should set logout instruction data on invalid access token UnauthorizedException', () => { 79 | const exception = new UnauthorizedException('Invalid Access Token'); 80 | exceptionHandler.catch(exception, hostMock); 81 | 82 | expect(mockSetStatus).toHaveBeenCalledWith(HttpStatus.UNAUTHORIZED); 83 | 84 | expect(mockSetJson).toHaveBeenCalledWith({ 85 | statusCode: StatusCode.INVALID_ACCESS_TOKEN, 86 | message: 'Invalid Access Token', 87 | url: 'test', 88 | }); 89 | 90 | expect(mockAppendHeader).toHaveBeenCalledWith('instruction', 'logout'); 91 | expect(exceptionHandler['logger'].error).not.toHaveBeenCalled(); 92 | }); 93 | 94 | it('should set bad request data on BadRequestException', () => { 95 | const exception = new BadRequestException('Bad Request'); 96 | exceptionHandler.catch(exception, hostMock); 97 | 98 | expect(mockSetStatus).toHaveBeenCalledWith(HttpStatus.BAD_REQUEST); 99 | 100 | expect(mockSetJson).toHaveBeenCalledWith({ 101 | statusCode: StatusCode.FAILURE, 102 | message: 'Bad Request', 103 | url: 'test', 104 | }); 105 | 106 | expect(mockAppendHeader).not.toHaveBeenCalled(); 107 | expect(exceptionHandler['logger'].error).not.toHaveBeenCalled(); 108 | }); 109 | 110 | it('should set internal error data on InternalServerErrorException', () => { 111 | const exception = new InternalServerErrorException('Something went wrong'); 112 | exceptionHandler.catch(exception, hostMock); 113 | 114 | expect(mockSetStatus).toHaveBeenCalledWith( 115 | HttpStatus.INTERNAL_SERVER_ERROR, 116 | ); 117 | 118 | expect(mockSetJson).toHaveBeenCalledWith({ 119 | statusCode: StatusCode.FAILURE, 120 | message: 'Something went wrong', 121 | url: 'test', 122 | }); 123 | 124 | expect(mockAppendHeader).not.toHaveBeenCalled(); 125 | expect(exceptionHandler['logger'].error).toHaveBeenCalled(); 126 | }); 127 | 128 | it('should log non http expections', () => { 129 | const exception = new Error('Other Error'); 130 | exceptionHandler.catch(exception, hostMock); 131 | 132 | expect(mockSetStatus).toHaveBeenCalledWith( 133 | HttpStatus.INTERNAL_SERVER_ERROR, 134 | ); 135 | 136 | expect(mockSetJson).toHaveBeenCalledWith({ 137 | statusCode: StatusCode.FAILURE, 138 | message: 'Other Error', 139 | url: 'test', 140 | }); 141 | 142 | expect(mockAppendHeader).not.toHaveBeenCalled(); 143 | expect(exceptionHandler['logger'].error).toHaveBeenCalled(); 144 | }); 145 | 146 | it('should not send actual error on production', () => { 147 | mockServiceConfig.mockReturnValue({ nodeEnv: 'production' }); 148 | const exception = new Error('Other Error'); 149 | exceptionHandler.catch(exception, hostMock); 150 | 151 | expect(mockSetStatus).toHaveBeenCalledWith( 152 | HttpStatus.INTERNAL_SERVER_ERROR, 153 | ); 154 | 155 | expect(mockSetJson).toHaveBeenCalledWith({ 156 | statusCode: StatusCode.FAILURE, 157 | message: 'Something went wrong', 158 | url: 'test', 159 | }); 160 | 161 | expect(mockAppendHeader).not.toHaveBeenCalled(); 162 | expect(exceptionHandler['logger'].error).toHaveBeenCalled(); 163 | }); 164 | }); 165 | --------------------------------------------------------------------------------