├── .eslintrc.js ├── .gitignore ├── .prettierrc ├── README.md ├── docker-compose.yml ├── knexfile.ts ├── migrations ├── 20220825173451_add_posts_table.ts ├── 20220831003140_add_users_table.ts ├── 20220831115100_add_addresses_table.ts ├── 20220908005809_add_author_column.ts ├── 20220914233800_add_categories_table.ts ├── 20221029170345_add_comments_table.ts ├── 20221105181728_add_post_author_id_index.ts ├── 20221113211441_add_post_tsvector.ts └── 20221201022319_add_comment_length_constraint.ts ├── nest-cli.json ├── package-lock.json ├── package.json ├── seeds └── 01_create_admin.ts ├── src ├── app.module.ts ├── authentication │ ├── authentication.controller.test.ts │ ├── authentication.controller.ts │ ├── authentication.module.ts │ ├── authentication.service.test.ts │ ├── authentication.service.ts │ ├── dto │ │ ├── address.dto.ts │ │ ├── logIn.dto.ts │ │ └── register.dto.ts │ ├── jwt-authentication.guard.ts │ ├── jwt.strategy.ts │ ├── local.strategy.ts │ ├── localAuthentication.guard.ts │ ├── requestWithUser.interface.ts │ └── tokenPayload.interface.ts ├── categories │ ├── categories.controller.ts │ ├── categories.module.ts │ ├── categories.repository.ts │ ├── categories.service.ts │ ├── category.dto.ts │ ├── category.model.ts │ └── categoryWithPosts.model.ts ├── comments │ ├── comment.dto.ts │ ├── comment.model.ts │ ├── comments.controller.ts │ ├── comments.module.ts │ ├── comments.repository.ts │ └── comments.service.ts ├── database │ ├── database.module-definition.ts │ ├── database.module.ts │ ├── database.service.ts │ ├── databaseOptions.ts │ └── postgresErrorCode.enum.ts ├── main.ts ├── posts │ ├── getPostsByAuthorQuery.ts │ ├── idParams.ts │ ├── post.dto.ts │ ├── post.model.ts │ ├── postAuthorStatistics.model.ts │ ├── postLengthParam.ts │ ├── postWithAuthor.model.ts │ ├── postWithCategoryIds.model.ts │ ├── postWithDetails.model.ts │ ├── posts.controller.ts │ ├── posts.module.ts │ ├── posts.repository.ts │ ├── posts.service.test.ts │ ├── posts.service.ts │ ├── postsSearch.repository.ts │ ├── postsStatistics.controller.ts │ ├── postsStatistics.repository.ts │ ├── postsStatistics.service.ts │ └── searchPostsQuery.ts ├── types │ └── databaseError.ts ├── users │ ├── address.model.ts │ ├── dto │ │ └── createUser.dto.ts │ ├── exceptions │ │ └── userAlreadyExists.exception.ts │ ├── user.model.ts │ ├── users.module.ts │ ├── users.repository.test.ts │ ├── users.repository.ts │ └── users.service.ts └── utils │ ├── findOneParams.ts │ ├── getDifferenceBetweenArrays.ts │ ├── isRecord.ts │ ├── logger.interceptor.ts │ └── paginationParams.ts ├── tsconfig.build.json └── tsconfig.json /.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 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # compiled output 2 | /dist 3 | /node_modules 4 | 5 | # Logs 6 | logs 7 | *.log 8 | npm-debug.log* 9 | pnpm-debug.log* 10 | yarn-debug.log* 11 | yarn-error.log* 12 | lerna-debug.log* 13 | 14 | # OS 15 | .DS_Store 16 | 17 | # Tests 18 | /coverage 19 | /.nyc_output 20 | 21 | # IDEs and editors 22 | /.idea 23 | .project 24 | .classpath 25 | .c9/ 26 | *.launch 27 | .settings/ 28 | *.sublime-workspace 29 | 30 | # IDE - VSCode 31 | .vscode/* 32 | !.vscode/settings.json 33 | !.vscode/tasks.json 34 | !.vscode/launch.json 35 | !.vscode/extensions.json 36 | 37 | .env 38 | docker.env -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "trailingComma": "all" 4 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | Nest Logo 3 |

4 | 5 | [circleci-image]: https://img.shields.io/circleci/build/github/nestjs/nest/master?token=abc123def456 6 | [circleci-url]: https://circleci.com/gh/nestjs/nest 7 | 8 |

A progressive Node.js framework for building efficient and scalable server-side applications.

9 |

10 | NPM Version 11 | Package License 12 | NPM Downloads 13 | CircleCI 14 | Coverage 15 | Discord 16 | Backers on Open Collective 17 | Sponsors on Open Collective 18 | 19 | Support us 20 | 21 |

22 | 24 | 25 | ## Description 26 | 27 | [Nest](https://github.com/nestjs/nest) framework TypeScript starter repository. 28 | 29 | ## Installation 30 | 31 | ```bash 32 | $ npm install 33 | ``` 34 | 35 | ## Running the app 36 | 37 | ```bash 38 | # development 39 | $ npm run start 40 | 41 | # watch mode 42 | $ npm run start:dev 43 | 44 | # production mode 45 | $ npm run start:prod 46 | ``` 47 | 48 | ## Test 49 | 50 | ```bash 51 | # unit tests 52 | $ npm run test 53 | 54 | # e2e tests 55 | $ npm run test:e2e 56 | 57 | # test coverage 58 | $ npm run test:cov 59 | ``` 60 | 61 | ## Support 62 | 63 | Nest is an MIT-licensed open source project. It can grow thanks to the sponsors and support by the amazing backers. If you'd like to join them, please [read more here](https://docs.nestjs.com/support). 64 | 65 | ## Stay in touch 66 | 67 | - Author - [Kamil Myśliwiec](https://kamilmysliwiec.com) 68 | - Website - [https://nestjs.com](https://nestjs.com/) 69 | - Twitter - [@nestframework](https://twitter.com/nestframework) 70 | 71 | ## License 72 | 73 | Nest is [MIT licensed](LICENSE). 74 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3" 2 | services: 3 | postgres: 4 | container_name: postgres-nestjs 5 | image: postgres:latest 6 | ports: 7 | - "5432:5432" 8 | volumes: 9 | - /data/postgres:/data/postgres 10 | env_file: 11 | - docker.env 12 | networks: 13 | - postgres 14 | 15 | pgadmin: 16 | links: 17 | - postgres:postgres 18 | container_name: pgadmin-nestjs 19 | image: dpage/pgadmin4 20 | ports: 21 | - "8080:80" 22 | volumes: 23 | - /data/pgadmin:/root/.pgadmin 24 | env_file: 25 | - docker.env 26 | networks: 27 | - postgres 28 | 29 | networks: 30 | postgres: 31 | driver: bridge -------------------------------------------------------------------------------- /knexfile.ts: -------------------------------------------------------------------------------- 1 | import type { Knex } from 'knex'; 2 | import { ConfigService } from '@nestjs/config'; 3 | import { config } from 'dotenv'; 4 | 5 | config(); 6 | 7 | const configService = new ConfigService(); 8 | 9 | const knexConfig: Knex.Config = { 10 | client: 'postgresql', 11 | connection: { 12 | host: configService.get('POSTGRES_HOST'), 13 | port: configService.get('POSTGRES_PORT'), 14 | user: configService.get('POSTGRES_USER'), 15 | password: configService.get('POSTGRES_PASSWORD'), 16 | database: configService.get('POSTGRES_DB'), 17 | }, 18 | }; 19 | 20 | module.exports = knexConfig; 21 | -------------------------------------------------------------------------------- /migrations/20220825173451_add_posts_table.ts: -------------------------------------------------------------------------------- 1 | import { Knex } from 'knex'; 2 | 3 | export async function up(knex: Knex): Promise { 4 | return knex.raw(` 5 | CREATE TABLE posts ( 6 | id int GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, 7 | title text NOT NULL, 8 | post_content text NOT NULL 9 | ) 10 | `); 11 | } 12 | 13 | export async function down(knex: Knex): Promise { 14 | return knex.raw(` 15 | DROP TABLE posts 16 | `); 17 | } 18 | -------------------------------------------------------------------------------- /migrations/20220831003140_add_users_table.ts: -------------------------------------------------------------------------------- 1 | import { Knex } from 'knex'; 2 | 3 | export async function up(knex: Knex): Promise { 4 | return knex.raw(` 5 | CREATE TABLE users ( 6 | id int GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, 7 | email text NOT NULL UNIQUE, 8 | name text NOT NULL, 9 | password text NOT NULL 10 | ) 11 | `); 12 | } 13 | 14 | export async function down(knex: Knex): Promise { 15 | return knex.raw(` 16 | DROP TABLE users 17 | `); 18 | } 19 | -------------------------------------------------------------------------------- /migrations/20220831115100_add_addresses_table.ts: -------------------------------------------------------------------------------- 1 | import { Knex } from 'knex'; 2 | 3 | export async function up(knex: Knex): Promise { 4 | return knex.raw(` 5 | CREATE TABLE addresses ( 6 | id int GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, 7 | street text, 8 | city text, 9 | country text 10 | ); 11 | ALTER TABLE users 12 | ADD COLUMN address_id int UNIQUE REFERENCES addresses(id); 13 | `); 14 | } 15 | 16 | export async function down(knex: Knex): Promise { 17 | return knex.raw(` 18 | ALTER TABLE users 19 | DROP COLUMN address_id; 20 | DROP TABLE addresses; 21 | `); 22 | } 23 | -------------------------------------------------------------------------------- /migrations/20220908005809_add_author_column.ts: -------------------------------------------------------------------------------- 1 | import { Knex } from 'knex'; 2 | 3 | export async function up(knex: Knex): Promise { 4 | const adminEmail = 'admin@admin.com'; 5 | 6 | const defaultUserResponse = await knex.raw( 7 | ` 8 | SELECT id FROM users 9 | WHERE email=? 10 | `, 11 | [adminEmail], 12 | ); 13 | 14 | const adminId = defaultUserResponse.rows[0]?.id; 15 | 16 | if (!adminId) { 17 | throw new Error('The default user does not exist'); 18 | } 19 | 20 | await knex.raw( 21 | ` 22 | ALTER TABLE posts 23 | ADD COLUMN author_id int REFERENCES users(id) 24 | `, 25 | ); 26 | 27 | await knex.raw( 28 | ` 29 | UPDATE posts SET author_id = ? 30 | `, 31 | [adminId], 32 | ); 33 | 34 | await knex.raw( 35 | ` 36 | ALTER TABLE posts 37 | ALTER COLUMN author_id SET NOT NULL 38 | `, 39 | ); 40 | } 41 | 42 | export async function down(knex: Knex): Promise { 43 | return knex.raw(` 44 | ALTER TABLE posts 45 | DROP COLUMN author_id; 46 | `); 47 | } 48 | -------------------------------------------------------------------------------- /migrations/20220914233800_add_categories_table.ts: -------------------------------------------------------------------------------- 1 | import { Knex } from 'knex'; 2 | 3 | export async function up(knex: Knex): Promise { 4 | return knex.raw(` 5 | CREATE TABLE categories ( 6 | id int GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, 7 | name text NOT NULL 8 | ); 9 | CREATE TABLE categories_posts ( 10 | category_id int REFERENCES categories(id), 11 | post_id int REFERENCES posts(id), 12 | PRIMARY KEY (category_id, post_id) 13 | ); 14 | `); 15 | } 16 | 17 | export async function down(knex: Knex): Promise { 18 | return knex.raw(` 19 | DROP TABLE categories, categories_posts; 20 | `); 21 | } 22 | -------------------------------------------------------------------------------- /migrations/20221029170345_add_comments_table.ts: -------------------------------------------------------------------------------- 1 | import { Knex } from 'knex'; 2 | 3 | export async function up(knex: Knex): Promise { 4 | return knex.raw(` 5 | CREATE TABLE comments ( 6 | id int GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, 7 | content text NOT NULL, 8 | post_id int REFERENCES posts(id) NOT NULL, 9 | author_id int REFERENCES posts(id) NOT NULL, 10 | deletion_date timestamptz 11 | ); 12 | `); 13 | } 14 | 15 | export async function down(knex: Knex): Promise { 16 | return knex.raw(` 17 | DROP TABLE comments; 18 | `); 19 | } 20 | -------------------------------------------------------------------------------- /migrations/20221105181728_add_post_author_id_index.ts: -------------------------------------------------------------------------------- 1 | import { Knex } from 'knex'; 2 | 3 | export async function up(knex: Knex): Promise { 4 | return knex.raw(` 5 | CREATE INDEX post_author_id_index ON posts (author_id) 6 | `); 7 | } 8 | 9 | export async function down(knex: Knex): Promise { 10 | return knex.raw(` 11 | DROP INDEX post_author_id_index 12 | `); 13 | } 14 | -------------------------------------------------------------------------------- /migrations/20221113211441_add_post_tsvector.ts: -------------------------------------------------------------------------------- 1 | import { Knex } from 'knex'; 2 | 3 | export async function up(knex: Knex): Promise { 4 | await knex.raw(` 5 | ALTER TABLE posts 6 | ADD COLUMN text_tsvector tsvector GENERATED ALWAYS AS ( 7 | setweight(to_tsvector('english', title), 'A') || 8 | setweight(to_tsvector('english', post_content), 'B') 9 | ) STORED 10 | `); 11 | 12 | return knex.raw(` 13 | CREATE INDEX post_text_tsvector_index ON posts USING GIN (text_tsvector) 14 | `); 15 | } 16 | 17 | export async function down(knex: Knex): Promise { 18 | return knex.raw(` 19 | ALTER TABLE posts 20 | DROP COLUMN text_tsvector 21 | `); 22 | } 23 | -------------------------------------------------------------------------------- /migrations/20221201022319_add_comment_length_constraint.ts: -------------------------------------------------------------------------------- 1 | import { Knex } from 'knex'; 2 | 3 | export async function up(knex: Knex): Promise { 4 | return knex.raw(` 5 | ALTER TABLE comments 6 | ADD CONSTRAINT comment_length_constraint CHECK ( 7 | length(content) > 0 8 | ) 9 | `); 10 | } 11 | 12 | export async function down(knex: Knex): Promise { 13 | return knex.raw(` 14 | ALTER TABLE comments DROP CONSTRAINT comment_length_constraint; 15 | `); 16 | } 17 | -------------------------------------------------------------------------------- /nest-cli.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/nest-cli", 3 | "collection": "@nestjs/schematics", 4 | "sourceRoot": "src" 5 | } 6 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "nestjs-raw-sql", 3 | "version": "0.0.1", 4 | "description": "", 5 | "author": "", 6 | "private": true, 7 | "license": "UNLICENSED", 8 | "scripts": { 9 | "prebuild": "rimraf dist", 10 | "build": "nest build", 11 | "format": "prettier --write \"src/**/*.ts\" \"migrations/**/*.ts\" \"seeds/**/*.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": "jest", 18 | "test:watch": "jest --watch", 19 | "test:cov": "jest --coverage", 20 | "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand", 21 | "test:e2e": "jest --config ./test/jest-e2e.json" 22 | }, 23 | "dependencies": { 24 | "@nestjs/common": "^9.2.1", 25 | "@nestjs/config": "^2.2.0", 26 | "@nestjs/core": "^9.2.1", 27 | "@nestjs/jwt": "^9.0.0", 28 | "@nestjs/passport": "^9.0.0", 29 | "@nestjs/platform-express": "^9.2.1", 30 | "@types/bcrypt": "^5.0.0", 31 | "@types/cookie-parser": "^1.4.3", 32 | "@types/passport-jwt": "^3.0.6", 33 | "@types/passport-local": "^1.0.34", 34 | "@types/pg": "^8.6.5", 35 | "bcrypt": "^5.0.1", 36 | "class-transformer": "^0.5.1", 37 | "class-validator": "^0.13.2", 38 | "cookie-parser": "^1.4.6", 39 | "joi": "^17.6.0", 40 | "knex": "^2.2.0", 41 | "passport": "^0.6.0", 42 | "passport-jwt": "^4.0.0", 43 | "passport-local": "^1.0.0", 44 | "pg": "^8.8.0", 45 | "reflect-metadata": "^0.1.13", 46 | "rimraf": "^3.0.2", 47 | "rxjs": "^7.2.0" 48 | }, 49 | "devDependencies": { 50 | "@nestjs/cli": "^9.1.5", 51 | "@nestjs/schematics": "^9.0.3", 52 | "@nestjs/testing": "^9.2.1", 53 | "@types/express": "^4.17.13", 54 | "@types/jest": "28.1.4", 55 | "@types/node": "^16.0.0", 56 | "@types/supertest": "^2.0.12", 57 | "@typescript-eslint/eslint-plugin": "^5.0.0", 58 | "@typescript-eslint/parser": "^5.0.0", 59 | "eslint": "^8.0.1", 60 | "eslint-config-prettier": "^8.3.0", 61 | "eslint-plugin-prettier": "^4.0.0", 62 | "jest": "28.1.2", 63 | "prettier": "^2.3.2", 64 | "source-map-support": "^0.5.20", 65 | "supertest": "^6.3.3", 66 | "ts-jest": "28.0.5", 67 | "ts-loader": "^9.2.3", 68 | "ts-node": "^10.0.0", 69 | "tsconfig-paths": "4.0.0", 70 | "typescript": "^4.3.5" 71 | }, 72 | "jest": { 73 | "moduleFileExtensions": [ 74 | "js", 75 | "json", 76 | "ts" 77 | ], 78 | "rootDir": "src", 79 | "testRegex": ".*\\.(spec|test)\\.ts$", 80 | "transform": { 81 | "^.+\\.(t|j)s$": "ts-jest" 82 | }, 83 | "collectCoverageFrom": [ 84 | "**/*.(t|j)s" 85 | ], 86 | "coverageDirectory": "../coverage", 87 | "testEnvironment": "node" 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /seeds/01_create_admin.ts: -------------------------------------------------------------------------------- 1 | import { Knex } from 'knex'; 2 | import * as bcrypt from 'bcrypt'; 3 | 4 | export async function seed(knex: Knex): Promise { 5 | const hashedPassword = await bcrypt.hash('1234567', 10); 6 | return knex.raw( 7 | ` 8 | INSERT INTO users ( 9 | email, 10 | name, 11 | password 12 | ) VALUES ( 13 | 'admin@admin.com', 14 | 'Admin', 15 | ? 16 | ) 17 | `, 18 | [hashedPassword], 19 | ); 20 | } 21 | -------------------------------------------------------------------------------- /src/app.module.ts: -------------------------------------------------------------------------------- 1 | import { Module, ValidationPipe } from '@nestjs/common'; 2 | import { PostsModule } from './posts/posts.module'; 3 | import { ConfigModule, ConfigService } from '@nestjs/config'; 4 | import * as Joi from 'joi'; 5 | import DatabaseModule from './database/database.module'; 6 | import { APP_PIPE } from '@nestjs/core'; 7 | import { AuthenticationModule } from './authentication/authentication.module'; 8 | import { CategoriesModule } from './categories/categories.module'; 9 | import { CommentsModule } from './comments/comments.module'; 10 | 11 | @Module({ 12 | imports: [ 13 | PostsModule, 14 | DatabaseModule.forRootAsync({ 15 | imports: [ConfigModule], 16 | inject: [ConfigService], 17 | useFactory: (configService: ConfigService) => ({ 18 | host: configService.get('POSTGRES_HOST'), 19 | port: configService.get('POSTGRES_PORT'), 20 | user: configService.get('POSTGRES_USER'), 21 | password: configService.get('POSTGRES_PASSWORD'), 22 | database: configService.get('POSTGRES_DB'), 23 | }), 24 | }), 25 | ConfigModule.forRoot({ 26 | validationSchema: Joi.object({ 27 | POSTGRES_HOST: Joi.string().required(), 28 | POSTGRES_PORT: Joi.number().required(), 29 | POSTGRES_USER: Joi.string().required(), 30 | POSTGRES_PASSWORD: Joi.string().required(), 31 | POSTGRES_DB: Joi.string().required(), 32 | JWT_SECRET: Joi.string().required(), 33 | JWT_EXPIRATION_TIME: Joi.string().required(), 34 | }), 35 | }), 36 | AuthenticationModule, 37 | CategoriesModule, 38 | CommentsModule, 39 | ], 40 | controllers: [], 41 | providers: [ 42 | { 43 | provide: APP_PIPE, 44 | useValue: new ValidationPipe({ 45 | transform: true, 46 | }), 47 | }, 48 | ], 49 | }) 50 | export class AppModule {} 51 | -------------------------------------------------------------------------------- /src/authentication/authentication.controller.test.ts: -------------------------------------------------------------------------------- 1 | import { Test } from '@nestjs/testing'; 2 | import { AuthenticationController } from './authentication.controller'; 3 | import { AuthenticationService } from './authentication.service'; 4 | import DatabaseService from '../database/database.service'; 5 | import { INestApplication, ValidationPipe } from '@nestjs/common'; 6 | import * as request from 'supertest'; 7 | import UsersService from '../users/users.service'; 8 | import { ConfigModule } from '@nestjs/config'; 9 | import { JwtModule } from '@nestjs/jwt'; 10 | import UsersRepository from '../users/users.repository'; 11 | import RegisterDto from './dto/register.dto'; 12 | import { UserModelData } from '../users/user.model'; 13 | 14 | describe('The AuthenticationController', () => { 15 | let runQueryMock: jest.Mock; 16 | let app: INestApplication; 17 | beforeEach(async () => { 18 | runQueryMock = jest.fn(); 19 | const module = await Test.createTestingModule({ 20 | imports: [ 21 | ConfigModule.forRoot(), 22 | JwtModule.register({ 23 | secretOrPrivateKey: 'Secret key', 24 | }), 25 | ], 26 | controllers: [AuthenticationController], 27 | providers: [ 28 | AuthenticationService, 29 | UsersRepository, 30 | UsersService, 31 | { 32 | provide: DatabaseService, 33 | useValue: { 34 | runQuery: runQueryMock, 35 | }, 36 | }, 37 | ], 38 | }).compile(); 39 | 40 | app = module.createNestApplication(); 41 | app.useGlobalPipes(new ValidationPipe()); 42 | await app.init(); 43 | }); 44 | describe('when making the /register POST request', () => { 45 | describe('and using an incorrect email', () => { 46 | it('should throw an error', () => { 47 | return request(app.getHttpServer()) 48 | .post('/authentication/register') 49 | .send({ 50 | email: 'not-an-email', 51 | name: 'John', 52 | password: 'strongPassword', 53 | }) 54 | .expect(400); 55 | }); 56 | }); 57 | describe('and using the correct data', () => { 58 | let registrationData: RegisterDto; 59 | let userModelData: UserModelData; 60 | beforeEach(() => { 61 | registrationData = { 62 | email: 'john@smith.com', 63 | name: 'John', 64 | password: 'strongPassword', 65 | }; 66 | 67 | userModelData = { 68 | id: 1, 69 | email: registrationData.email, 70 | name: registrationData.name, 71 | password: registrationData.password, 72 | address_id: null, 73 | address_country: null, 74 | address_city: null, 75 | address_street: null, 76 | }; 77 | 78 | runQueryMock.mockResolvedValue({ 79 | rows: [userModelData], 80 | }); 81 | }); 82 | it('should result with the 201 status', () => { 83 | return request(app.getHttpServer()) 84 | .post('/authentication/register') 85 | .send(registrationData) 86 | .expect(201); 87 | }); 88 | it('should respond with the data without the password', () => { 89 | return request(app.getHttpServer()) 90 | .post('/authentication/register') 91 | .send(registrationData) 92 | .expect({ 93 | id: userModelData.id, 94 | name: userModelData.name, 95 | email: userModelData.email, 96 | }); 97 | }); 98 | }); 99 | }); 100 | }); 101 | -------------------------------------------------------------------------------- /src/authentication/authentication.controller.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Body, 3 | Req, 4 | Controller, 5 | HttpCode, 6 | Post, 7 | UseGuards, 8 | Res, 9 | Get, 10 | ClassSerializerInterceptor, 11 | UseInterceptors, 12 | } from '@nestjs/common'; 13 | import { Response } from 'express'; 14 | import { AuthenticationService } from './authentication.service'; 15 | import RegisterDto from './dto/register.dto'; 16 | import RequestWithUser from './requestWithUser.interface'; 17 | import { LocalAuthenticationGuard } from './localAuthentication.guard'; 18 | import JwtAuthenticationGuard from './jwt-authentication.guard'; 19 | import { LoggerInterceptor } from '../utils/logger.interceptor'; 20 | 21 | @Controller('authentication') 22 | @UseInterceptors(ClassSerializerInterceptor) 23 | export class AuthenticationController { 24 | constructor(private readonly authenticationService: AuthenticationService) {} 25 | 26 | @Post('register') 27 | @UseInterceptors(LoggerInterceptor) 28 | async register(@Body() registrationData: RegisterDto) { 29 | return this.authenticationService.register(registrationData); 30 | } 31 | 32 | @HttpCode(200) 33 | @UseGuards(LocalAuthenticationGuard) 34 | @Post('log-in') 35 | async logIn( 36 | @Req() request: RequestWithUser, 37 | @Res({ passthrough: true }) response: Response, 38 | ) { 39 | const { user } = request; 40 | const cookie = this.authenticationService.getCookieWithJwtToken(user.id); 41 | response.setHeader('Set-Cookie', cookie); 42 | return user; 43 | } 44 | 45 | @UseGuards(JwtAuthenticationGuard) 46 | @Post('log-out') 47 | async logOut(@Res() response: Response) { 48 | response.setHeader( 49 | 'Set-Cookie', 50 | this.authenticationService.getCookieForLogOut(), 51 | ); 52 | return response.sendStatus(200); 53 | } 54 | 55 | @UseGuards(JwtAuthenticationGuard) 56 | @Get() 57 | authenticate(@Req() request: RequestWithUser) { 58 | return request.user; 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /src/authentication/authentication.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { AuthenticationService } from './authentication.service'; 3 | import { UsersModule } from '../users/users.module'; 4 | import { AuthenticationController } from './authentication.controller'; 5 | import { PassportModule } from '@nestjs/passport'; 6 | import { LocalStrategy } from './local.strategy'; 7 | import { JwtModule } from '@nestjs/jwt'; 8 | import { ConfigModule, ConfigService } from '@nestjs/config'; 9 | import { JwtStrategy } from './jwt.strategy'; 10 | 11 | @Module({ 12 | imports: [ 13 | UsersModule, 14 | PassportModule, 15 | ConfigModule, 16 | JwtModule.registerAsync({ 17 | imports: [ConfigModule], 18 | inject: [ConfigService], 19 | useFactory: async (configService: ConfigService) => ({ 20 | secret: configService.get('JWT_SECRET'), 21 | signOptions: { 22 | expiresIn: `${configService.get('JWT_EXPIRATION_TIME')}s`, 23 | }, 24 | }), 25 | }), 26 | ], 27 | providers: [AuthenticationService, LocalStrategy, JwtStrategy], 28 | controllers: [AuthenticationController], 29 | }) 30 | export class AuthenticationModule {} 31 | -------------------------------------------------------------------------------- /src/authentication/authentication.service.test.ts: -------------------------------------------------------------------------------- 1 | import { AuthenticationService } from './authentication.service'; 2 | import { JwtModule } from '@nestjs/jwt'; 3 | import { ConfigModule } from '@nestjs/config'; 4 | import { Test } from '@nestjs/testing'; 5 | import UsersService from '../users/users.service'; 6 | import UserAlreadyExistsException from '../users/exceptions/userAlreadyExists.exception'; 7 | import { BadRequestException } from '@nestjs/common'; 8 | import RegisterDto from './dto/register.dto'; 9 | 10 | describe('The AuthenticationService', () => { 11 | let registrationData: RegisterDto; 12 | let authenticationService: AuthenticationService; 13 | let createUserMock: jest.Mock; 14 | beforeEach(async () => { 15 | registrationData = { 16 | email: 'john@smith.com', 17 | name: 'John', 18 | password: 'strongPassword123', 19 | }; 20 | createUserMock = jest.fn(); 21 | const module = await Test.createTestingModule({ 22 | providers: [ 23 | AuthenticationService, 24 | { 25 | provide: UsersService, 26 | useValue: { 27 | create: createUserMock, 28 | }, 29 | }, 30 | ], 31 | imports: [ 32 | ConfigModule.forRoot(), 33 | JwtModule.register({ 34 | secretOrPrivateKey: 'Secret key', 35 | }), 36 | ], 37 | }).compile(); 38 | 39 | authenticationService = await module.get(AuthenticationService); 40 | }); 41 | describe('when calling the getCookieForLogOut method', () => { 42 | it('should return a correct string', () => { 43 | const result = authenticationService.getCookieForLogOut(); 44 | expect(result).toBe('Authentication=; HttpOnly; Path=/; Max-Age=0'); 45 | }); 46 | }); 47 | describe('when registering a new user', () => { 48 | describe('and when the usersService returns the new user', () => { 49 | beforeEach(() => { 50 | createUserMock.mockReturnValue(registrationData); 51 | }); 52 | it('should return the new user', async () => { 53 | const result = await authenticationService.register(registrationData); 54 | expect(result).toBe(registrationData); 55 | }); 56 | }); 57 | describe('and when the usersService throws the UserAlreadyExistsException', () => { 58 | beforeEach(() => { 59 | createUserMock.mockImplementation(() => { 60 | throw new UserAlreadyExistsException(registrationData.email); 61 | }); 62 | }); 63 | it('should throw the BadRequestException', () => { 64 | return expect(() => 65 | authenticationService.register(registrationData), 66 | ).rejects.toThrow(BadRequestException); 67 | }); 68 | }); 69 | }); 70 | }); 71 | -------------------------------------------------------------------------------- /src/authentication/authentication.service.ts: -------------------------------------------------------------------------------- 1 | import { 2 | BadRequestException, 3 | HttpException, 4 | HttpStatus, 5 | Injectable, 6 | } from '@nestjs/common'; 7 | import UsersService from '../users/users.service'; 8 | import RegisterDto from './dto/register.dto'; 9 | import * as bcrypt from 'bcrypt'; 10 | import { JwtService } from '@nestjs/jwt'; 11 | import { ConfigService } from '@nestjs/config'; 12 | import TokenPayload from './tokenPayload.interface'; 13 | import UserAlreadyExistsException from '../users/exceptions/userAlreadyExists.exception'; 14 | 15 | @Injectable() 16 | export class AuthenticationService { 17 | constructor( 18 | private readonly usersService: UsersService, 19 | private readonly jwtService: JwtService, 20 | private readonly configService: ConfigService, 21 | ) {} 22 | 23 | public async register(registrationData: RegisterDto) { 24 | const hashedPassword = await bcrypt.hash(registrationData.password, 10); 25 | try { 26 | return await this.usersService.create({ 27 | ...registrationData, 28 | password: hashedPassword, 29 | }); 30 | } catch (error: unknown) { 31 | if (error instanceof UserAlreadyExistsException) { 32 | throw new BadRequestException('User with that email already exists'); 33 | } 34 | throw new HttpException( 35 | 'Something went wrong', 36 | HttpStatus.INTERNAL_SERVER_ERROR, 37 | ); 38 | } 39 | } 40 | 41 | public getCookieWithJwtToken(userId: number) { 42 | const payload: TokenPayload = { userId }; 43 | const token = this.jwtService.sign(payload); 44 | return `Authentication=${token}; HttpOnly; Path=/; Max-Age=${this.configService.get( 45 | 'JWT_EXPIRATION_TIME', 46 | )}`; 47 | } 48 | 49 | public getCookieForLogOut() { 50 | return `Authentication=; HttpOnly; Path=/; Max-Age=0`; 51 | } 52 | 53 | public async getAuthenticatedUser(email: string, plainTextPassword: string) { 54 | try { 55 | const user = await this.usersService.getByEmail(email); 56 | await this.verifyPassword(plainTextPassword, user.password); 57 | return user; 58 | } catch (error) { 59 | throw new HttpException( 60 | 'Wrong credentials provided', 61 | HttpStatus.BAD_REQUEST, 62 | ); 63 | } 64 | } 65 | 66 | private async verifyPassword( 67 | plainTextPassword: string, 68 | hashedPassword: string, 69 | ) { 70 | const isPasswordMatching = await bcrypt.compare( 71 | plainTextPassword, 72 | hashedPassword, 73 | ); 74 | if (!isPasswordMatching) { 75 | throw new HttpException( 76 | 'Wrong credentials provided', 77 | HttpStatus.BAD_REQUEST, 78 | ); 79 | } 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /src/authentication/dto/address.dto.ts: -------------------------------------------------------------------------------- 1 | import { IsNotEmpty, IsString, IsOptional } from 'class-validator'; 2 | 3 | export class AddressDto { 4 | @IsString() 5 | @IsOptional() 6 | street: string; 7 | @IsString() 8 | @IsNotEmpty() 9 | @IsOptional() 10 | city: string; 11 | @IsString() 12 | @IsNotEmpty() 13 | @IsOptional() 14 | country: string; 15 | } 16 | 17 | export default AddressDto; 18 | -------------------------------------------------------------------------------- /src/authentication/dto/logIn.dto.ts: -------------------------------------------------------------------------------- 1 | import { IsEmail, IsString, IsNotEmpty } from 'class-validator'; 2 | 3 | export class LogInDto { 4 | @IsEmail() 5 | email: string; 6 | 7 | @IsString() 8 | @IsNotEmpty() 9 | password: string; 10 | } 11 | 12 | export default LogInDto; 13 | -------------------------------------------------------------------------------- /src/authentication/dto/register.dto.ts: -------------------------------------------------------------------------------- 1 | import { 2 | IsEmail, 3 | IsNotEmpty, 4 | IsOptional, 5 | IsString, 6 | ValidateNested, 7 | } from 'class-validator'; 8 | import AddressDto from './address.dto'; 9 | import { Type } from 'class-transformer'; 10 | 11 | export class RegisterDto { 12 | @IsEmail() 13 | email: string; 14 | @IsString() 15 | @IsNotEmpty() 16 | name: string; 17 | @IsString() 18 | @IsNotEmpty() 19 | password: string; 20 | @IsOptional() 21 | @ValidateNested() 22 | @Type(() => AddressDto) 23 | address?: AddressDto; 24 | } 25 | 26 | export default RegisterDto; 27 | -------------------------------------------------------------------------------- /src/authentication/jwt-authentication.guard.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { AuthGuard } from '@nestjs/passport'; 3 | 4 | @Injectable() 5 | export default class JwtAuthenticationGuard extends AuthGuard('jwt') {} 6 | -------------------------------------------------------------------------------- /src/authentication/jwt.strategy.ts: -------------------------------------------------------------------------------- 1 | import { ExtractJwt, Strategy } from 'passport-jwt'; 2 | import { PassportStrategy } from '@nestjs/passport'; 3 | import { Injectable } from '@nestjs/common'; 4 | import { ConfigService } from '@nestjs/config'; 5 | import { Request } from 'express'; 6 | import UsersService from '../users/users.service'; 7 | import TokenPayload from './tokenPayload.interface'; 8 | 9 | @Injectable() 10 | export class JwtStrategy extends PassportStrategy(Strategy) { 11 | constructor( 12 | private readonly configService: ConfigService, 13 | private readonly userService: UsersService, 14 | ) { 15 | super({ 16 | jwtFromRequest: ExtractJwt.fromExtractors([ 17 | (request: Request) => { 18 | return request?.cookies?.Authentication; 19 | }, 20 | ]), 21 | secretOrKey: configService.get('JWT_SECRET'), 22 | }); 23 | } 24 | 25 | async validate(payload: TokenPayload) { 26 | return this.userService.getById(payload.userId); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/authentication/local.strategy.ts: -------------------------------------------------------------------------------- 1 | import { Strategy } from 'passport-local'; 2 | import { PassportStrategy } from '@nestjs/passport'; 3 | import { Injectable } from '@nestjs/common'; 4 | import { AuthenticationService } from './authentication.service'; 5 | import UserModel from '../users/user.model'; 6 | 7 | @Injectable() 8 | export class LocalStrategy extends PassportStrategy(Strategy) { 9 | constructor(private authenticationService: AuthenticationService) { 10 | super({ 11 | usernameField: 'email', 12 | }); 13 | } 14 | async validate(email: string, password: string): Promise { 15 | return this.authenticationService.getAuthenticatedUser(email, password); 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/authentication/localAuthentication.guard.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { AuthGuard } from '@nestjs/passport'; 3 | 4 | @Injectable() 5 | export class LocalAuthenticationGuard extends AuthGuard('local') {} 6 | -------------------------------------------------------------------------------- /src/authentication/requestWithUser.interface.ts: -------------------------------------------------------------------------------- 1 | import { Request } from 'express'; 2 | import UserModel from '../users/user.model'; 3 | 4 | interface RequestWithUser extends Request { 5 | user: UserModel; 6 | } 7 | 8 | export default RequestWithUser; 9 | -------------------------------------------------------------------------------- /src/authentication/tokenPayload.interface.ts: -------------------------------------------------------------------------------- 1 | interface TokenPayload { 2 | userId: number; 3 | } 4 | 5 | export default TokenPayload; 6 | -------------------------------------------------------------------------------- /src/categories/categories.controller.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Body, 3 | ClassSerializerInterceptor, 4 | Controller, 5 | Delete, 6 | Get, 7 | Param, 8 | Post, 9 | Put, 10 | UseGuards, 11 | UseInterceptors, 12 | } from '@nestjs/common'; 13 | import FindOneParams from '../utils/findOneParams'; 14 | import JwtAuthenticationGuard from '../authentication/jwt-authentication.guard'; 15 | import CategoriesService from './categories.service'; 16 | import CategoryDto from './category.dto'; 17 | 18 | @Controller('categories') 19 | @UseInterceptors(ClassSerializerInterceptor) 20 | export default class CategoriesController { 21 | constructor(private readonly categoriesService: CategoriesService) {} 22 | 23 | @Get() 24 | getCategories() { 25 | return this.categoriesService.getCategories(); 26 | } 27 | 28 | @Get(':id') 29 | getCategoryById(@Param() { id }: FindOneParams) { 30 | return this.categoriesService.getCategoryById(id); 31 | } 32 | 33 | @Put(':id') 34 | @UseGuards(JwtAuthenticationGuard) 35 | updateCategory( 36 | @Param() { id }: FindOneParams, 37 | @Body() categoryData: CategoryDto, 38 | ) { 39 | return this.categoriesService.updateCategory(id, categoryData); 40 | } 41 | 42 | @Post() 43 | @UseGuards(JwtAuthenticationGuard) 44 | createCategory(@Body() categoryData: CategoryDto) { 45 | return this.categoriesService.createCategory(categoryData); 46 | } 47 | 48 | @Delete(':id') 49 | @UseGuards(JwtAuthenticationGuard) 50 | deleteCategory(@Param() { id }: FindOneParams) { 51 | return this.categoriesService.deleteCategory(id); 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/categories/categories.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import CategoriesRepository from './categories.repository'; 3 | import CategoriesController from './categories.controller'; 4 | import CategoriesService from './categories.service'; 5 | 6 | @Module({ 7 | imports: [], 8 | controllers: [CategoriesController], 9 | providers: [CategoriesService, CategoriesRepository], 10 | }) 11 | export class CategoriesModule {} 12 | -------------------------------------------------------------------------------- /src/categories/categories.repository.ts: -------------------------------------------------------------------------------- 1 | import { 2 | BadRequestException, 3 | Injectable, 4 | NotFoundException, 5 | } from '@nestjs/common'; 6 | import DatabaseService from '../database/database.service'; 7 | import CategoryModel from './category.model'; 8 | import CategoryDto from './category.dto'; 9 | import CategoryWithPostsModel from './categoryWithPosts.model'; 10 | import { isDatabaseError } from '../types/databaseError'; 11 | import PostgresErrorCode from '../database/postgresErrorCode.enum'; 12 | 13 | @Injectable() 14 | class CategoriesRepository { 15 | constructor(private readonly databaseService: DatabaseService) {} 16 | 17 | async getAll() { 18 | const databaseResponse = await this.databaseService.runQuery(` 19 | SELECT * FROM categories 20 | `); 21 | return databaseResponse.rows.map( 22 | (databaseRow) => new CategoryModel(databaseRow), 23 | ); 24 | } 25 | 26 | async getById(id: number) { 27 | const databaseResponse = await this.databaseService.runQuery( 28 | ` 29 | SELECT * FROM categories WHERE id=$1 30 | `, 31 | [id], 32 | ); 33 | const entity = databaseResponse.rows[0]; 34 | if (!entity) { 35 | throw new NotFoundException(); 36 | } 37 | return new CategoryModel(entity); 38 | } 39 | 40 | async getCategoryWithPosts(categoryId: number) { 41 | const categoriesDatabaseResponse = await this.databaseService.runQuery( 42 | ` 43 | SELECT * FROM categories WHERE id=$1 44 | `, 45 | [categoryId], 46 | ); 47 | if (!categoriesDatabaseResponse.rows[0]) { 48 | throw new NotFoundException(); 49 | } 50 | 51 | const postsDatabaseResponse = await this.databaseService.runQuery( 52 | ` 53 | SELECT posts.id AS id, posts.title AS title, posts.post_content AS post_content, posts.author_id AS author_id 54 | FROM categories_posts 55 | JOIN posts ON posts.id=categories_posts.post_id 56 | WHERE category_id = $1 57 | `, 58 | [categoryId], 59 | ); 60 | 61 | return new CategoryWithPostsModel({ 62 | ...categoriesDatabaseResponse.rows[0], 63 | posts: postsDatabaseResponse.rows, 64 | }); 65 | } 66 | 67 | async create(categoryData: CategoryDto) { 68 | try { 69 | const databaseResponse = await this.databaseService.runQuery( 70 | ` 71 | INSERT INTO categories ( 72 | name 73 | ) VALUES ( 74 | $1 75 | ) RETURNING * 76 | `, 77 | [categoryData.name], 78 | ); 79 | return new CategoryModel(databaseResponse.rows[0]); 80 | } catch (error) { 81 | if (!isDatabaseError(error) || error.column !== 'id') { 82 | throw error; 83 | } 84 | if ( 85 | error.code === PostgresErrorCode.UniqueViolation || 86 | error.code === PostgresErrorCode.NotNullViolation 87 | ) { 88 | throw new BadRequestException( 89 | 'The value for the id column violates the primary key constraint', 90 | ); 91 | } 92 | throw error; 93 | } 94 | } 95 | 96 | async update(id: number, categoryData: CategoryDto) { 97 | const databaseResponse = await this.databaseService.runQuery( 98 | ` 99 | UPDATE categories 100 | SET name = $2 101 | WHERE id = $1 102 | RETURNING * 103 | `, 104 | [id, categoryData.name], 105 | ); 106 | const entity = databaseResponse.rows[0]; 107 | if (!entity) { 108 | throw new NotFoundException(); 109 | } 110 | return new CategoryModel(entity); 111 | } 112 | 113 | async delete(id: number) { 114 | const poolClient = await this.databaseService.getPoolClient(); 115 | 116 | try { 117 | await poolClient.query('BEGIN;'); 118 | 119 | // Disconnecting posts from a given category 120 | await poolClient.query( 121 | ` 122 | DELETE FROM categories_posts 123 | WHERE category_id=$1; 124 | `, 125 | [id], 126 | ); 127 | 128 | // Disconnecting posts from a given category 129 | const categoriesResponse = await poolClient.query( 130 | ` 131 | DELETE FROM categories 132 | WHERE id=$1; 133 | `, 134 | [id], 135 | ); 136 | 137 | if (categoriesResponse.rowCount === 0) { 138 | throw new NotFoundException(); 139 | } 140 | 141 | await poolClient.query(` 142 | COMMIT; 143 | `); 144 | } catch (error) { 145 | await poolClient.query(` 146 | ROLLBACK; 147 | `); 148 | throw error; 149 | } finally { 150 | poolClient.release(); 151 | } 152 | } 153 | } 154 | 155 | export default CategoriesRepository; 156 | -------------------------------------------------------------------------------- /src/categories/categories.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import CategoriesRepository from './categories.repository'; 3 | import CategoryDto from './category.dto'; 4 | 5 | @Injectable() 6 | class CategoriesService { 7 | constructor(private readonly categoriesRepository: CategoriesRepository) {} 8 | 9 | getCategories() { 10 | return this.categoriesRepository.getAll(); 11 | } 12 | 13 | getCategoryById(id: number) { 14 | return this.categoriesRepository.getCategoryWithPosts(id); 15 | } 16 | 17 | createCategory(categoryData: CategoryDto) { 18 | return this.categoriesRepository.create(categoryData); 19 | } 20 | 21 | updateCategory(id: number, categoryData: CategoryDto) { 22 | return this.categoriesRepository.update(id, categoryData); 23 | } 24 | 25 | deleteCategory(id: number) { 26 | return this.categoriesRepository.delete(id); 27 | } 28 | } 29 | 30 | export default CategoriesService; 31 | -------------------------------------------------------------------------------- /src/categories/category.dto.ts: -------------------------------------------------------------------------------- 1 | import { IsString, IsNotEmpty } from 'class-validator'; 2 | 3 | class CategoryDto { 4 | @IsString() 5 | @IsNotEmpty() 6 | name: string; 7 | } 8 | 9 | export default CategoryDto; 10 | -------------------------------------------------------------------------------- /src/categories/category.model.ts: -------------------------------------------------------------------------------- 1 | export interface CategoryModelData { 2 | id: number; 3 | name: string; 4 | } 5 | class CategoryModel { 6 | id: number; 7 | name: string; 8 | constructor(categoryData: CategoryModelData) { 9 | this.id = categoryData.id; 10 | this.name = categoryData.name; 11 | } 12 | } 13 | 14 | export default CategoryModel; 15 | -------------------------------------------------------------------------------- /src/categories/categoryWithPosts.model.ts: -------------------------------------------------------------------------------- 1 | import CategoryModel, { CategoryModelData } from './category.model'; 2 | import PostModel, { PostModelData } from '../posts/post.model'; 3 | 4 | export interface CategoryWithPostsModelData extends CategoryModelData { 5 | posts: PostModelData[]; 6 | } 7 | class CategoryWithPostsModel extends CategoryModel { 8 | posts: PostModel[]; 9 | constructor(categoryData: CategoryWithPostsModelData) { 10 | super(categoryData); 11 | this.posts = categoryData.posts.map((postData) => { 12 | return new PostModel(postData); 13 | }); 14 | } 15 | } 16 | 17 | export default CategoryWithPostsModel; 18 | -------------------------------------------------------------------------------- /src/comments/comment.dto.ts: -------------------------------------------------------------------------------- 1 | import { IsString, IsNotEmpty, IsNumber } from 'class-validator'; 2 | 3 | class CommentDto { 4 | @IsString() 5 | @IsNotEmpty() 6 | content: string; 7 | 8 | @IsNumber() 9 | postId: number; 10 | } 11 | 12 | export default CommentDto; 13 | -------------------------------------------------------------------------------- /src/comments/comment.model.ts: -------------------------------------------------------------------------------- 1 | export interface CommentModelData { 2 | id: number; 3 | content: string; 4 | author_id: number; 5 | post_id: number; 6 | deletion_date: Date | null; 7 | } 8 | class CommentModel { 9 | id: number; 10 | content: string; 11 | authorId: number; 12 | postId: number; 13 | deletionDate: Date | null; 14 | constructor(commentData: CommentModelData) { 15 | this.id = commentData.id; 16 | this.content = commentData.content; 17 | this.authorId = commentData.author_id; 18 | this.postId = commentData.post_id; 19 | this.deletionDate = commentData.deletion_date; 20 | } 21 | } 22 | 23 | export default CommentModel; 24 | -------------------------------------------------------------------------------- /src/comments/comments.controller.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Body, 3 | ClassSerializerInterceptor, 4 | Controller, 5 | Delete, 6 | Get, 7 | Param, 8 | Post, 9 | Put, 10 | Req, 11 | UseGuards, 12 | UseInterceptors, 13 | } from '@nestjs/common'; 14 | import FindOneParams from '../utils/findOneParams'; 15 | import JwtAuthenticationGuard from '../authentication/jwt-authentication.guard'; 16 | import CommentsService from './comments.service'; 17 | import CommentDto from './comment.dto'; 18 | import RequestWithUser from '../authentication/requestWithUser.interface'; 19 | 20 | @Controller('comments') 21 | @UseInterceptors(ClassSerializerInterceptor) 22 | export default class CommentsController { 23 | constructor(private readonly commentsService: CommentsService) {} 24 | 25 | @Get() 26 | getAll() { 27 | return this.commentsService.getAll(); 28 | } 29 | 30 | @Get(':id') 31 | getById(@Param() { id }: FindOneParams) { 32 | return this.commentsService.getBytId(id); 33 | } 34 | 35 | @Put(':id') 36 | @UseGuards(JwtAuthenticationGuard) 37 | update(@Param() { id }: FindOneParams, @Body() commentData: CommentDto) { 38 | return this.commentsService.update(id, commentData); 39 | } 40 | 41 | @Post() 42 | @UseGuards(JwtAuthenticationGuard) 43 | create(@Body() commentData: CommentDto, @Req() request: RequestWithUser) { 44 | return this.commentsService.create(commentData, request.user.id); 45 | } 46 | 47 | @Delete(':id') 48 | @UseGuards(JwtAuthenticationGuard) 49 | delete(@Param() { id }: FindOneParams) { 50 | return this.commentsService.delete(id); 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/comments/comments.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import CommentsController from './comments.controller'; 3 | import CommentsService from './comments.service'; 4 | import CommentsRepository from './comments.repository'; 5 | 6 | @Module({ 7 | imports: [], 8 | controllers: [CommentsController], 9 | providers: [CommentsService, CommentsRepository], 10 | }) 11 | export class CommentsModule {} 12 | -------------------------------------------------------------------------------- /src/comments/comments.repository.ts: -------------------------------------------------------------------------------- 1 | import { 2 | BadRequestException, 3 | Injectable, 4 | NotFoundException, 5 | } from '@nestjs/common'; 6 | import DatabaseService from '../database/database.service'; 7 | import CommentModel from './comment.model'; 8 | import CommentDto from './comment.dto'; 9 | import { isDatabaseError } from '../types/databaseError'; 10 | import PostgresErrorCode from '../database/postgresErrorCode.enum'; 11 | 12 | @Injectable() 13 | class CommentsRepository { 14 | constructor(private readonly databaseService: DatabaseService) {} 15 | 16 | async getAll() { 17 | const databaseResponse = await this.databaseService.runQuery(` 18 | SELECT * FROM comments 19 | WHERE deletion_date = NULL 20 | `); 21 | return databaseResponse.rows.map( 22 | (databaseRow) => new CommentModel(databaseRow), 23 | ); 24 | } 25 | 26 | async getById(id: number) { 27 | const databaseResponse = await this.databaseService.runQuery( 28 | ` 29 | SELECT * FROM comments 30 | WHERE id=$1 AND deletion_date = NULL 31 | `, 32 | [id], 33 | ); 34 | const entity = databaseResponse.rows[0]; 35 | if (!entity) { 36 | throw new NotFoundException(); 37 | } 38 | return new CommentModel(entity); 39 | } 40 | 41 | async create(commentData: CommentDto, authorId: number) { 42 | try { 43 | const databaseResponse = await this.databaseService.runQuery( 44 | ` 45 | INSERT INTO comments ( 46 | content, 47 | post_id, 48 | author_id 49 | ) VALUES ( 50 | $1, 51 | $2, 52 | $3 53 | ) RETURNING * 54 | `, 55 | [commentData.content, commentData.postId, authorId], 56 | ); 57 | return new CommentModel(databaseResponse.rows[0]); 58 | } catch (error) { 59 | if ( 60 | isDatabaseError(error) && 61 | error.code === PostgresErrorCode.CheckViolation 62 | ) { 63 | throw new BadRequestException( 64 | 'The length of the content needs to be greater than 0', 65 | ); 66 | } 67 | throw error; 68 | } 69 | } 70 | 71 | async update(id: number, commentDto: CommentDto) { 72 | const databaseResponse = await this.databaseService.runQuery( 73 | ` 74 | UPDATE comments 75 | SET content = $2, post_id = $3 76 | WHERE id = $1 AND deletion_date=NULL 77 | RETURNING * 78 | `, 79 | [id, commentDto.content, commentDto.postId], 80 | ); 81 | const entity = databaseResponse.rows[0]; 82 | if (!entity) { 83 | throw new NotFoundException(); 84 | } 85 | return new CommentModel(entity); 86 | } 87 | 88 | async delete(id: number) { 89 | const databaseResponse = await this.databaseService.runQuery( 90 | ` 91 | UPDATE comments 92 | SET deletion_date=now() 93 | WHERE id = $1 AND deletion_date=NULL 94 | RETURNING * 95 | `, 96 | [id], 97 | ); 98 | if (databaseResponse.rowCount === 0) { 99 | throw new NotFoundException(); 100 | } 101 | } 102 | 103 | async restore(id: number) { 104 | const databaseResponse = await this.databaseService.runQuery( 105 | ` 106 | UPDATE comments 107 | SET deletion_date=NULL 108 | WHERE id = $1 109 | RETURNING * 110 | `, 111 | [id], 112 | ); 113 | if (databaseResponse.rowCount === 0) { 114 | throw new NotFoundException(); 115 | } 116 | } 117 | } 118 | 119 | export default CommentsRepository; 120 | -------------------------------------------------------------------------------- /src/comments/comments.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import CommentsRepository from './comments.repository'; 3 | import CommentDto from './comment.dto'; 4 | 5 | @Injectable() 6 | class CommentsService { 7 | constructor(private readonly commentsRepository: CommentsRepository) {} 8 | 9 | getAll() { 10 | return this.commentsRepository.getAll(); 11 | } 12 | 13 | getBytId(id: number) { 14 | return this.commentsRepository.getById(id); 15 | } 16 | 17 | create(commentData: CommentDto, authorId: number) { 18 | return this.commentsRepository.create(commentData, authorId); 19 | } 20 | 21 | update(id: number, commentData: CommentDto) { 22 | return this.commentsRepository.update(id, commentData); 23 | } 24 | 25 | delete(id: number) { 26 | return this.commentsRepository.delete(id); 27 | } 28 | } 29 | 30 | export default CommentsService; 31 | -------------------------------------------------------------------------------- /src/database/database.module-definition.ts: -------------------------------------------------------------------------------- 1 | import { ConfigurableModuleBuilder } from '@nestjs/common'; 2 | import DatabaseOptions from './databaseOptions'; 3 | 4 | export const CONNECTION_POOL = 'CONNECTION_POOL'; 5 | 6 | export const { 7 | ConfigurableModuleClass: ConfigurableDatabaseModule, 8 | MODULE_OPTIONS_TOKEN: DATABASE_OPTIONS, 9 | } = new ConfigurableModuleBuilder() 10 | .setClassMethodName('forRoot') 11 | .build(); 12 | -------------------------------------------------------------------------------- /src/database/database.module.ts: -------------------------------------------------------------------------------- 1 | import { Global, Module } from '@nestjs/common'; 2 | import { 3 | ConfigurableDatabaseModule, 4 | CONNECTION_POOL, 5 | DATABASE_OPTIONS, 6 | } from './database.module-definition'; 7 | import DatabaseOptions from './databaseOptions'; 8 | import { Pool } from 'pg'; 9 | import DatabaseService from './database.service'; 10 | 11 | @Global() 12 | @Module({ 13 | exports: [DatabaseService], 14 | providers: [ 15 | DatabaseService, 16 | { 17 | provide: CONNECTION_POOL, 18 | inject: [DATABASE_OPTIONS], 19 | useFactory: (databaseOptions: DatabaseOptions) => { 20 | return new Pool({ 21 | host: databaseOptions.host, 22 | port: databaseOptions.port, 23 | user: databaseOptions.user, 24 | password: databaseOptions.password, 25 | database: databaseOptions.database, 26 | }); 27 | }, 28 | }, 29 | ], 30 | }) 31 | export default class DatabaseModule extends ConfigurableDatabaseModule {} 32 | -------------------------------------------------------------------------------- /src/database/database.service.ts: -------------------------------------------------------------------------------- 1 | import { Inject, Injectable, Logger } from '@nestjs/common'; 2 | import { Pool, PoolClient } from 'pg'; 3 | import { CONNECTION_POOL } from './database.module-definition'; 4 | 5 | @Injectable() 6 | class DatabaseService { 7 | private readonly logger = new Logger('SQL'); 8 | constructor(@Inject(CONNECTION_POOL) private readonly pool: Pool) {} 9 | 10 | async runQuery(query: string, params?: unknown[]) { 11 | return this.queryWithLogging(this.pool, query, params); 12 | } 13 | 14 | getLogMessage(query: string, params?: unknown[]) { 15 | if (!params) { 16 | return `Query: ${query}`; 17 | } 18 | return `Query: ${query} Params: ${JSON.stringify(params)}`; 19 | } 20 | 21 | async queryWithLogging( 22 | source: Pool | PoolClient, 23 | query: string, 24 | params?: unknown[], 25 | ) { 26 | const queryPromise = source.query(query, params); 27 | 28 | // message without unnecessary spaces and newlines 29 | const message = this.getLogMessage(query, params) 30 | .replace(/\n|/g, '') 31 | .replace(/ +/g, ' '); 32 | 33 | queryPromise 34 | .then(() => { 35 | this.logger.log(message); 36 | }) 37 | .catch((error) => { 38 | this.logger.warn(message); 39 | throw error; 40 | }); 41 | 42 | return queryPromise; 43 | } 44 | 45 | async getPoolClient() { 46 | const poolClient = await this.pool.connect(); 47 | 48 | return new Proxy(poolClient, { 49 | get: (target: PoolClient, propertyName: keyof PoolClient) => { 50 | if (propertyName === 'query') { 51 | return (query: string, params?: unknown[]) => { 52 | return this.queryWithLogging(target, query, params); 53 | }; 54 | } 55 | return target[propertyName]; 56 | }, 57 | }); 58 | } 59 | } 60 | 61 | export default DatabaseService; 62 | -------------------------------------------------------------------------------- /src/database/databaseOptions.ts: -------------------------------------------------------------------------------- 1 | interface DatabaseOptions { 2 | host: string; 3 | port: number; 4 | user: string; 5 | password: string; 6 | database: string; 7 | } 8 | 9 | export default DatabaseOptions; 10 | -------------------------------------------------------------------------------- /src/database/postgresErrorCode.enum.ts: -------------------------------------------------------------------------------- 1 | enum PostgresErrorCode { 2 | UniqueViolation = '23505', 3 | NotNullViolation = '23502', 4 | ForeignKeyViolation = '23503', 5 | CheckViolation = '23514', 6 | } 7 | 8 | export default PostgresErrorCode; 9 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import { NestFactory } from '@nestjs/core'; 2 | import { AppModule } from './app.module'; 3 | import * as cookieParser from 'cookie-parser'; 4 | import { LogLevel } from '@nestjs/common/services/logger.service'; 5 | 6 | async function bootstrap() { 7 | const isProduction = process.env.NODE_ENV === 'production'; 8 | const logLevels: LogLevel[] = isProduction 9 | ? ['error', 'warn', 'log'] 10 | : ['error', 'warn', 'log', 'verbose', 'debug']; 11 | 12 | const app = await NestFactory.create(AppModule, { 13 | logger: logLevels, 14 | }); 15 | 16 | app.use(cookieParser()); 17 | 18 | await app.listen(3000); 19 | } 20 | bootstrap(); 21 | -------------------------------------------------------------------------------- /src/posts/getPostsByAuthorQuery.ts: -------------------------------------------------------------------------------- 1 | import { Transform } from 'class-transformer'; 2 | import { IsNumber, IsOptional, Min } from 'class-validator'; 3 | 4 | class GetPostsByAuthorQuery { 5 | @IsNumber() 6 | @Min(1) 7 | @IsOptional() 8 | @Transform(({ value }) => Number(value)) 9 | authorId?: number; 10 | } 11 | 12 | export default GetPostsByAuthorQuery; 13 | -------------------------------------------------------------------------------- /src/posts/idParams.ts: -------------------------------------------------------------------------------- 1 | import { IsNumber } from 'class-validator'; 2 | import { Transform } from 'class-transformer'; 3 | 4 | class IdParams { 5 | @IsNumber() 6 | @Transform(({ value }) => Number(value)) 7 | id: number; 8 | } 9 | 10 | export default IdParams; 11 | -------------------------------------------------------------------------------- /src/posts/post.dto.ts: -------------------------------------------------------------------------------- 1 | import { IsString, IsNotEmpty, IsOptional, IsNumber } from 'class-validator'; 2 | 3 | class PostDto { 4 | @IsString() 5 | @IsNotEmpty() 6 | title: string; 7 | 8 | @IsString() 9 | @IsNotEmpty() 10 | content: string; 11 | 12 | @IsOptional() 13 | @IsNumber({}, { each: true }) 14 | categoryIds?: number[]; 15 | } 16 | 17 | export default PostDto; 18 | -------------------------------------------------------------------------------- /src/posts/post.model.ts: -------------------------------------------------------------------------------- 1 | export interface PostModelData { 2 | id: number; 3 | title: string; 4 | post_content: string; 5 | author_id: number; 6 | } 7 | class PostModel { 8 | id: number; 9 | title: string; 10 | content: string; 11 | authorId: number; 12 | constructor(postData: PostModelData) { 13 | this.id = postData.id; 14 | this.title = postData.title; 15 | this.content = postData.post_content; 16 | this.authorId = postData.author_id; 17 | } 18 | } 19 | 20 | export default PostModel; 21 | -------------------------------------------------------------------------------- /src/posts/postAuthorStatistics.model.ts: -------------------------------------------------------------------------------- 1 | export interface PostAuthorStatisticsModelData { 2 | author_id: number; 3 | posts_count: number; 4 | longest_post_length: number; 5 | shortest_post_length: number; 6 | all_posts_content_sum: number; 7 | average_post_content_length: number; 8 | } 9 | class PostAuthorStatisticsModel { 10 | authorId: number; 11 | postsCount: number; 12 | longestPostLength: number; 13 | shortestPostLength: number; 14 | allPostsContentSum: number; 15 | averagePostContentLength: number; 16 | constructor(postAuthorStatisticsData: PostAuthorStatisticsModelData) { 17 | this.authorId = postAuthorStatisticsData.author_id; 18 | this.postsCount = postAuthorStatisticsData.posts_count; 19 | this.longestPostLength = postAuthorStatisticsData.longest_post_length; 20 | this.shortestPostLength = postAuthorStatisticsData.shortest_post_length; 21 | this.allPostsContentSum = postAuthorStatisticsData.all_posts_content_sum; 22 | this.averagePostContentLength = 23 | postAuthorStatisticsData.average_post_content_length; 24 | } 25 | } 26 | 27 | export default PostAuthorStatisticsModel; 28 | -------------------------------------------------------------------------------- /src/posts/postLengthParam.ts: -------------------------------------------------------------------------------- 1 | import { Transform } from 'class-transformer'; 2 | import { IsNumber, Min } from 'class-validator'; 3 | 4 | class PostLengthParam { 5 | @IsNumber() 6 | @Min(1) 7 | @Transform(({ value }) => Number(value)) 8 | postLength: number; 9 | } 10 | 11 | export default PostLengthParam; 12 | -------------------------------------------------------------------------------- /src/posts/postWithAuthor.model.ts: -------------------------------------------------------------------------------- 1 | import PostModel, { PostModelData } from './post.model'; 2 | import UserModel from '../users/user.model'; 3 | 4 | interface PostWithAuthorModelData extends PostModelData { 5 | user_id: number; 6 | user_email: string; 7 | user_name: string; 8 | user_password: string; 9 | address_id: number | null; 10 | address_street: string | null; 11 | address_city: string | null; 12 | address_country: string | null; 13 | } 14 | class PostWithAuthorModel extends PostModel { 15 | author: UserModel; 16 | constructor(postData: PostWithAuthorModelData) { 17 | super(postData); 18 | this.author = new UserModel({ 19 | id: postData.user_id, 20 | email: postData.user_email, 21 | name: postData.user_name, 22 | password: postData.user_password, 23 | ...postData, 24 | }); 25 | } 26 | } 27 | 28 | export default PostWithAuthorModel; 29 | -------------------------------------------------------------------------------- /src/posts/postWithCategoryIds.model.ts: -------------------------------------------------------------------------------- 1 | import PostModel, { PostModelData } from './post.model'; 2 | 3 | export interface PostWithCategoryIdsModelData extends PostModelData { 4 | category_ids: number[] | null; 5 | } 6 | class PostWithCategoryIdsModel extends PostModel { 7 | categoryIds: number[]; 8 | constructor(postData: PostWithCategoryIdsModelData) { 9 | super(postData); 10 | this.categoryIds = postData.category_ids || []; 11 | } 12 | } 13 | 14 | export default PostWithCategoryIdsModel; 15 | -------------------------------------------------------------------------------- /src/posts/postWithDetails.model.ts: -------------------------------------------------------------------------------- 1 | import PostModel, { PostModelData } from './post.model'; 2 | import UserModel from '../users/user.model'; 3 | 4 | interface PostWithDetailsModelData extends PostModelData { 5 | user_id: number; 6 | user_email: string; 7 | user_name: string; 8 | user_password: string; 9 | address_id: number | null; 10 | address_street: string | null; 11 | address_city: string | null; 12 | address_country: string | null; 13 | category_ids: number[] | null; 14 | } 15 | class PostWithDetails extends PostModel { 16 | author: UserModel; 17 | categoryIds: number[]; 18 | constructor(postData: PostWithDetailsModelData) { 19 | super(postData); 20 | this.author = new UserModel({ 21 | ...postData, 22 | id: postData.user_id, 23 | email: postData.user_email, 24 | name: postData.user_name, 25 | password: postData.user_password, 26 | }); 27 | this.categoryIds = postData.category_ids || []; 28 | } 29 | } 30 | 31 | export default PostWithDetails; 32 | -------------------------------------------------------------------------------- /src/posts/posts.controller.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Body, 3 | ClassSerializerInterceptor, 4 | Controller, 5 | Delete, 6 | Get, 7 | Param, 8 | Post, 9 | Put, 10 | Query, 11 | Req, 12 | UseGuards, 13 | UseInterceptors, 14 | } from '@nestjs/common'; 15 | import { PostsService } from './posts.service'; 16 | import FindOneParams from '../utils/findOneParams'; 17 | import PostDto from './post.dto'; 18 | import GetPostsByAuthorQuery from './getPostsByAuthorQuery'; 19 | import JwtAuthenticationGuard from '../authentication/jwt-authentication.guard'; 20 | import RequestWithUser from '../authentication/requestWithUser.interface'; 21 | import PaginationParams from '../utils/paginationParams'; 22 | import SearchPostsQuery from './searchPostsQuery'; 23 | 24 | @Controller('posts') 25 | @UseInterceptors(ClassSerializerInterceptor) 26 | export default class PostsController { 27 | constructor(private readonly postsService: PostsService) {} 28 | 29 | @Get() 30 | getPosts( 31 | @Query() { authorId }: GetPostsByAuthorQuery, 32 | @Query() { search }: SearchPostsQuery, 33 | @Query() { offset, limit, idsToSkip }: PaginationParams, 34 | ) { 35 | return this.postsService.getPosts( 36 | authorId, 37 | offset, 38 | limit, 39 | idsToSkip, 40 | search, 41 | ); 42 | } 43 | 44 | @Get('statistics') 45 | getStatistics() { 46 | return this.postsService.getPostAuthorStatistics(); 47 | } 48 | 49 | @Get(':id') 50 | getPostById(@Param() { id }: FindOneParams) { 51 | return this.postsService.getPostById(id); 52 | } 53 | 54 | @Put(':id') 55 | updatePost(@Param() { id }: FindOneParams, @Body() postData: PostDto) { 56 | return this.postsService.updatePost(id, postData); 57 | } 58 | 59 | @Post() 60 | @UseGuards(JwtAuthenticationGuard) 61 | createPost(@Body() postData: PostDto, @Req() request: RequestWithUser) { 62 | return this.postsService.createPost(postData, request.user.id); 63 | } 64 | 65 | @Delete(':id') 66 | deletePost(@Param() { id }: FindOneParams) { 67 | return this.postsService.deletePost(id); 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /src/posts/posts.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { PostsService } from './posts.service'; 3 | import PostsController from './posts.controller'; 4 | import PostsRepository from './posts.repository'; 5 | import PostsStatisticsRepository from './postsStatistics.repository'; 6 | import PostsSearchRepository from './postsSearch.repository'; 7 | import PostsStatisticsController from './postsStatistics.controller'; 8 | import PostsStatisticsService from './postsStatistics.service'; 9 | 10 | @Module({ 11 | imports: [], 12 | controllers: [PostsController, PostsStatisticsController], 13 | providers: [ 14 | PostsService, 15 | PostsRepository, 16 | PostsStatisticsRepository, 17 | PostsSearchRepository, 18 | PostsStatisticsService, 19 | ], 20 | }) 21 | export class PostsModule {} 22 | -------------------------------------------------------------------------------- /src/posts/posts.repository.ts: -------------------------------------------------------------------------------- 1 | import { 2 | BadRequestException, 3 | Injectable, 4 | NotFoundException, 5 | } from '@nestjs/common'; 6 | import DatabaseService from '../database/database.service'; 7 | import PostModel from './post.model'; 8 | import PostDto from './post.dto'; 9 | import PostWithCategoryIdsModel from './postWithCategoryIds.model'; 10 | import PostWithDetails from './postWithDetails.model'; 11 | import { PoolClient } from 'pg'; 12 | import PostgresErrorCode from '../database/postgresErrorCode.enum'; 13 | import getDifferenceBetweenArrays from '../utils/getDifferenceBetweenArrays'; 14 | import { isDatabaseError } from '../types/databaseError'; 15 | 16 | @Injectable() 17 | class PostsRepository { 18 | constructor(private readonly databaseService: DatabaseService) {} 19 | 20 | async get(offset = 0, limit: number | null = null, idsToSkip = 0) { 21 | const databaseResponse = await this.databaseService.runQuery( 22 | ` 23 | WITH selected_posts AS ( 24 | SELECT * FROM posts 25 | WHERE id > $3 26 | ORDER BY id ASC 27 | OFFSET $1 28 | LIMIT $2 29 | ), 30 | total_posts_count_response AS ( 31 | SELECT COUNT(*)::int AS total_posts_count FROM posts 32 | ) 33 | SELECT * FROM selected_posts, total_posts_count_response 34 | `, 35 | [offset, limit, idsToSkip], 36 | ); 37 | const items = databaseResponse.rows.map( 38 | (databaseRow) => new PostModel(databaseRow), 39 | ); 40 | const count = databaseResponse.rows[0]?.total_posts_count || 0; 41 | return { 42 | items, 43 | count, 44 | }; 45 | } 46 | 47 | async getByAuthorId( 48 | authorId: number, 49 | offset = 0, 50 | limit: number | null = null, 51 | idsToSkip = 0, 52 | ) { 53 | const databaseResponse = await this.databaseService.runQuery( 54 | ` 55 | WITH selected_posts AS ( 56 | SELECT * FROM posts 57 | WHERE author_id=$1 AND id > $4 58 | ORDER BY id ASC 59 | OFFSET $2 60 | LIMIT $3 61 | ), 62 | total_posts_count_response AS ( 63 | SELECT COUNT(*)::int AS total_posts_count FROM posts 64 | WHERE author_id=$1 AND id > $4 65 | ) 66 | SELECT * FROM selected_posts, total_posts_count_response 67 | `, 68 | [authorId, offset, limit, idsToSkip], 69 | ); 70 | const items = databaseResponse.rows.map( 71 | (databaseRow) => new PostModel(databaseRow), 72 | ); 73 | const count = databaseResponse.rows[0]?.total_posts_count || 0; 74 | return { 75 | items, 76 | count, 77 | }; 78 | } 79 | 80 | async getById(id: number) { 81 | const databaseResponse = await this.databaseService.runQuery( 82 | ` 83 | SELECT * FROM posts WHERE id=$1 84 | `, 85 | [id], 86 | ); 87 | const entity = databaseResponse.rows[0]; 88 | if (!entity) { 89 | throw new NotFoundException(); 90 | } 91 | return new PostModel(entity); 92 | } 93 | 94 | async getWithDetails(postId: number) { 95 | const postResponse = await this.databaseService.runQuery( 96 | ` 97 | SELECT 98 | posts.id AS id, posts.title AS title, posts.post_content AS post_content, posts.author_id as author_id, 99 | users.id AS user_id, users.email AS user_email, users.name AS user_name, users.password AS user_password, 100 | addresses.id AS address_id, addresses.street AS address_street, addresses.city AS address_city, addresses.country AS address_country 101 | FROM posts 102 | JOIN users ON posts.author_id = users.id 103 | LEFT JOIN addresses ON users.address_id = addresses.id 104 | WHERE posts.id=$1 105 | `, 106 | [postId], 107 | ); 108 | const postEntity = postResponse.rows[0]; 109 | if (!postEntity) { 110 | throw new NotFoundException(); 111 | } 112 | 113 | const categoryIdsResponse = await this.databaseService.runQuery( 114 | ` 115 | SELECT ARRAY( 116 | SELECT category_id FROM categories_posts 117 | WHERE post_id = $1 118 | ) AS category_ids 119 | `, 120 | [postId], 121 | ); 122 | 123 | return new PostWithDetails({ 124 | ...postEntity, 125 | category_ids: categoryIdsResponse.rows[0].category_ids, 126 | }); 127 | } 128 | 129 | async create(postData: PostDto, authorId: number) { 130 | try { 131 | const databaseResponse = await this.databaseService.runQuery( 132 | ` 133 | INSERT INTO posts ( 134 | title, 135 | post_content, 136 | author_id 137 | ) VALUES ( 138 | $1, 139 | $2, 140 | $3 141 | ) RETURNING * 142 | `, 143 | [postData.title, postData.content, authorId], 144 | ); 145 | return new PostModel(databaseResponse.rows[0]); 146 | } catch (error) { 147 | if ( 148 | !isDatabaseError(error) || 149 | !['title', 'post_content'].includes(error.column) 150 | ) { 151 | throw error; 152 | } 153 | if (error.code === PostgresErrorCode.NotNullViolation) { 154 | throw new BadRequestException( 155 | `A null value can't be set for the ${error.column} column`, 156 | ); 157 | } 158 | throw error; 159 | } 160 | } 161 | 162 | async createWithCategories(postData: PostDto, authorId: number) { 163 | const databaseResponse = await this.databaseService.runQuery( 164 | ` 165 | WITH created_post AS ( 166 | INSERT INTO posts ( 167 | title, 168 | post_content, 169 | author_id 170 | ) VALUES ( 171 | $1, 172 | $2, 173 | $3 174 | ) RETURNING * 175 | ), 176 | created_relationships AS ( 177 | INSERT INTO categories_posts ( 178 | post_id, category_id 179 | ) 180 | SELECT created_post.id AS post_id, unnest($4::int[]) AS category_id 181 | FROM created_post 182 | ) 183 | SELECT *, $4 as category_ids FROM created_post 184 | `, 185 | [postData.title, postData.content, authorId, postData.categoryIds], 186 | ); 187 | return new PostWithCategoryIdsModel(databaseResponse.rows[0]); 188 | } 189 | 190 | private async removeCategoriesFromPost( 191 | client: PoolClient, 192 | postId: number, 193 | categoryIdsToRemove: number[], 194 | ) { 195 | if (!categoryIdsToRemove.length) { 196 | return; 197 | } 198 | return client.query( 199 | ` 200 | DELETE FROM categories_posts WHERE post_id = $1 AND category_id = ANY($2::int[]) 201 | `, 202 | [postId, categoryIdsToRemove], 203 | ); 204 | } 205 | 206 | private async addCategoriesToPost( 207 | client: PoolClient, 208 | postId: number, 209 | categoryIdsToAdd: number[], 210 | ) { 211 | if (!categoryIdsToAdd.length) { 212 | return; 213 | } 214 | try { 215 | await client.query( 216 | ` 217 | INSERT INTO categories_posts ( 218 | post_id, category_id 219 | ) 220 | SELECT $1 AS post_id, unnest($2::int[]) AS category_id 221 | `, 222 | [postId, categoryIdsToAdd], 223 | ); 224 | } catch (error) { 225 | if ( 226 | isDatabaseError(error) && 227 | error.code === PostgresErrorCode.ForeignKeyViolation 228 | ) { 229 | throw new BadRequestException('Category not found'); 230 | } 231 | throw error; 232 | } 233 | } 234 | 235 | private async getCategoryIdsRelatedToPost( 236 | client: PoolClient, 237 | postId: number, 238 | ): Promise { 239 | const categoryIdsResponse = await client.query( 240 | ` 241 | SELECT ARRAY( 242 | SELECT category_id FROM categories_posts 243 | WHERE post_id = $1 244 | ) AS category_ids 245 | `, 246 | [postId], 247 | ); 248 | return categoryIdsResponse.rows[0].category_ids; 249 | } 250 | 251 | private async updateCategories( 252 | client: PoolClient, 253 | postId: number, 254 | newCategoryIds: number[], 255 | ) { 256 | const existingCategoryIds = await this.getCategoryIdsRelatedToPost( 257 | client, 258 | postId, 259 | ); 260 | 261 | const categoryIdsToRemove = getDifferenceBetweenArrays( 262 | existingCategoryIds, 263 | newCategoryIds, 264 | ); 265 | 266 | const categoryIdsToAdd = getDifferenceBetweenArrays( 267 | newCategoryIds, 268 | existingCategoryIds, 269 | ); 270 | 271 | await this.removeCategoriesFromPost(client, postId, categoryIdsToRemove); 272 | await this.addCategoriesToPost(client, postId, categoryIdsToAdd); 273 | 274 | return this.getCategoryIdsRelatedToPost(client, postId); 275 | } 276 | 277 | async update(id: number, postData: PostDto) { 278 | const client = await this.databaseService.getPoolClient(); 279 | 280 | try { 281 | await client.query('BEGIN;'); 282 | 283 | const databaseResponse = await client.query( 284 | ` 285 | UPDATE posts 286 | SET title = $2, post_content = $3 287 | WHERE id = $1 288 | RETURNING * 289 | `, 290 | [id, postData.title, postData.content], 291 | ); 292 | const entity = databaseResponse.rows[0]; 293 | if (!entity) { 294 | throw new NotFoundException(); 295 | } 296 | 297 | const newCategoryIds = postData.categoryIds || []; 298 | 299 | const categoryIds = await this.updateCategories( 300 | client, 301 | id, 302 | newCategoryIds, 303 | ); 304 | 305 | return new PostWithCategoryIdsModel({ 306 | ...entity, 307 | category_ids: categoryIds, 308 | }); 309 | } catch (error) { 310 | await client.query('ROLLBACK;'); 311 | throw error; 312 | } finally { 313 | client.release(); 314 | } 315 | } 316 | 317 | async delete(id: number) { 318 | const databaseResponse = await this.databaseService.runQuery( 319 | `DELETE FROM posts WHERE id=$1`, 320 | [id], 321 | ); 322 | if (databaseResponse.rowCount === 0) { 323 | throw new NotFoundException(); 324 | } 325 | } 326 | } 327 | 328 | export default PostsRepository; 329 | -------------------------------------------------------------------------------- /src/posts/posts.service.test.ts: -------------------------------------------------------------------------------- 1 | import PostDto from './post.dto'; 2 | import { Test } from '@nestjs/testing'; 3 | import DatabaseService from '../database/database.service'; 4 | import { PostsService } from './posts.service'; 5 | import PostWithCategoryIdsModel, { 6 | PostWithCategoryIdsModelData, 7 | } from './postWithCategoryIds.model'; 8 | import PostsRepository from './posts.repository'; 9 | import PostsStatisticsRepository from './postsStatistics.repository'; 10 | import PostsSearchRepository from './postsSearch.repository'; 11 | import PostModel, { PostModelData } from './post.model'; 12 | 13 | describe('The PostsService', () => { 14 | let postData: PostDto; 15 | let runQueryMock: jest.Mock; 16 | let postsService: PostsService; 17 | beforeEach(async () => { 18 | runQueryMock = jest.fn(); 19 | 20 | const module = await Test.createTestingModule({ 21 | providers: [ 22 | PostsService, 23 | PostsRepository, 24 | PostsStatisticsRepository, 25 | PostsSearchRepository, 26 | { 27 | provide: DatabaseService, 28 | useValue: { 29 | runQuery: runQueryMock, 30 | }, 31 | }, 32 | ], 33 | }).compile(); 34 | 35 | postsService = await module.get(PostsService); 36 | }); 37 | describe('when calling the create method with category ids', () => { 38 | let sqlQueryResult: PostWithCategoryIdsModelData; 39 | beforeEach(() => { 40 | postData = { 41 | title: 'Hello world!', 42 | content: 'Lorem ipsum', 43 | categoryIds: [1, 2, 3], 44 | }; 45 | sqlQueryResult = { 46 | id: 1, 47 | author_id: 2, 48 | title: postData.title, 49 | post_content: postData.content, 50 | category_ids: postData.categoryIds, 51 | }; 52 | runQueryMock.mockResolvedValue({ 53 | rows: [sqlQueryResult], 54 | }); 55 | }); 56 | it('should return an instance of the PostWithCategoryIdsModel', async () => { 57 | const result = await postsService.createPost(postData, 1); 58 | 59 | expect(result instanceof PostWithCategoryIdsModel).toBe(true); 60 | }); 61 | it('should return an object with the correct properties', async () => { 62 | const result = (await postsService.createPost( 63 | postData, 64 | 1, 65 | )) as PostWithCategoryIdsModel; 66 | 67 | expect(result.id).toBe(sqlQueryResult.id); 68 | expect(result.authorId).toBe(sqlQueryResult.author_id); 69 | expect(result.title).toBe(sqlQueryResult.title); 70 | expect(result.content).toBe(sqlQueryResult.post_content); 71 | expect(result.categoryIds).toBe(sqlQueryResult.category_ids); 72 | }); 73 | }); 74 | describe('when calling the create method without category ids', () => { 75 | let sqlQueryResult: PostModelData; 76 | beforeEach(() => { 77 | postData = { 78 | title: 'Hello world!', 79 | content: 'Lorem ipsum', 80 | }; 81 | sqlQueryResult = { 82 | id: 1, 83 | author_id: 2, 84 | title: postData.title, 85 | post_content: postData.content, 86 | }; 87 | runQueryMock.mockResolvedValue({ 88 | rows: [sqlQueryResult], 89 | }); 90 | }); 91 | it('should return an instance of the PostModel', async () => { 92 | const result = await postsService.createPost(postData, 1); 93 | 94 | expect(result instanceof PostModel).toBe(true); 95 | }); 96 | it('should return an object with the correct properties', async () => { 97 | const result = await postsService.createPost(postData, 1); 98 | 99 | expect(result.id).toBe(sqlQueryResult.id); 100 | expect(result.authorId).toBe(sqlQueryResult.author_id); 101 | expect(result.title).toBe(sqlQueryResult.title); 102 | expect(result.content).toBe(sqlQueryResult.post_content); 103 | }); 104 | }); 105 | }); 106 | -------------------------------------------------------------------------------- /src/posts/posts.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import PostsRepository from './posts.repository'; 3 | import PostDto from './post.dto'; 4 | import PostsStatisticsRepository from './postsStatistics.repository'; 5 | import PostsSearchRepository from './postsSearch.repository'; 6 | 7 | @Injectable() 8 | export class PostsService { 9 | constructor( 10 | private readonly postsRepository: PostsRepository, 11 | private readonly postsStatisticsRepository: PostsStatisticsRepository, 12 | private readonly postsSearchRepository: PostsSearchRepository, 13 | ) {} 14 | 15 | getPosts( 16 | authorId?: number, 17 | offset?: number, 18 | limit?: number, 19 | idsToSkip?: number, 20 | searchQuery?: string, 21 | ) { 22 | if (authorId && searchQuery) { 23 | return this.postsSearchRepository.searchByAuthor( 24 | authorId, 25 | offset, 26 | limit, 27 | idsToSkip, 28 | searchQuery, 29 | ); 30 | } 31 | if (authorId) { 32 | return this.postsRepository.getByAuthorId( 33 | authorId, 34 | offset, 35 | limit, 36 | idsToSkip, 37 | ); 38 | } 39 | if (searchQuery) { 40 | return this.postsSearchRepository.search( 41 | offset, 42 | limit, 43 | idsToSkip, 44 | searchQuery, 45 | ); 46 | } 47 | return this.postsRepository.get(offset, limit, idsToSkip); 48 | } 49 | 50 | getPostById(id: number) { 51 | return this.postsRepository.getWithDetails(id); 52 | } 53 | 54 | createPost(postData: PostDto, authorId: number) { 55 | if (postData.categoryIds?.length) { 56 | return this.postsRepository.createWithCategories(postData, authorId); 57 | } 58 | return this.postsRepository.create(postData, authorId); 59 | } 60 | 61 | updatePost(id: number, postData: PostDto) { 62 | return this.postsRepository.update(id, postData); 63 | } 64 | 65 | deletePost(id: number) { 66 | return this.postsRepository.delete(id); 67 | } 68 | 69 | getPostAuthorStatistics() { 70 | return this.postsStatisticsRepository.getPostsAuthorStatistics(); 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /src/posts/postsSearch.repository.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import DatabaseService from '../database/database.service'; 3 | import PostModel from './post.model'; 4 | 5 | @Injectable() 6 | class PostsSearchRepository { 7 | constructor(private readonly databaseService: DatabaseService) {} 8 | 9 | async search( 10 | offset = 0, 11 | limit: number | null = null, 12 | idsToSkip = 0, 13 | searchQuery: string, 14 | ) { 15 | const databaseResponse = await this.databaseService.runQuery( 16 | ` 17 | WITH selected_posts AS ( 18 | SELECT * FROM posts 19 | WHERE id > $3 AND text_tsvector @@ plainto_tsquery($4) 20 | ORDER BY id ASC 21 | OFFSET $1 22 | LIMIT $2 23 | ), 24 | total_posts_count_response AS ( 25 | SELECT COUNT(*)::int AS total_posts_count FROM posts 26 | WHERE text_tsvector @@ plainto_tsquery($4) 27 | ) 28 | SELECT * FROM selected_posts, total_posts_count_response 29 | `, 30 | [offset, limit, idsToSkip, searchQuery], 31 | ); 32 | const items = databaseResponse.rows.map( 33 | (databaseRow) => new PostModel(databaseRow), 34 | ); 35 | const count = databaseResponse.rows[0]?.total_posts_count || 0; 36 | return { 37 | items, 38 | count, 39 | }; 40 | } 41 | 42 | async searchByAuthor( 43 | authorId: number, 44 | offset = 0, 45 | limit: number | null = null, 46 | idsToSkip = 0, 47 | searchQuery: string, 48 | ) { 49 | const databaseResponse = await this.databaseService.runQuery( 50 | ` 51 | WITH selected_posts AS ( 52 | SELECT * FROM posts 53 | WHERE author_id=$1 AND id > $4 AND text_tsvector @@ plainto_tsquery($5) 54 | ORDER BY id ASC 55 | OFFSET $2 56 | LIMIT $3 57 | ), 58 | total_posts_count_response AS ( 59 | SELECT COUNT(*)::int AS total_posts_count FROM posts 60 | WHERE author_id=$1 AND id > $4 AND text_tsvector @@ plainto_tsquery($5) 61 | ) 62 | SELECT * FROM selected_posts, total_posts_count_response 63 | `, 64 | [authorId, offset, limit, idsToSkip, searchQuery], 65 | ); 66 | const items = databaseResponse.rows.map( 67 | (databaseRow) => new PostModel(databaseRow), 68 | ); 69 | const count = databaseResponse.rows[0]?.total_posts_count || 0; 70 | return { 71 | items, 72 | count, 73 | }; 74 | } 75 | } 76 | 77 | export default PostsSearchRepository; 78 | -------------------------------------------------------------------------------- /src/posts/postsStatistics.controller.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ClassSerializerInterceptor, 3 | Controller, 4 | Get, 5 | Param, 6 | Query, 7 | UseInterceptors, 8 | } from '@nestjs/common'; 9 | import PostsStatisticsService from './postsStatistics.service'; 10 | import IdParams from './idParams'; 11 | import PostLengthParam from './postLengthParam'; 12 | 13 | @Controller('posts-statistics') 14 | @UseInterceptors(ClassSerializerInterceptor) 15 | export default class PostsStatisticsController { 16 | constructor( 17 | private readonly postsStatisticsService: PostsStatisticsService, 18 | ) {} 19 | 20 | @Get('users-with-any-posts') 21 | getAuthorsWithAnyPosts() { 22 | return this.postsStatisticsService.getAuthorsWithAnyPosts(); 23 | } 24 | 25 | @Get('users-without-any-posts') 26 | getAuthorsWithoutAnyPosts() { 27 | return this.postsStatisticsService.getAuthorsWithoutAnyPosts(); 28 | } 29 | 30 | @Get('users-with-posts-in-category/:id') 31 | getAuthorsWithoutPostsInCategory(@Param() { id: categoryId }: IdParams) { 32 | return this.postsStatisticsService.getAuthorsWithPostsInCategory( 33 | categoryId, 34 | ); 35 | } 36 | 37 | @Get('users-with-posts-longer-than') 38 | getAuthorsWithPostsLongerThan(@Query() { postLength }: PostLengthParam) { 39 | return this.postsStatisticsService.getAuthorsWithPostsLongerThan( 40 | postLength, 41 | ); 42 | } 43 | 44 | @Get('users-with-posts-shorter-than-average') 45 | getUsersWithPostsShorterThanAverage( 46 | @Query() { postLength }: PostLengthParam, 47 | ) { 48 | return this.postsStatisticsService.getAuthorsWithPostsLongerThan( 49 | postLength, 50 | ); 51 | } 52 | 53 | @Get('posts-shorter-than-posts-of-a-user/:id') 54 | getPostsShorterThanPostsOfAGivenUser(@Query() { id: userId }: IdParams) { 55 | return this.postsStatisticsService.getPostsShorterThanPostsOfAGivenUser( 56 | userId, 57 | ); 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /src/posts/postsStatistics.repository.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import DatabaseService from '../database/database.service'; 3 | import PostAuthorStatisticsModel from './postAuthorStatistics.model'; 4 | import UserModel from '../users/user.model'; 5 | import PostModel from './post.model'; 6 | 7 | @Injectable() 8 | class PostsStatisticsRepository { 9 | constructor(private readonly databaseService: DatabaseService) {} 10 | 11 | async getAuthorsWithAnyPosts() { 12 | const databaseResponse = await this.databaseService.runQuery(` 13 | SELECT * FROM users 14 | WHERE EXISTS ( 15 | SELECT id FROM posts 16 | WHERE posts.author_id=users.id 17 | ) 18 | `); 19 | 20 | return databaseResponse.rows.map( 21 | (databaseRow) => new UserModel(databaseRow), 22 | ); 23 | } 24 | 25 | async getAuthorsWithoutAnyPosts() { 26 | const databaseResponse = await this.databaseService.runQuery(` 27 | SELECT * FROM users 28 | WHERE NOT EXISTS ( 29 | SELECT id FROM posts 30 | WHERE posts.author_id=users.id 31 | ) 32 | `); 33 | 34 | return databaseResponse.rows.map( 35 | (databaseRow) => new UserModel(databaseRow), 36 | ); 37 | } 38 | 39 | async getAuthorsWithPostsInCategory(categoryId: number) { 40 | const databaseResponse = await this.databaseService.runQuery( 41 | ` 42 | SELECT email FROM users 43 | WHERE EXISTS ( 44 | SELECT * FROM posts 45 | JOIN categories_posts ON posts.id = categories_posts.post_id 46 | WHERE posts.author_id = users.id AND categories_posts.category_id = $1 47 | ) 48 | `, 49 | [categoryId], 50 | ); 51 | 52 | return databaseResponse.rows.map( 53 | (databaseRow) => new UserModel(databaseRow), 54 | ); 55 | } 56 | 57 | async getAuthorsWithPostsLongerThan(postLength: number) { 58 | const databaseResponse = await this.databaseService.runQuery( 59 | ` 60 | SELECT email FROM users 61 | WHERE id IN ( 62 | SELECT posts.author_id FROM posts 63 | WHERE length(posts.post_content) >= $1 64 | ) 65 | `, 66 | [postLength], 67 | ); 68 | 69 | return databaseResponse.rows.map( 70 | (databaseRow) => new UserModel(databaseRow), 71 | ); 72 | } 73 | 74 | async getUsersWithPostsShorterThanAverage() { 75 | const databaseResponse = await this.databaseService.runQuery( 76 | ` 77 | SELECT email FROM users 78 | JOIN posts ON posts.author_id = users.id 79 | GROUP BY email 80 | HAVING avg(length(post_content)) < ALL ( 81 | SELECT avg(length(post_content)) FROM POSTS 82 | ) 83 | `, 84 | [], 85 | ); 86 | 87 | return databaseResponse.rows.map( 88 | (databaseRow) => new UserModel(databaseRow), 89 | ); 90 | } 91 | 92 | async getPostsShorterThanPostsOfAGivenUser(userId: number) { 93 | const databaseResponse = await this.databaseService.runQuery( 94 | ` 95 | SELECT title FROM posts 96 | WHERE length(post_content) < ALL ( 97 | SELECT length(post_content) FROM posts 98 | WHERE author_id = $1 99 | ) 100 | `, 101 | [userId], 102 | ); 103 | return databaseResponse.rows.map( 104 | (databaseRow) => new PostModel(databaseRow), 105 | ); 106 | } 107 | 108 | async getPostsAuthorStatistics() { 109 | const databaseResponse = await this.databaseService.runQuery( 110 | ` 111 | SELECT 112 | author_id, 113 | count(*)::int AS posts_count, 114 | max(length(post_content)) AS longest_post_length, 115 | min(length(post_content)) AS shortest_post_length, 116 | sum(length(post_content))::int AS all_posts_content_sum, 117 | avg(length(post_content))::real AS average_post_content_length 118 | FROM posts 119 | GROUP BY author_id 120 | ORDER BY posts_count DESC 121 | `, 122 | [], 123 | ); 124 | return databaseResponse.rows.map( 125 | (databaseRow) => new PostAuthorStatisticsModel(databaseRow), 126 | ); 127 | } 128 | } 129 | 130 | export default PostsStatisticsRepository; 131 | -------------------------------------------------------------------------------- /src/posts/postsStatistics.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import PostsStatisticsRepository from './postsStatistics.repository'; 3 | 4 | @Injectable() 5 | export default class PostsStatisticsService { 6 | constructor( 7 | private readonly postsStatisticsRepository: PostsStatisticsRepository, 8 | ) {} 9 | 10 | getAuthorsWithAnyPosts() { 11 | return this.postsStatisticsRepository.getAuthorsWithAnyPosts(); 12 | } 13 | 14 | getAuthorsWithoutAnyPosts() { 15 | return this.postsStatisticsRepository.getAuthorsWithoutAnyPosts(); 16 | } 17 | 18 | getAuthorsWithPostsInCategory(categoryId: number) { 19 | return this.postsStatisticsRepository.getAuthorsWithPostsInCategory( 20 | categoryId, 21 | ); 22 | } 23 | 24 | getAuthorsWithPostsLongerThan(postLength: number) { 25 | return this.postsStatisticsRepository.getAuthorsWithPostsLongerThan( 26 | postLength, 27 | ); 28 | } 29 | 30 | getUsersWithPostsShorterThanAverage() { 31 | return this.postsStatisticsRepository.getUsersWithPostsShorterThanAverage(); 32 | } 33 | 34 | async getPostsShorterThanPostsOfAGivenUser(userId: number) { 35 | return this.postsStatisticsRepository.getPostsShorterThanPostsOfAGivenUser( 36 | userId, 37 | ); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/posts/searchPostsQuery.ts: -------------------------------------------------------------------------------- 1 | import { IsString, IsNotEmpty, IsOptional } from 'class-validator'; 2 | 3 | class SearchPostsQuery { 4 | @IsString() 5 | @IsNotEmpty() 6 | @IsOptional() 7 | search?: string; 8 | } 9 | 10 | export default SearchPostsQuery; 11 | -------------------------------------------------------------------------------- /src/types/databaseError.ts: -------------------------------------------------------------------------------- 1 | import PostgresErrorCode from '../database/postgresErrorCode.enum'; 2 | import isRecord from '../utils/isRecord'; 3 | 4 | interface DatabaseError { 5 | code: PostgresErrorCode; 6 | detail: string; 7 | table: string; 8 | column?: string; 9 | } 10 | 11 | export function isDatabaseError(value: unknown): value is DatabaseError { 12 | if (!isRecord(value)) { 13 | return false; 14 | } 15 | const { code, detail, table } = value; 16 | return Boolean(code && detail && table); 17 | } 18 | 19 | export default DatabaseError; 20 | -------------------------------------------------------------------------------- /src/users/address.model.ts: -------------------------------------------------------------------------------- 1 | interface AddressModelData { 2 | id: number; 3 | street: string; 4 | city: string; 5 | country: string; 6 | } 7 | class AddressModel { 8 | id: number; 9 | street: string; 10 | city: string; 11 | country: string; 12 | constructor(data: AddressModelData) { 13 | this.id = data.id; 14 | this.street = data.street; 15 | this.city = data.city; 16 | this.country = data.country; 17 | } 18 | } 19 | 20 | export default AddressModel; 21 | -------------------------------------------------------------------------------- /src/users/dto/createUser.dto.ts: -------------------------------------------------------------------------------- 1 | export class CreateUserDto { 2 | email: string; 3 | name: string; 4 | password: string; 5 | address?: { 6 | street?: string; 7 | city?: string; 8 | country?: string; 9 | }; 10 | } 11 | -------------------------------------------------------------------------------- /src/users/exceptions/userAlreadyExists.exception.ts: -------------------------------------------------------------------------------- 1 | import { BadRequestException } from '@nestjs/common'; 2 | 3 | class UserAlreadyExistsException extends BadRequestException { 4 | constructor(email: string) { 5 | super(`User with ${email} email already exists`); 6 | } 7 | } 8 | 9 | export default UserAlreadyExistsException; 10 | -------------------------------------------------------------------------------- /src/users/user.model.ts: -------------------------------------------------------------------------------- 1 | import { Exclude } from 'class-transformer'; 2 | import AddressModel from './address.model'; 3 | 4 | export type UserModelData = { 5 | id: number; 6 | name: string; 7 | email: string; 8 | password: string; 9 | address_id: number | null; 10 | address_street: string | null; 11 | address_city: string | null; 12 | address_country: string | null; 13 | }; 14 | class UserModel { 15 | id: number; 16 | name: string; 17 | email: string; 18 | @Exclude() 19 | password: string; 20 | address?: AddressModel; 21 | 22 | constructor(data: UserModelData) { 23 | this.id = data.id; 24 | this.name = data.name; 25 | this.email = data.email; 26 | this.password = data.password; 27 | if (data.address_id) { 28 | this.address = new AddressModel({ 29 | id: data.address_id, 30 | street: data.address_street, 31 | city: data.address_city, 32 | country: data.address_country, 33 | }); 34 | } 35 | } 36 | } 37 | 38 | export default UserModel; 39 | -------------------------------------------------------------------------------- /src/users/users.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import UsersService from './users.service'; 3 | import UsersRepository from './users.repository'; 4 | 5 | @Module({ 6 | imports: [], 7 | providers: [UsersService, UsersRepository], 8 | exports: [UsersService], 9 | }) 10 | export class UsersModule {} 11 | -------------------------------------------------------------------------------- /src/users/users.repository.test.ts: -------------------------------------------------------------------------------- 1 | import { CreateUserDto } from './dto/createUser.dto'; 2 | import { Test } from '@nestjs/testing'; 3 | import DatabaseService from '../database/database.service'; 4 | import UsersRepository from './users.repository'; 5 | import UserModel, { UserModelData } from './user.model'; 6 | import DatabaseError from '../types/databaseError'; 7 | import PostgresErrorCode from '../database/postgresErrorCode.enum'; 8 | import UserAlreadyExistsException from './exceptions/userAlreadyExists.exception'; 9 | 10 | describe('The UsersRepository class', () => { 11 | let runQueryMock: jest.Mock; 12 | let createUserData: CreateUserDto; 13 | let usersRepository: UsersRepository; 14 | beforeEach(async () => { 15 | createUserData = { 16 | name: 'John', 17 | email: 'john@smith.com', 18 | password: 'strongPassword123', 19 | }; 20 | runQueryMock = jest.fn(); 21 | const module = await Test.createTestingModule({ 22 | providers: [ 23 | UsersRepository, 24 | { 25 | provide: DatabaseService, 26 | useValue: { 27 | runQuery: runQueryMock, 28 | }, 29 | }, 30 | ], 31 | }).compile(); 32 | 33 | usersRepository = await module.get(UsersRepository); 34 | }); 35 | describe('when the create method is called', () => { 36 | describe('and the database returns valid data', () => { 37 | let userModelData: UserModelData; 38 | beforeEach(() => { 39 | userModelData = { 40 | id: 1, 41 | name: 'John', 42 | email: 'john@smith.com', 43 | password: 'strongPassword123', 44 | address_id: null, 45 | address_street: null, 46 | address_city: null, 47 | address_country: null, 48 | }; 49 | runQueryMock.mockResolvedValue({ 50 | rows: [userModelData], 51 | }); 52 | }); 53 | it('should return an instance of the UserModel', async () => { 54 | const result = await usersRepository.create(createUserData); 55 | 56 | expect(result instanceof UserModel).toBe(true); 57 | }); 58 | it('should return the UserModel with correct properties', async () => { 59 | const result = await usersRepository.create(createUserData); 60 | 61 | expect(result.id).toBe(userModelData.id); 62 | expect(result.email).toBe(userModelData.email); 63 | expect(result.name).toBe(userModelData.name); 64 | expect(result.password).toBe(userModelData.password); 65 | expect(result.address).not.toBeDefined(); 66 | }); 67 | }); 68 | describe('and the database throws the UniqueViolation', () => { 69 | beforeEach(() => { 70 | const databaseError: DatabaseError = { 71 | code: PostgresErrorCode.UniqueViolation, 72 | table: 'users', 73 | detail: 'Key (email)=(john@smith.com) already exists.', 74 | }; 75 | runQueryMock.mockImplementation(() => { 76 | throw databaseError; 77 | }); 78 | }); 79 | it('should throw the UserAlreadyExistsException exception', () => { 80 | return expect(() => 81 | usersRepository.create(createUserData), 82 | ).rejects.toThrow(UserAlreadyExistsException); 83 | }); 84 | }); 85 | }); 86 | }); 87 | -------------------------------------------------------------------------------- /src/users/users.repository.ts: -------------------------------------------------------------------------------- 1 | import { Injectable, NotFoundException } from '@nestjs/common'; 2 | import DatabaseService from '../database/database.service'; 3 | import UserModel from './user.model'; 4 | import { CreateUserDto } from './dto/createUser.dto'; 5 | import PostgresErrorCode from '../database/postgresErrorCode.enum'; 6 | import UserAlreadyExistsException from './exceptions/userAlreadyExists.exception'; 7 | import { isDatabaseError } from '../types/databaseError'; 8 | 9 | @Injectable() 10 | class UsersRepository { 11 | constructor(private readonly databaseService: DatabaseService) {} 12 | 13 | async getByEmail(email: string) { 14 | const databaseResponse = await this.databaseService.runQuery( 15 | ` 16 | SELECT users.*, 17 | addresses.street AS address_street, addresses.city AS address_city, addresses.country AS address_country 18 | FROM users 19 | LEFT JOIN addresses ON users.address_id = addresses.id 20 | WHERE email=$1 21 | `, 22 | [email], 23 | ); 24 | const entity = databaseResponse.rows[0]; 25 | if (!entity) { 26 | throw new NotFoundException(); 27 | } 28 | return new UserModel(entity); 29 | } 30 | 31 | async getById(id: number) { 32 | const databaseResponse = await this.databaseService.runQuery( 33 | ` 34 | SELECT users.*, 35 | addresses.street AS address_street, addresses.city AS address_city, addresses.country AS address_country 36 | FROM users 37 | LEFT JOIN addresses ON users.address_id = addresses.id 38 | WHERE users.id=$1 39 | `, 40 | [id], 41 | ); 42 | const entity = databaseResponse.rows[0]; 43 | if (!entity) { 44 | throw new NotFoundException(); 45 | } 46 | return new UserModel(entity); 47 | } 48 | 49 | private async createUserWithAddress(userData: CreateUserDto) { 50 | try { 51 | const databaseResponse = await this.databaseService.runQuery( 52 | ` 53 | WITH created_address AS ( 54 | INSERT INTO addresses ( 55 | street, 56 | city, 57 | country 58 | ) VALUES ( 59 | $1, 60 | $2, 61 | $3 62 | ) RETURNING * 63 | ), 64 | created_user AS ( 65 | INSERT INTO users ( 66 | email, 67 | name, 68 | password, 69 | address_id 70 | ) VALUES ( 71 | $4, 72 | $5, 73 | $6, 74 | (SELECT id FROM created_address) 75 | ) RETURNING * 76 | ) 77 | SELECT created_user.id AS id, created_user.email AS email, created_user.name AS name, created_user.password AS password, 78 | created_address.id AS address_id, street AS address_street, city AS address_city, country AS address_country 79 | FROM created_user, created_address 80 | `, 81 | [ 82 | userData.address.street, 83 | userData.address.city, 84 | userData.address.country, 85 | userData.email, 86 | userData.name, 87 | userData.password, 88 | ], 89 | ); 90 | return new UserModel(databaseResponse.rows[0]); 91 | } catch (error) { 92 | if ( 93 | isDatabaseError(error) && 94 | error.code === PostgresErrorCode.UniqueViolation 95 | ) { 96 | throw new UserAlreadyExistsException(userData.email); 97 | } 98 | throw error; 99 | } 100 | } 101 | 102 | async create(userData: CreateUserDto) { 103 | if (userData.address) { 104 | return this.createUserWithAddress(userData); 105 | } 106 | try { 107 | const databaseResponse = await this.databaseService.runQuery( 108 | ` 109 | INSERT INTO users ( 110 | email, 111 | name, 112 | password 113 | ) VALUES ( 114 | $1, 115 | $2, 116 | $3 117 | ) RETURNING * 118 | `, 119 | [userData.email, userData.name, userData.password], 120 | ); 121 | return new UserModel(databaseResponse.rows[0]); 122 | } catch (error) { 123 | if ( 124 | isDatabaseError(error) && 125 | error.code === PostgresErrorCode.UniqueViolation 126 | ) { 127 | throw new UserAlreadyExistsException(userData.email); 128 | } 129 | throw error; 130 | } 131 | } 132 | } 133 | 134 | export default UsersRepository; 135 | -------------------------------------------------------------------------------- /src/users/users.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable, Logger } from '@nestjs/common'; 2 | import { CreateUserDto } from './dto/createUser.dto'; 3 | import UsersRepository from './users.repository'; 4 | import UserAlreadyExistsException from './exceptions/userAlreadyExists.exception'; 5 | 6 | @Injectable() 7 | class UsersService { 8 | private readonly logger = new Logger(UsersService.name); 9 | 10 | constructor(private readonly usersRepository: UsersRepository) {} 11 | 12 | async getByEmail(email: string) { 13 | return this.usersRepository.getByEmail(email); 14 | } 15 | 16 | async getById(id: number) { 17 | return this.usersRepository.getById(id); 18 | } 19 | 20 | async create(user: CreateUserDto) { 21 | try { 22 | return await this.usersRepository.create(user); 23 | } catch (error) { 24 | if (error instanceof UserAlreadyExistsException) { 25 | this.logger.warn(error.message); 26 | } 27 | throw error; 28 | } 29 | } 30 | } 31 | 32 | export default UsersService; 33 | -------------------------------------------------------------------------------- /src/utils/findOneParams.ts: -------------------------------------------------------------------------------- 1 | import { IsNumber } from 'class-validator'; 2 | import { Transform } from 'class-transformer'; 3 | 4 | class FindOneParams { 5 | @IsNumber() 6 | @Transform(({ value }) => Number(value)) 7 | id: number; 8 | } 9 | 10 | export default FindOneParams; 11 | -------------------------------------------------------------------------------- /src/utils/getDifferenceBetweenArrays.ts: -------------------------------------------------------------------------------- 1 | function getDifferenceBetweenArrays( 2 | firstArray: ListType[], 3 | secondArray: unknown[], 4 | ): ListType[] { 5 | return firstArray.filter((arrayElement) => { 6 | return !secondArray.includes(arrayElement); 7 | }); 8 | } 9 | 10 | export default getDifferenceBetweenArrays; 11 | -------------------------------------------------------------------------------- /src/utils/isRecord.ts: -------------------------------------------------------------------------------- 1 | function isRecord(value: unknown): value is Record { 2 | return value !== null && typeof value === 'object' && !Array.isArray(value); 3 | } 4 | 5 | export default isRecord; 6 | -------------------------------------------------------------------------------- /src/utils/logger.interceptor.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Injectable, 3 | NestInterceptor, 4 | ExecutionContext, 5 | CallHandler, 6 | Logger, 7 | } from '@nestjs/common'; 8 | import { Request, Response } from 'express'; 9 | 10 | @Injectable() 11 | export class LoggerInterceptor implements NestInterceptor { 12 | private readonly logger = new Logger('HTTP'); 13 | 14 | intercept(context: ExecutionContext, next: CallHandler) { 15 | const httpContext = context.switchToHttp(); 16 | const request = httpContext.getRequest(); 17 | const response = httpContext.getResponse(); 18 | 19 | response.on('finish', () => { 20 | const { method, originalUrl } = request; 21 | const { statusCode, statusMessage } = response; 22 | 23 | const message = `${method} ${originalUrl} ${statusCode} ${statusMessage}`; 24 | 25 | if (statusCode >= 500) { 26 | return this.logger.error(message); 27 | } 28 | 29 | if (statusCode >= 400) { 30 | return this.logger.warn(message); 31 | } 32 | 33 | return this.logger.log(message); 34 | }); 35 | 36 | return next.handle(); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/utils/paginationParams.ts: -------------------------------------------------------------------------------- 1 | import { IsNumber, Min, IsOptional } from 'class-validator'; 2 | import { Type } from 'class-transformer'; 3 | 4 | class PaginationParams { 5 | @IsOptional() 6 | @Type(() => Number) 7 | @IsNumber() 8 | @Min(0) 9 | offset?: number; 10 | 11 | @IsOptional() 12 | @Type(() => Number) 13 | @IsNumber() 14 | @Min(1) 15 | limit?: number; 16 | 17 | @IsOptional() 18 | @Type(() => Number) 19 | @IsNumber() 20 | @Min(1) 21 | idsToSkip?: number; 22 | } 23 | 24 | export default PaginationParams; 25 | -------------------------------------------------------------------------------- /tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "exclude": ["node_modules", "test", "dist", "**/*spec.ts"] 4 | } 5 | -------------------------------------------------------------------------------- /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": "es2017", 10 | "sourceMap": true, 11 | "outDir": "./dist", 12 | "baseUrl": "./", 13 | "strict": true, 14 | "incremental": true, 15 | "skipLibCheck": true, 16 | "strictNullChecks": false, 17 | "noImplicitAny": true, 18 | "strictBindCallApply": false, 19 | "forceConsistentCasingInFileNames": false, 20 | "noFallthroughCasesInSwitch": false 21 | } 22 | } 23 | --------------------------------------------------------------------------------