├── .editorconfig ├── .env.example ├── .eslintrc.js ├── .gitignore ├── .nvmrc ├── .prettierrc ├── @types └── express │ └── index.d.ts ├── README.md ├── migrations ├── 1607633702774-CreatePosts.ts ├── 1607860726641-AddSlugToPosts.ts ├── 1608913716329-CreateUserTable.ts ├── 1609176277798-AddAuthorToPosts.ts ├── 1609276522815-AddRoleToUser.ts ├── 1610032855562-CreatePostComment.ts └── 1610192757054-CreateAttachedFilesTable.ts ├── nest-cli.json ├── package-lock.json ├── package.json ├── src ├── app.controller.ts ├── app.module.ts ├── auth │ ├── auth.module.ts │ ├── constants.ts │ ├── decorators │ │ ├── public.decorator.ts │ │ └── user.decorator.ts │ ├── dtos │ │ └── jwt-payload.dto.ts │ ├── guards │ │ ├── jwt-auth.guard.ts │ │ └── local-auth.guard.ts │ ├── services │ │ ├── auth.service.spec.ts │ │ └── auth.service.ts │ └── strategies │ │ ├── jwt.strategy.ts │ │ └── local.strategy.ts ├── casl │ ├── casl-ability.factory.ts │ ├── casl.module.ts │ ├── constants.ts │ ├── decorators │ │ ├── acl.decorator.ts │ │ └── check-policies.decorator.ts │ ├── enums │ │ └── action.enum.ts │ ├── guards │ │ └── policies.guard.ts │ ├── policies │ │ ├── attached-files │ │ │ ├── detach-file-policy.handler.ts │ │ │ └── index.ts │ │ ├── index.ts │ │ ├── policy-handler.interface.ts │ │ ├── posts │ │ │ ├── create-post-policy.handler.ts │ │ │ ├── edit-post-policy.handler.ts │ │ │ ├── index.ts │ │ │ └── remove-post-policy.handler.ts │ │ └── users │ │ │ ├── create-user-policy.handler.ts │ │ │ ├── index.ts │ │ │ └── search-user-policy.handler.ts │ ├── providers │ │ ├── attached-files │ │ │ ├── detach-file-policy.provider.ts │ │ │ └── index.ts │ │ ├── index.ts │ │ ├── posts │ │ │ ├── create-post-policy.provider.ts │ │ │ ├── edit-post-policy.provider.ts │ │ │ ├── index.ts │ │ │ └── remove-post-policy.provider.ts │ │ └── users │ │ │ ├── create-user-policy.provider.ts │ │ │ ├── index.ts │ │ │ └── search-user-policy.provider.ts │ └── types.ts ├── comments │ ├── comments.module.ts │ ├── controllers │ │ └── comments.controller.ts │ ├── dtos │ │ └── create-comment.dto.ts │ ├── entities │ │ └── comment.entity.ts │ ├── enums │ │ └── expose.enum.ts │ ├── repositories │ │ └── comment.repository.ts │ └── services │ │ └── comments.service.ts ├── common │ ├── database │ │ ├── database-errors.interface.ts │ │ ├── database-errors.ts │ │ ├── database.config.ts │ │ ├── database.module.ts │ │ ├── mysql-errors.enum.ts │ │ └── sqlite-errors.enum.ts │ ├── decorators │ │ └── match.decorator.ts │ ├── env-variables.ts │ ├── files-upload │ │ ├── constants.ts │ │ ├── files-upload.module.ts │ │ └── utils.ts │ ├── filters │ │ └── all-exceptions.filter.ts │ ├── interceptors │ │ ├── logging.interceptor.ts │ │ └── transform-response.interceptor.ts │ └── utils │ │ ├── bcrypt.service.ts │ │ └── slug.service.ts ├── main.ts ├── ormconfig.ts ├── posts │ ├── controllers │ │ ├── attached-files │ │ │ └── attached-files.controller.ts │ │ ├── posts.controller.spec.ts │ │ └── posts.controller.ts │ ├── decorators │ │ ├── files-uploaded.decorator.ts │ │ └── post-entity.decorator.ts │ ├── dtos │ │ ├── attached-file.dto.ts │ │ ├── create-post.dto.ts │ │ └── update-post.dto.ts │ ├── entities │ │ ├── attached-file.entity.ts │ │ └── post.entity.ts │ ├── enums │ │ ├── attached-file-expose-groups.enum.ts │ │ └── post-expose-groups.enum.ts │ ├── middlewares │ │ └── retrieve-post-by-slug.middleware.ts │ ├── posts.module.ts │ ├── repositories │ │ ├── attached-file.repository.ts │ │ └── post.repository.ts │ ├── services │ │ ├── attached-files.service.ts │ │ ├── posts.service.spec.ts │ │ └── posts.service.ts │ └── subscribers │ │ └── post.subscriber.ts └── users │ ├── controllers │ └── users.controller.ts │ ├── dtos │ ├── create-user.dto.ts │ ├── register-user.dto.ts │ └── user.dto.ts │ ├── entities │ └── user.entity.ts │ ├── enums │ ├── expose.enum.ts │ └── role.enum.ts │ ├── repositories │ └── user.repository.ts │ ├── services │ ├── users.service.spec.ts │ └── users.service.ts │ └── users.module.ts ├── test ├── app.e2e-spec.ts └── jest-e2e.json ├── tsconfig.build.json └── tsconfig.json /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = space 5 | indent_size = 2 6 | charset = utf-8 7 | trim_trailing_whitespace = true 8 | insert_final_newline = true 9 | 10 | [*.md] 11 | trim_trailing_whitespace = false 12 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | 2 | # General config 3 | NODE_ENV=development 4 | PORT=3000 5 | 6 | 7 | # Database config - Development 8 | DATABASE_NAME=my-db.sqlite3 9 | 10 | # Database config - Production 11 | DATABASE_NAME= 12 | DATABASE_HOST= 13 | DATABASE_PORT= 14 | DATABASE_USER= 15 | DATABASE_PASSWORD= 16 | 17 | # JWT 18 | JWT_SECRET="my-awesome-secret-jwt-key" 19 | JWT_EXPIRES_IN="60s" 20 | 21 | # File storage 22 | FILES_DEST="./uploads" 23 | MAX_FILE_SIZE=5000000 # File size in bytes 24 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | parser: '@typescript-eslint/parser', 3 | parserOptions: { 4 | project: 'tsconfig.json', 5 | sourceType: 'module', 6 | }, 7 | plugins: ['@typescript-eslint/eslint-plugin'], 8 | extends: [ 9 | 'plugin:@typescript-eslint/recommended', 10 | 'prettier/@typescript-eslint', 11 | 'plugin:prettier/recommended', 12 | ], 13 | root: true, 14 | env: { 15 | node: true, 16 | jest: true, 17 | }, 18 | rules: { 19 | '@typescript-eslint/interface-name-prefix': 'off', 20 | '@typescript-eslint/explicit-function-return-type': 'off', 21 | '@typescript-eslint/explicit-module-boundary-types': 'off', 22 | '@typescript-eslint/no-explicit-any': 'off', 23 | }, 24 | }; 25 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # compiled output 2 | /dist 3 | /node_modules 4 | 5 | # Logs 6 | logs 7 | *.log 8 | npm-debug.log* 9 | yarn-debug.log* 10 | yarn-error.log* 11 | lerna-debug.log* 12 | 13 | # OS 14 | .DS_Store 15 | 16 | # Tests 17 | /coverage 18 | /.nyc_output 19 | 20 | # IDEs and editors 21 | /.idea 22 | .project 23 | .classpath 24 | .c9/ 25 | *.launch 26 | .settings/ 27 | *.sublime-workspace 28 | 29 | # IDE - VSCode 30 | .vscode/* 31 | !.vscode/settings.json 32 | !.vscode/tasks.json 33 | !.vscode/launch.json 34 | !.vscode/extensions.json 35 | 36 | # SQlite-db 37 | *.sqlite3 38 | 39 | # Environment files 40 | .env 41 | 42 | # Uploads 43 | uploads 44 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | lts/erbium 2 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "trailingComma": "all" 4 | } -------------------------------------------------------------------------------- /@types/express/index.d.ts: -------------------------------------------------------------------------------- 1 | 2 | import { Post } from '@Posts/entities/post.entity'; 3 | import { User as UserEntity } from '@Users/entities/user.entity'; 4 | 5 | declare global { 6 | namespace Express { 7 | interface Request { 8 | post?: Post; 9 | } 10 | interface User extends UserEntity { } 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Blog Api 2 | --- 3 | ## ToDo 4 | 5 | - [x] Hacer las validaciones y usar los pipes para las validaciones. 6 | - [x] Terminar de hacer el controller de los posts 7 | - [x] Hacer la parte de los usuarios (login, jwt, roles...) 8 | - [x] Login 9 | - [x] JWT 10 | - [x] Roles 11 | - [x] Añadir Guard para que solo un usuario autenticado (con jwt) pueda crear un post. 12 | - [x] Relacionar post con su author 13 | - [x] Añadir Guard para que solo el autor o un admin pueda editar un post 14 | - [ ] Añadir un modulo de admin para gestionar los usuarios (controlador para añadir nuevos admins) 15 | - [x] Crear submodulo Comentarios dentro de los posts. 16 | - [x] Relacionar los comentarios con los posts 17 | - [x] Añadir Guard para que solo un usuario autenticado (con jwt) pueda crear un comentario 18 | - [ ] Añadir Guard para que solo el autor o un admin pueda eliminar un comentario 19 | - [x] Dar la opción de subir documentos/imagenes a un post 20 | --- 21 | 22 | ## Description 23 | 24 | [Nest](https://github.com/nestjs/nest) framework TypeScript starter repository. 25 | 26 | ## Installation 27 | 28 | ```bash 29 | $ npm install 30 | ``` 31 | 32 | ## Running the app 33 | 34 | ```bash 35 | # development 36 | $ npm run start 37 | 38 | # watch mode 39 | $ npm run start:dev 40 | 41 | # production mode 42 | $ npm run start:prod 43 | ``` 44 | 45 | ## Test 46 | 47 | ```bash 48 | # unit tests 49 | $ npm run test 50 | 51 | # e2e tests 52 | $ npm run test:e2e 53 | 54 | # test coverage 55 | $ npm run test:cov 56 | ``` 57 | 58 | ## Support 59 | 60 | 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). 61 | 62 | ## Stay in touch 63 | 64 | - Author - [Kamil Myśliwiec](https://kamilmysliwiec.com) 65 | - Website - [https://nestjs.com](https://nestjs.com/) 66 | - Twitter - [@nestframework](https://twitter.com/nestframework) 67 | 68 | ## License 69 | 70 | Nest is [MIT licensed](LICENSE). 71 | -------------------------------------------------------------------------------- /migrations/1607633702774-CreatePosts.ts: -------------------------------------------------------------------------------- 1 | import {MigrationInterface, QueryRunner} from "typeorm"; 2 | 3 | export class CreatePosts1607633702774 implements MigrationInterface { 4 | name = 'CreatePosts1607633702774' 5 | 6 | public async up(queryRunner: QueryRunner): Promise { 7 | await queryRunner.query(`CREATE TABLE "post" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "title" varchar NOT NULL, "text" varchar NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')))`); 8 | } 9 | 10 | public async down(queryRunner: QueryRunner): Promise { 11 | await queryRunner.query(`DROP TABLE "post"`); 12 | } 13 | 14 | } 15 | -------------------------------------------------------------------------------- /migrations/1607860726641-AddSlugToPosts.ts: -------------------------------------------------------------------------------- 1 | import {MigrationInterface, QueryRunner} from "typeorm"; 2 | 3 | export class AddSlugToPosts1607860726641 implements MigrationInterface { 4 | name = 'AddSlugToPosts1607860726641' 5 | 6 | public async up(queryRunner: QueryRunner): Promise { 7 | await queryRunner.query(`CREATE TABLE "temporary_post" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "title" varchar NOT NULL, "text" varchar NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "slug" varchar NOT NULL, CONSTRAINT "UQ_415175ed3348fd59e285907a40c" UNIQUE ("slug"))`); 8 | await queryRunner.query(`INSERT INTO "temporary_post"("id", "title", "text", "createdAt", "updatedAt", "slug") SELECT "id", "title", "text", "createdAt", "updatedAt", ("title" || " " || datetime('now')) FROM "post"`); 9 | await queryRunner.query(`DROP TABLE "post"`); 10 | await queryRunner.query(`ALTER TABLE "temporary_post" RENAME TO "post"`); 11 | } 12 | 13 | public async down(queryRunner: QueryRunner): Promise { 14 | await queryRunner.query(`ALTER TABLE "post" RENAME TO "temporary_post"`); 15 | await queryRunner.query(`CREATE TABLE "post" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "title" varchar NOT NULL, "text" varchar NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')))`); 16 | await queryRunner.query(`INSERT INTO "post"("id", "title", "text", "createdAt", "updatedAt") SELECT "id", "title", "text", "createdAt", "updatedAt" FROM "temporary_post"`); 17 | await queryRunner.query(`DROP TABLE "temporary_post"`); 18 | } 19 | 20 | } 21 | -------------------------------------------------------------------------------- /migrations/1608913716329-CreateUserTable.ts: -------------------------------------------------------------------------------- 1 | import {MigrationInterface, QueryRunner} from "typeorm"; 2 | 3 | export class CreateUserTable1608913716329 implements MigrationInterface { 4 | name = 'CreateUserTable1608913716329' 5 | 6 | public async up(queryRunner: QueryRunner): Promise { 7 | await queryRunner.query(`CREATE TABLE "user" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "login" varchar NOT NULL, "hashedPassword" varchar NOT NULL, "firstname" varchar NOT NULL, "lastname" varchar NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), CONSTRAINT "UQ_a62473490b3e4578fd683235c5e" UNIQUE ("login"))`); 8 | await queryRunner.query(`CREATE INDEX "IDX_a62473490b3e4578fd683235c5" ON "user" ("login") `); 9 | await queryRunner.query(`CREATE INDEX "IDX_cd1bddce36edc3e766798eab37" ON "post" ("slug") `); 10 | } 11 | 12 | public async down(queryRunner: QueryRunner): Promise { 13 | await queryRunner.query(`DROP INDEX "IDX_cd1bddce36edc3e766798eab37"`); 14 | await queryRunner.query(`DROP INDEX "IDX_a62473490b3e4578fd683235c5"`); 15 | await queryRunner.query(`DROP TABLE "user"`); 16 | } 17 | 18 | } 19 | -------------------------------------------------------------------------------- /migrations/1609176277798-AddAuthorToPosts.ts: -------------------------------------------------------------------------------- 1 | import {MigrationInterface, QueryRunner} from "typeorm"; 2 | 3 | export class AddAuthorToPosts1609176277798 implements MigrationInterface { 4 | name = 'AddAuthorToPosts1609176277798' 5 | 6 | public async up(queryRunner: QueryRunner): Promise { 7 | await queryRunner.query(`DROP INDEX "IDX_cd1bddce36edc3e766798eab37"`); 8 | await queryRunner.query(`CREATE TABLE "temporary_post" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "title" varchar NOT NULL, "text" varchar NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "slug" varchar NOT NULL, "authorId" integer, CONSTRAINT "UQ_cd1bddce36edc3e766798eab376" UNIQUE ("slug"))`); 9 | await queryRunner.query(`INSERT INTO "temporary_post"("id", "title", "text", "createdAt", "updatedAt", "slug") SELECT "id", "title", "text", "createdAt", "updatedAt", "slug" FROM "post"`); 10 | await queryRunner.query(`DROP TABLE "post"`); 11 | await queryRunner.query(`ALTER TABLE "temporary_post" RENAME TO "post"`); 12 | await queryRunner.query(`CREATE INDEX "IDX_cd1bddce36edc3e766798eab37" ON "post" ("slug") `); 13 | await queryRunner.query(`CREATE INDEX "IDX_c6fb082a3114f35d0cc27c518e" ON "post" ("authorId") `); 14 | await queryRunner.query(`DROP INDEX "IDX_cd1bddce36edc3e766798eab37"`); 15 | await queryRunner.query(`DROP INDEX "IDX_c6fb082a3114f35d0cc27c518e"`); 16 | await queryRunner.query(`CREATE TABLE "temporary_post" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "title" varchar NOT NULL, "text" varchar NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "slug" varchar NOT NULL, "authorId" integer, CONSTRAINT "UQ_cd1bddce36edc3e766798eab376" UNIQUE ("slug"), CONSTRAINT "FK_c6fb082a3114f35d0cc27c518e0" FOREIGN KEY ("authorId") REFERENCES "user" ("id") ON DELETE SET NULL ON UPDATE NO ACTION)`); 17 | await queryRunner.query(`INSERT INTO "temporary_post"("id", "title", "text", "createdAt", "updatedAt", "slug", "authorId") SELECT "id", "title", "text", "createdAt", "updatedAt", "slug", "authorId" FROM "post"`); 18 | await queryRunner.query(`DROP TABLE "post"`); 19 | await queryRunner.query(`ALTER TABLE "temporary_post" RENAME TO "post"`); 20 | await queryRunner.query(`CREATE INDEX "IDX_cd1bddce36edc3e766798eab37" ON "post" ("slug") `); 21 | await queryRunner.query(`CREATE INDEX "IDX_c6fb082a3114f35d0cc27c518e" ON "post" ("authorId") `); 22 | } 23 | 24 | public async down(queryRunner: QueryRunner): Promise { 25 | await queryRunner.query(`DROP INDEX "IDX_c6fb082a3114f35d0cc27c518e"`); 26 | await queryRunner.query(`DROP INDEX "IDX_cd1bddce36edc3e766798eab37"`); 27 | await queryRunner.query(`ALTER TABLE "post" RENAME TO "temporary_post"`); 28 | await queryRunner.query(`CREATE TABLE "post" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "title" varchar NOT NULL, "text" varchar NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "slug" varchar NOT NULL, "authorId" integer, CONSTRAINT "UQ_cd1bddce36edc3e766798eab376" UNIQUE ("slug"))`); 29 | await queryRunner.query(`INSERT INTO "post"("id", "title", "text", "createdAt", "updatedAt", "slug", "authorId") SELECT "id", "title", "text", "createdAt", "updatedAt", "slug", "authorId" FROM "temporary_post"`); 30 | await queryRunner.query(`DROP TABLE "temporary_post"`); 31 | await queryRunner.query(`CREATE INDEX "IDX_c6fb082a3114f35d0cc27c518e" ON "post" ("authorId") `); 32 | await queryRunner.query(`CREATE INDEX "IDX_cd1bddce36edc3e766798eab37" ON "post" ("slug") `); 33 | await queryRunner.query(`DROP INDEX "IDX_c6fb082a3114f35d0cc27c518e"`); 34 | await queryRunner.query(`DROP INDEX "IDX_cd1bddce36edc3e766798eab37"`); 35 | await queryRunner.query(`ALTER TABLE "post" RENAME TO "temporary_post"`); 36 | await queryRunner.query(`CREATE TABLE "post" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "title" varchar NOT NULL, "text" varchar NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "slug" varchar NOT NULL, CONSTRAINT "UQ_cd1bddce36edc3e766798eab376" UNIQUE ("slug"))`); 37 | await queryRunner.query(`INSERT INTO "post"("id", "title", "text", "createdAt", "updatedAt", "slug") SELECT "id", "title", "text", "createdAt", "updatedAt", "slug" FROM "temporary_post"`); 38 | await queryRunner.query(`DROP TABLE "temporary_post"`); 39 | await queryRunner.query(`CREATE INDEX "IDX_cd1bddce36edc3e766798eab37" ON "post" ("slug") `); 40 | } 41 | 42 | } 43 | -------------------------------------------------------------------------------- /migrations/1609276522815-AddRoleToUser.ts: -------------------------------------------------------------------------------- 1 | import {MigrationInterface, QueryRunner} from "typeorm"; 2 | 3 | export class AddRoleToUser1609276522815 implements MigrationInterface { 4 | name = 'AddRoleToUser1609276522815' 5 | 6 | public async up(queryRunner: QueryRunner): Promise { 7 | await queryRunner.query(`DROP INDEX "IDX_c6fb082a3114f35d0cc27c518e"`); 8 | await queryRunner.query(`DROP INDEX "IDX_cd1bddce36edc3e766798eab37"`); 9 | await queryRunner.query(`CREATE TABLE "temporary_post" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "title" varchar NOT NULL, "text" varchar NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "slug" varchar NOT NULL, "authorId" integer, CONSTRAINT "UQ_cd1bddce36edc3e766798eab376" UNIQUE ("slug"))`); 10 | await queryRunner.query(`INSERT INTO "temporary_post"("id", "title", "text", "createdAt", "updatedAt", "slug", "authorId") SELECT "id", "title", "text", "createdAt", "updatedAt", "slug", "authorId" FROM "post"`); 11 | await queryRunner.query(`DROP TABLE "post"`); 12 | await queryRunner.query(`ALTER TABLE "temporary_post" RENAME TO "post"`); 13 | await queryRunner.query(`CREATE INDEX "IDX_c6fb082a3114f35d0cc27c518e" ON "post" ("authorId") `); 14 | await queryRunner.query(`CREATE INDEX "IDX_cd1bddce36edc3e766798eab37" ON "post" ("slug") `); 15 | await queryRunner.query(`DROP INDEX "IDX_a62473490b3e4578fd683235c5"`); 16 | await queryRunner.query(`CREATE TABLE "temporary_user" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "login" varchar NOT NULL, "hashedPassword" varchar NOT NULL, "firstname" varchar NOT NULL, "lastname" varchar NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "role" varchar CHECK( role IN ('user','admin') ) NOT NULL DEFAULT ('user'), CONSTRAINT "UQ_a62473490b3e4578fd683235c5e" UNIQUE ("login"))`); 17 | await queryRunner.query(`INSERT INTO "temporary_user"("id", "login", "hashedPassword", "firstname", "lastname", "createdAt", "updatedAt") SELECT "id", "login", "hashedPassword", "firstname", "lastname", "createdAt", "updatedAt" FROM "user"`); 18 | await queryRunner.query(`DROP TABLE "user"`); 19 | await queryRunner.query(`ALTER TABLE "temporary_user" RENAME TO "user"`); 20 | await queryRunner.query(`CREATE INDEX "IDX_a62473490b3e4578fd683235c5" ON "user" ("login") `); 21 | await queryRunner.query(`DROP INDEX "IDX_c6fb082a3114f35d0cc27c518e"`); 22 | await queryRunner.query(`DROP INDEX "IDX_cd1bddce36edc3e766798eab37"`); 23 | await queryRunner.query(`CREATE TABLE "temporary_post" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "title" varchar NOT NULL, "text" varchar NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "slug" varchar NOT NULL, "authorId" integer, CONSTRAINT "UQ_cd1bddce36edc3e766798eab376" UNIQUE ("slug"), CONSTRAINT "FK_c6fb082a3114f35d0cc27c518e0" FOREIGN KEY ("authorId") REFERENCES "user" ("id") ON DELETE SET NULL ON UPDATE NO ACTION)`); 24 | await queryRunner.query(`INSERT INTO "temporary_post"("id", "title", "text", "createdAt", "updatedAt", "slug", "authorId") SELECT "id", "title", "text", "createdAt", "updatedAt", "slug", "authorId" FROM "post"`); 25 | await queryRunner.query(`DROP TABLE "post"`); 26 | await queryRunner.query(`ALTER TABLE "temporary_post" RENAME TO "post"`); 27 | await queryRunner.query(`CREATE INDEX "IDX_c6fb082a3114f35d0cc27c518e" ON "post" ("authorId") `); 28 | await queryRunner.query(`CREATE INDEX "IDX_cd1bddce36edc3e766798eab37" ON "post" ("slug") `); 29 | } 30 | 31 | public async down(queryRunner: QueryRunner): Promise { 32 | await queryRunner.query(`DROP INDEX "IDX_cd1bddce36edc3e766798eab37"`); 33 | await queryRunner.query(`DROP INDEX "IDX_c6fb082a3114f35d0cc27c518e"`); 34 | await queryRunner.query(`ALTER TABLE "post" RENAME TO "temporary_post"`); 35 | await queryRunner.query(`CREATE TABLE "post" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "title" varchar NOT NULL, "text" varchar NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "slug" varchar NOT NULL, "authorId" integer, CONSTRAINT "UQ_cd1bddce36edc3e766798eab376" UNIQUE ("slug"))`); 36 | await queryRunner.query(`INSERT INTO "post"("id", "title", "text", "createdAt", "updatedAt", "slug", "authorId") SELECT "id", "title", "text", "createdAt", "updatedAt", "slug", "authorId" FROM "temporary_post"`); 37 | await queryRunner.query(`DROP TABLE "temporary_post"`); 38 | await queryRunner.query(`CREATE INDEX "IDX_cd1bddce36edc3e766798eab37" ON "post" ("slug") `); 39 | await queryRunner.query(`CREATE INDEX "IDX_c6fb082a3114f35d0cc27c518e" ON "post" ("authorId") `); 40 | await queryRunner.query(`DROP INDEX "IDX_a62473490b3e4578fd683235c5"`); 41 | await queryRunner.query(`ALTER TABLE "user" RENAME TO "temporary_user"`); 42 | await queryRunner.query(`CREATE TABLE "user" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "login" varchar NOT NULL, "hashedPassword" varchar NOT NULL, "firstname" varchar NOT NULL, "lastname" varchar NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), CONSTRAINT "UQ_a62473490b3e4578fd683235c5e" UNIQUE ("login"))`); 43 | await queryRunner.query(`INSERT INTO "user"("id", "login", "hashedPassword", "firstname", "lastname", "createdAt", "updatedAt") SELECT "id", "login", "hashedPassword", "firstname", "lastname", "createdAt", "updatedAt" FROM "temporary_user"`); 44 | await queryRunner.query(`DROP TABLE "temporary_user"`); 45 | await queryRunner.query(`CREATE INDEX "IDX_a62473490b3e4578fd683235c5" ON "user" ("login") `); 46 | await queryRunner.query(`DROP INDEX "IDX_cd1bddce36edc3e766798eab37"`); 47 | await queryRunner.query(`DROP INDEX "IDX_c6fb082a3114f35d0cc27c518e"`); 48 | await queryRunner.query(`ALTER TABLE "post" RENAME TO "temporary_post"`); 49 | await queryRunner.query(`CREATE TABLE "post" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "title" varchar NOT NULL, "text" varchar NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "slug" varchar NOT NULL, "authorId" integer, CONSTRAINT "UQ_cd1bddce36edc3e766798eab376" UNIQUE ("slug"), CONSTRAINT "FK_c6fb082a3114f35d0cc27c518e0" FOREIGN KEY ("authorId") REFERENCES "temporary_user" ("id") ON DELETE SET NULL ON UPDATE NO ACTION)`); 50 | await queryRunner.query(`INSERT INTO "post"("id", "title", "text", "createdAt", "updatedAt", "slug", "authorId") SELECT "id", "title", "text", "createdAt", "updatedAt", "slug", "authorId" FROM "temporary_post"`); 51 | await queryRunner.query(`DROP TABLE "temporary_post"`); 52 | await queryRunner.query(`CREATE INDEX "IDX_cd1bddce36edc3e766798eab37" ON "post" ("slug") `); 53 | await queryRunner.query(`CREATE INDEX "IDX_c6fb082a3114f35d0cc27c518e" ON "post" ("authorId") `); 54 | } 55 | 56 | } 57 | -------------------------------------------------------------------------------- /migrations/1610032855562-CreatePostComment.ts: -------------------------------------------------------------------------------- 1 | import {MigrationInterface, QueryRunner} from "typeorm"; 2 | 3 | export class CreatePostComment1610032855562 implements MigrationInterface { 4 | name = 'CreatePostComment1610032855562' 5 | 6 | public async up(queryRunner: QueryRunner): Promise { 7 | await queryRunner.query(`DROP INDEX "IDX_a62473490b3e4578fd683235c5"`); 8 | await queryRunner.query(`CREATE TABLE "temporary_user" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "login" varchar NOT NULL, "hashedPassword" varchar NOT NULL, "firstname" varchar NOT NULL, "lastname" varchar NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "role" varchar CHECK( role IN ('user','admin') ) NOT NULL DEFAULT ('user'), CONSTRAINT "UQ_a62473490b3e4578fd683235c5e" UNIQUE ("login"))`); 9 | await queryRunner.query(`INSERT INTO "temporary_user"("id", "login", "hashedPassword", "firstname", "lastname", "createdAt", "updatedAt", "role") SELECT "id", "login", "hashedPassword", "firstname", "lastname", "createdAt", "updatedAt", "role" FROM "user"`); 10 | await queryRunner.query(`DROP TABLE "user"`); 11 | await queryRunner.query(`ALTER TABLE "temporary_user" RENAME TO "user"`); 12 | await queryRunner.query(`CREATE INDEX "IDX_a62473490b3e4578fd683235c5" ON "user" ("login") `); 13 | await queryRunner.query(`CREATE TABLE "comment" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "text" varchar NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "authorId" integer, "postId" integer NOT NULL)`); 14 | await queryRunner.query(`CREATE INDEX "IDX_276779da446413a0d79598d4fb" ON "comment" ("authorId") `); 15 | await queryRunner.query(`CREATE INDEX "IDX_94a85bb16d24033a2afdd5df06" ON "comment" ("postId") `); 16 | await queryRunner.query(`DROP INDEX "IDX_a62473490b3e4578fd683235c5"`); 17 | await queryRunner.query(`CREATE TABLE "temporary_user" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "login" varchar NOT NULL, "hashedPassword" varchar NOT NULL, "firstname" varchar NOT NULL, "lastname" varchar NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "role" varchar CHECK( role IN ('user','admin') ) NOT NULL DEFAULT ('user'), CONSTRAINT "UQ_a62473490b3e4578fd683235c5e" UNIQUE ("login"))`); 18 | await queryRunner.query(`INSERT INTO "temporary_user"("id", "login", "hashedPassword", "firstname", "lastname", "createdAt", "updatedAt", "role") SELECT "id", "login", "hashedPassword", "firstname", "lastname", "createdAt", "updatedAt", "role" FROM "user"`); 19 | await queryRunner.query(`DROP TABLE "user"`); 20 | await queryRunner.query(`ALTER TABLE "temporary_user" RENAME TO "user"`); 21 | await queryRunner.query(`CREATE INDEX "IDX_a62473490b3e4578fd683235c5" ON "user" ("login") `); 22 | await queryRunner.query(`DROP INDEX "IDX_276779da446413a0d79598d4fb"`); 23 | await queryRunner.query(`DROP INDEX "IDX_94a85bb16d24033a2afdd5df06"`); 24 | await queryRunner.query(`CREATE TABLE "temporary_comment" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "text" varchar NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "authorId" integer, "postId" integer NOT NULL, CONSTRAINT "FK_276779da446413a0d79598d4fbd" FOREIGN KEY ("authorId") REFERENCES "user" ("id") ON DELETE SET NULL ON UPDATE NO ACTION, CONSTRAINT "FK_94a85bb16d24033a2afdd5df060" FOREIGN KEY ("postId") REFERENCES "post" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)`); 25 | await queryRunner.query(`INSERT INTO "temporary_comment"("id", "text", "createdAt", "updatedAt", "authorId", "postId") SELECT "id", "text", "createdAt", "updatedAt", "authorId", "postId" FROM "comment"`); 26 | await queryRunner.query(`DROP TABLE "comment"`); 27 | await queryRunner.query(`ALTER TABLE "temporary_comment" RENAME TO "comment"`); 28 | await queryRunner.query(`CREATE INDEX "IDX_276779da446413a0d79598d4fb" ON "comment" ("authorId") `); 29 | await queryRunner.query(`CREATE INDEX "IDX_94a85bb16d24033a2afdd5df06" ON "comment" ("postId") `); 30 | } 31 | 32 | public async down(queryRunner: QueryRunner): Promise { 33 | await queryRunner.query(`DROP INDEX "IDX_94a85bb16d24033a2afdd5df06"`); 34 | await queryRunner.query(`DROP INDEX "IDX_276779da446413a0d79598d4fb"`); 35 | await queryRunner.query(`ALTER TABLE "comment" RENAME TO "temporary_comment"`); 36 | await queryRunner.query(`CREATE TABLE "comment" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "text" varchar NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "authorId" integer, "postId" integer NOT NULL)`); 37 | await queryRunner.query(`INSERT INTO "comment"("id", "text", "createdAt", "updatedAt", "authorId", "postId") SELECT "id", "text", "createdAt", "updatedAt", "authorId", "postId" FROM "temporary_comment"`); 38 | await queryRunner.query(`DROP TABLE "temporary_comment"`); 39 | await queryRunner.query(`CREATE INDEX "IDX_94a85bb16d24033a2afdd5df06" ON "comment" ("postId") `); 40 | await queryRunner.query(`CREATE INDEX "IDX_276779da446413a0d79598d4fb" ON "comment" ("authorId") `); 41 | await queryRunner.query(`DROP INDEX "IDX_a62473490b3e4578fd683235c5"`); 42 | await queryRunner.query(`ALTER TABLE "user" RENAME TO "temporary_user"`); 43 | await queryRunner.query(`CREATE TABLE "user" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "login" varchar NOT NULL, "hashedPassword" varchar NOT NULL, "firstname" varchar NOT NULL, "lastname" varchar NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "role" varchar CHECK( role IN ('user','admin') ) NOT NULL DEFAULT ('user'), CONSTRAINT "UQ_a62473490b3e4578fd683235c5e" UNIQUE ("login"))`); 44 | await queryRunner.query(`INSERT INTO "user"("id", "login", "hashedPassword", "firstname", "lastname", "createdAt", "updatedAt", "role") SELECT "id", "login", "hashedPassword", "firstname", "lastname", "createdAt", "updatedAt", "role" FROM "temporary_user"`); 45 | await queryRunner.query(`DROP TABLE "temporary_user"`); 46 | await queryRunner.query(`CREATE INDEX "IDX_a62473490b3e4578fd683235c5" ON "user" ("login") `); 47 | await queryRunner.query(`DROP INDEX "IDX_94a85bb16d24033a2afdd5df06"`); 48 | await queryRunner.query(`DROP INDEX "IDX_276779da446413a0d79598d4fb"`); 49 | await queryRunner.query(`DROP TABLE "comment"`); 50 | await queryRunner.query(`DROP INDEX "IDX_a62473490b3e4578fd683235c5"`); 51 | await queryRunner.query(`ALTER TABLE "user" RENAME TO "temporary_user"`); 52 | await queryRunner.query(`CREATE TABLE "user" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "login" varchar NOT NULL, "hashedPassword" varchar NOT NULL, "firstname" varchar NOT NULL, "lastname" varchar NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "role" varchar CHECK( role IN ('user','admin') ) NOT NULL DEFAULT ('user'), CONSTRAINT "UQ_a62473490b3e4578fd683235c5e" UNIQUE ("login"))`); 53 | await queryRunner.query(`INSERT INTO "user"("id", "login", "hashedPassword", "firstname", "lastname", "createdAt", "updatedAt", "role") SELECT "id", "login", "hashedPassword", "firstname", "lastname", "createdAt", "updatedAt", "role" FROM "temporary_user"`); 54 | await queryRunner.query(`DROP TABLE "temporary_user"`); 55 | await queryRunner.query(`CREATE INDEX "IDX_a62473490b3e4578fd683235c5" ON "user" ("login") `); 56 | } 57 | 58 | } 59 | -------------------------------------------------------------------------------- /migrations/1610192757054-CreateAttachedFilesTable.ts: -------------------------------------------------------------------------------- 1 | import {MigrationInterface, QueryRunner} from "typeorm"; 2 | 3 | export class CreateAttachedFilesTable1610192757054 implements MigrationInterface { 4 | name = 'CreateAttachedFilesTable1610192757054' 5 | 6 | public async up(queryRunner: QueryRunner): Promise { 7 | await queryRunner.query(`DROP INDEX "IDX_a62473490b3e4578fd683235c5"`); 8 | await queryRunner.query(`CREATE TABLE "temporary_user" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "login" varchar NOT NULL, "hashedPassword" varchar NOT NULL, "firstname" varchar NOT NULL, "lastname" varchar NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "role" varchar CHECK( role IN ('user','admin') ) NOT NULL DEFAULT ('user'), CONSTRAINT "UQ_a62473490b3e4578fd683235c5e" UNIQUE ("login"))`); 9 | await queryRunner.query(`INSERT INTO "temporary_user"("id", "login", "hashedPassword", "firstname", "lastname", "createdAt", "updatedAt", "role") SELECT "id", "login", "hashedPassword", "firstname", "lastname", "createdAt", "updatedAt", "role" FROM "user"`); 10 | await queryRunner.query(`DROP TABLE "user"`); 11 | await queryRunner.query(`ALTER TABLE "temporary_user" RENAME TO "user"`); 12 | await queryRunner.query(`CREATE INDEX "IDX_a62473490b3e4578fd683235c5" ON "user" ("login") `); 13 | await queryRunner.query(`CREATE TABLE "attached_file" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "path" varchar NOT NULL, "filename" varchar NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "postId" integer NOT NULL)`); 14 | await queryRunner.query(`CREATE INDEX "IDX_60479e70986befe8282b9b8cd0" ON "attached_file" ("postId") `); 15 | await queryRunner.query(`DROP INDEX "IDX_a62473490b3e4578fd683235c5"`); 16 | await queryRunner.query(`CREATE TABLE "temporary_user" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "login" varchar NOT NULL, "hashedPassword" varchar NOT NULL, "firstname" varchar NOT NULL, "lastname" varchar NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "role" varchar CHECK( role IN ('user','admin') ) NOT NULL DEFAULT ('user'), CONSTRAINT "UQ_a62473490b3e4578fd683235c5e" UNIQUE ("login"))`); 17 | await queryRunner.query(`INSERT INTO "temporary_user"("id", "login", "hashedPassword", "firstname", "lastname", "createdAt", "updatedAt", "role") SELECT "id", "login", "hashedPassword", "firstname", "lastname", "createdAt", "updatedAt", "role" FROM "user"`); 18 | await queryRunner.query(`DROP TABLE "user"`); 19 | await queryRunner.query(`ALTER TABLE "temporary_user" RENAME TO "user"`); 20 | await queryRunner.query(`CREATE INDEX "IDX_a62473490b3e4578fd683235c5" ON "user" ("login") `); 21 | await queryRunner.query(`DROP INDEX "IDX_60479e70986befe8282b9b8cd0"`); 22 | await queryRunner.query(`CREATE TABLE "temporary_attached_file" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "path" varchar NOT NULL, "filename" varchar NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "postId" integer NOT NULL, CONSTRAINT "FK_60479e70986befe8282b9b8cd04" FOREIGN KEY ("postId") REFERENCES "post" ("id") ON DELETE CASCADE ON UPDATE NO ACTION)`); 23 | await queryRunner.query(`INSERT INTO "temporary_attached_file"("id", "path", "filename", "createdAt", "updatedAt", "postId") SELECT "id", "path", "filename", "createdAt", "updatedAt", "postId" FROM "attached_file"`); 24 | await queryRunner.query(`DROP TABLE "attached_file"`); 25 | await queryRunner.query(`ALTER TABLE "temporary_attached_file" RENAME TO "attached_file"`); 26 | await queryRunner.query(`CREATE INDEX "IDX_60479e70986befe8282b9b8cd0" ON "attached_file" ("postId") `); 27 | } 28 | 29 | public async down(queryRunner: QueryRunner): Promise { 30 | await queryRunner.query(`DROP INDEX "IDX_60479e70986befe8282b9b8cd0"`); 31 | await queryRunner.query(`ALTER TABLE "attached_file" RENAME TO "temporary_attached_file"`); 32 | await queryRunner.query(`CREATE TABLE "attached_file" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "path" varchar NOT NULL, "filename" varchar NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "postId" integer NOT NULL)`); 33 | await queryRunner.query(`INSERT INTO "attached_file"("id", "path", "filename", "createdAt", "updatedAt", "postId") SELECT "id", "path", "filename", "createdAt", "updatedAt", "postId" FROM "temporary_attached_file"`); 34 | await queryRunner.query(`DROP TABLE "temporary_attached_file"`); 35 | await queryRunner.query(`CREATE INDEX "IDX_60479e70986befe8282b9b8cd0" ON "attached_file" ("postId") `); 36 | await queryRunner.query(`DROP INDEX "IDX_a62473490b3e4578fd683235c5"`); 37 | await queryRunner.query(`ALTER TABLE "user" RENAME TO "temporary_user"`); 38 | await queryRunner.query(`CREATE TABLE "user" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "login" varchar NOT NULL, "hashedPassword" varchar NOT NULL, "firstname" varchar NOT NULL, "lastname" varchar NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "role" varchar CHECK( role IN ('user','admin') ) NOT NULL DEFAULT ('user'), CONSTRAINT "UQ_a62473490b3e4578fd683235c5e" UNIQUE ("login"))`); 39 | await queryRunner.query(`INSERT INTO "user"("id", "login", "hashedPassword", "firstname", "lastname", "createdAt", "updatedAt", "role") SELECT "id", "login", "hashedPassword", "firstname", "lastname", "createdAt", "updatedAt", "role" FROM "temporary_user"`); 40 | await queryRunner.query(`DROP TABLE "temporary_user"`); 41 | await queryRunner.query(`CREATE INDEX "IDX_a62473490b3e4578fd683235c5" ON "user" ("login") `); 42 | await queryRunner.query(`DROP INDEX "IDX_60479e70986befe8282b9b8cd0"`); 43 | await queryRunner.query(`DROP TABLE "attached_file"`); 44 | await queryRunner.query(`DROP INDEX "IDX_a62473490b3e4578fd683235c5"`); 45 | await queryRunner.query(`ALTER TABLE "user" RENAME TO "temporary_user"`); 46 | await queryRunner.query(`CREATE TABLE "user" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "login" varchar NOT NULL, "hashedPassword" varchar NOT NULL, "firstname" varchar NOT NULL, "lastname" varchar NOT NULL, "createdAt" datetime NOT NULL DEFAULT (datetime('now')), "updatedAt" datetime NOT NULL DEFAULT (datetime('now')), "role" varchar CHECK( role IN ('user','admin') ) NOT NULL DEFAULT ('user'), CONSTRAINT "UQ_a62473490b3e4578fd683235c5e" UNIQUE ("login"))`); 47 | await queryRunner.query(`INSERT INTO "user"("id", "login", "hashedPassword", "firstname", "lastname", "createdAt", "updatedAt", "role") SELECT "id", "login", "hashedPassword", "firstname", "lastname", "createdAt", "updatedAt", "role" FROM "temporary_user"`); 48 | await queryRunner.query(`DROP TABLE "temporary_user"`); 49 | await queryRunner.query(`CREATE INDEX "IDX_a62473490b3e4578fd683235c5" ON "user" ("login") `); 50 | } 51 | 52 | } 53 | -------------------------------------------------------------------------------- /nest-cli.json: -------------------------------------------------------------------------------- 1 | { 2 | "collection": "@nestjs/schematics", 3 | "sourceRoot": "src" 4 | } 5 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "blog-api", 3 | "version": "0.0.1", 4 | "description": "", 5 | "author": "", 6 | "private": true, 7 | "license": "UNLICENSED", 8 | "scripts": { 9 | "typeorm": "ts-node -r tsconfig-paths/register ./node_modules/typeorm/cli.js --config ./src/ormconfig.ts", 10 | "migration:create": "npm run typeorm migration:create -- -n", 11 | "migration:generate": "npm run typeorm migration:generate -- -n", 12 | "migration:migrate": "npm run typeorm migration:run", 13 | "migration:revert": "npm run typeorm migration:revert", 14 | "migration:show": "npm run typeorm migration:show", 15 | "migration:status": "npm run migration:show", 16 | "prebuild": "rimraf dist", 17 | "build": "nest build", 18 | "format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"", 19 | "start": "nest start", 20 | "start:dev": "nest start --watch", 21 | "start:debug": "nest start --debug --watch", 22 | "start:prod": "node dist/main", 23 | "lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix", 24 | "test": "jest", 25 | "test:watch": "jest --watch", 26 | "test:cov": "jest --coverage", 27 | "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand", 28 | "test:e2e": "jest --config ./test/jest-e2e.json" 29 | }, 30 | "dependencies": { 31 | "@casl/ability": "^5.1.2", 32 | "@nestjs/common": "^7.6.5", 33 | "@nestjs/config": "^0.6.1", 34 | "@nestjs/core": "^7.5.1", 35 | "@nestjs/jwt": "^7.2.0", 36 | "@nestjs/passport": "^7.1.5", 37 | "@nestjs/platform-express": "^7.5.1", 38 | "@nestjs/typeorm": "^7.1.5", 39 | "bcrypt": "^5.0.0", 40 | "class-transformer": "^0.3.1", 41 | "class-validator": "^0.12.2", 42 | "dotenv": "^8.2.0", 43 | "figlet": "^1.5.0", 44 | "multer": "^1.4.2", 45 | "passport": "^0.4.1", 46 | "passport-jwt": "^4.0.0", 47 | "passport-local": "^1.0.0", 48 | "reflect-metadata": "^0.1.13", 49 | "rimraf": "^3.0.2", 50 | "rxjs": "^6.6.3", 51 | "sqlite3": "^5.0.0", 52 | "typeorm": "^0.2.29", 53 | "url-slug": "^3.0.0-beta.3" 54 | }, 55 | "devDependencies": { 56 | "@nestjs/cli": "^7.5.1", 57 | "@nestjs/schematics": "^7.1.3", 58 | "@nestjs/testing": "^7.5.1", 59 | "@types/bcrypt": "^3.0.0", 60 | "@types/express": "^4.17.8", 61 | "@types/figlet": "^1.2.1", 62 | "@types/jest": "^26.0.15", 63 | "@types/multer": "^1.4.5", 64 | "@types/node": "^14.14.6", 65 | "@types/passport-jwt": "^3.0.3", 66 | "@types/passport-local": "^1.0.33", 67 | "@types/supertest": "^2.0.10", 68 | "@typescript-eslint/eslint-plugin": "^4.6.1", 69 | "@typescript-eslint/parser": "^4.6.1", 70 | "eslint": "^7.12.1", 71 | "eslint-config-prettier": "7.0.0", 72 | "eslint-plugin-prettier": "^3.1.4", 73 | "jest": "^26.6.3", 74 | "prettier": "^2.1.2", 75 | "supertest": "^6.0.0", 76 | "ts-jest": "^26.4.3", 77 | "ts-loader": "^8.0.8", 78 | "ts-node": "^9.0.0", 79 | "tsconfig-paths": "^3.9.0", 80 | "typescript": "^4.0.5" 81 | }, 82 | "jest": { 83 | "moduleFileExtensions": [ 84 | "js", 85 | "json", 86 | "ts" 87 | ], 88 | "rootDir": "src", 89 | "testRegex": ".*\\.spec\\.ts$", 90 | "transform": { 91 | "^.+\\.(t|j)s$": "ts-jest" 92 | }, 93 | "collectCoverageFrom": [ 94 | "**/*.(t|j)s" 95 | ], 96 | "coverageDirectory": "../coverage", 97 | "testEnvironment": "node" 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /src/app.controller.ts: -------------------------------------------------------------------------------- 1 | 2 | import { Controller, UseGuards, Post, Get } from '@nestjs/common'; 3 | 4 | import { LocalAuthGuard } from '@Auth/guards/local-auth.guard'; 5 | import { AuthService } from '@Auth/services/auth.service'; 6 | import { ReqUser } from '@Auth/decorators/user.decorator'; 7 | import { User } from '@Users/entities/user.entity'; 8 | 9 | @Controller() 10 | export class AppController { 11 | 12 | constructor(private authService: AuthService) {} 13 | 14 | @UseGuards(LocalAuthGuard) 15 | @Post('auth/login') 16 | async login(@ReqUser() user: User) { 17 | return { token: await this.authService.login(user) }; 18 | } 19 | 20 | } 21 | -------------------------------------------------------------------------------- /src/app.module.ts: -------------------------------------------------------------------------------- 1 | import { ClassSerializerInterceptor, Module, ValidationPipe } from '@nestjs/common'; 2 | import { ConfigModule } from '@nestjs/config'; 3 | import { APP_FILTER, APP_INTERCEPTOR, APP_PIPE } from '@nestjs/core'; 4 | 5 | import { AppController } from '@AppRoot/app.controller'; 6 | 7 | import { PostsModule } from '@Posts/posts.module'; 8 | import { DatabaseModule } from '@Common/database/database.module'; 9 | import { LoggingInterceptor } from '@Common/interceptors/logging.interceptor'; 10 | import { TransformResponseInterceptor } from '@Common/interceptors/transform-response.interceptor'; 11 | import { AllExceptionsFilter } from '@Common/filters/all-exceptions.filter'; 12 | import { AuthModule } from '@Auth/auth.module'; 13 | import { UsersModule } from '@Users/users.module'; 14 | import { CaslModule } from '@Acl/casl.module'; 15 | import { CommentsModule } from '@Comments/comments.module'; 16 | 17 | @Module({ 18 | imports: [ 19 | ConfigModule.forRoot({ 20 | isGlobal: true, 21 | envFilePath: ['.env'], 22 | expandVariables: true 23 | }), 24 |   DatabaseModule, 25 | PostsModule, 26 | AuthModule, 27 | UsersModule, 28 | CaslModule, 29 | CommentsModule 30 | ], 31 | controllers: [AppController], 32 | providers: [ 33 | { 34 | provide: APP_PIPE, 35 | useValue: new ValidationPipe({ 36 | whitelist: true, 37 | transform: true, 38 | forbidUnknownValues: true 39 | }) 40 | }, 41 | { 42 | provide: APP_INTERCEPTOR, 43 | useClass: TransformResponseInterceptor 44 | }, 45 | { 46 | provide: APP_INTERCEPTOR, 47 | useClass: LoggingInterceptor 48 | }, 49 | { 50 | provide: APP_FILTER, 51 | useClass: AllExceptionsFilter 52 | }, 53 | { 54 | provide: APP_INTERCEPTOR, 55 | useClass: ClassSerializerInterceptor 56 | } 57 | ], 58 | }) 59 | export class AppModule {} 60 | -------------------------------------------------------------------------------- /src/auth/auth.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { ConfigModule, ConfigService } from '@nestjs/config'; 3 | 4 | import { PassportModule } from '@nestjs/passport'; 5 | import { JwtModule } from '@nestjs/jwt'; 6 | 7 | import { AuthService } from '@Auth/services/auth.service'; 8 | import { LocalStrategy } from '@Auth/strategies/local.strategy'; 9 | import { JwtStrategy } from '@Auth/strategies/jwt.strategy'; 10 | import { JwtConstants } from '@Auth/constants'; 11 | 12 | import { UsersModule } from '@Users/users.module'; 13 | 14 | import { BcryptService } from '@Common/utils/bcrypt.service'; 15 | import { Env } from '@Common/env-variables'; 16 | 17 | @Module({ 18 | imports: [ 19 | UsersModule, 20 | PassportModule, 21 | JwtModule.registerAsync({ 22 | imports: [ConfigModule], 23 | useFactory: async (configService: ConfigService) => ({ 24 | secret: configService.get(Env.JWT_SECRET, JwtConstants.secret), 25 | signOptions: { 26 | expiresIn: configService.get(Env.JWT_EXPIRES_IN, JwtConstants.expiresIn) 27 | } 28 | }), 29 | inject: [ConfigService] 30 | }) 31 | ], 32 | providers: [ 33 | BcryptService, 34 | AuthService, 35 | LocalStrategy, 36 | JwtStrategy 37 | ], 38 | exports: [AuthService] 39 | }) 40 | export class AuthModule {} 41 | -------------------------------------------------------------------------------- /src/auth/constants.ts: -------------------------------------------------------------------------------- 1 | 2 | export const JwtConstants = { 3 | secret: 'my-awesome-secret-key', 4 | expiresIn: '60s' 5 | } 6 | -------------------------------------------------------------------------------- /src/auth/decorators/public.decorator.ts: -------------------------------------------------------------------------------- 1 | import { SetMetadata } from '@nestjs/common'; 2 | 3 | export const IS_PUBLIC_KEY = 'isPublic'; 4 | export const Public = (isPublic: boolean = true) => SetMetadata(IS_PUBLIC_KEY, isPublic); 5 | -------------------------------------------------------------------------------- /src/auth/decorators/user.decorator.ts: -------------------------------------------------------------------------------- 1 | 2 | import { createParamDecorator, ExecutionContext } from '@nestjs/common'; 3 | import { Request } from 'express'; 4 | 5 | import { User } from '@Users/entities/user.entity'; 6 | 7 | export const ReqUser = createParamDecorator( 8 | (data: unknown, ctx: ExecutionContext) => { 9 | const req = ctx.switchToHttp().getRequest(); 10 | const user: User = req.user; 11 | return user as User; 12 | } 13 | ) 14 | -------------------------------------------------------------------------------- /src/auth/dtos/jwt-payload.dto.ts: -------------------------------------------------------------------------------- 1 | 2 | import { Role } from "@Users/enums/role.enum"; 3 | 4 | export class JwtPayload { 5 | sub: number; 6 | login: string; 7 | firstname: string; 8 | lastname: string; 9 | role: Role; 10 | } 11 | -------------------------------------------------------------------------------- /src/auth/guards/jwt-auth.guard.ts: -------------------------------------------------------------------------------- 1 | 2 | import { ExecutionContext, Injectable } from '@nestjs/common'; 3 | import { Reflector } from '@nestjs/core'; 4 | import { AuthGuard } from '@nestjs/passport'; 5 | 6 | import { IS_PUBLIC_KEY } from '@Auth/decorators/public.decorator'; 7 | 8 | @Injectable() 9 | export class JwtAuthGuard extends AuthGuard('jwt') { 10 | constructor(private reflector: Reflector) { 11 | super(); 12 | } 13 | 14 | canActivate(ctx: ExecutionContext) { 15 | const isPublic = this.reflector.getAllAndOverride(IS_PUBLIC_KEY, [ 16 | ctx.getHandler(), 17 | ctx.getClass() 18 | ]); 19 | if (isPublic) { 20 | return true; 21 | } else { 22 | return super.canActivate(ctx); 23 | } 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/auth/guards/local-auth.guard.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { AuthGuard } from '@nestjs/passport'; 3 | 4 | @Injectable() 5 | export class LocalAuthGuard extends AuthGuard('local') {} 6 | -------------------------------------------------------------------------------- /src/auth/services/auth.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { AuthService } from './auth.service'; 3 | 4 | describe('AuthService', () => { 5 | let service: AuthService; 6 | 7 | beforeEach(async () => { 8 | const module: TestingModule = await Test.createTestingModule({ 9 | providers: [AuthService], 10 | }).compile(); 11 | 12 | service = module.get(AuthService); 13 | }); 14 | 15 | it('should be defined', () => { 16 | expect(service).toBeDefined(); 17 | }); 18 | }); 19 | -------------------------------------------------------------------------------- /src/auth/services/auth.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { JwtService } from '@nestjs/jwt'; 3 | 4 | import { BcryptService } from '@Common/utils/bcrypt.service'; 5 | import { UsersService } from '@Users/services/users.service'; 6 | import { User } from '@Users/entities/user.entity'; 7 | 8 | import { JwtPayload } from '@Auth/dtos/jwt-payload.dto'; 9 | 10 | @Injectable() 11 | export class AuthService { 12 | constructor( 13 | private usersService: UsersService, 14 | private bcryptService: BcryptService, 15 | private jwtService: JwtService 16 | ) {} 17 | 18 | async validateUser(login: string, password: string): Promise { 19 | const user = await this.usersService.findByLogin(login); 20 | if (user) { 21 | const comparedPassword = await this.bcryptService.compare(password, user.hashedPassword); 22 | if (comparedPassword) return user; 23 | } 24 | return null; 25 | } 26 | 27 | async login(user: User) { 28 | const payload: JwtPayload = { 29 | login: user.login, 30 | sub: user.id, 31 | firstname: user.firstname, 32 | lastname: user.lastname, 33 | role: user.role 34 | }; 35 | 36 | return this.jwtService.sign(payload); 37 | } 38 | 39 | async retrieveUserFromJwt(jwtPayload: JwtPayload): Promise { 40 | const user: User = await this.usersService.findById(jwtPayload.sub); 41 | if (user) { 42 | if (user.login === jwtPayload.login) { 43 | return user; 44 | } 45 | } 46 | return null; 47 | } 48 | 49 | } 50 | -------------------------------------------------------------------------------- /src/auth/strategies/jwt.strategy.ts: -------------------------------------------------------------------------------- 1 | 2 | import { ExtractJwt, Strategy } from 'passport-jwt'; 3 | import { PassportStrategy } from '@nestjs/passport'; 4 | import { Injectable, UnauthorizedException } from '@nestjs/common'; 5 | import { ConfigService } from '@nestjs/config'; 6 | 7 | import { Env } from '@Common/env-variables'; 8 | import { User } from '@Users/entities/user.entity'; 9 | 10 | import { JwtConstants } from '@Auth/constants'; 11 | import { JwtPayload } from '@Auth/dtos/jwt-payload.dto'; 12 | import { AuthService } from '@Auth/services/auth.service'; 13 | 14 | interface FullJwtPayload extends JwtPayload { 15 | iat: number; 16 | exp: number; 17 | } 18 | 19 | @Injectable() 20 | export class JwtStrategy extends PassportStrategy(Strategy) { 21 | constructor( 22 | private configService: ConfigService, 23 | private authService: AuthService 24 | ) { 25 | super({ 26 | jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), 27 | ignoreExpiration: false, 28 | secretOrKey: configService.get(Env.JWT_SECRET, JwtConstants.secret) 29 | }); 30 | } 31 | 32 | async validate(fullPayload: FullJwtPayload): Promise { 33 | const { exp, iat, ...payload } = fullPayload; 34 | const user = await this.authService.retrieveUserFromJwt(payload); 35 | if (!user) { 36 | throw new UnauthorizedException(); 37 | } 38 | return user; 39 | } 40 | 41 | } 42 | -------------------------------------------------------------------------------- /src/auth/strategies/local.strategy.ts: -------------------------------------------------------------------------------- 1 | 2 | import { Strategy } from 'passport-local'; 3 | import { PassportStrategy } from '@nestjs/passport'; 4 | import { Injectable, UnauthorizedException } from '@nestjs/common'; 5 | 6 | import { User } from '@Users/entities/user.entity'; 7 | 8 | import { AuthService } from '@Auth/services/auth.service'; 9 | 10 | @Injectable() 11 | export class LocalStrategy extends PassportStrategy(Strategy) { 12 | 13 | constructor(private authService: AuthService) { 14 | super({ 15 | usernameField: 'login', 16 | passwordField: 'password' 17 | }); 18 | } 19 | 20 | async validate(username: string, password: string): Promise { 21 | const user = await this.authService.validateUser(username, password); 22 | if (!user) { 23 | throw new UnauthorizedException(); 24 | } 25 | return user; 26 | } 27 | 28 | } 29 | -------------------------------------------------------------------------------- /src/casl/casl-ability.factory.ts: -------------------------------------------------------------------------------- 1 | 2 | import { Ability, AbilityBuilder, AbilityClass, ExtractSubjectType } from '@casl/ability'; 3 | import { Injectable } from '@nestjs/common'; 4 | 5 | import { FlatPost, FlatAttachedFile } from '@Acl/types'; 6 | 7 | import { Action } from '@Acl/enums/action.enum'; 8 | import { Post } from '@Posts/entities/post.entity'; 9 | import { AttachedFile } from '@Posts/entities/attached-file.entity'; 10 | import { User } from '@Users/entities/user.entity'; 11 | import { Role } from '@Users/enums/role.enum'; 12 | 13 | type Subjects = typeof Post | typeof User | typeof AttachedFile | Post | User | AttachedFile | 'all'; 14 | 15 | export type AppAbility = Ability<[Action, Subjects]>; 16 | 17 | @Injectable() 18 | export class CaslAbilityFactory { 19 | 20 | createForUser(user: User) { 21 | 22 | const { can, cannot, build } = new AbilityBuilder< 23 | Ability<[Action, Subjects]> 24 | >(Ability as AbilityClass); 25 | 26 | if (user.role === Role.Admin) { 27 | can(Action.MANAGE, 'all'); 28 | can(Action.DELETE, Post); 29 | } else { 30 | can(Action.READ, Post); 31 | can(Action.UPDATE, User, { id: user.id }); 32 | can(Action.READ, User, { id: user.id }); 33 | can(Action.READ, User, { login: user.login }); 34 | can(Action.CREATE, Post); 35 | cannot(Action.DELETE, Post); 36 | 37 | can(Action.DELETE, AttachedFile, { 'post.author.id': user.id }); 38 | } 39 | 40 | can(Action.UPDATE, Post, { 'author.id': user.id }); 41 | 42 | return build({ 43 | detectSubjectType: type => type.constructor as ExtractSubjectType 44 | }); 45 | } 46 | 47 | } 48 | -------------------------------------------------------------------------------- /src/casl/casl.module.ts: -------------------------------------------------------------------------------- 1 | import { Module, Global } from '@nestjs/common'; 2 | 3 | import { CaslAbilityFactory } from '@Acl/casl-ability.factory'; 4 | import { aclProviders } from '@Acl/providers' 5 | import { policies } from '@Acl/policies'; 6 | 7 | @Global() 8 | @Module({ 9 | providers: [ 10 | CaslAbilityFactory, 11 | ...aclProviders 12 | ], 13 | exports: [ 14 | CaslAbilityFactory, 15 | ...policies 16 | ] 17 | }) 18 | export class CaslModule {} 19 | -------------------------------------------------------------------------------- /src/casl/constants.ts: -------------------------------------------------------------------------------- 1 | 2 | export const CHECK_POLICIES_KEY = '__check_policy__'; 3 | -------------------------------------------------------------------------------- /src/casl/decorators/acl.decorator.ts: -------------------------------------------------------------------------------- 1 | 2 | import { CanActivate, UseGuards, Type } from '@nestjs/common'; 3 | 4 | import { PoliciesGuard } from '@Acl/guards/policies.guard'; 5 | 6 | export function Acl(authGuard: Type) { 7 | return UseGuards(authGuard, PoliciesGuard) 8 | } 9 | -------------------------------------------------------------------------------- /src/casl/decorators/check-policies.decorator.ts: -------------------------------------------------------------------------------- 1 | import { SetMetadata, Type } from '@nestjs/common'; 2 | 3 | import { PolicyHandler } from '@Acl/policies/policy-handler.interface'; 4 | import { CHECK_POLICIES_KEY } from '@Acl/constants'; 5 | 6 | 7 | export const CheckPolicies = (...handlers: Type[]) => SetMetadata(CHECK_POLICIES_KEY, handlers); 8 | -------------------------------------------------------------------------------- /src/casl/enums/action.enum.ts: -------------------------------------------------------------------------------- 1 | 2 | export enum Action { 3 | MANAGE = 'manage', 4 | CREATE = 'create', 5 | READ = 'read', 6 | UPDATE = 'update', 7 | DELETE = 'delete' 8 | } 9 | -------------------------------------------------------------------------------- /src/casl/guards/policies.guard.ts: -------------------------------------------------------------------------------- 1 | 2 | import { Injectable, CanActivate, ExecutionContext, Type, Scope } from '@nestjs/common'; 3 | import { ContextIdFactory, ModuleRef, Reflector } from '@nestjs/core'; 4 | import { Request } from 'express'; 5 | 6 | import { CaslAbilityFactory } from '@Acl/casl-ability.factory'; 7 | import { PolicyHandler } from '@Acl/policies/policy-handler.interface'; 8 | import { CHECK_POLICIES_KEY } from '@Acl/constants'; 9 | 10 | @Injectable() 11 | export class PoliciesGuard implements CanActivate { 12 | 13 | constructor( 14 | private caslAbilityFactory: CaslAbilityFactory, 15 | private reflector: Reflector, 16 | private moduleRef: ModuleRef 17 | ) {} 18 | 19 | async canActivate(ctx: ExecutionContext) { 20 | const policiesHandlersRef = this.reflector.get[]>( 21 | CHECK_POLICIES_KEY, 22 | ctx.getHandler() 23 | ) || []; 24 | 25 | if (policiesHandlersRef.length === 0) return true; 26 | 27 | const contextId = ContextIdFactory.create(); 28 | this.moduleRef.registerRequestByContextId(ctx.switchToHttp().getRequest(), contextId); 29 | 30 | let policyHandlers: PolicyHandler[] = []; 31 | for (let i = 0; i < policiesHandlersRef.length; i++) { 32 | const policyHandlerRef = policiesHandlersRef[i]; 33 | const policyScope = this.moduleRef.introspect(policyHandlerRef).scope; 34 | let policyHandler: PolicyHandler; 35 | if (policyScope === Scope.DEFAULT) { 36 | policyHandler = this.moduleRef.get(policyHandlerRef, { strict: false }); 37 | } else { 38 | policyHandler = await this.moduleRef.resolve(policyHandlerRef, contextId, {strict: false}); 39 | } 40 | policyHandlers.push(policyHandler); 41 | } 42 | 43 | const { user } = ctx.switchToHttp().getRequest(); 44 | if (!user) return false; 45 | 46 | const ability = this.caslAbilityFactory.createForUser(user); 47 | return policyHandlers.every((handler) => handler.handle(ability)); 48 | } 49 | 50 | } 51 | -------------------------------------------------------------------------------- /src/casl/policies/attached-files/detach-file-policy.handler.ts: -------------------------------------------------------------------------------- 1 | 2 | import { AttachedFile } from '@Posts/entities/attached-file.entity'; 3 | 4 | import { AppAbility } from '@Acl/casl-ability.factory'; 5 | import { Action } from '@Acl/enums/action.enum'; 6 | 7 | import { PolicyHandler } from '../policy-handler.interface'; 8 | 9 | export class DetachFileHandler implements PolicyHandler { 10 | 11 | constructor(private attachedFile: AttachedFile) {} 12 | 13 | handle(ability: AppAbility): boolean { 14 | console.log({file: this.attachedFile}) 15 | if (!this.attachedFile) return false; 16 | return ability.can(Action.DELETE, this.attachedFile); 17 | } 18 | 19 | } 20 | -------------------------------------------------------------------------------- /src/casl/policies/attached-files/index.ts: -------------------------------------------------------------------------------- 1 | 2 | import { Type } from '@nestjs/common'; 3 | 4 | import { PolicyHandler } from '../policy-handler.interface'; 5 | 6 | import { DetachFileHandler } from './detach-file-policy.handler'; 7 | 8 | export * from './detach-file-policy.handler'; 9 | 10 | export const attachedFilesPolicies: Type[] = [ 11 | DetachFileHandler 12 | ] 13 | -------------------------------------------------------------------------------- /src/casl/policies/index.ts: -------------------------------------------------------------------------------- 1 | import { Type } from '@nestjs/common'; 2 | 3 | import { PolicyHandler } from './policy-handler.interface'; 4 | 5 | import { postPolicies } from './posts'; 6 | import { userPolicies } from './users'; 7 | import { attachedFilesPolicies } from './attached-files'; 8 | 9 | export * from './posts'; 10 | export * from './users'; 11 | export * from './attached-files'; 12 | 13 | export const policies: Type[] = [ 14 | ...postPolicies, 15 | ...userPolicies, 16 | ...attachedFilesPolicies 17 | ] 18 | -------------------------------------------------------------------------------- /src/casl/policies/policy-handler.interface.ts: -------------------------------------------------------------------------------- 1 | 2 | import { AppAbility } from '@Acl/casl-ability.factory'; 3 | 4 | export interface PolicyHandler { 5 | handle(ability: AppAbility): boolean; 6 | } 7 | -------------------------------------------------------------------------------- /src/casl/policies/posts/create-post-policy.handler.ts: -------------------------------------------------------------------------------- 1 | 2 | import { Post } from '@Posts/entities/post.entity'; 3 | 4 | import { AppAbility } from '@Acl/casl-ability.factory'; 5 | import { Action } from '@Acl/enums/action.enum'; 6 | 7 | import { PolicyHandler } from '../policy-handler.interface'; 8 | 9 | export class CreatePostHandler implements PolicyHandler { 10 | 11 | handle(ability: AppAbility): boolean { 12 | return ability.can(Action.CREATE, Post); 13 | } 14 | 15 | } 16 | -------------------------------------------------------------------------------- /src/casl/policies/posts/edit-post-policy.handler.ts: -------------------------------------------------------------------------------- 1 | 2 | import { Post } from '@Posts/entities/post.entity'; 3 | 4 | import { AppAbility } from '@Acl/casl-ability.factory'; 5 | import { Action } from '@Acl/enums/action.enum'; 6 | 7 | import { PolicyHandler } from '../policy-handler.interface'; 8 | 9 | export class EditPostHandler implements PolicyHandler { 10 | 11 | constructor(private post: Post) {} 12 | 13 | handle(ability: AppAbility): boolean { 14 | if (!this.post) return false; 15 | return ability.can(Action.UPDATE, this.post); 16 | } 17 | 18 | } 19 | -------------------------------------------------------------------------------- /src/casl/policies/posts/index.ts: -------------------------------------------------------------------------------- 1 | 2 | import { Type } from '@nestjs/common'; 3 | 4 | import { PolicyHandler } from '../policy-handler.interface'; 5 | 6 | import { CreatePostHandler } from './create-post-policy.handler'; 7 | import { EditPostHandler } from './edit-post-policy.handler'; 8 | import { RemovePostHandler } from './remove-post-policy.handler'; 9 | 10 | export * from './create-post-policy.handler'; 11 | export * from './edit-post-policy.handler'; 12 | export * from './remove-post-policy.handler'; 13 | 14 | export const postPolicies: Type[] = [ 15 | EditPostHandler, 16 | CreatePostHandler, 17 | RemovePostHandler 18 | ] 19 | -------------------------------------------------------------------------------- /src/casl/policies/posts/remove-post-policy.handler.ts: -------------------------------------------------------------------------------- 1 | 2 | import { Post } from '@Posts/entities/post.entity'; 3 | 4 | import { AppAbility } from '@Acl/casl-ability.factory'; 5 | import { Action } from '@Acl/enums/action.enum'; 6 | 7 | import { PolicyHandler } from '../policy-handler.interface'; 8 | 9 | export class RemovePostHandler implements PolicyHandler { 10 | 11 | constructor(private post: Post) {} 12 | 13 | handle(ability: AppAbility): boolean { 14 | if (!this.post) return false; 15 | return ability.can(Action.DELETE, this.post); 16 | } 17 | 18 | } 19 | -------------------------------------------------------------------------------- /src/casl/policies/users/create-user-policy.handler.ts: -------------------------------------------------------------------------------- 1 | 2 | import { User } from '@Users/entities/user.entity'; 3 | 4 | import { AppAbility } from '@Acl/casl-ability.factory'; 5 | import { Action } from '@Acl/enums/action.enum'; 6 | 7 | import { PolicyHandler } from '../policy-handler.interface'; 8 | 9 | export class CreateUserHandler implements PolicyHandler { 10 | 11 | handle(ability: AppAbility): boolean { 12 | return ability.can(Action.CREATE, User); 13 | } 14 | 15 | } 16 | -------------------------------------------------------------------------------- /src/casl/policies/users/index.ts: -------------------------------------------------------------------------------- 1 | 2 | import { Type } from '@nestjs/common'; 3 | 4 | import { PolicyHandler } from '../policy-handler.interface'; 5 | 6 | import { CreateUserHandler } from './create-user-policy.handler'; 7 | import { SearchUserHandler } from './search-user-policy.handler'; 8 | 9 | export * from './create-user-policy.handler'; 10 | export * from './search-user-policy.handler'; 11 | 12 | export const userPolicies: Type[] = [ 13 | CreateUserHandler, 14 | SearchUserHandler 15 | ] 16 | -------------------------------------------------------------------------------- /src/casl/policies/users/search-user-policy.handler.ts: -------------------------------------------------------------------------------- 1 | 2 | import { AppAbility } from '@AppRoot/casl/casl-ability.factory'; 3 | import { Action } from '@AppRoot/casl/enums/action.enum'; 4 | import { User } from '@Users/entities/user.entity'; 5 | 6 | import { PolicyHandler } from '../policy-handler.interface'; 7 | 8 | export class SearchUserHandler implements PolicyHandler { 9 | 10 | constructor(private loginToSearch: string) {} 11 | 12 | handle(ability: AppAbility): boolean { 13 | if (!this.loginToSearch) return false; 14 | const mockUserToSearch: User = new User(); 15 | mockUserToSearch.login = this.loginToSearch; 16 | return ability.can(Action.READ, mockUserToSearch); 17 | } 18 | 19 | } 20 | -------------------------------------------------------------------------------- /src/casl/providers/attached-files/detach-file-policy.provider.ts: -------------------------------------------------------------------------------- 1 | 2 | import { Provider } from '@nestjs/common'; 3 | import { REQUEST } from '@nestjs/core'; 4 | import { Request } from 'express'; 5 | 6 | import { DetachFileHandler } from '@Acl/policies/attached-files/detach-file-policy.handler'; 7 | 8 | export const DetachFilePolicyProvider: Provider = { 9 | provide: DetachFileHandler, 10 | inject: [ REQUEST ], 11 | useFactory: (req: Request) => { 12 | if (!req.post) return new DetachFileHandler(undefined); 13 | 14 | const fileId = parseInt(req.params.file_id); 15 | const file = req.post.files.find(file => file.id === fileId); 16 | file.post = req.post; 17 | return new DetachFileHandler(file); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/casl/providers/attached-files/index.ts: -------------------------------------------------------------------------------- 1 | 2 | import { Provider } from '@nestjs/common'; 3 | 4 | import { DetachFilePolicyProvider } from './detach-file-policy.provider'; 5 | 6 | export const attachedFilesPolicyProviders: Provider[] = [ 7 | DetachFilePolicyProvider 8 | ] 9 | -------------------------------------------------------------------------------- /src/casl/providers/index.ts: -------------------------------------------------------------------------------- 1 | 2 | import { Provider } from '@nestjs/common'; 3 | 4 | import { postPolicyProviders } from './posts'; 5 | import { userPolicyProviders } from './users'; 6 | import { attachedFilesPolicyProviders } from './attached-files'; 7 | 8 | export const aclProviders: Provider[] = [ 9 | ...postPolicyProviders, 10 | ...userPolicyProviders, 11 | ...attachedFilesPolicyProviders 12 | ] 13 | -------------------------------------------------------------------------------- /src/casl/providers/posts/create-post-policy.provider.ts: -------------------------------------------------------------------------------- 1 | 2 | import { Provider, Scope } from '@nestjs/common'; 3 | 4 | import { CreatePostHandler } from '@Acl/policies/posts/create-post-policy.handler'; 5 | 6 | export const CreatePostPolicyProvider: Provider = CreatePostHandler; 7 | -------------------------------------------------------------------------------- /src/casl/providers/posts/edit-post-policy.provider.ts: -------------------------------------------------------------------------------- 1 | 2 | import { Provider } from '@nestjs/common'; 3 | import { REQUEST } from '@nestjs/core'; 4 | import { Request } from 'express'; 5 | 6 | import { EditPostHandler } from '@Acl/policies/posts/edit-post-policy.handler'; 7 | 8 | export const EditPostPolicyProvider: Provider = { 9 | provide: EditPostHandler, 10 | inject: [ REQUEST ], 11 | useFactory: (request: Request) => { 12 | return new EditPostHandler(request.post); 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/casl/providers/posts/index.ts: -------------------------------------------------------------------------------- 1 | 2 | import { Provider } from '@nestjs/common'; 3 | 4 | import { EditPostPolicyProvider } from './edit-post-policy.provider'; 5 | import { CreatePostPolicyProvider } from './create-post-policy.provider'; 6 | import { RemovePostPolicyProvider } from './remove-post-policy.provider'; 7 | 8 | export const postPolicyProviders: Provider[] = [ 9 | EditPostPolicyProvider, 10 | CreatePostPolicyProvider, 11 | RemovePostPolicyProvider 12 | ] 13 | -------------------------------------------------------------------------------- /src/casl/providers/posts/remove-post-policy.provider.ts: -------------------------------------------------------------------------------- 1 | 2 | import { Provider } from '@nestjs/common'; 3 | import { REQUEST } from '@nestjs/core'; 4 | import { Request } from 'express'; 5 | 6 | import { RemovePostHandler } from '@Acl/policies/posts/remove-post-policy.handler'; 7 | 8 | export const RemovePostPolicyProvider: Provider = { 9 | provide: RemovePostHandler, 10 | inject: [ REQUEST ], 11 | useFactory: (request: Request) => { 12 | return new RemovePostHandler(request.post); 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/casl/providers/users/create-user-policy.provider.ts: -------------------------------------------------------------------------------- 1 | 2 | import { Provider } from '@nestjs/common'; 3 | 4 | import { CreateUserHandler } from '@Acl/policies/users/create-user-policy.handler'; 5 | 6 | export const CreateUserPolicyProvider: Provider = CreateUserHandler; 7 | -------------------------------------------------------------------------------- /src/casl/providers/users/index.ts: -------------------------------------------------------------------------------- 1 | 2 | import { Provider } from '@nestjs/common'; 3 | 4 | import { CreateUserPolicyProvider } from './create-user-policy.provider'; 5 | import { SearchUserPolicyProvider } from './search-user-policy.provider'; 6 | 7 | export const userPolicyProviders: Provider[] = [ 8 | CreateUserPolicyProvider, 9 | SearchUserPolicyProvider 10 | ] 11 | -------------------------------------------------------------------------------- /src/casl/providers/users/search-user-policy.provider.ts: -------------------------------------------------------------------------------- 1 | 2 | import { Provider } from '@nestjs/common'; 3 | import { REQUEST } from '@nestjs/core'; 4 | import { Request } from 'express'; 5 | 6 | import { SearchUserHandler } from '@Acl/policies/users/search-user-policy.handler'; 7 | 8 | export const SearchUserPolicyProvider: Provider = { 9 | provide: SearchUserHandler, 10 | inject: [ REQUEST ], 11 | useFactory: (request: Request) => { 12 | return new SearchUserHandler(request.params.login); 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/casl/types.ts: -------------------------------------------------------------------------------- 1 | 2 | import { Post } from '@Posts/entities/post.entity'; 3 | import { AttachedFile } from '@Posts/entities/attached-file.entity'; 4 | 5 | export type FlatPost = Post & { 6 | 'author.id': Post['author']['id'] 7 | } 8 | 9 | export type FlatAttachedFile = AttachedFile & { 10 | 'post.author.id': AttachedFile['post']['author']['id'] 11 | } 12 | -------------------------------------------------------------------------------- /src/comments/comments.module.ts: -------------------------------------------------------------------------------- 1 | import { MiddlewareConsumer, Module, NestModule } from '@nestjs/common'; 2 | import { TypeOrmModule } from '@nestjs/typeorm'; 3 | 4 | import { CommentsController } from './controllers/comments.controller'; 5 | import { CommentsService } from './services/comments.service'; 6 | import { CommentRepository } from './repositories/comment.repository'; 7 | 8 | import { RetrievePostBySlugMiddleware } from '@Posts/middlewares/retrieve-post-by-slug.middleware'; 9 | import { PostsModule } from '@Posts/posts.module'; 10 | 11 | @Module({ 12 | controllers: [CommentsController], 13 | imports: [ 14 | TypeOrmModule.forFeature([ 15 | CommentRepository 16 | ]), 17 | PostsModule 18 | ], 19 | providers: [ CommentsService ] 20 | }) 21 | export class CommentsModule implements NestModule { 22 | configure(consumer: MiddlewareConsumer) { 23 | consumer 24 | .apply(RetrievePostBySlugMiddleware) 25 | .forRoutes(CommentsController) 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/comments/controllers/comments.controller.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Body, 3 | Controller, 4 | Logger, 5 | Post 6 | } from '@nestjs/common'; 7 | 8 | import { JwtAuthGuard } from '@Auth/guards/jwt-auth.guard'; 9 | import { ReqUser } from '@Auth/decorators/user.decorator'; 10 | 11 | import { Acl } from '@Acl/decorators/acl.decorator'; 12 | import { CheckPolicies } from '@Acl/decorators/check-policies.decorator'; 13 | 14 | import { PostEntity as PostEntityDecorator } from '@Posts/decorators/post-entity.decorator'; 15 | import { Post as PostEntity } from '@Posts/entities/post.entity'; 16 | 17 | import { CreateCommentDto } from '@Comments/dtos/create-comment.dto'; 18 | import { CommentsService } from '@Comments/services/comments.service'; 19 | 20 | import { User } from '@Users/entities/user.entity'; 21 | 22 | @Controller('posts/:slug/comments') 23 | @Acl(JwtAuthGuard) 24 | export class CommentsController { 25 | 26 | private readonly logger: Logger; 27 | 28 | constructor(private commentsService: CommentsService) { 29 | this.logger = new Logger(CommentsController.name); 30 | } 31 | 32 | @Post() 33 | async createComment(@Body() createCommentDto: CreateCommentDto, @PostEntityDecorator() post: PostEntity, @ReqUser() author: User) { 34 | createCommentDto.author = author; 35 | return await this.commentsService.commentPost(post, createCommentDto); 36 | } 37 | 38 | } 39 | -------------------------------------------------------------------------------- /src/comments/dtos/create-comment.dto.ts: -------------------------------------------------------------------------------- 1 | 2 | import { IsString } from 'class-validator'; 3 | 4 | import { UserDto } from '@Users/dtos/user.dto'; 5 | 6 | export class CreateCommentDto { 7 | 8 | @IsString() 9 | text: string; 10 | 11 | author: UserDto; 12 | 13 | } 14 | -------------------------------------------------------------------------------- /src/comments/entities/comment.entity.ts: -------------------------------------------------------------------------------- 1 | 2 | import { 3 | Entity, 4 | Column, 5 | PrimaryGeneratedColumn, 6 | CreateDateColumn, 7 | UpdateDateColumn, 8 | ManyToOne, 9 | Index 10 | } from 'typeorm'; 11 | import { Expose } from 'class-transformer'; 12 | 13 | import { Post } from '@Posts/entities/post.entity'; 14 | import { User } from '@Users/entities/user.entity'; 15 | 16 | import { CommentExposeGroups } from '@Comments/enums/expose.enum'; 17 | 18 | @Entity() 19 | export class Comment { 20 | 21 | @PrimaryGeneratedColumn() 22 | id: number; 23 | 24 | @ManyToOne(() => User, 25 | { 26 | eager: true, 27 | nullable: true, 28 | onDelete: 'SET NULL' 29 | } 30 | ) 31 | @Index() 32 | author: User; 33 | 34 | @Expose({ 35 | groups: [ 36 | CommentExposeGroups.FULL, 37 | CommentExposeGroups.WITH_POST 38 | ] 39 | }) 40 | @ManyToOne(() => Post, 41 | { 42 | eager: false, 43 | nullable: false, 44 | onDelete: 'CASCADE' 45 | } 46 | ) 47 | @Index() 48 | post: Post; 49 | 50 | @Column() 51 | text: string; 52 | 53 | @CreateDateColumn() 54 | createdAt?: Date; 55 | 56 | @UpdateDateColumn() 57 | updatedAt?: Date; 58 | 59 | } 60 | -------------------------------------------------------------------------------- /src/comments/enums/expose.enum.ts: -------------------------------------------------------------------------------- 1 | 2 | export enum CommentExposeGroups { 3 | FULL = 'comment.full', 4 | BASIC = 'comment.simple', 5 | WITH_AUTHOR = 'comment.with-author', 6 | WITH_POST = 'comment.with-post' 7 | } 8 | -------------------------------------------------------------------------------- /src/comments/repositories/comment.repository.ts: -------------------------------------------------------------------------------- 1 | import { EntityRepository, Repository } from 'typeorm'; 2 | 3 | import { Comment } from '@Comments/entities/comment.entity'; 4 | 5 | @EntityRepository(Comment) 6 | export class CommentRepository extends Repository { } 7 | -------------------------------------------------------------------------------- /src/comments/services/comments.service.ts: -------------------------------------------------------------------------------- 1 | 2 | import { Injectable } from '@nestjs/common'; 3 | 4 | import { Post } from '@Posts/entities/post.entity'; 5 | 6 | import { CreateCommentDto } from '@Comments/dtos/create-comment.dto'; 7 | import { CommentRepository } from '@Comments/repositories/comment.repository'; 8 | 9 | @Injectable() 10 | export class CommentsService { 11 | 12 | constructor(private commentRepository: CommentRepository) {} 13 | 14 | async commentPost(post: Post, comment: CreateCommentDto) { 15 | const commentEntity = this.commentRepository.create(comment); 16 | commentEntity.post = post; 17 | return await this.commentRepository.save(commentEntity); 18 | } 19 | 20 | } 21 | -------------------------------------------------------------------------------- /src/common/database/database-errors.interface.ts: -------------------------------------------------------------------------------- 1 | 2 | export interface IDbErrorCodes { 3 | UniqueViolation: number; 4 | } 5 | 6 | export interface IDbErrors { 7 | UniqueViolation: string; 8 | } 9 | -------------------------------------------------------------------------------- /src/common/database/database-errors.ts: -------------------------------------------------------------------------------- 1 | 2 | import { IDbErrorCodes, IDbErrors } from './database-errors.interface'; 3 | import { MySQLErrorCodes, MySQLErrors } from './mysql-errors.enum'; 4 | import { SqliteErrorCode, SqliteErrors } from './sqlite-errors.enum'; 5 | 6 | import * as ormconfig from '@AppRoot/ormconfig'; 7 | 8 | export const DbErrors: IDbErrors = (ormconfig.type === 'mysql') ? MySQLErrors : SqliteErrors; 9 | export const DbErrorCodes: IDbErrorCodes = (ormconfig.type === 'mysql') ? MySQLErrorCodes : SqliteErrorCode; 10 | -------------------------------------------------------------------------------- /src/common/database/database.config.ts: -------------------------------------------------------------------------------- 1 | 2 | import { TypeOrmModuleOptions } from '@nestjs/typeorm'; 3 | import * as ormconfig from '@AppRoot/ormconfig'; 4 | 5 | export default () => { 6 | let config: TypeOrmModuleOptions = { 7 | ...ormconfig, 8 | autoLoadEntities: true 9 | } 10 | return config; 11 | } 12 | -------------------------------------------------------------------------------- /src/common/database/database.module.ts: -------------------------------------------------------------------------------- 1 | 2 | import { TypeOrmModule } from "@nestjs/typeorm"; 3 | 4 | import databaseConfig from './database.config'; 5 | 6 | export const DatabaseModule = TypeOrmModule.forRootAsync({ 7 | imports: [], 8 | useFactory: databaseConfig, 9 | inject: [] 10 | }); 11 | -------------------------------------------------------------------------------- /src/common/database/mysql-errors.enum.ts: -------------------------------------------------------------------------------- 1 | 2 | import { IDbErrorCodes, IDbErrors } from './database-errors.interface'; 3 | 4 | 5 | export const MySQLErrorCodes: IDbErrorCodes = { 6 | UniqueViolation: 1169 7 | } 8 | 9 | export const MySQLErrors: IDbErrors = { 10 | UniqueViolation: 'ER_DUP_UNIQUE' 11 | } 12 | -------------------------------------------------------------------------------- /src/common/database/sqlite-errors.enum.ts: -------------------------------------------------------------------------------- 1 | 2 | 3 | import { IDbErrorCodes, IDbErrors } from './database-errors.interface'; 4 | 5 | export const SqliteErrorCode: IDbErrorCodes = { 6 | UniqueViolation: 19 7 | } 8 | 9 | export const SqliteErrors: IDbErrors = { 10 | UniqueViolation: 'SQLITE_CONSTRAINT' 11 | } 12 | -------------------------------------------------------------------------------- /src/common/decorators/match.decorator.ts: -------------------------------------------------------------------------------- 1 | import { 2 | registerDecorator, 3 | ValidationArguments, 4 | ValidationOptions, 5 | ValidatorConstraint, 6 | ValidatorConstraintInterface 7 | } from 'class-validator'; 8 | 9 | export function Match(property: string, validationOptions?: ValidationOptions) { 10 | return (object: any, propertyName: string) => { 11 | registerDecorator({ 12 | target: object.constructor, 13 | propertyName, 14 | options: validationOptions, 15 | constraints: [property], 16 | validator: MatchConstraint, 17 | }); 18 | }; 19 | } 20 | 21 | @ValidatorConstraint({ name: 'Match' }) 22 | export class MatchConstraint implements ValidatorConstraintInterface { 23 | validate(value: any, args: ValidationArguments) { 24 | const [ relatedPropertyName ] = args.constraints; 25 | const relatedValue = (args.object as any)[relatedPropertyName]; 26 | return value === relatedValue; 27 | } 28 | 29 |   defaultMessage(validationArguments?: ValidationArguments) { 30 | const [relatedPropertyName] = validationArguments.constraints; 31 | const property = validationArguments.property; 32 | return `${property} must match ${relatedPropertyName} exactly`; 33 | } 34 | 35 | } 36 | -------------------------------------------------------------------------------- /src/common/env-variables.ts: -------------------------------------------------------------------------------- 1 | 2 | export enum Env { 3 | NODE_ENV = 'NODE_ENV', 4 | PORT = 'PORT', 5 | DATABASE_HOST = 'DATABASE_HOST', 6 | DATABASE_PORT = 'DATABASE_PORT', 7 | DATABASE_NAME = 'DATABASE_NAME', 8 | DATABASE_USER = 'DATABASE_USER', 9 | DATABASE_PASSWORD = 'DATABASE_PASSWORD', 10 | JWT_SECRET = 'JWT_SECRET', 11 | JWT_EXPIRES_IN = 'JWT_EXPIRES_IN', 12 | FILES_DEST = 'FILES_DEST', 13 | MAX_FILE_SIZE = 'MAX_FILE_SIZE' 14 | } 15 | 16 | export enum NodeEnvValues { 17 | prod = 'production', 18 | dev = 'development', 19 | test = 'test' 20 | } 21 | -------------------------------------------------------------------------------- /src/common/files-upload/constants.ts: -------------------------------------------------------------------------------- 1 | 2 | export const FileConstants = { 3 | defaultRootFolder: './uploads', 4 | defaultMaxFileSize: 5 * 1000 * 1000, // 5Mb 5 | mimetypes: ['image/jpeg', 'image/png', 'image/gif', 'text/plain', 'application/pdf', 'application/json', 'application/vnd.ms-powerpoint'] 6 | } 7 | -------------------------------------------------------------------------------- /src/common/files-upload/files-upload.module.ts: -------------------------------------------------------------------------------- 1 | 2 | import { Module, BadRequestException } from '@nestjs/common'; 3 | import { ConfigModule, ConfigService } from '@nestjs/config'; 4 | import { MulterModule } from '@nestjs/platform-express'; 5 | 6 | import { diskStorage } from 'multer'; 7 | 8 | import { Env } from '@Common/env-variables'; 9 | 10 | import { FileConstants } from './constants'; 11 | import { destPath, finalFilename } from './utils'; 12 | 13 | @Module({ 14 | imports: [ 15 | MulterModule.registerAsync({ 16 | imports: [ConfigModule], 17 | inject: [ConfigService], 18 | useFactory: async (configService: ConfigService) => { 19 | return { 20 | storage: diskStorage({ 21 | destination: (req, file, next) => { 22 | const rootFolder = configService.get(Env.FILES_DEST, FileConstants.defaultRootFolder) 23 | let finalDest = rootFolder; 24 | try { 25 | finalDest = destPath(rootFolder); 26 | } catch (err) { 27 | console.error(err); 28 | } 29 | next(null, finalDest); 30 | }, 31 | filename: (req, file, next) => { 32 | const filename: string = finalFilename(file.originalname); 33 | next(null, filename); 34 | } 35 | }), 36 | limits: { 37 | fileSize: configService.get(Env.MAX_FILE_SIZE, FileConstants.defaultMaxFileSize) 38 | }, 39 | fileFilter: (req, file, next) => { 40 | if (FileConstants.mimetypes.some((mime) => mime == file.mimetype)) { 41 | next(null, true); 42 | } else { 43 | next(new BadRequestException(`${file.originalname} - Invalid mime-type`), false); 44 | } 45 | } 46 | } 47 | } 48 | }) 49 | ], 50 | exports: [MulterModule] 51 | }) 52 | export class FilesUploadModule {} 53 | -------------------------------------------------------------------------------- /src/common/files-upload/utils.ts: -------------------------------------------------------------------------------- 1 | 2 | import * as path from 'path'; 3 | import * as fs from 'fs'; 4 | 5 | export function destPath(rootFolder: string) { 6 | const today = new Date(Date.now()); 7 | const year = `${today.getFullYear()}`; 8 | const month = `${today.getMonth() + 1}`; 9 | const day = `${today.getDate()}`; 10 | const finalDest = path.join(rootFolder, year, month, day); 11 | fs.mkdirSync(finalDest, { recursive: true }); 12 | return finalDest; 13 | } 14 | 15 | export function finalFilename(originalFilename: string): string { 16 | const uniquePrefix = Date.now() + '_' + Math.round(Math.random() * 1E9); 17 | return `${uniquePrefix}-${originalFilename}`; 18 | } 19 | -------------------------------------------------------------------------------- /src/common/filters/all-exceptions.filter.ts: -------------------------------------------------------------------------------- 1 | 2 | import { ExceptionFilter, Catch, ArgumentsHost, Logger, HttpException, HttpStatus, InternalServerErrorException } from '@nestjs/common'; 3 | import { ConfigService } from '@nestjs/config'; 4 | import { Request } from 'express'; 5 | 6 | import { Env, NodeEnvValues } from '@Common/env-variables'; 7 | 8 | interface ErrorResponse { 9 | statusCode: number; 10 | message: string; 11 | path?: string; 12 | } 13 | 14 | 15 | @Catch() 16 | export class AllExceptionsFilter implements ExceptionFilter { 17 | 18 | private readonly logger: Logger; 19 | 20 | constructor(private configService: ConfigService) { 21 | this.logger = new Logger(AllExceptionsFilter.name); 22 | } 23 | 24 | catch(exception: any, host: ArgumentsHost) { 25 | const ctx = host.switchToHttp(); 26 | const res = ctx.getResponse(); 27 | const req = ctx.getRequest(); 28 | 29 | let error: HttpException = exception; 30 | if (!(error instanceof HttpException)) { 31 | error = new InternalServerErrorException(); 32 | } 33 | 34 | let response: ErrorResponse = { 35 | statusCode: error.getStatus(), 36 | message: error.message 37 | } 38 | 39 | const nodeEnv = this.configService.get(Env.NODE_ENV); 40 | if (nodeEnv !== NodeEnvValues.prod) { 41 | response.path = req.url; 42 | } 43 | 44 | if (nodeEnv === NodeEnvValues.dev) { 45 | this.logger.error(exception.toString()); 46 | console.error(exception); 47 | } 48 | 49 | res.status(response.statusCode).json(response); 50 | } 51 | 52 | } 53 | -------------------------------------------------------------------------------- /src/common/interceptors/logging.interceptor.ts: -------------------------------------------------------------------------------- 1 | import { CallHandler, ExecutionContext, Injectable, NestInterceptor, Logger, HttpException } from '@nestjs/common'; 2 | import { Observable, throwError } from 'rxjs'; 3 | import { catchError, tap } from 'rxjs/operators'; 4 | import { Request } from 'express'; 5 | 6 | @Injectable() 7 | export class LoggingInterceptor implements NestInterceptor { 8 | 9 | private readonly logger: Logger; 10 | 11 | constructor() { 12 | this.logger = new Logger(); 13 | } 14 | 15 | intercept(ctx: ExecutionContext, next: CallHandler): Observable { 16 | const now = Date.now(); 17 | if (ctx.getType() === 'http') { 18 | this.logger.setContext(ctx.getClass().name); 19 | const req = ctx.switchToHttp().getRequest(); 20 | const method = req.method; 21 | const url = req.url; 22 | 23 | return next.handle().pipe( 24 | tap(() => { 25 | this.logger.log(`${method} ${url}`); 26 | }), 27 | catchError(err => { 28 | if (err instanceof HttpException) { 29 | this.logger.log(`(${err.getStatus()}) ${method} ${url}`); 30 | } 31 | return throwError(err); 32 | }) 33 | ); 34 | } 35 | return next.handle(); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/common/interceptors/transform-response.interceptor.ts: -------------------------------------------------------------------------------- 1 | import { CallHandler, ExecutionContext, NestInterceptor } from "@nestjs/common"; 2 | import { Observable } from "rxjs"; 3 | import { map } from "rxjs/operators"; 4 | 5 | export interface BaseResponse { 6 | data: T; 7 | } 8 | 9 | export interface ArrayResponse extends BaseResponse> { 10 | total: number; 11 | } 12 | 13 | type Response = BaseResponse | ArrayResponse; 14 | 15 | export class TransformResponseInterceptor implements NestInterceptor> { 16 | intercept(ctx: ExecutionContext, next: CallHandler): Observable> { 17 | return next.handle().pipe( 18 | map(data => { 19 | if (data instanceof Array) return { data, total: data.length }; 20 | else return { data }; 21 | }) 22 | ); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/common/utils/bcrypt.service.ts: -------------------------------------------------------------------------------- 1 | 2 | import { Injectable } from '@nestjs/common'; 3 | 4 | import * as bcrypt from 'bcrypt'; 5 | 6 | @Injectable() 7 | export class BcryptService { 8 | 9 | private static readonly SALT_ROUNDS: number = 10; 10 | 11 | async compare(rawStr: string, hashedStr: string) { 12 | return bcrypt.compare(rawStr, hashedStr); 13 | } 14 | 15 | async hash(rawStr: string, salt?: string) { 16 | return bcrypt.hash(rawStr, salt || BcryptService.SALT_ROUNDS); 17 | } 18 | 19 | async genSalt() { 20 | return bcrypt.genSalt(BcryptService.SALT_ROUNDS); 21 | } 22 | 23 | } 24 | -------------------------------------------------------------------------------- /src/common/utils/slug.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | 3 | import { convert, revert as revertSlug } from 'url-slug'; 4 | 5 | @Injectable() 6 | export class SlugService { 7 | 8 | public static readonly SEPARATOR = '-'; 9 | 10 | slug(text: string): string { 11 | return convert(text, { separator: SlugService.SEPARATOR }); 12 | } 13 | 14 | revert(slug: string): string { 15 | return revertSlug(slug, { separator: SlugService.SEPARATOR }); 16 | } 17 | 18 | } 19 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | 2 | import { Logger } from '@nestjs/common'; 3 | import { NestFactory } from '@nestjs/core'; 4 | 5 | import { text as figletText } from 'figlet'; 6 | 7 | import { AppModule } from './app.module'; 8 | 9 | async function bootstrap() { 10 | const app = await NestFactory.create(AppModule); 11 | 12 | const port = process.env.PORT || 3000; 13 | 14 | await app.listen(port); 15 | 16 | Logger.log(`Application running on port ${port}`, 'Main'); 17 | 18 | figletText('BlogApi', { font: 'ANSI Shadow' }, (err, data) => { 19 | if (err) return; 20 | Logger.log('\n' + data, 'Main', false); 21 | }); 22 | } 23 | bootstrap(); 24 | -------------------------------------------------------------------------------- /src/ormconfig.ts: -------------------------------------------------------------------------------- 1 | 2 | import { ConnectionOptions } from 'typeorm'; 3 | import { config as loadEnv } from 'dotenv'; 4 | import { join } from 'path'; 5 | 6 | loadEnv(); 7 | 8 | type ConfigPerEnv = { 9 | [env in 'production' | 'development' | 'test']: Partial; 10 | }; 11 | 12 | const eachEnvConfig: ConfigPerEnv = { 13 | production: { 14 | type: 'mysql', 15 | host: process.env.DATABASE_HOST, 16 | port: parseInt(process.env.DATABASE_PORT), 17 | username: process.env.DATABASE_USER, 18 | password: process.env.DATABASE_PASSWORD, 19 | database: process.env.DATABASE_NAME 20 | }, 21 | development: { 22 | type: 'sqlite', 23 | database: process.env.DATABASE_NAME 24 | }, 25 | test: { 26 | type: 'sqlite', 27 | database: process.env.DATABASE_NAME 28 | } 29 | } 30 | 31 | const commonConfig: Partial = { 32 | synchronize: false, 33 | migrationsTableName: 'db_migrations', 34 | migrationsRun: false, 35 | migrations: [join(__dirname, '..', 'migrations/**/*{.ts,.js}')], 36 | entities: [join(__dirname, '**/*.entity{.ts,.js}')], 37 | cli: { 38 | migrationsDir: './migrations', 39 | } 40 | } 41 | 42 | const config: Partial = { 43 | ...eachEnvConfig[process.env.NODE_ENV || 'development'], 44 | ...commonConfig 45 | } 46 | 47 | export = config; 48 | -------------------------------------------------------------------------------- /src/posts/controllers/attached-files/attached-files.controller.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Controller, 3 | Delete, 4 | Get, 5 | InternalServerErrorException, 6 | NotFoundException, 7 | Param, 8 | Res 9 | } from '@nestjs/common'; 10 | import { Response } from 'express'; 11 | 12 | import { AttachedFilesService } from '@Posts/services/attached-files.service'; 13 | import { PostEntity as PostEntityDecorator } from '@Posts/decorators/post-entity.decorator'; 14 | import { Post as PostEntity } from '@Posts/entities/post.entity'; 15 | 16 | import { JwtAuthGuard } from '@Auth/guards/jwt-auth.guard'; 17 | import { Public } from '@Auth/decorators/public.decorator'; 18 | 19 | import { Acl } from '@Acl/decorators/acl.decorator'; 20 | import { CheckPolicies } from '@Acl/decorators/check-policies.decorator'; 21 | import { DetachFileHandler } from '@Acl/policies'; 22 | 23 | @Controller('posts/:slug/files') 24 | @Acl(JwtAuthGuard) 25 | export class AttachedFilesController { 26 | 27 | constructor(private attachedFilesService: AttachedFilesService) {} 28 | 29 | @Delete(':file_id') 30 | @CheckPolicies(DetachFileHandler) 31 | async detachFile(@Param('file_id') fileId: number, @PostEntityDecorator() post: PostEntity) { 32 | const detached = await this.attachedFilesService.detachFile(fileId, post); 33 | if (!detached) throw new InternalServerErrorException('An error occurred while trying to detach the file'); 34 | } 35 | 36 | @Get(':file_id') 37 | @Public(true) 38 | async downloadFile(@Param('file_id') fileId: number, @PostEntityDecorator() post: PostEntity, @Res() res: Response) { 39 | const file = post.files.find(f => f.id === fileId); 40 | if (!file) throw new NotFoundException('File not found'); 41 | return res.download(file.path, file.filename); 42 | } 43 | 44 | } 45 | -------------------------------------------------------------------------------- /src/posts/controllers/posts.controller.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { PostsController } from './posts.controller'; 3 | 4 | describe('PostsController', () => { 5 | let controller: PostsController; 6 | 7 | beforeEach(async () => { 8 | const module: TestingModule = await Test.createTestingModule({ 9 | controllers: [PostsController], 10 | }).compile(); 11 | 12 | controller = module.get(PostsController); 13 | }); 14 | 15 | it('should be defined', () => { 16 | expect(controller).toBeDefined(); 17 | }); 18 | }); 19 | -------------------------------------------------------------------------------- /src/posts/controllers/posts.controller.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Body, 3 | Controller, 4 | Get, 5 | Logger, 6 | NotFoundException, 7 | Param, 8 | Post, 9 | Put, 10 | Delete, 11 | InternalServerErrorException, 12 | SerializeOptions, 13 | UseInterceptors 14 | } from '@nestjs/common'; 15 | import { FilesInterceptor } from '@nestjs/platform-express'; 16 | 17 | 18 | import { CreatePostDto } from '@Posts/dtos/create-post.dto'; 19 | import { UpdatePostDto } from '@Posts/dtos/update-post.dto'; 20 | import { PostsService } from '@Posts/services/posts.service'; 21 | import { Post as PostEntity } from '@Posts/entities/post.entity'; 22 | import { PostEntity as PostEntityDecorator } from '@Posts/decorators/post-entity.decorator'; 23 | import { FilesUploaded } from '@Posts/decorators/files-uploaded.decorator'; 24 | import { PostExposeGroups } from '@Posts/enums/post-expose-groups.enum'; 25 | import { AttachedFileDto } from '@Posts/dtos/attached-file.dto'; 26 | 27 | import { JwtAuthGuard } from '@Auth/guards/jwt-auth.guard'; 28 | import { Public } from '@Auth/decorators/public.decorator'; 29 | import { ReqUser } from '@Auth/decorators/user.decorator'; 30 | 31 | import { CheckPolicies } from '@Acl/decorators/check-policies.decorator'; 32 | import { Acl } from '@Acl/decorators/acl.decorator'; 33 | import { EditPostHandler, CreatePostHandler, RemovePostHandler } from '@Acl/policies'; 34 | 35 | import { User } from '@Users/entities/user.entity'; 36 | 37 | @Controller('posts') 38 | @Acl(JwtAuthGuard) 39 | export class PostsController { 40 | 41 | private readonly logger: Logger; 42 | 43 | constructor(private postsService: PostsService) { 44 | this.logger = new Logger(PostsController.name); 45 | } 46 | 47 | @Get() 48 | @Public(true) 49 | async findAll() { 50 | return await this.postsService.findAll(); 51 | } 52 | 53 | @Get(':slug') 54 | @Public(true) 55 | @SerializeOptions({ 56 | groups: [ PostExposeGroups.FULL ] 57 | }) 58 | async findOne(@Param('slug') slug: string) { 59 | const post = await this.postsService.findBySlug(slug); 60 | if (!post) { 61 | throw new NotFoundException(); 62 | } else { 63 | return this.postsService.findBySlug(slug); 64 | } 65 | } 66 | 67 | @Post() 68 | @CheckPolicies(CreatePostHandler) 69 | @UseInterceptors(FilesInterceptor('files')) 70 | @SerializeOptions({ 71 | groups: [ PostExposeGroups.FULL ] 72 | }) 73 | async create(@Body() createPostDto: CreatePostDto, @ReqUser() author: User, @FilesUploaded(AttachedFileDto) files: AttachedFileDto[]) { 74 | createPostDto.author = author; 75 | createPostDto.files = files; 76 | const postCreated = await this.postsService.create(createPostDto); 77 | return postCreated; 78 | } 79 | 80 | @Put(':slug') 81 | @CheckPolicies(EditPostHandler) 82 | async update(@Body() updatePostDto: UpdatePostDto, @PostEntityDecorator() postToUpdate: PostEntity) { 83 | return await this.postsService.update(postToUpdate, updatePostDto); 84 | } 85 | 86 | @Delete(':slug') 87 | @CheckPolicies(RemovePostHandler) 88 | async delete(@PostEntityDecorator() postToDelete: PostEntity) { 89 | const removed = await this.postsService.remove(postToDelete); 90 | if (removed) return; 91 | else throw new InternalServerErrorException(); 92 | } 93 | 94 | } 95 | -------------------------------------------------------------------------------- /src/posts/decorators/files-uploaded.decorator.ts: -------------------------------------------------------------------------------- 1 | 2 | import * as path from 'path'; 3 | import { createParamDecorator, ExecutionContext, Type } from '@nestjs/common'; 4 | import { Request } from 'express'; 5 | 6 | import { AttachedFileDto } from '@Posts/dtos/attached-file.dto'; 7 | 8 | export const FilesUploaded = createParamDecorator( 9 | (fileKlass: Type, ctx: ExecutionContext) => { 10 | const req = ctx.switchToHttp().getRequest(); 11 | const reqfiles = req.files; 12 | const files: AttachedFileDto[] = [] 13 | 14 | for (let i = 0; i < reqfiles.length; i++) { 15 | const reqfile = reqfiles[i]; 16 | const file = new fileKlass(); 17 | file.filename = reqfile.originalname; 18 | file.path = path.resolve(reqfile.path); 19 | files.push(file); 20 | } 21 | 22 | return files; 23 | } 24 | ); 25 | 26 | -------------------------------------------------------------------------------- /src/posts/decorators/post-entity.decorator.ts: -------------------------------------------------------------------------------- 1 | 2 | import { createParamDecorator, ExecutionContext } from '@nestjs/common'; 3 | import { Request } from 'express'; 4 | 5 | import { Post } from '@Posts/entities/post.entity'; 6 | 7 | export const PostEntity = createParamDecorator( 8 | (data: unknown, ctx: ExecutionContext) => { 9 | const request = ctx.switchToHttp().getRequest(); 10 | return request.post; 11 | }, 12 | ) 13 | -------------------------------------------------------------------------------- /src/posts/dtos/attached-file.dto.ts: -------------------------------------------------------------------------------- 1 | 2 | export class AttachedFileDto { 3 | 4 | path: string; 5 | 6 | filename: string; 7 | 8 | } 9 | -------------------------------------------------------------------------------- /src/posts/dtos/create-post.dto.ts: -------------------------------------------------------------------------------- 1 | 2 | import { IsString } from 'class-validator'; 3 | 4 | import { UserDto } from '@Users/dtos/user.dto'; 5 | import { AttachedFileDto } from './attached-file.dto'; 6 | 7 | export class CreatePostDto { 8 | 9 | @IsString() 10 | title: string; 11 | 12 | @IsString() 13 | text: string; 14 | 15 | author: UserDto; 16 | 17 | files: AttachedFileDto[]; 18 | 19 | } 20 | -------------------------------------------------------------------------------- /src/posts/dtos/update-post.dto.ts: -------------------------------------------------------------------------------- 1 | 2 | import { IsOptional, IsString } from 'class-validator'; 3 | 4 | export class UpdatePostDto { 5 | 6 | @IsString() 7 | @IsOptional() 8 | title: string; 9 | 10 | @IsString() 11 | @IsOptional() 12 | text: string; 13 | 14 | } 15 | -------------------------------------------------------------------------------- /src/posts/entities/attached-file.entity.ts: -------------------------------------------------------------------------------- 1 | 2 | import { 3 | Entity, 4 | Column, 5 | PrimaryGeneratedColumn, 6 | CreateDateColumn, 7 | UpdateDateColumn, 8 | ManyToOne, 9 | Index 10 | } from 'typeorm'; 11 | import { Expose } from 'class-transformer'; 12 | 13 | import { Post } from './post.entity'; 14 | import { AttchFileExposeGroups } from '@Posts/enums/attached-file-expose-groups.enum'; 15 | 16 | @Entity() 17 | export class AttachedFile { 18 | 19 | @PrimaryGeneratedColumn() 20 | id: number; 21 | 22 | @Column({ nullable: false }) 23 | @Expose({ 24 | groups: [ AttchFileExposeGroups.FULL ] 25 | }) 26 | path: string; 27 | 28 | @Column() 29 | filename: string; 30 | 31 | @ManyToOne(() => Post, 32 | { 33 | eager: false, 34 | nullable: false, 35 | onDelete: 'CASCADE' 36 | } 37 | ) 38 | @Index() 39 | post: Post; 40 | 41 | @CreateDateColumn() 42 | createdAt?: Date; 43 | 44 | @UpdateDateColumn() 45 | updatedAt?: Date; 46 | 47 | } 48 | -------------------------------------------------------------------------------- /src/posts/entities/post.entity.ts: -------------------------------------------------------------------------------- 1 | 2 | import { 3 | Entity, 4 | Column, 5 | PrimaryGeneratedColumn, 6 | CreateDateColumn, 7 | UpdateDateColumn, 8 | Index, 9 | ManyToOne, 10 | OneToMany 11 | } from 'typeorm'; 12 | import { Expose } from 'class-transformer'; 13 | 14 | import { User } from '@Users/entities/user.entity'; 15 | import { Comment } from '@Comments/entities/comment.entity'; 16 | import { PostExposeGroups } from '@Posts/enums/post-expose-groups.enum'; 17 | import { AttachedFile } from './attached-file.entity'; 18 | 19 | @Entity() 20 | export class Post { 21 | 22 | @PrimaryGeneratedColumn() 23 | id: number; 24 | 25 | @Column({ nullable: false }) 26 | title: string; 27 | 28 | @Index() 29 | @Column({ unique: true }) 30 | slug: string; 31 | 32 | @Column() 33 | text: string; 34 | 35 | @ManyToOne(() => User, 36 | { 37 | eager: true, 38 | nullable: true, 39 | onDelete: 'SET NULL' 40 | } 41 | ) 42 | @Index({ unique: false }) 43 | author: User; 44 | 45 | @OneToMany(() => Comment, comment => comment.post, { eager: true }) 46 | @Expose({ 47 | groups: [ PostExposeGroups.FULL, PostExposeGroups.WITH_COMMENTS ] 48 | }) 49 | comments: Comment[]; 50 | 51 | @OneToMany(() => AttachedFile, file => file.post, { eager: true }) 52 | @Expose({ 53 | groups: [ PostExposeGroups.FULL, PostExposeGroups.WITH_FILES ] 54 | }) 55 | files: AttachedFile[]; 56 | 57 | @CreateDateColumn() 58 | createdAt?: Date; 59 | 60 | @UpdateDateColumn() 61 | updatedAt?: Date; 62 | 63 | } 64 | -------------------------------------------------------------------------------- /src/posts/enums/attached-file-expose-groups.enum.ts: -------------------------------------------------------------------------------- 1 | 2 | export enum AttchFileExposeGroups { 3 | FULL = 'attchfile.full' 4 | } 5 | -------------------------------------------------------------------------------- /src/posts/enums/post-expose-groups.enum.ts: -------------------------------------------------------------------------------- 1 | 2 | export enum PostExposeGroups { 3 | FULL = 'post.full', 4 | WITH_COMMENTS = 'post.with-comments', 5 | WITH_FILES = 'post.with-files' 6 | } 7 | -------------------------------------------------------------------------------- /src/posts/middlewares/retrieve-post-by-slug.middleware.ts: -------------------------------------------------------------------------------- 1 | import { BadRequestException, Injectable, NestMiddleware, NotFoundException } from "@nestjs/common"; 2 | import { Request, Response } from "express"; 3 | 4 | import { PostsService } from "@Posts/services/posts.service"; 5 | 6 | @Injectable() 7 | export class RetrievePostBySlugMiddleware implements NestMiddleware { 8 | 9 | constructor(private postsService: PostsService) {} 10 | 11 | async use(req: Request, res: Response, next: () => void) { 12 | if (!req.params.slug) throw new BadRequestException('Missing slug'); 13 | const post = await this.postsService.findBySlug(req.params.slug); 14 | if (!post) throw new NotFoundException(); 15 | req.post = post; 16 | next(); 17 | } 18 | 19 | } 20 | -------------------------------------------------------------------------------- /src/posts/posts.module.ts: -------------------------------------------------------------------------------- 1 | 2 | import { MiddlewareConsumer, Module, NestModule, RequestMethod } from '@nestjs/common'; 3 | import { TypeOrmModule } from '@nestjs/typeorm'; 4 | 5 | import { SlugService } from '@Common/utils/slug.service'; 6 | import { PostsController } from '@Posts/controllers/posts.controller'; 7 | import { AttachedFilesController } from '@Posts/controllers/attached-files/attached-files.controller'; 8 | import { PostsService } from '@Posts/services/posts.service'; 9 | import { PostRepository } from '@Posts/repositories/post.repository'; 10 | import { AttachedFileRepository } from '@Posts/repositories/attached-file.repository'; 11 | import { PostSubscriber } from '@Posts/subscribers/post.subscriber'; 12 | import { RetrievePostBySlugMiddleware } from '@Posts/middlewares/retrieve-post-by-slug.middleware'; 13 | import { FilesUploadModule } from '@Common/files-upload/files-upload.module'; 14 | import { AttachedFilesService } from '@Posts/services/attached-files.service'; 15 | 16 | @Module({ 17 | imports: [ 18 | TypeOrmModule.forFeature([ 19 | PostRepository, 20 | AttachedFileRepository 21 | ]), 22 | FilesUploadModule 23 | ], 24 | controllers: [PostsController, AttachedFilesController], 25 | providers: [ 26 | PostsService, 27 | PostSubscriber, 28 | SlugService, 29 | AttachedFilesService 30 | ], 31 | exports: [ PostsService ] 32 | }) 33 | export class PostsModule implements NestModule { 34 | configure(consumer: MiddlewareConsumer) { 35 | consumer 36 | .apply(RetrievePostBySlugMiddleware) 37 | .forRoutes( 38 | { path: 'posts/:slug', method: RequestMethod.PUT }, 39 | { path: 'posts/:slug', method: RequestMethod.DELETE }, 40 | AttachedFilesController 41 | ) 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/posts/repositories/attached-file.repository.ts: -------------------------------------------------------------------------------- 1 | 2 | import { Repository, EntityRepository } from 'typeorm'; 3 | 4 | import { AttachedFile } from '@Posts/entities/attached-file.entity'; 5 | 6 | @EntityRepository(AttachedFile) 7 | export class AttachedFileRepository extends Repository {} 8 | -------------------------------------------------------------------------------- /src/posts/repositories/post.repository.ts: -------------------------------------------------------------------------------- 1 | import { EntityRepository, AbstractRepository } from "typeorm"; 2 | 3 | import { CreatePostDto } from "@Posts/dtos/create-post.dto"; 4 | import { Post } from '@Posts/entities/post.entity'; 5 | import { UpdatePostDto } from "../dtos/update-post.dto"; 6 | 7 | @EntityRepository(Post) 8 | export class PostRepository extends AbstractRepository { 9 | 10 | async findOne(id: number) { 11 | const result = await this.repository.findOne(id); 12 | return result; 13 | } 14 | 15 | async findBySlug(slug: string) { 16 | return await this.repository.findOne({ slug: slug }); 17 | } 18 | 19 | async findAll() { 20 | return await this.repository.find(); 21 | } 22 | 23 | async createPost(createPostDto: CreatePostDto) { 24 | const post = this.repository.create(createPostDto); 25 | const postCreated = await this.repository.save(post); 26 | return postCreated; 27 | } 28 | 29 | async updatePost(postToUpdate: Post, updatePostDto: UpdatePostDto) { 30 | await this.repository.update(postToUpdate.id, updatePostDto); 31 | return await this.repository.findOne(postToUpdate.id); 32 | } 33 | 34 | async countPostsWithSimilarSlug(slug: string): Promise { 35 | return await this.repository.createQueryBuilder('post') 36 | .where("post.slug like :slug", { slug: `${slug}%` }) 37 | .getCount(); 38 | } 39 | 40 | async remove(postToRemove: Post) { 41 | const postRemoved = await this.repository.remove(postToRemove) 42 | return postRemoved !== undefined; 43 | } 44 | 45 | } 46 | -------------------------------------------------------------------------------- /src/posts/services/attached-files.service.ts: -------------------------------------------------------------------------------- 1 | 2 | import { Injectable, Logger } from '@nestjs/common'; 3 | 4 | import { AttachedFileDto } from '@Posts/dtos/attached-file.dto'; 5 | import { Post } from '@Posts/entities/post.entity'; 6 | import { AttachedFile } from '@Posts/entities/attached-file.entity'; 7 | import { AttachedFileRepository } from '@Posts/repositories/attached-file.repository'; 8 | 9 | import * as bfs from 'fs'; 10 | const fs = bfs.promises; 11 | 12 | @Injectable() 13 | export class AttachedFilesService { 14 | 15 | private logger: Logger; 16 | 17 | constructor(private attachedFileRepository: AttachedFileRepository) { 18 | this.logger = new Logger(AttachedFilesService.name); 19 | } 20 | 21 | async attachFilesToPost(files: AttachedFileDto[], post: Post) { 22 | const filesToSave: AttachedFile[] = []; 23 | for (let i = 0; i < files.length; i++) { 24 | const file: AttachedFile = this.attachedFileRepository.create(files[i]); 25 | file.post = post; 26 | filesToSave.push(file); 27 | } 28 | return await this.attachedFileRepository.save(filesToSave); 29 | } 30 | 31 | async detachFile(fileId: number, post: Post) { 32 | let fileDetached: boolean = false; 33 | const file = post.files.find(f => f.id === fileId); 34 | try { 35 | if (file) await this.attachedFileRepository.remove(file); 36 | } catch (err) { 37 | this.logger.warn('An error occurred while trying to delete a file from the DB'); 38 | console.error(err); 39 | } 40 | 41 | try { 42 | await fs.unlink(file.path); 43 | fileDetached = true; 44 | } catch (err) { 45 | this.logger.warn('An error occurred while trying to delete a file from the system'); 46 | console.error(err); 47 | await this.attachedFileRepository.save(file); 48 | } 49 | return fileDetached; 50 | } 51 | 52 | } 53 | -------------------------------------------------------------------------------- /src/posts/services/posts.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { PostsService } from './posts.service'; 3 | 4 | describe('PostsService', () => { 5 | let service: PostsService; 6 | 7 | beforeEach(async () => { 8 | const module: TestingModule = await Test.createTestingModule({ 9 | providers: [PostsService], 10 | }).compile(); 11 | 12 | service = module.get(PostsService); 13 | }); 14 | 15 | it('should be defined', () => { 16 | expect(service).toBeDefined(); 17 | }); 18 | }); 19 | -------------------------------------------------------------------------------- /src/posts/services/posts.service.ts: -------------------------------------------------------------------------------- 1 | 2 | import { Injectable } from '@nestjs/common'; 3 | 4 | import { CreatePostDto } from '@Posts/dtos/create-post.dto'; 5 | import { Post } from '@Posts/entities/post.entity'; 6 | import { PostRepository } from '@Posts/repositories/post.repository'; 7 | import { UpdatePostDto } from '@Posts/dtos/update-post.dto'; 8 | 9 | import { AttachedFilesService } from './attached-files.service'; 10 | 11 | @Injectable() 12 | export class PostsService { 13 | 14 | // constructor( 15 | // @InjectRepository(Post) 16 | // private postsRepository: Repository, 17 | // private connection: Connection 18 | // ) {} 19 | 20 | constructor( 21 | private postsRepository: PostRepository, 22 | private attachedFilesService: AttachedFilesService 23 | ) {} 24 | 25 | async findAll(): Promise { 26 | return await this.postsRepository.findAll(); 27 | } 28 | 29 | async findBySlug(slug: string): Promise { 30 | return await this.postsRepository.findBySlug(slug); 31 | } 32 | 33 | async create(createPostDto: CreatePostDto) { 34 | const postCreated = await this.postsRepository.createPost(createPostDto); 35 | await this.attachedFilesService.attachFilesToPost(createPostDto.files, postCreated); 36 | return await this.postsRepository.findOne(postCreated.id); 37 | } 38 | 39 | async update(postToUpdate: Post, updatePostDto: UpdatePostDto) { 40 | return this.postsRepository.updatePost(postToUpdate, updatePostDto); 41 | } 42 | 43 | async remove(postToRemove: Post) { 44 | return this.postsRepository.remove(postToRemove); 45 | } 46 | 47 | // async createMany(posts: Post[]) { 48 | // const queryRunner = this.connection.createQueryRunner(); 49 | 50 | // await queryRunner.connect(); 51 | // await queryRunner.startTransaction(); 52 | // try { 53 | // for (let i = 0; i < posts.length; i++) { 54 | // let post = posts[i]; 55 | // await queryRunner.manager.save(post); 56 | // } 57 | // await queryRunner.commitTransaction(); 58 | // } catch (err) { 59 | // await queryRunner.rollbackTransaction(); 60 | // } finally { 61 | // await queryRunner.release(); 62 | // } 63 | // } 64 | 65 | } 66 | -------------------------------------------------------------------------------- /src/posts/subscribers/post.subscriber.ts: -------------------------------------------------------------------------------- 1 | 2 | import { SlugService } from '@Common/utils/slug.service'; 3 | import { Connection, EntitySubscriberInterface, EventSubscriber, InsertEvent } from 'typeorm'; 4 | 5 | 6 | import { Post } from '@Posts/entities/post.entity'; 7 | import { PostRepository } from '@Posts/repositories/post.repository'; 8 | 9 | @EventSubscriber() 10 | export class PostSubscriber implements EntitySubscriberInterface { 11 | 12 | constructor( 13 | connection: Connection, 14 | private slugService: SlugService 15 | ) { 16 | connection.subscribers.push(this); 17 | } 18 | 19 | listenTo() { 20 | return Post; 21 | } 22 | 23 | async beforeInsert(event: InsertEvent) { 24 | if (!event.entity.slug) { 25 | event.entity.slug = this.slugService.slug(event.entity.title); 26 | const postsWithSimilarSlug = await event.manager.getCustomRepository(PostRepository).countPostsWithSimilarSlug(event.entity.slug); 27 | if (postsWithSimilarSlug > 0) { 28 | event.entity.slug += `-${postsWithSimilarSlug + 1}`; 29 | } 30 | } 31 | } 32 | 33 | } 34 | -------------------------------------------------------------------------------- /src/users/controllers/users.controller.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Body, 3 | Controller, 4 | Get, 5 | Logger, 6 | Post, 7 | Param, 8 | NotFoundException 9 | } from '@nestjs/common'; 10 | 11 | import { UsersService } from '@Users/services/users.service'; 12 | import { CreateUserDto } from '@Users/dtos/create-user.dto'; 13 | 14 | import { JwtAuthGuard } from '@Auth/guards/jwt-auth.guard'; 15 | 16 | import { Acl } from '@Acl/decorators/acl.decorator'; 17 | import { CheckPolicies } from '@Acl/decorators/check-policies.decorator'; 18 | import { CreateUserHandler, SearchUserHandler } from '@Acl/policies'; 19 | 20 | @Controller('users') 21 | @Acl(JwtAuthGuard) 22 | export class UsersController { 23 | 24 | private readonly logger: Logger; 25 | 26 | constructor(private usersService: UsersService) { 27 | this.logger = new Logger(UsersController.name); 28 | } 29 | 30 | @Post() 31 | @CheckPolicies(CreateUserHandler) 32 | async createUser(@Body() createUserDto: CreateUserDto) { 33 | try { 34 | return await this.usersService.registerUser(createUserDto); 35 | } catch (err) { 36 | throw err; 37 | } 38 | } 39 | 40 | @Get(':login') 41 | @CheckPolicies(SearchUserHandler) 42 | async findOne(@Param('login') login: string) { 43 | const user = await this.usersService.findByLogin(login); 44 | if (!user) { 45 | throw new NotFoundException(); 46 | } else { 47 | return user; 48 | } 49 | } 50 | 51 | 52 | } 53 | -------------------------------------------------------------------------------- /src/users/dtos/create-user.dto.ts: -------------------------------------------------------------------------------- 1 | 2 | import { IsString, Length, MaxLength, MinLength } from 'class-validator'; 3 | import { Match } from '@Common/decorators/match.decorator'; 4 | 5 | export class CreateUserDto { 6 | 7 | @IsString() 8 | @Length(7, 7) 9 | login: string; 10 | 11 | @IsString() 12 | firstname: string; 13 | 14 | @IsString() 15 | lastname: string; 16 | 17 | @IsString() 18 | @MinLength(4) 19 | @MaxLength(20) 20 | password: string; 21 | 22 | @IsString() 23 | @Match('password') 24 | repeatPassword: string; 25 | 26 | } 27 | -------------------------------------------------------------------------------- /src/users/dtos/register-user.dto.ts: -------------------------------------------------------------------------------- 1 | 2 | export class RegisterUserDto { 3 | login: string; 4 | firstname: string; 5 | lastname: string; 6 | hashedPassword: string; 7 | } 8 | -------------------------------------------------------------------------------- /src/users/dtos/user.dto.ts: -------------------------------------------------------------------------------- 1 | import { Role } from '@Users/enums/role.enum'; 2 | 3 | export class UserDto { 4 | id: number; 5 | login: string; 6 | firstname: string; 7 | lastname: string; 8 | role: Role; 9 | } 10 | -------------------------------------------------------------------------------- /src/users/entities/user.entity.ts: -------------------------------------------------------------------------------- 1 | 2 | import { 3 | Entity, 4 | Column, 5 | PrimaryGeneratedColumn, 6 | CreateDateColumn, 7 | UpdateDateColumn, 8 | Index 9 | } from 'typeorm'; 10 | import { Expose } from 'class-transformer'; 11 | 12 | import { UserExposeGroups } from '@Users/enums/expose.enum'; 13 | import { Role } from '@Users/enums/role.enum'; 14 | 15 | @Entity() 16 | export class User { 17 | 18 | @PrimaryGeneratedColumn() 19 | id: number; 20 | 21 | @Index() 22 | @Column({ nullable: false, unique: true }) 23 | login: string; 24 | 25 | @Column({ nullable: false }) 26 | @Expose({ 27 | groups: [ UserExposeGroups.FULL ], 28 | }) 29 | hashedPassword: string; 30 | 31 | @Column({ nullable: false }) 32 | firstname: string; 33 | 34 | @Column({ nullable: false }) 35 | lastname: string; 36 | 37 | @Column({ 38 | type: 'simple-enum', 39 | enum: Role, 40 | default: Role.User 41 | }) 42 | role: Role; 43 | 44 | @CreateDateColumn() 45 | @Expose({ 46 | groups: [ UserExposeGroups.FULL ] 47 | }) 48 | createdAt?: Date; 49 | 50 | @UpdateDateColumn() 51 | @Expose({ 52 | groups: [ UserExposeGroups.FULL ] 53 | }) 54 | updatedAt?: Date; 55 | 56 | } 57 | -------------------------------------------------------------------------------- /src/users/enums/expose.enum.ts: -------------------------------------------------------------------------------- 1 | 2 | export enum UserExposeGroups { 3 | FULL = 'user.full', 4 | BASIC = 'user.basic' 5 | } 6 | -------------------------------------------------------------------------------- /src/users/enums/role.enum.ts: -------------------------------------------------------------------------------- 1 | 2 | export enum Role { 3 | User = 'user', 4 | Admin = 'admin' 5 | } 6 | -------------------------------------------------------------------------------- /src/users/repositories/user.repository.ts: -------------------------------------------------------------------------------- 1 | 2 | import { EntityRepository, AbstractRepository } from 'typeorm'; 3 | 4 | import { User } from '@Users/entities/user.entity'; 5 | import { RegisterUserDto } from '@Users/dtos/register-user.dto'; 6 | 7 | @EntityRepository(User) 8 | export class UserRepository extends AbstractRepository { 9 | 10 | async findOneById(id: number) { 11 | return this.repository.findOne(id); 12 | } 13 | 14 | async findByLogin(login: string) { 15 | return this.repository.findOne({ login: login }); 16 | } 17 | 18 | async create(registerUserDto: RegisterUserDto) { 19 | const user = this.repository.create(registerUserDto); 20 | const userSaved = await this.repository.save(user); 21 | return userSaved; 22 | } 23 | 24 | } 25 | 26 | -------------------------------------------------------------------------------- /src/users/services/users.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { UsersService } from './users.service'; 3 | 4 | describe('UsersService', () => { 5 | let service: UsersService; 6 | 7 | beforeEach(async () => { 8 | const module: TestingModule = await Test.createTestingModule({ 9 | providers: [UsersService], 10 | }).compile(); 11 | 12 | service = module.get(UsersService); 13 | }); 14 | 15 | it('should be defined', () => { 16 | expect(service).toBeDefined(); 17 | }); 18 | }); 19 | -------------------------------------------------------------------------------- /src/users/services/users.service.ts: -------------------------------------------------------------------------------- 1 | import { BadRequestException, Injectable, InternalServerErrorException, Logger} from '@nestjs/common'; 2 | 3 | import { UserRepository } from '@Users/repositories/user.repository'; 4 | import { CreateUserDto } from '@Users/dtos/create-user.dto'; 5 | import { RegisterUserDto } from '@Users/dtos/register-user.dto'; 6 | import { UserDto } from '@Users/dtos/user.dto'; 7 | 8 | import { BcryptService } from '@Common/utils/bcrypt.service'; 9 | import { DbErrors } from '@Common/database/database-errors'; 10 | 11 | @Injectable() 12 | export class UsersService { 13 | 14 | private readonly logger: Logger; 15 | 16 | constructor( 17 | private userRepository: UserRepository, 18 | private bcryptService: BcryptService 19 | ) { 20 | this.logger = new Logger(UsersService.name); 21 | } 22 | 23 | async findByLogin(login: string) { 24 | return this.userRepository.findByLogin(login); 25 | } 26 | 27 | async findById(id: number) { 28 | return this.userRepository.findOneById(id); 29 | } 30 | 31 | async registerUser(createUserDto: CreateUserDto): Promise { 32 | const { password, repeatPassword, ...userData } = createUserDto; 33 | const hashedPassword = await this.bcryptService.hash(password); 34 | const dbInsertUserDto: RegisterUserDto = {...userData, hashedPassword}; 35 | try { 36 | const userCreated = await this.userRepository.create(dbInsertUserDto); 37 | return { 38 | id: userCreated.id, 39 | firstname: userCreated.firstname, 40 | lastname: userCreated.lastname, 41 | login: userCreated.login, 42 | role: userCreated.role 43 | } 44 | } catch (err) { 45 | if (err?.code === DbErrors.UniqueViolation) { 46 | throw new BadRequestException('User with that login already exists'); 47 | } else { 48 | throw new InternalServerErrorException(); 49 | } 50 | } 51 | } 52 | 53 | } 54 | -------------------------------------------------------------------------------- /src/users/users.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { TypeOrmModule } from '@nestjs/typeorm'; 3 | 4 | import { UsersService } from '@Users/services/users.service'; 5 | import { UserRepository } from '@Users/repositories/user.repository'; 6 | import { UsersController } from '@Users/controllers/users.controller'; 7 | 8 | import { BcryptService } from '@Common/utils/bcrypt.service'; 9 | 10 | @Module({ 11 | imports: [ 12 | TypeOrmModule.forFeature([ 13 | UserRepository 14 | ]) 15 | ], 16 | providers: [ 17 | UsersService, 18 | BcryptService 19 | ], 20 | exports: [UsersService], 21 | controllers: [UsersController] 22 | }) 23 | export class UsersModule {} 24 | -------------------------------------------------------------------------------- /test/app.e2e-spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { INestApplication } from '@nestjs/common'; 3 | import * as request from 'supertest'; 4 | import { AppModule } from './../src/app.module'; 5 | 6 | describe('AppController (e2e)', () => { 7 | let app: INestApplication; 8 | 9 | beforeEach(async () => { 10 | const moduleFixture: TestingModule = await Test.createTestingModule({ 11 | imports: [AppModule], 12 | }).compile(); 13 | 14 | app = moduleFixture.createNestApplication(); 15 | await app.init(); 16 | }); 17 | 18 | it('/ (GET)', () => { 19 | return request(app.getHttpServer()) 20 | .get('/') 21 | .expect(200) 22 | .expect('Hello World!'); 23 | }); 24 | }); 25 | -------------------------------------------------------------------------------- /test/jest-e2e.json: -------------------------------------------------------------------------------- 1 | { 2 | "moduleFileExtensions": ["js", "json", "ts"], 3 | "rootDir": ".", 4 | "testEnvironment": "node", 5 | "testRegex": ".e2e-spec.ts$", 6 | "transform": { 7 | "^.+\\.(t|j)s$": "ts-jest" 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "exclude": ["node_modules", "test", "dist", "**/*spec.ts", "uploads"] 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 | "incremental": true, 14 | "typeRoots": [ 15 | "@types", 16 | "./node_modules/@types" 17 | ], 18 | "paths": { 19 | "@AppRoot/*": ["src/*"], 20 | "@Common/*": ["src/common/*"], 21 | "@Posts/*": ["src/posts/*"], 22 | "@Auth/*": ["src/auth/*"], 23 | "@Users/*": ["src/users/*"], 24 | "@Comments/*": ["src/comments/*"], 25 | "@Acl/*": ["src/casl/*"] 26 | } 27 | }, 28 | "exclude": ["node_modules", "dist"] 29 | } 30 | --------------------------------------------------------------------------------