├── .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 |
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 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
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 |
--------------------------------------------------------------------------------