diff --git a/.github/workflows/docker-e2e.yml b/.github/workflows/docker-e2e.yml index bb085bd38..95ef8e70e 100644 --- a/.github/workflows/docker-e2e.yml +++ b/.github/workflows/docker-e2e.yml @@ -12,5 +12,7 @@ jobs: steps: - uses: actions/checkout@v4 - - name: Run e2e tests - run: docker compose -f docker-compose.ci.yaml --env-file env-example -p ci up --build --exit-code-from api + - name: Run e2e tests for NestJS with TypeORM + run: docker compose -f docker-compose.relational.ci.yaml --env-file env-example-relational -p ci-relational up --build --exit-code-from api + - name: Run e2e tests for NestJS with Mongoose + run: docker compose -f docker-compose.document.ci.yaml --env-file env-example-document -p ci-document up --build --exit-code-from api diff --git a/.hygen/seeds/create-document/module.ejs.t b/.hygen/seeds/create-document/module.ejs.t new file mode 100644 index 000000000..748260ada --- /dev/null +++ b/.hygen/seeds/create-document/module.ejs.t @@ -0,0 +1,21 @@ +--- +to: src/database/seeds/document/<%= h.inflection.transform(name, ['underscore', 'dasherize']) %>/<%= h.inflection.transform(name, ['underscore', 'dasherize']) %>-seed.module.ts +--- +import { Module } from '@nestjs/common'; +import { MongooseModule } from '@nestjs/mongoose'; +import { <%= name %>Schema, <%= name %>SchemaClass } from 'src/<%= h.inflection.transform(name, ['pluralize', 'underscore', 'dasherize']) %>/entities/<%= h.inflection.transform(name, ['underscore', 'dasherize']) %>.schema'; +import { <%= name %>SeedService } from './<%= h.inflection.transform(name, ['underscore', 'dasherize']) %>-seed.service'; + +@Module({ + imports: [ + MongooseModule.forFeature([ + { + name: <%= name %>SchemaClass.name, + schema: <%= name %>Schema, + }, + ]), + ], + providers: [<%= name %>SeedService], + exports: [<%= name %>SeedService], +}) +export class <%= name %>SeedModule {} diff --git a/.hygen/seeds/create/run-seed-import.ejs.t b/.hygen/seeds/create-document/run-seed-import.ejs.t similarity index 83% rename from .hygen/seeds/create/run-seed-import.ejs.t rename to .hygen/seeds/create-document/run-seed-import.ejs.t index 2cedcb83e..8f4f71c2d 100644 --- a/.hygen/seeds/create/run-seed-import.ejs.t +++ b/.hygen/seeds/create-document/run-seed-import.ejs.t @@ -1,6 +1,6 @@ --- inject: true -to: src/database/seeds/run-seed.ts +to: src/database/seeds/document/run-seed.ts after: \@nestjs\/core --- import { <%= name %>SeedService } from './<%= h.inflection.transform(name, ['underscore', 'dasherize']) %>/<%= h.inflection.transform(name, ['underscore', 'dasherize']) %>-seed.service'; \ No newline at end of file diff --git a/.hygen/seeds/create/run-seed-service.ejs.t b/.hygen/seeds/create-document/run-seed-service.ejs.t similarity index 65% rename from .hygen/seeds/create/run-seed-service.ejs.t rename to .hygen/seeds/create-document/run-seed-service.ejs.t index 9daa73987..1fc573a37 100644 --- a/.hygen/seeds/create/run-seed-service.ejs.t +++ b/.hygen/seeds/create-document/run-seed-service.ejs.t @@ -1,6 +1,6 @@ --- inject: true -to: src/database/seeds/run-seed.ts +to: src/database/seeds/document/run-seed.ts before: close --- await app.get(<%= name %>SeedService).run(); diff --git a/.hygen/seeds/create/seed-module-import.ejs.t b/.hygen/seeds/create-document/seed-module-import.ejs.t similarity index 82% rename from .hygen/seeds/create/seed-module-import.ejs.t rename to .hygen/seeds/create-document/seed-module-import.ejs.t index ae61a9e83..f18e94239 100644 --- a/.hygen/seeds/create/seed-module-import.ejs.t +++ b/.hygen/seeds/create-document/seed-module-import.ejs.t @@ -1,6 +1,6 @@ --- inject: true -to: src/database/seeds/seed.module.ts +to: src/database/seeds/document/seed.module.ts before: \@Module --- import { <%= name %>SeedModule } from './<%= h.inflection.transform(name, ['underscore', 'dasherize']) %>/<%= h.inflection.transform(name, ['underscore', 'dasherize']) %>-seed.module'; diff --git a/.hygen/seeds/create/seed-module.ejs.t b/.hygen/seeds/create-document/seed-module.ejs.t similarity index 56% rename from .hygen/seeds/create/seed-module.ejs.t rename to .hygen/seeds/create-document/seed-module.ejs.t index 30a92ad61..e48a1452c 100644 --- a/.hygen/seeds/create/seed-module.ejs.t +++ b/.hygen/seeds/create-document/seed-module.ejs.t @@ -1,6 +1,6 @@ --- inject: true -to: src/database/seeds/seed.module.ts +to: src/database/seeds/document/seed.module.ts after: imports --- <%= name %>SeedModule, \ No newline at end of file diff --git a/.hygen/seeds/create-document/service.ejs.t b/.hygen/seeds/create-document/service.ejs.t new file mode 100644 index 000000000..ba9a5b335 --- /dev/null +++ b/.hygen/seeds/create-document/service.ejs.t @@ -0,0 +1,24 @@ +--- +to: src/database/seeds/document/<%= h.inflection.transform(name, ['underscore', 'dasherize']) %>/<%= h.inflection.transform(name, ['underscore', 'dasherize']) %>-seed.service.ts +--- +import { Injectable } from '@nestjs/common'; +import { InjectModel } from '@nestjs/mongoose'; +import { Model } from 'mongoose'; +import { <%= name %>SchemaClass } from 'src/<%= h.inflection.transform(name, ['pluralize', 'underscore', 'dasherize']) %>/entities/<%= h.inflection.transform(name, ['underscore', 'dasherize']) %>.schema'; + +@Injectable() +export class <%= name %>SeedService { + constructor( + @InjectModel(<%= name %>SchemaClass.name) + private readonly model: Model<<%= name %>SchemaClass>, + ) {} + + async run() { + const count = await this.model.countDocuments(); + + if (count === 0) { + const data = new this.model({}); + await data.save(); + } + } +} diff --git a/.hygen/seeds/create/module.ejs.t b/.hygen/seeds/create-relational/module.ejs.t similarity index 76% rename from .hygen/seeds/create/module.ejs.t rename to .hygen/seeds/create-relational/module.ejs.t index 7f6861797..79609fbf4 100644 --- a/.hygen/seeds/create/module.ejs.t +++ b/.hygen/seeds/create-relational/module.ejs.t @@ -1,5 +1,5 @@ --- -to: src/database/seeds/<%= h.inflection.transform(name, ['underscore', 'dasherize']) %>/<%= h.inflection.transform(name, ['underscore', 'dasherize']) %>-seed.module.ts +to: src/database/seeds/relational/<%= h.inflection.transform(name, ['underscore', 'dasherize']) %>/<%= h.inflection.transform(name, ['underscore', 'dasherize']) %>-seed.module.ts --- import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; diff --git a/.hygen/seeds/create-relational/run-seed-import.ejs.t b/.hygen/seeds/create-relational/run-seed-import.ejs.t new file mode 100644 index 000000000..ef86fb32f --- /dev/null +++ b/.hygen/seeds/create-relational/run-seed-import.ejs.t @@ -0,0 +1,6 @@ +--- +inject: true +to: src/database/seeds/relational/run-seed.ts +after: \@nestjs\/core +--- +import { <%= name %>SeedService } from './<%= h.inflection.transform(name, ['underscore', 'dasherize']) %>/<%= h.inflection.transform(name, ['underscore', 'dasherize']) %>-seed.service'; \ No newline at end of file diff --git a/.hygen/seeds/create-relational/run-seed-service.ejs.t b/.hygen/seeds/create-relational/run-seed-service.ejs.t new file mode 100644 index 000000000..7bc5d1d06 --- /dev/null +++ b/.hygen/seeds/create-relational/run-seed-service.ejs.t @@ -0,0 +1,6 @@ +--- +inject: true +to: src/database/seeds/relational/run-seed.ts +before: close +--- + await app.get(<%= name %>SeedService).run(); diff --git a/.hygen/seeds/create-relational/seed-module-import.ejs.t b/.hygen/seeds/create-relational/seed-module-import.ejs.t new file mode 100644 index 000000000..e9c478040 --- /dev/null +++ b/.hygen/seeds/create-relational/seed-module-import.ejs.t @@ -0,0 +1,6 @@ +--- +inject: true +to: src/database/seeds/relational/seed.module.ts +before: \@Module +--- +import { <%= name %>SeedModule } from './<%= h.inflection.transform(name, ['underscore', 'dasherize']) %>/<%= h.inflection.transform(name, ['underscore', 'dasherize']) %>-seed.module'; diff --git a/.hygen/seeds/create-relational/seed-module.ejs.t b/.hygen/seeds/create-relational/seed-module.ejs.t new file mode 100644 index 000000000..b40a9ad32 --- /dev/null +++ b/.hygen/seeds/create-relational/seed-module.ejs.t @@ -0,0 +1,6 @@ +--- +inject: true +to: src/database/seeds/relational/seed.module.ts +after: imports +--- + <%= name %>SeedModule, \ No newline at end of file diff --git a/.hygen/seeds/create/service.ejs.t b/.hygen/seeds/create-relational/service.ejs.t similarity index 78% rename from .hygen/seeds/create/service.ejs.t rename to .hygen/seeds/create-relational/service.ejs.t index a281a6aaf..6b86ba2d8 100644 --- a/.hygen/seeds/create/service.ejs.t +++ b/.hygen/seeds/create-relational/service.ejs.t @@ -1,5 +1,5 @@ --- -to: src/database/seeds/<%= h.inflection.transform(name, ['underscore', 'dasherize']) %>/<%= h.inflection.transform(name, ['underscore', 'dasherize']) %>-seed.service.ts +to: src/database/seeds/relational/<%= h.inflection.transform(name, ['underscore', 'dasherize']) %>/<%= h.inflection.transform(name, ['underscore', 'dasherize']) %>-seed.service.ts --- import { Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; diff --git a/Dockerfile b/Dockerfile index e64a02a50..1efd32410 100644 --- a/Dockerfile +++ b/Dockerfile @@ -10,13 +10,13 @@ COPY . /usr/src/app RUN cp -a /tmp/app/node_modules /usr/src/app COPY ./wait-for-it.sh /opt/wait-for-it.sh RUN chmod +x /opt/wait-for-it.sh -COPY ./startup.dev.sh /opt/startup.dev.sh -RUN chmod +x /opt/startup.dev.sh +COPY ./startup.relational.dev.sh /opt/startup.relational.dev.sh +RUN chmod +x /opt/startup.relational.dev.sh RUN sed -i 's/\r//g' /opt/wait-for-it.sh -RUN sed -i 's/\r//g' /opt/startup.dev.sh +RUN sed -i 's/\r//g' /opt/startup.relational.dev.sh WORKDIR /usr/src/app -RUN if [ ! -f .env ]; then cp env-example .env; fi +RUN if [ ! -f .env ]; then cp env-example-relational .env; fi RUN npm run build -CMD ["/opt/startup.dev.sh"] +CMD ["/opt/startup.relational.dev.sh"] diff --git a/Procfile b/Procfile index 2adfbabf0..c5bea1fc0 100644 --- a/Procfile +++ b/Procfile @@ -1,2 +1,2 @@ web: npm run start:prod -release: echo '' > .env && npm run migration:run && npm run seed:run \ No newline at end of file +release: echo '' > .env && npm run migration:run && npm run seed:run:relational \ No newline at end of file diff --git a/README.md b/README.md index a9a247232..cf97fe37f 100644 --- a/README.md +++ b/README.md @@ -20,139 +20,24 @@ Frontend (React, Next.js): - [Features](#features) -- [Quick run](#quick-run) -- [Comfortable development](#comfortable-development) -- [Links](#links) -- [Automatic update of dependencies](#automatic-update-of-dependencies) -- [Database utils](#database-utils) -- [Tests](#tests) -- [Tests in Docker](#tests-in-docker) -- [Test benchmarking](#test-benchmarking) - [Contributors](#contributors) ## Features -- [x] Database ([typeorm](https://www.npmjs.com/package/typeorm)). +- [x] Database. Support [TypeORM](https://www.npmjs.com/package/typeorm) and [Mongoose](https://www.npmjs.com/package/mongoose). - [x] Seeding. - [x] Config Service ([@nestjs/config](https://www.npmjs.com/package/@nestjs/config)). - [x] Mailing ([nodemailer](https://www.npmjs.com/package/nodemailer)). - [x] Sign in and sign up via email. - [x] Social sign in (Apple, Facebook, Google, Twitter). - [x] Admin and User roles. -- [x] I18N ([nestjs-i18n](https://www.npmjs.com/package/nestjs-i18n)). +- [x] Translations (I18N) ([nestjs-i18n](https://www.npmjs.com/package/nestjs-i18n)). - [x] File uploads. Support local and Amazon S3 drivers. - [x] Swagger. - [x] E2E and units tests. - [x] Docker. - [x] CI (Github Actions). -## Quick run - -```bash -git clone --depth 1 https://github.com/brocoders/nestjs-boilerplate.git my-app -cd my-app/ -cp env-example .env -docker compose up -d -``` - -For check status run - -```bash -docker compose logs -``` - -## Comfortable development - -```bash -git clone --depth 1 https://github.com/brocoders/nestjs-boilerplate.git my-app -cd my-app/ -cp env-example .env -``` - -Change `DATABASE_HOST=postgres` to `DATABASE_HOST=localhost` - -Change `MAIL_HOST=maildev` to `MAIL_HOST=localhost` - -Run additional container: - -```bash -docker compose up -d postgres adminer maildev -``` - -```bash -npm install - -npm run migration:run - -npm run seed:run - -npm run start:dev -``` - -## Links - -- Swagger: -- Adminer (client for DB): -- Maildev: - -## Automatic update of dependencies - -If you want to automatically update dependencies, you can connect [Renovate](https://github.com/marketplace/renovate) for your project. - -## Database utils - -Generate migration - -```bash -npm run migration:generate -- src/database/migrations/CreateNameTable -``` - -Run migration - -```bash -npm run migration:run -``` - -Revert migration - -```bash -npm run migration:revert -``` - -Drop all tables in database - -```bash -npm run schema:drop -``` - -Run seed - -```bash -npm run seed:run -``` - -## Tests - -```bash -# unit tests -npm run test - -# e2e tests -npm run test:e2e -``` - -## Tests in Docker - -```bash -docker compose -f docker-compose.ci.yaml --env-file env-example -p ci up --build --exit-code-from api && docker compose -p ci rm -svf -``` - -## Test benchmarking - -```bash -docker run --rm jordi/ab -n 100 -c 100 -T application/json -H "Authorization: Bearer USER_TOKEN" -v 2 http://:3000/api/v1/users -``` - ## Contributors diff --git a/docker-compose.document.ci.yaml b/docker-compose.document.ci.yaml new file mode 100644 index 000000000..fd1d1cc64 --- /dev/null +++ b/docker-compose.document.ci.yaml @@ -0,0 +1,25 @@ +services: + mongo: + image: mongo:7.0.3 + restart: always + expose: + - 27017 + + maildev: + build: + context: . + dockerfile: maildev.Dockerfile + expose: + - 1080 + - 1025 + + # Uncomment to use redis + # redis: + # image: redis:7-alpine + # expose: + # - 6379 + + api: + build: + context: . + dockerfile: document.e2e.Dockerfile diff --git a/docker-compose.document.yaml b/docker-compose.document.yaml new file mode 100644 index 000000000..0aef476cc --- /dev/null +++ b/docker-compose.document.yaml @@ -0,0 +1,42 @@ +services: + maildev: + build: + context: . + dockerfile: maildev.Dockerfile + ports: + - ${MAIL_CLIENT_PORT}:1080 + - ${MAIL_PORT}:1025 + + mongo: + image: mongo:7.0.3 + restart: always + volumes: + - boilerplate-mongo-db:/data/db + ports: + - 27017:27017 + + mongo-express: + image: mongo-express + restart: always + ports: + - 8081:8081 + environment: + ME_CONFIG_BASICAUTH_USERNAME: ${DATABASE_USERNAME} + ME_CONFIG_BASICAUTH_PASSWORD: ${DATABASE_PASSWORD} + ME_CONFIG_MONGODB_URL: mongodb://mongo:27017/ + + # Uncomment to use redis + # redis: + # image: redis:7-alpine + # ports: + # - 6379:6379 + + api: + build: + context: . + dockerfile: document.Dockerfile + ports: + - ${APP_PORT}:${APP_PORT} + +volumes: + boilerplate-mongo-db: diff --git a/docker-compose.ci.yaml b/docker-compose.relational.ci.yaml similarity index 79% rename from docker-compose.ci.yaml rename to docker-compose.relational.ci.yaml index 08bf5ef8b..83015de8c 100644 --- a/docker-compose.ci.yaml +++ b/docker-compose.relational.ci.yaml @@ -1,8 +1,6 @@ services: postgres: image: postgres:16.1-alpine - volumes: - - boilerplate-db:/var/lib/postgresql/data expose: - 5432 environment: @@ -27,8 +25,4 @@ services: api: build: context: . - dockerfile: e2e.Dockerfile - - -volumes: - boilerplate-db: + dockerfile: relational.e2e.Dockerfile diff --git a/docs/automatic-update-dependencies.md b/docs/automatic-update-dependencies.md new file mode 100644 index 000000000..cd36b231f --- /dev/null +++ b/docs/automatic-update-dependencies.md @@ -0,0 +1,7 @@ +# Automatic update of dependencies + +If you want to automatically update dependencies, you can connect [Renovate](https://github.com/marketplace/renovate) for your project. + +--- + +Previous: [Benchmarking](benchmarking.md) \ No newline at end of file diff --git a/docs/benchmarking.md b/docs/benchmarking.md new file mode 100644 index 000000000..384a8a7b8 --- /dev/null +++ b/docs/benchmarking.md @@ -0,0 +1,17 @@ +# Test benchmarking + +## Table of Contents + +- [Apache Benchmark](#apache-benchmark) + +## Apache Benchmark + +```bash +docker run --rm jordi/ab -n 100 -c 100 -T application/json -H "Authorization: Bearer USER_TOKEN" -v 2 http://:3000/api/v1/users +``` + +--- + +Next: [Automatic update of dependencies](automatic-update-dependencies.md) + +Previous: [Tests](tests.md) \ No newline at end of file diff --git a/docs/database.md b/docs/database.md index 44b6a473f..dd4b3eee2 100644 --- a/docs/database.md +++ b/docs/database.md @@ -6,22 +6,27 @@ In NestJS Boilerplate uses [TypeORM](https://www.npmjs.com/package/typeorm) and ## Table of Contents -- [Working with database schema](#working-with-database-schema) +- [Working with database schema (TypeORM)](#working-with-database-schema-typeorm) - [Generate migration](#generate-migration) - [Run migration](#run-migration) - [Revert migration](#revert-migration) - [Drop all tables in database](#drop-all-tables-in-database) -- [Seeding](#seeding) - - [Creating seeds](#creating-seeds) - - [Run seed](#run-seed) - - [Factory and Faker](#factory-and-faker) -- [Performance optimization](#performance-optimization) +- [Working with database schema (Mongoose)](#working-with-database-schema-mongoose) + - [Create schema](#create-schema) +- [Seeding (TypeORM)](#seeding-typeorm) + - [Creating seeds (TypeORM)](#creating-seeds-typeorm) + - [Run seed (TypeORM)](#run-seed-typeorm) + - [Factory and Faker (TypeORM)](#factory-and-faker-typeorm) +- [Seeding (Mongoose)](#seeding-mongoose) + - [Creating seeds (Mongoose)](#creating-seeds-mongoose) + - [Run seed (Mongoose)](#run-seed-mongoose) +- [Performance optimization (PostgreSQL + TypeORM)](#performance-optimization-postgresql--typeorm) - [Indexes and Foreign Keys](#indexes-and-foreign-keys) - [Max connections](#max-connections) --- -## Working with database schema +## Working with database schema (TypeORM) ### Generate migration @@ -76,22 +81,59 @@ npm run schema:drop --- -## Seeding +## Working with database schema (Mongoose) -### Creating seeds +### Create schema -1. Create seed file with `npm run seed:create -- --name=Post`. Where `Post` is name of entity. -1. Go to `src/database/seeds/post/post-seed.service.ts`. +1. Create entity file with extension `.schema.ts`. For example `post.schema.ts`: + + ```ts + // /src/posts/entities/post.schema.ts + + import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose'; + import { HydratedDocument } from 'mongoose'; + + export type PostSchemaDocument = HydratedDocument; + + + @Schema({ + timestamps: true, + toJSON: { + virtuals: true, + getters: true, + }, + }) + export class PostSchemaClass extends EntityDocumentHelper { + @Prop() + title: string; + + @Prop() + body: string; + + // Here any fields what you need + } + + export const PostSchema = SchemaFactory.createForClass(PostSchemaClass); + ``` + +--- + +## Seeding (TypeORM) + +### Creating seeds (TypeORM) + +1. Create seed file with `npm run seed:create:relational -- --name=Post`. Where `Post` is name of entity. +1. Go to `src/database/seeds/relational/post/post-seed.service.ts`. 1. In `run` method extend your logic. -1. Run [npm run seed:run](#run-seed) +1. Run [npm run seed:run:relational](#run-seed-typeorm) -### Run seed +### Run seed (TypeORM) ```bash -npm run seed:run +npm run seed:run:relational ``` -### Factory and Faker +### Factory and Faker (TypeORM) 1. Install faker: @@ -199,7 +241,24 @@ npm run seed:run --- -## Performance optimization +## Seeding (Mongoose) + +### Creating seeds (Mongoose) + +1. Create seed file with `npm run seed:create:document -- --name=Post`. Where `Post` is name of entity. +1. Go to `src/database/seeds/document/post/post-seed.service.ts`. +1. In `run` method extend your logic. +1. Run [npm run seed:run:document](#run-seed-mongoose) + +### Run seed (Mongoose) + +```bash +npm run seed:run:document +``` + +--- + +## Performance optimization (PostgreSQL + TypeORM) ### Indexes and Foreign Keys diff --git a/docs/file-uploading.md b/docs/file-uploading.md index 7edff4c24..aef0f8867 100644 --- a/docs/file-uploading.md +++ b/docs/file-uploading.md @@ -53,3 +53,5 @@ We prefer not to delete files, as this may have negative experience during resto --- Previous: [Serialization](serialization.md) + +Next: [Tests](tests.md) diff --git a/docs/installing-and-running.md b/docs/installing-and-running.md index 08e124670..90fd56b1b 100644 --- a/docs/installing-and-running.md +++ b/docs/installing-and-running.md @@ -4,14 +4,16 @@ ## Table of Contents -- [Comfortable development](#comfortable-development) -- [Quick run](#quick-run) +- [Comfortable development (PostgreSQL + TypeORM)](#comfortable-development-postgresql--typeorm) +- [Comfortable development (MongoDB + Mongoose)](#comfortable-development-mongodb--mongoose) +- [Quick run (PostgreSQL + TypeORM)](#quick-run-postgresql--typeorm) - [Video guideline](#video-guideline) +- [Quick run (MongoDB + Mongoose)](#quick-run-mongodb--mongoose) - [Links](#links) --- -## Comfortable development +## Comfortable development (PostgreSQL + TypeORM) 1. Clone repository @@ -19,11 +21,11 @@ git clone --depth 1 https://github.com/brocoders/nestjs-boilerplate.git my-app ``` -1. Go to folder, and copy `env-example` as `.env`. +1. Go to folder, and copy `env-example-relational` as `.env`. ```bash cd my-app/ - cp env-example .env + cp env-example-relational .env ``` 1. Change `DATABASE_HOST=postgres` to `DATABASE_HOST=localhost` @@ -51,7 +53,7 @@ 1. Run seeds ```bash - npm run seed:run + npm run seed:run:relational ``` 1. Run app in dev mode @@ -64,7 +66,58 @@ --- -## Quick run +## Comfortable development (MongoDB + Mongoose) + +1. Clone repository + + ```bash + git clone --depth 1 https://github.com/brocoders/nestjs-boilerplate.git my-app + ``` + +1. Go to folder, and copy `env-example-document` as `.env`. + + ```bash + cd my-app/ + cp env-example-document .env + ``` + +2. Change `DATABASE_URL=mongodb://mongo:27017/api` to `DATABASE_URL=mongodb://localhost:27017/api` + +3. Run additional container: + + ```bash + docker compose -f docker-compose.document.yaml up -d mongo mongo-express maildev + ``` + +4. Install dependency + + ```bash + npm install + ``` + +5. Run migrations + + ```bash + npm run migration:run + ``` + +6. Run seeds + + ```bash + npm run seed:run:document + ``` + +7. Run app in dev mode + + ```bash + npm run start:dev + ``` + +8. Open + +--- + +## Quick run (PostgreSQL + TypeORM) If you want quick run your app, you can use following commands: @@ -74,11 +127,11 @@ If you want quick run your app, you can use following commands: git clone --depth 1 https://github.com/brocoders/nestjs-boilerplate.git my-app ``` -1. Go to folder, and copy `env-example` as `.env`. +1. Go to folder, and copy `env-example-relational` as `.env`. ```bash cd my-app/ - cp env-example .env + cp env-example-relational .env ``` 1. Run containers @@ -101,10 +154,44 @@ If you want quick run your app, you can use following commands: --- +## Quick run (MongoDB + Mongoose) + +If you want quick run your app, you can use following commands: + +1. Clone repository + + ```bash + git clone --depth 1 https://github.com/brocoders/nestjs-boilerplate.git my-app + ``` + +1. Go to folder, and copy `env-example-document` as `.env`. + + ```bash + cd my-app/ + cp env-example-document .env + ``` + +1. Run containers + + ```bash + docker compose -f docker-compose.document.yaml up -d + ``` + +1. For check status run + + ```bash + docker compose -f docker-compose.document.yaml logs + ``` + +1. Open + +--- + ## Links - Swagger (API docs): - Adminer (client for DB): +- MongoDB Express (client for DB): - Maildev: --- diff --git a/docs/introduction.md b/docs/introduction.md index 4df91cb28..14e89fde1 100644 --- a/docs/introduction.md +++ b/docs/introduction.md @@ -8,14 +8,14 @@ Frontend (React, Next.js): + +- [Unit Tests](#unit-tests) +- [E2E Tests](#e2e-tests) +- [Tests in Docker](#tests-in-docker) + +## Unit Tests + +```bash +npm run test +``` + +## E2E Tests + +```bash +npm run test:e2e +``` + +## Tests in Docker + +```bash +docker compose -f docker-compose.ci.yaml --env-file env-example -p ci up --build --exit-code-from api && docker compose -p ci rm -svf +``` + +--- + +Previous: [File uploading](file-uploading.md) + +Next: [Benchmarking](benchmarking.md) diff --git a/document.Dockerfile b/document.Dockerfile new file mode 100644 index 000000000..ab66581f9 --- /dev/null +++ b/document.Dockerfile @@ -0,0 +1,22 @@ +FROM node:20.9.0-alpine + +RUN apk add --no-cache bash +RUN npm i -g @nestjs/cli typescript ts-node + +COPY package*.json /tmp/app/ +RUN cd /tmp/app && npm install + +COPY . /usr/src/app +RUN cp -a /tmp/app/node_modules /usr/src/app +COPY ./wait-for-it.sh /opt/wait-for-it.sh +RUN chmod +x /opt/wait-for-it.sh +COPY ./startup.document.dev.sh /opt/startup.document.dev.sh +RUN chmod +x /opt/startup.document.dev.sh +RUN sed -i 's/\r//g' /opt/wait-for-it.sh +RUN sed -i 's/\r//g' /opt/startup.document.dev.sh + +WORKDIR /usr/src/app +RUN if [ ! -f .env ]; then cp env-example-document .env; fi +RUN npm run build + +CMD ["/opt/startup.document.dev.sh"] diff --git a/e2e.Dockerfile b/document.e2e.Dockerfile similarity index 61% rename from e2e.Dockerfile rename to document.e2e.Dockerfile index 7ab91afd2..dec86ee0a 100644 --- a/e2e.Dockerfile +++ b/document.e2e.Dockerfile @@ -10,13 +10,13 @@ COPY . /usr/src/app RUN cp -a /tmp/app/node_modules /usr/src/app COPY ./wait-for-it.sh /opt/wait-for-it.sh RUN chmod +x /opt/wait-for-it.sh -COPY ./startup.ci.sh /opt/startup.ci.sh -RUN chmod +x /opt/startup.ci.sh +COPY ./startup.document.ci.sh /opt/startup.document.ci.sh +RUN chmod +x /opt/startup.document.ci.sh RUN sed -i 's/\r//g' /opt/wait-for-it.sh -RUN sed -i 's/\r//g' /opt/startup.ci.sh +RUN sed -i 's/\r//g' /opt/startup.document.ci.sh WORKDIR /usr/src/app -RUN if [ ! -f .env ]; then cp env-example .env; fi +RUN if [ ! -f .env ]; then cp env-example-document .env; fi RUN npm run build -CMD ["/opt/startup.ci.sh"] +CMD ["/opt/startup.document.ci.sh"] diff --git a/env-example-document b/env-example-document new file mode 100644 index 000000000..d523ac4e4 --- /dev/null +++ b/env-example-document @@ -0,0 +1,51 @@ +NODE_ENV=development +APP_PORT=3000 +APP_NAME="NestJS API" +API_PREFIX=api +APP_FALLBACK_LANGUAGE=en +APP_HEADER_LANGUAGE=x-custom-lang +FRONTEND_DOMAIN=http://localhost:3000 +BACKEND_DOMAIN=http://localhost:3000 + +DATABASE_TYPE=mongodb +DATABASE_URL=mongodb://mongo:27017/api + +# Support "local", "s3" +FILE_DRIVER=local +ACCESS_KEY_ID= +SECRET_ACCESS_KEY= +AWS_S3_REGION= +AWS_DEFAULT_S3_BUCKET= + +MAIL_HOST=maildev +MAIL_PORT=1025 +MAIL_USER= +MAIL_PASSWORD= +MAIL_IGNORE_TLS=true +MAIL_SECURE=false +MAIL_REQUIRE_TLS=false +MAIL_DEFAULT_EMAIL=noreply@example.com +MAIL_DEFAULT_NAME=Api +MAIL_CLIENT_PORT=1080 + +AUTH_JWT_SECRET=secret +AUTH_JWT_TOKEN_EXPIRES_IN=15m +AUTH_REFRESH_SECRET=secret_for_refresh +AUTH_REFRESH_TOKEN_EXPIRES_IN=3650d +AUTH_FORGOT_SECRET=secret_for_forgot +AUTH_FORGOT_TOKEN_EXPIRES_IN=30m +AUTH_CONFIRM_EMAIL_SECRET=secret_for_confirm_email +AUTH_CONFIRM_EMAIL_TOKEN_EXPIRES_IN=1d + +FACEBOOK_APP_ID= +FACEBOOK_APP_SECRET= + +GOOGLE_CLIENT_ID= +GOOGLE_CLIENT_SECRET= + +APPLE_APP_AUDIENCE=[] + +TWITTER_CONSUMER_KEY= +TWITTER_CONSUMER_SECRET= + +WORKER_HOST=redis://redis:6379/1 diff --git a/env-example b/env-example-relational similarity index 98% rename from env-example rename to env-example-relational index 3a152ce37..8af3157c1 100644 --- a/env-example +++ b/env-example-relational @@ -20,6 +20,7 @@ DATABASE_REJECT_UNAUTHORIZED=false DATABASE_CA= DATABASE_KEY= DATABASE_CERT= +DATABASE_URL= # Support "local", "s3" FILE_DRIVER=local diff --git a/package-lock.json b/package-lock.json index c4463da28..04c56b70a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7,13 +7,14 @@ "": { "name": "nestjs-boilerplate", "version": "0.0.1", - "license": "UNLICENSED", + "license": "MIT", "dependencies": { "@aws-sdk/client-s3": "3.462.0", "@nestjs/common": "10.2.10", "@nestjs/config": "3.1.1", "@nestjs/core": "10.2.10", "@nestjs/jwt": "10.2.0", + "@nestjs/mongoose": "^10.0.2", "@nestjs/passport": "10.0.3", "@nestjs/platform-express": "10.2.10", "@nestjs/swagger": "7.1.16", @@ -23,9 +24,11 @@ "bcryptjs": "2.4.3", "class-transformer": "0.5.1", "class-validator": "0.14.0", + "dotenv": "^16.3.1", "fb": "2.0.0", "google-auth-library": "9.4.1", "handlebars": "4.7.8", + "mongoose": "^8.0.1", "ms": "2.1.3", "multer": "1.4.4", "multer-s3": "3.0.1", @@ -3412,6 +3415,14 @@ "url": "https://github.com/sindresorhus/file-type?sponsor=1" } }, + "node_modules/@mongodb-js/saslprep": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@mongodb-js/saslprep/-/saslprep-1.1.1.tgz", + "integrity": "sha512-t7c5K033joZZMspnHg/gWPE4kandgc2OxE74aYOtGKfgB9VPuVJPix0H6fhmm2erj5PBJ21mqcx34lpIGtUCsQ==", + "dependencies": { + "sparse-bitfield": "^3.0.3" + } + }, "node_modules/@nestjs/cli": { "version": "10.2.1", "resolved": "https://registry.npmjs.org/@nestjs/cli/-/cli-10.2.1.tgz", @@ -3769,6 +3780,18 @@ } } }, + "node_modules/@nestjs/mongoose": { + "version": "10.0.2", + "resolved": "https://registry.npmjs.org/@nestjs/mongoose/-/mongoose-10.0.2.tgz", + "integrity": "sha512-ITHh075DynjPIaKeJh6WkarS21WXYslu4nrLkNPbWaCP6JfxVAOftaA2X5tPSiiE/gNJWgs+QFWsfCFZUUenow==", + "peerDependencies": { + "@nestjs/common": "^8.0.0 || ^9.0.0 || ^10.0.0", + "@nestjs/core": "^8.0.0 || ^9.0.0 || ^10.0.0", + "mongoose": "^6.0.2 || ^7.0.0 || ^8.0.0", + "reflect-metadata": "^0.1.12", + "rxjs": "^7.0.0" + } + }, "node_modules/@nestjs/passport": { "version": "10.0.3", "resolved": "https://registry.npmjs.org/@nestjs/passport/-/passport-10.0.3.tgz", @@ -5479,6 +5502,20 @@ "resolved": "https://registry.npmjs.org/@types/validator/-/validator-13.7.10.tgz", "integrity": "sha512-t1yxFAR2n0+VO6hd/FJ9F2uezAZVWHLmpmlJzm1eX03+H7+HsuTAp7L8QJs+2pQCfWkP1+EXsGK9Z9v7o/qPVQ==" }, + "node_modules/@types/webidl-conversions": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/@types/webidl-conversions/-/webidl-conversions-7.0.3.tgz", + "integrity": "sha512-CiJJvcRtIgzadHCYXw7dqEnMNRjhGZlYK05Mj9OyktqV8uVT8fD2BFOB7S1uwBE3Kj2Z+4UyPmFw/Ixgw/LAlA==" + }, + "node_modules/@types/whatwg-url": { + "version": "8.2.2", + "resolved": "https://registry.npmjs.org/@types/whatwg-url/-/whatwg-url-8.2.2.tgz", + "integrity": "sha512-FtQu10RWgn3D9U4aazdwIE2yzphmTJREDqNdODHrbrZmmMqI0vMheC/6NE/J1Yveaj8H+ela+YwWTjq5PGmuhA==", + "dependencies": { + "@types/node": "*", + "@types/webidl-conversions": "*" + } + }, "node_modules/@types/yargs": { "version": "17.0.12", "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.12.tgz", @@ -7016,6 +7053,14 @@ "node-int64": "^0.4.0" } }, + "node_modules/bson": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/bson/-/bson-6.2.0.tgz", + "integrity": "sha512-ID1cI+7bazPDyL9wYy9GaQ8gEEohWvcUl/Yf0dIdutJxnmInEEyCsb4awy/OiBfall7zBA179Pahi3vCdFze3Q==", + "engines": { + "node": ">=16.20.1" + } + }, "node_modules/buffer": { "version": "5.7.1", "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", @@ -13414,6 +13459,14 @@ "safe-buffer": "^5.0.1" } }, + "node_modules/kareem": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/kareem/-/kareem-2.5.1.tgz", + "integrity": "sha512-7jFxRVm+jD+rkq3kY0iZDJfsO2/t4BBPeEb2qKn2lR/9KhuksYk5hxzfRYWMPV8P/x2d0kHD306YyWLzjjH+uA==", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", @@ -13740,6 +13793,11 @@ "node": ">= 4.0.0" } }, + "node_modules/memory-pager": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/memory-pager/-/memory-pager-1.5.0.tgz", + "integrity": "sha512-ZS4Bp4r/Zoeq6+NLJpP+0Zzm0pR8whtGPf1XExKLJBAczGMnSi3It14OiNCStjQjM6NU1okjQGSxgEZN8eBYKg==" + }, "node_modules/merge-descriptors": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.1.tgz", @@ -13865,6 +13923,203 @@ "mkdirp": "bin/cmd.js" } }, + "node_modules/mongodb-connection-string-url": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/mongodb-connection-string-url/-/mongodb-connection-string-url-2.6.0.tgz", + "integrity": "sha512-WvTZlI9ab0QYtTYnuMLgobULWhokRjtC7db9LtcVfJ+Hsnyr5eo6ZtNAt3Ly24XZScGMelOcGtm7lSn0332tPQ==", + "dependencies": { + "@types/whatwg-url": "^8.2.1", + "whatwg-url": "^11.0.0" + } + }, + "node_modules/mongoose": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/mongoose/-/mongoose-8.0.1.tgz", + "integrity": "sha512-O3TJrtLCt4H1eGf2HoHGcnOcCTWloQkpmIP3hA9olybX3OX2KUjdIIq135HD5paGjZEDJYKn9fw4eH5N477zqQ==", + "dependencies": { + "bson": "^6.2.0", + "kareem": "2.5.1", + "mongodb": "6.2.0", + "mpath": "0.9.0", + "mquery": "5.0.0", + "ms": "2.1.3", + "sift": "16.0.1" + }, + "engines": { + "node": ">=16.20.1" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mongoose" + } + }, + "node_modules/mongoose/node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "optional": true, + "peer": true, + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/mongoose/node_modules/debug": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", + "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", + "optional": true, + "peer": true, + "dependencies": { + "ms": "2.1.2" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/mongoose/node_modules/debug/node_modules/ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", + "optional": true, + "peer": true + }, + "node_modules/mongoose/node_modules/gaxios": { + "version": "5.1.3", + "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-5.1.3.tgz", + "integrity": "sha512-95hVgBRgEIRQQQHIbnxBXeHbW4TqFk4ZDJW7wmVtvYar72FdhRIo1UGOLS2eRAKCPEdPBWu+M7+A33D9CdX9rA==", + "optional": true, + "peer": true, + "dependencies": { + "extend": "^3.0.2", + "https-proxy-agent": "^5.0.0", + "is-stream": "^2.0.0", + "node-fetch": "^2.6.9" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/mongoose/node_modules/gcp-metadata": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-5.3.0.tgz", + "integrity": "sha512-FNTkdNEnBdlqF2oatizolQqNANMrcqJt6AAYt99B3y1aLLC8Hc5IOBb+ZnnzllodEEf6xMBp6wRcBbc16fa65w==", + "optional": true, + "peer": true, + "dependencies": { + "gaxios": "^5.0.0", + "json-bigint": "^1.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/mongoose/node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "optional": true, + "peer": true, + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/mongoose/node_modules/mongodb": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/mongodb/-/mongodb-6.2.0.tgz", + "integrity": "sha512-d7OSuGjGWDZ5usZPqfvb36laQ9CPhnWkAGHT61x5P95p/8nMVeH8asloMwW6GcYFeB0Vj4CB/1wOTDG2RA9BFA==", + "dependencies": { + "@mongodb-js/saslprep": "^1.1.0", + "bson": "^6.2.0", + "mongodb-connection-string-url": "^2.6.0" + }, + "engines": { + "node": ">=16.20.1" + }, + "peerDependencies": { + "@aws-sdk/credential-providers": "^3.188.0", + "@mongodb-js/zstd": "^1.1.0", + "gcp-metadata": "^5.2.0", + "kerberos": "^2.0.1", + "mongodb-client-encryption": ">=6.0.0 <7", + "snappy": "^7.2.2", + "socks": "^2.7.1" + }, + "peerDependenciesMeta": { + "@aws-sdk/credential-providers": { + "optional": true + }, + "@mongodb-js/zstd": { + "optional": true + }, + "gcp-metadata": { + "optional": true + }, + "kerberos": { + "optional": true + }, + "mongodb-client-encryption": { + "optional": true + }, + "snappy": { + "optional": true + }, + "socks": { + "optional": true + } + } + }, + "node_modules/mpath": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/mpath/-/mpath-0.9.0.tgz", + "integrity": "sha512-ikJRQTk8hw5DEoFVxHG1Gn9T/xcjtdnOKIU1JTmGjZZlg9LST2mBLmcX3/ICIbgJydT2GOc15RnNy5mHmzfSew==", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/mquery": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/mquery/-/mquery-5.0.0.tgz", + "integrity": "sha512-iQMncpmEK8R8ncT8HJGsGc9Dsp8xcgYMVSbs5jgnm1lFHTZqMJTUWTDx1LBO8+mK3tPNZWFLBghQEIOULSTHZg==", + "dependencies": { + "debug": "4.x" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/mquery/node_modules/debug": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", + "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", + "dependencies": { + "ms": "2.1.2" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/mquery/node_modules/ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -15764,6 +16019,11 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/sift": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/sift/-/sift-16.0.1.tgz", + "integrity": "sha512-Wv6BjQ5zbhW7VFefWusVP33T/EM0vYikCaQ2qR8yULbsilAT8/wQaXvuQ3ptGLpoKx+lihJE3y2UTgKDyyNHZQ==" + }, "node_modules/signal-exit": { "version": "3.0.7", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", @@ -15844,6 +16104,14 @@ "node": ">=0.10.0" } }, + "node_modules/sparse-bitfield": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/sparse-bitfield/-/sparse-bitfield-3.0.3.tgz", + "integrity": "sha512-kvzhi7vqKTfkh0PZU+2D2PIllw2ymqJKujUcyPMd9Y75Nv4nPbGJZXNhxsgdQab2BmlDct1YnfQCguEvHr7VsQ==", + "dependencies": { + "memory-pager": "^1.0.2" + } + }, "node_modules/split2": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/split2/-/split2-4.1.0.tgz", @@ -16542,6 +16810,17 @@ "url": "https://github.com/sponsors/Borewit" } }, + "node_modules/tr46": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-3.0.0.tgz", + "integrity": "sha512-l7FvfAHlcmulp8kr+flpQZmVwtu7nfRV7NZujtN0OqES8EL4O4e0qqzL0DC5gAvx/ZC/9lk6rhcUwYvkBnBnYA==", + "dependencies": { + "punycode": "^2.1.1" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/tree-kill": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", @@ -17500,6 +17779,14 @@ "defaults": "^1.0.3" } }, + "node_modules/webidl-conversions": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", + "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", + "engines": { + "node": ">=12" + } + }, "node_modules/webpack": { "version": "5.89.0", "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.89.0.tgz", @@ -17565,6 +17852,18 @@ "node": ">=10.13.0" } }, + "node_modules/whatwg-url": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-11.0.0.tgz", + "integrity": "sha512-RKT8HExMpoYx4igMiVMY83lN6UeITKJlBQ+vR/8ZJ8OCdSiN3RwCq+9gH0+Xzj0+5IrM6i4j/6LuvzbZIQgEcQ==", + "dependencies": { + "tr46": "^3.0.0", + "webidl-conversions": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", diff --git a/package.json b/package.json index 4159e8a3a..0323660df 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,7 @@ "description": "", "author": "", "private": true, - "license": "UNLICENSED", + "license": "MIT", "scripts": { "typeorm": "env-cmd ts-node -r tsconfig-paths/register ./node_modules/typeorm/cli.js", "migration:generate": "npm run typeorm -- --dataSource=src/database/data-source.ts migration:generate", @@ -12,8 +12,10 @@ "migration:run": "npm run typeorm -- --dataSource=src/database/data-source.ts migration:run", "migration:revert": "npm run typeorm -- --dataSource=src/database/data-source.ts migration:revert", "schema:drop": "npm run typeorm -- --dataSource=src/database/data-source.ts schema:drop", - "seed:create": "hygen seeds create", - "seed:run": "ts-node -r tsconfig-paths/register ./src/database/seeds/run-seed.ts", + "seed:create:relational": "hygen seeds create-relational", + "seed:create:document": "hygen seeds create-document", + "seed:run:relational": "ts-node -r tsconfig-paths/register ./src/database/seeds/relational/run-seed.ts", + "seed:run:document": "ts-node -r tsconfig-paths/register ./src/database/seeds/document/run-seed.ts", "prebuild": "rimraf dist", "build": "nest build", "format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"", @@ -36,6 +38,7 @@ "@nestjs/config": "3.1.1", "@nestjs/core": "10.2.10", "@nestjs/jwt": "10.2.0", + "@nestjs/mongoose": "^10.0.2", "@nestjs/passport": "10.0.3", "@nestjs/platform-express": "10.2.10", "@nestjs/swagger": "7.1.16", @@ -45,9 +48,11 @@ "bcryptjs": "2.4.3", "class-transformer": "0.5.1", "class-validator": "0.14.0", + "dotenv": "^16.3.1", "fb": "2.0.0", "google-auth-library": "9.4.1", "handlebars": "4.7.8", + "mongoose": "^8.0.1", "ms": "2.1.3", "multer": "1.4.4", "multer-s3": "3.0.1", diff --git a/relational.e2e.Dockerfile b/relational.e2e.Dockerfile new file mode 100644 index 000000000..f2adc6246 --- /dev/null +++ b/relational.e2e.Dockerfile @@ -0,0 +1,22 @@ +FROM node:20.9.0-alpine + +RUN apk add --no-cache bash +RUN npm i -g @nestjs/cli typescript ts-node + +COPY package*.json /tmp/app/ +RUN cd /tmp/app && npm install + +COPY . /usr/src/app +RUN cp -a /tmp/app/node_modules /usr/src/app +COPY ./wait-for-it.sh /opt/wait-for-it.sh +RUN chmod +x /opt/wait-for-it.sh +COPY ./startup.relational.ci.sh /opt/startup.relational.ci.sh +RUN chmod +x /opt/startup.relational.ci.sh +RUN sed -i 's/\r//g' /opt/wait-for-it.sh +RUN sed -i 's/\r//g' /opt/startup.relational.ci.sh + +WORKDIR /usr/src/app +RUN if [ ! -f .env ]; then cp env-example-relational .env; fi +RUN npm run build + +CMD ["/opt/startup.relational.ci.sh"] diff --git a/src/app.module.ts b/src/app.module.ts index 403862d70..8372eea57 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -27,6 +27,9 @@ import { DataSource, DataSourceOptions } from 'typeorm'; import { AllConfigType } from './config/config.type'; import { SessionModule } from './session/session.module'; import { MailerModule } from './mailer/mailer.module'; +import { MongooseModule } from '@nestjs/mongoose'; +import { MongooseConfigService } from './database/mongoose-config.service'; +import { DatabaseConfig } from './database/config/database-config.type'; @Module({ imports: [ @@ -45,12 +48,16 @@ import { MailerModule } from './mailer/mailer.module'; ], envFilePath: ['.env'], }), - TypeOrmModule.forRootAsync({ - useClass: TypeOrmConfigService, - dataSourceFactory: async (options: DataSourceOptions) => { - return new DataSource(options).initialize(); - }, - }), + (databaseConfig() as DatabaseConfig).isDocumentDatabase + ? MongooseModule.forRootAsync({ + useClass: MongooseConfigService, + }) + : TypeOrmModule.forRootAsync({ + useClass: TypeOrmConfigService, + dataSourceFactory: async (options: DataSourceOptions) => { + return new DataSource(options).initialize(); + }, + }), I18nModule.forRootAsync({ useFactory: (configService: ConfigService) => ({ fallbackLanguage: configService.getOrThrow('app.fallbackLanguage', { diff --git a/src/auth/auth.controller.ts b/src/auth/auth.controller.ts index 607b5d2b4..a2edf9384 100644 --- a/src/auth/auth.controller.ts +++ b/src/auth/auth.controller.ts @@ -21,8 +21,8 @@ import { AuthUpdateDto } from './dto/auth-update.dto'; import { AuthGuard } from '@nestjs/passport'; import { AuthRegisterLoginDto } from './dto/auth-register-login.dto'; import { LoginResponseType } from './types/login-response.type'; -import { User } from '../users/entities/user.entity'; import { NullableType } from '../utils/types/nullable.type'; +import { UserType } from 'src/users/entities/user.type'; @ApiTags('Auth') @Controller({ @@ -81,7 +81,7 @@ export class AuthController { @Get('me') @UseGuards(AuthGuard('jwt')) @HttpCode(HttpStatus.OK) - public me(@Request() request): Promise> { + public me(@Request() request): Promise> { return this.service.me(request.user); } @@ -118,7 +118,7 @@ export class AuthController { public update( @Request() request, @Body() userDto: AuthUpdateDto, - ): Promise> { + ): Promise> { return this.service.update(request.user, userDto); } diff --git a/src/auth/auth.module.ts b/src/auth/auth.module.ts index 0f9e81ce6..8fd15f49e 100644 --- a/src/auth/auth.module.ts +++ b/src/auth/auth.module.ts @@ -5,11 +5,9 @@ import { PassportModule } from '@nestjs/passport'; import { JwtModule } from '@nestjs/jwt'; import { JwtStrategy } from './strategies/jwt.strategy'; import { AnonymousStrategy } from './strategies/anonymous.strategy'; -import { UsersModule } from '../users/users.module'; -import { MailModule } from '../mail/mail.module'; -import { IsExist } from '../utils/validators/is-exists.validator'; -import { IsNotExist } from '../utils/validators/is-not-exists.validator'; -import { SessionModule } from '../session/session.module'; +import { UsersModule } from 'src/users/users.module'; +import { MailModule } from 'src/mail/mail.module'; +import { SessionModule } from 'src/session/session.module'; import { JwtRefreshStrategy } from './strategies/jwt-refresh.strategy'; @Module({ @@ -21,14 +19,7 @@ import { JwtRefreshStrategy } from './strategies/jwt-refresh.strategy'; JwtModule.register({}), ], controllers: [AuthController], - providers: [ - IsExist, - IsNotExist, - AuthService, - JwtStrategy, - JwtRefreshStrategy, - AnonymousStrategy, - ], + providers: [AuthService, JwtStrategy, JwtRefreshStrategy, AnonymousStrategy], exports: [AuthService], }) export class AuthModule {} diff --git a/src/auth/auth.service.ts b/src/auth/auth.service.ts index 3b46d934d..66fb8f7ea 100644 --- a/src/auth/auth.service.ts +++ b/src/auth/auth.service.ts @@ -10,31 +10,29 @@ import { User } from '../users/entities/user.entity'; import bcrypt from 'bcryptjs'; import { AuthEmailLoginDto } from './dto/auth-email-login.dto'; import { AuthUpdateDto } from './dto/auth-update.dto'; -import { RoleEnum } from '../roles/roles.enum'; -import { StatusEnum } from '../statuses/statuses.enum'; -import { plainToClass } from 'class-transformer'; -import { Status } from '../statuses/entities/status.entity'; -import { Role } from '../roles/entities/role.entity'; +import { RoleEnum } from 'src/roles/roles.enum'; +import { StatusEnum } from 'src/statuses/statuses.enum'; import { AuthProvidersEnum } from './auth-providers.enum'; import { SocialInterface } from '../social/interfaces/social.interface'; import { AuthRegisterLoginDto } from './dto/auth-register-login.dto'; -import { UsersService } from '../users/users.service'; -import { MailService } from '../mail/mail.service'; +import { MailService } from 'src/mail/mail.service'; import { NullableType } from '../utils/types/nullable.type'; import { LoginResponseType } from './types/login-response.type'; import { ConfigService } from '@nestjs/config'; -import { AllConfigType } from '../config/config.type'; -import { SessionService } from '../session/session.service'; +import { AllConfigType } from 'src/config/config.type'; import { JwtRefreshPayloadType } from './strategies/types/jwt-refresh-payload.type'; -import { Session } from '../session/entities/session.entity'; import { JwtPayloadType } from './strategies/types/jwt-payload.type'; +import { UserType } from 'src/users/entities/user.type'; +import { SessionType } from 'src/session/entities/session.type'; +import { UsersServiceAbstract } from 'src/users/users-abstract.service'; +import { SessionAbstractService } from 'src/session/session-abstract.service'; @Injectable() export class AuthService { constructor( private jwtService: JwtService, - private usersService: UsersService, - private sessionService: SessionService, + private usersService: UsersServiceAbstract, + private sessionService: SessionAbstractService, private mailService: MailService, private configService: ConfigService, ) {} @@ -107,9 +105,9 @@ export class AuthService { authProvider: string, socialData: SocialInterface, ): Promise { - let user: NullableType = null; + let user: NullableType = null; const socialEmail = socialData.email?.toLowerCase(); - let userByEmail: NullableType = null; + let userByEmail: NullableType = null; if (socialEmail) { userByEmail = await this.usersService.findOne({ @@ -132,12 +130,12 @@ export class AuthService { } else if (userByEmail) { user = userByEmail; } else { - const role = plainToClass(Role, { + const role = { id: RoleEnum.user, - }); - const status = plainToClass(Status, { + }; + const status = { id: StatusEnum.active, - }); + }; user = await this.usersService.create({ email: socialEmail ?? null, @@ -150,7 +148,7 @@ export class AuthService { }); user = await this.usersService.findOne({ - id: user.id, + id: user?.id, }); } @@ -194,10 +192,10 @@ export class AuthService { email: dto.email, role: { id: RoleEnum.user, - } as Role, + }, status: { id: StatusEnum.inactive, - } as Status, + }, }); const hash = await this.jwtService.signAsync( @@ -261,10 +259,11 @@ export class AuthService { ); } - user.status = plainToClass(Status, { + user.status = { id: StatusEnum.active, - }); - await user.save(); + }; + + await this.usersService.update(user.id, user); } async forgotPassword(email: string): Promise { @@ -354,10 +353,11 @@ export class AuthService { id: user.id, }, }); - await user.save(); + + await this.usersService.update(user.id, user); } - async me(userJwtPayload: JwtPayloadType): Promise> { + async me(userJwtPayload: JwtPayloadType): Promise> { return this.usersService.findOne({ id: userJwtPayload.id, }); @@ -366,7 +366,7 @@ export class AuthService { async update( userJwtPayload: JwtPayloadType, userDto: AuthUpdateDto, - ): Promise> { + ): Promise> { if (userDto.password) { if (!userDto.oldPassword) { throw new HttpException( @@ -432,9 +432,7 @@ export class AuthService { data: Pick, ): Promise> { const session = await this.sessionService.findOne({ - where: { - id: data.sessionId, - }, + id: data.sessionId, }); if (!session) { @@ -465,9 +463,9 @@ export class AuthService { } private async getTokensData(data: { - id: User['id']; - role: User['role']; - sessionId: Session['id']; + id: UserType['id']; + role: UserType['role']; + sessionId: SessionType['id']; }) { const tokenExpiresIn = this.configService.getOrThrow('auth.expires', { infer: true, diff --git a/src/auth/dto/auth-email-login.dto.ts b/src/auth/dto/auth-email-login.dto.ts index e87b18726..ea27f1915 100644 --- a/src/auth/dto/auth-email-login.dto.ts +++ b/src/auth/dto/auth-email-login.dto.ts @@ -1,15 +1,13 @@ import { ApiProperty } from '@nestjs/swagger'; -import { IsNotEmpty, Validate } from 'class-validator'; -import { IsExist } from '../../utils/validators/is-exists.validator'; +import { IsEmail, IsNotEmpty } from 'class-validator'; import { Transform } from 'class-transformer'; import { lowerCaseTransformer } from '../../utils/transformers/lower-case.transformer'; export class AuthEmailLoginDto { @ApiProperty({ example: 'test1@example.com' }) @Transform(lowerCaseTransformer) - @Validate(IsExist, ['User'], { - message: 'emailNotExists', - }) + @IsEmail() + @IsNotEmpty() email: string; @ApiProperty() diff --git a/src/auth/dto/auth-register-login.dto.ts b/src/auth/dto/auth-register-login.dto.ts index 03b6612cb..06e57f9d6 100644 --- a/src/auth/dto/auth-register-login.dto.ts +++ b/src/auth/dto/auth-register-login.dto.ts @@ -1,15 +1,11 @@ import { ApiProperty } from '@nestjs/swagger'; -import { IsEmail, IsNotEmpty, MinLength, Validate } from 'class-validator'; -import { IsNotExist } from '../../utils/validators/is-not-exists.validator'; +import { IsEmail, IsNotEmpty, MinLength } from 'class-validator'; import { Transform } from 'class-transformer'; import { lowerCaseTransformer } from '../../utils/transformers/lower-case.transformer'; export class AuthRegisterLoginDto { @ApiProperty({ example: 'test1@example.com' }) @Transform(lowerCaseTransformer) - @Validate(IsNotExist, ['User'], { - message: 'emailAlreadyExists', - }) @IsEmail() email: string; diff --git a/src/auth/dto/auth-update.dto.ts b/src/auth/dto/auth-update.dto.ts index a1174062e..09605f6b9 100644 --- a/src/auth/dto/auth-update.dto.ts +++ b/src/auth/dto/auth-update.dto.ts @@ -1,15 +1,11 @@ import { ApiProperty } from '@nestjs/swagger'; -import { IsNotEmpty, IsOptional, MinLength, Validate } from 'class-validator'; -import { IsExist } from '../../utils/validators/is-exists.validator'; -import { FileEntity } from '../../files/entities/file.entity'; +import { IsNotEmpty, IsOptional, MinLength } from 'class-validator'; +import { FileDto } from 'src/files/dto/file.dto'; export class AuthUpdateDto { - @ApiProperty({ type: () => FileEntity }) + @ApiProperty({ type: () => FileDto }) @IsOptional() - @Validate(IsExist, ['FileEntity', 'id'], { - message: 'imageNotExists', - }) - photo?: FileEntity; + photo?: FileDto; @ApiProperty({ example: 'John' }) @IsOptional() diff --git a/src/auth/types/login-response.type.ts b/src/auth/types/login-response.type.ts index 17577758f..dc33c3a51 100644 --- a/src/auth/types/login-response.type.ts +++ b/src/auth/types/login-response.type.ts @@ -1,8 +1,8 @@ -import { User } from '../../users/entities/user.entity'; +import { UserType } from 'src/users/entities/user.type'; export type LoginResponseType = Readonly<{ token: string; refreshToken: string; tokenExpires: number; - user: User; + user: UserType; }>; diff --git a/src/database/config/database-config.type.ts b/src/database/config/database-config.type.ts index 9c4023c4b..d958b4500 100644 --- a/src/database/config/database-config.type.ts +++ b/src/database/config/database-config.type.ts @@ -1,4 +1,5 @@ export type DatabaseConfig = { + isDocumentDatabase: boolean; url?: string; type?: string; host?: string; diff --git a/src/database/config/database.config.ts b/src/database/config/database.config.ts index 6b0721d71..1687e070b 100644 --- a/src/database/config/database.config.ts +++ b/src/database/config/database.config.ts @@ -77,6 +77,7 @@ export default registerAs('database', () => { validateConfig(process.env, EnvironmentVariablesValidator); return { + isDocumentDatabase: ['mongodb'].includes(process.env.DATABASE_TYPE ?? ''), url: process.env.DATABASE_URL, type: process.env.DATABASE_TYPE, host: process.env.DATABASE_HOST, diff --git a/src/database/mongoose-config.service.ts b/src/database/mongoose-config.service.ts new file mode 100644 index 000000000..89a24291b --- /dev/null +++ b/src/database/mongoose-config.service.ts @@ -0,0 +1,18 @@ +import { Injectable } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { + MongooseModuleOptions, + MongooseOptionsFactory, +} from '@nestjs/mongoose'; +import { AllConfigType } from 'src/config/config.type'; + +@Injectable() +export class MongooseConfigService implements MongooseOptionsFactory { + constructor(private configService: ConfigService) {} + + createMongooseOptions(): MongooseModuleOptions { + return { + uri: this.configService.get('database.url', { infer: true }), + }; + } +} diff --git a/src/database/seeds/document/run-seed.ts b/src/database/seeds/document/run-seed.ts new file mode 100644 index 000000000..9e43fbdef --- /dev/null +++ b/src/database/seeds/document/run-seed.ts @@ -0,0 +1,15 @@ +import { NestFactory } from '@nestjs/core'; +import { UserSeedService } from './user/user-seed.service'; + +import { SeedModule } from './seed.module'; + +const runSeed = async () => { + const app = await NestFactory.create(SeedModule); + + // run + await app.get(UserSeedService).run(); + + await app.close(); +}; + +void runSeed(); diff --git a/src/database/seeds/document/seed.module.ts b/src/database/seeds/document/seed.module.ts new file mode 100644 index 000000000..9355332b3 --- /dev/null +++ b/src/database/seeds/document/seed.module.ts @@ -0,0 +1,22 @@ +import { Module } from '@nestjs/common'; +import { ConfigModule } from '@nestjs/config'; +import appConfig from 'src/config/app.config'; +import databaseConfig from 'src/database/config/database.config'; +import { MongooseModule } from '@nestjs/mongoose'; +import { MongooseConfigService } from 'src/database/mongoose-config.service'; +import { UserSeedModule } from './user/user-seed.module'; + +@Module({ + imports: [ + UserSeedModule, + ConfigModule.forRoot({ + isGlobal: true, + load: [databaseConfig, appConfig], + envFilePath: ['.env'], + }), + MongooseModule.forRootAsync({ + useClass: MongooseConfigService, + }), + ], +}) +export class SeedModule {} diff --git a/src/database/seeds/document/user/user-seed.module.ts b/src/database/seeds/document/user/user-seed.module.ts new file mode 100644 index 000000000..930e8e388 --- /dev/null +++ b/src/database/seeds/document/user/user-seed.module.ts @@ -0,0 +1,18 @@ +import { Module } from '@nestjs/common'; +import { MongooseModule } from '@nestjs/mongoose'; +import { UserSchema, UserSchemaClass } from 'src/users/entities/user.schema'; +import { UserSeedService } from './user-seed.service'; + +@Module({ + imports: [ + MongooseModule.forFeature([ + { + name: UserSchemaClass.name, + schema: UserSchema, + }, + ]), + ], + providers: [UserSeedService], + exports: [UserSeedService], +}) +export class UserSeedModule {} diff --git a/src/database/seeds/document/user/user-seed.service.ts b/src/database/seeds/document/user/user-seed.service.ts new file mode 100644 index 000000000..796f6810c --- /dev/null +++ b/src/database/seeds/document/user/user-seed.service.ts @@ -0,0 +1,64 @@ +import { Injectable } from '@nestjs/common'; +import { InjectModel } from '@nestjs/mongoose'; +import bcrypt from 'bcryptjs'; +import { Model } from 'mongoose'; +import { RoleEnum } from 'src/roles/roles.enum'; +import { StatusEnum } from 'src/statuses/statuses.enum'; +import { UserSchemaClass } from 'src/users/entities/user.schema'; + +@Injectable() +export class UserSeedService { + constructor( + @InjectModel(UserSchemaClass.name) + private readonly model: Model, + ) {} + + async run() { + const admin = await this.model.findOne({ + email: 'admin@example.com', + }); + + if (!admin) { + const salt = await bcrypt.genSalt(); + const password = await bcrypt.hash('secret', salt); + + const data = new this.model({ + email: 'admin@example.com', + password: password, + firstName: 'Super', + lastName: 'Admin', + role: { + id: RoleEnum.admin, + }, + status: { + id: StatusEnum.active, + }, + }); + await data.save(); + } + + const user = await this.model.findOne({ + email: 'john.doe@example.com', + }); + + if (!user) { + const salt = await bcrypt.genSalt(); + const password = await bcrypt.hash('secret', salt); + + const data = new this.model({ + email: 'john.doe@example.com', + password: password, + firstName: 'John', + lastName: 'Doe', + role: { + id: RoleEnum.user, + }, + status: { + id: StatusEnum.active, + }, + }); + + await data.save(); + } + } +} diff --git a/src/database/seeds/role/role-seed.module.ts b/src/database/seeds/relational/role/role-seed.module.ts similarity index 100% rename from src/database/seeds/role/role-seed.module.ts rename to src/database/seeds/relational/role/role-seed.module.ts diff --git a/src/database/seeds/role/role-seed.service.ts b/src/database/seeds/relational/role/role-seed.service.ts similarity index 88% rename from src/database/seeds/role/role-seed.service.ts rename to src/database/seeds/relational/role/role-seed.service.ts index 1baffbf0d..77675dd31 100644 --- a/src/database/seeds/role/role-seed.service.ts +++ b/src/database/seeds/relational/role/role-seed.service.ts @@ -1,7 +1,7 @@ import { Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; -import { Role } from '../../../roles/entities/role.entity'; -import { RoleEnum } from '../../../roles/roles.enum'; +import { Role } from 'src/roles/entities/role.entity'; +import { RoleEnum } from 'src/roles/roles.enum'; import { Repository } from 'typeorm'; @Injectable() diff --git a/src/database/seeds/run-seed.ts b/src/database/seeds/relational/run-seed.ts similarity index 100% rename from src/database/seeds/run-seed.ts rename to src/database/seeds/relational/run-seed.ts diff --git a/src/database/seeds/seed.module.ts b/src/database/seeds/relational/seed.module.ts similarity index 93% rename from src/database/seeds/seed.module.ts rename to src/database/seeds/relational/seed.module.ts index 84d671728..346dae228 100644 --- a/src/database/seeds/seed.module.ts +++ b/src/database/seeds/relational/seed.module.ts @@ -4,7 +4,7 @@ import { TypeOrmModule } from '@nestjs/typeorm'; import appConfig from 'src/config/app.config'; import databaseConfig from 'src/database/config/database.config'; import { DataSource, DataSourceOptions } from 'typeorm'; -import { TypeOrmConfigService } from '../typeorm-config.service'; +import { TypeOrmConfigService } from '../../typeorm-config.service'; import { RoleSeedModule } from './role/role-seed.module'; import { StatusSeedModule } from './status/status-seed.module'; import { UserSeedModule } from './user/user-seed.module'; diff --git a/src/database/seeds/status/status-seed.module.ts b/src/database/seeds/relational/status/status-seed.module.ts similarity index 82% rename from src/database/seeds/status/status-seed.module.ts rename to src/database/seeds/relational/status/status-seed.module.ts index acc0cb114..74723ebd2 100644 --- a/src/database/seeds/status/status-seed.module.ts +++ b/src/database/seeds/relational/status/status-seed.module.ts @@ -1,7 +1,7 @@ import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; -import { Status } from '../../../statuses/entities/status.entity'; import { StatusSeedService } from './status-seed.service'; +import { Status } from 'src/statuses/entities/status.entity'; @Module({ imports: [TypeOrmModule.forFeature([Status])], diff --git a/src/database/seeds/status/status-seed.service.ts b/src/database/seeds/relational/status/status-seed.service.ts similarity index 83% rename from src/database/seeds/status/status-seed.service.ts rename to src/database/seeds/relational/status/status-seed.service.ts index c7a44706d..1b69c031d 100644 --- a/src/database/seeds/status/status-seed.service.ts +++ b/src/database/seeds/relational/status/status-seed.service.ts @@ -1,7 +1,7 @@ import { Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; -import { Status } from '../../../statuses/entities/status.entity'; -import { StatusEnum } from '../../../statuses/statuses.enum'; +import { Status } from 'src/statuses/entities/status.entity'; +import { StatusEnum } from 'src/statuses/statuses.enum'; import { Repository } from 'typeorm'; @Injectable() diff --git a/src/database/seeds/user/user-seed.module.ts b/src/database/seeds/relational/user/user-seed.module.ts similarity index 100% rename from src/database/seeds/user/user-seed.module.ts rename to src/database/seeds/relational/user/user-seed.module.ts diff --git a/src/database/seeds/user/user-seed.service.ts b/src/database/seeds/relational/user/user-seed.service.ts similarity index 79% rename from src/database/seeds/user/user-seed.service.ts rename to src/database/seeds/relational/user/user-seed.service.ts index ea5281cd6..6ffac0434 100644 --- a/src/database/seeds/user/user-seed.service.ts +++ b/src/database/seeds/relational/user/user-seed.service.ts @@ -1,9 +1,10 @@ import { Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; -import { RoleEnum } from '../../../roles/roles.enum'; import { StatusEnum } from 'src/statuses/statuses.enum'; -import { User } from '../../../users/entities/user.entity'; import { Repository } from 'typeorm'; +import bcrypt from 'bcryptjs'; +import { User } from 'src/users/entities/user.entity'; +import { RoleEnum } from 'src/roles/roles.enum'; @Injectable() export class UserSeedService { @@ -22,12 +23,15 @@ export class UserSeedService { }); if (!countAdmin) { + const salt = await bcrypt.genSalt(); + const password = await bcrypt.hash('secret', salt); + await this.repository.save( this.repository.create({ firstName: 'Super', lastName: 'Admin', email: 'admin@example.com', - password: 'secret', + password, role: { id: RoleEnum.admin, name: 'Admin', @@ -49,12 +53,15 @@ export class UserSeedService { }); if (!countUser) { + const salt = await bcrypt.genSalt(); + const password = await bcrypt.hash('secret', salt); + await this.repository.save( this.repository.create({ firstName: 'John', lastName: 'Doe', email: 'john.doe@example.com', - password: 'secret', + password, role: { id: RoleEnum.user, name: 'Admin', diff --git a/src/files/dto/file.dto.ts b/src/files/dto/file.dto.ts new file mode 100644 index 000000000..157c416f4 --- /dev/null +++ b/src/files/dto/file.dto.ts @@ -0,0 +1,11 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { FileType } from '../entities/file.type'; +import { IsString } from 'class-validator'; + +export class FileDto implements FileType { + @ApiProperty() + @IsString() + id: string; + + path: string; +} diff --git a/src/files/entities/file.entity.ts b/src/files/entities/file.entity.ts index 19e871d6c..42bc634ea 100644 --- a/src/files/entities/file.entity.ts +++ b/src/files/entities/file.entity.ts @@ -7,12 +7,13 @@ import { } from 'typeorm'; import { ApiProperty } from '@nestjs/swagger'; import { Allow } from 'class-validator'; -import { EntityHelper } from '../../utils/entity-helper'; +import { EntityRelationalHelper } from 'src/utils/relational-entity-helper'; import appConfig from '../../config/app.config'; import { AppConfig } from 'src/config/app-config.type'; +import { FileType } from './file.type'; @Entity({ name: 'file' }) -export class FileEntity extends EntityHelper { +export class FileEntity extends EntityRelationalHelper implements FileType { @ApiProperty({ example: 'cbcfa8b8-3a25-4adb-a9c6-e325f0d0f3ae' }) @PrimaryGeneratedColumn('uuid') id: string; diff --git a/src/files/entities/file.schema.ts b/src/files/entities/file.schema.ts new file mode 100644 index 000000000..578b6b0a8 --- /dev/null +++ b/src/files/entities/file.schema.ts @@ -0,0 +1,35 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { Allow } from 'class-validator'; +import appConfig from '../../config/app.config'; +import { AppConfig } from 'src/config/app-config.type'; +import { FileType } from './file.type'; +import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose'; +import { HydratedDocument } from 'mongoose'; +import { EntityDocumentHelper } from 'src/utils/document-entity-helper'; + +export type FileSchemaDocument = HydratedDocument; + +@Schema({ + toJSON: { + virtuals: true, + getters: true, + }, +}) +export class FileSchemaClass extends EntityDocumentHelper implements FileType { + @ApiProperty({ example: 'cbcfa8b8-3a25-4adb-a9c6-e325f0d0f3ae' }) + id: string; + + @Allow() + @Prop({ + get: (value) => { + if (value.indexOf('/') === 0) { + return (appConfig() as AppConfig).backendDomain + value; + } + + return value; + }, + }) + path: string; +} + +export const FileSchema = SchemaFactory.createForClass(FileSchemaClass); diff --git a/src/files/entities/file.type.ts b/src/files/entities/file.type.ts new file mode 100644 index 000000000..a5c50ec00 --- /dev/null +++ b/src/files/entities/file.type.ts @@ -0,0 +1,5 @@ +export abstract class FileType { + _id?: string; + id: string; + path: string; +} diff --git a/src/files/files-abstract.service.ts b/src/files/files-abstract.service.ts new file mode 100644 index 000000000..33d436de3 --- /dev/null +++ b/src/files/files-abstract.service.ts @@ -0,0 +1,13 @@ +import { EntityCondition } from 'src/utils/types/entity-condition.type'; +import { FileType } from './entities/file.type'; +import { NullableType } from 'src/utils/types/nullable.type'; + +export abstract class FilesServiceAbstract { + abstract create( + file: Express.Multer.File | Express.MulterS3.File, + ): Promise; + + abstract findOne( + fields: EntityCondition, + ): Promise>; +} diff --git a/src/files/files-document.service.ts b/src/files/files-document.service.ts new file mode 100644 index 000000000..42b11c324 --- /dev/null +++ b/src/files/files-document.service.ts @@ -0,0 +1,65 @@ +import { HttpException, HttpStatus, Injectable } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { AllConfigType } from 'src/config/config.type'; +import { FilesServiceAbstract } from './files-abstract.service'; +import { FileSchemaClass } from './entities/file.schema'; +import { InjectModel } from '@nestjs/mongoose'; +import { Model } from 'mongoose'; +import { FileType } from './entities/file.type'; +import { plainToInstance } from 'class-transformer'; +import { EntityCondition } from 'src/utils/types/entity-condition.type'; +import { NullableType } from 'src/utils/types/nullable.type'; + +@Injectable() +export class FilesDocumentService implements FilesServiceAbstract { + constructor( + private readonly configService: ConfigService, + @InjectModel(FileSchemaClass.name) + private fileModel: Model, + ) {} + + async create( + file: Express.Multer.File | Express.MulterS3.File, + ): Promise { + if (!file) { + throw new HttpException( + { + status: HttpStatus.UNPROCESSABLE_ENTITY, + errors: { + file: 'selectFile', + }, + }, + HttpStatus.UNPROCESSABLE_ENTITY, + ); + } + + const path = { + local: `/${this.configService.get('app.apiPrefix', { infer: true })}/v1/${ + file.path + }`, + s3: (file as Express.MulterS3.File).location, + }; + + const createdFile = new this.fileModel({ + path: path[this.configService.getOrThrow('file.driver', { infer: true })], + }); + const fileObject = await createdFile.save(); + return plainToInstance(FileSchemaClass, fileObject.toJSON()); + } + + async findOne( + fields: EntityCondition, + ): Promise> { + if (fields.id) { + const fileObject = await this.fileModel.findById(fields.id); + return plainToInstance(FileSchemaClass, fileObject?.toJSON(), { + groups: ['system'], + }); + } + + const userObject = await this.fileModel.findOne(fields); + return plainToInstance(FileSchemaClass, userObject?.toJSON(), { + groups: ['system'], + }); + } +} diff --git a/src/files/files.service.ts b/src/files/files-relational.service.ts similarity index 68% rename from src/files/files.service.ts rename to src/files/files-relational.service.ts index 991239072..d8f9c975c 100644 --- a/src/files/files.service.ts +++ b/src/files/files-relational.service.ts @@ -2,18 +2,21 @@ import { HttpException, HttpStatus, Injectable } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { InjectRepository } from '@nestjs/typeorm'; import { FileEntity } from './entities/file.entity'; -import { Repository } from 'typeorm'; +import { FindOptionsWhere, Repository } from 'typeorm'; import { AllConfigType } from 'src/config/config.type'; +import { FilesServiceAbstract } from './files-abstract.service'; +import { EntityCondition } from 'src/utils/types/entity-condition.type'; +import { NullableType } from 'src/utils/types/nullable.type'; @Injectable() -export class FilesService { +export class FilesRelationalService implements FilesServiceAbstract { constructor( private readonly configService: ConfigService, @InjectRepository(FileEntity) private readonly fileRepository: Repository, ) {} - async uploadFile( + async create( file: Express.Multer.File | Express.MulterS3.File, ): Promise { if (!file) { @@ -43,4 +46,12 @@ export class FilesService { }), ); } + + findOne( + fields: EntityCondition, + ): Promise> { + return this.fileRepository.findOne({ + where: fields as FindOptionsWhere, + }); + } } diff --git a/src/files/files.controller.ts b/src/files/files.controller.ts index bd49218c8..caa53650a 100644 --- a/src/files/files.controller.ts +++ b/src/files/files.controller.ts @@ -11,7 +11,7 @@ import { import { FileInterceptor } from '@nestjs/platform-express'; import { ApiBearerAuth, ApiBody, ApiConsumes, ApiTags } from '@nestjs/swagger'; import { AuthGuard } from '@nestjs/passport'; -import { FilesService } from './files.service'; +import { FilesServiceAbstract } from './files-abstract.service'; @ApiTags('Files') @Controller({ @@ -19,7 +19,7 @@ import { FilesService } from './files.service'; version: '1', }) export class FilesController { - constructor(private readonly filesService: FilesService) {} + constructor(private readonly filesService: FilesServiceAbstract) {} @ApiBearerAuth() @UseGuards(AuthGuard('jwt')) @@ -40,7 +40,7 @@ export class FilesController { async uploadFile( @UploadedFile() file: Express.Multer.File | Express.MulterS3.File, ) { - return this.filesService.uploadFile(file); + return this.filesService.create(file); } @Get(':path') diff --git a/src/files/files.module.ts b/src/files/files.module.ts index 522ac1063..6018ab0bb 100644 --- a/src/files/files.module.ts +++ b/src/files/files.module.ts @@ -8,12 +8,22 @@ import { S3Client } from '@aws-sdk/client-s3'; import multerS3 from 'multer-s3'; import { TypeOrmModule } from '@nestjs/typeorm'; import { FileEntity } from './entities/file.entity'; -import { FilesService } from './files.service'; +import { FilesRelationalService } from './files-relational.service'; import { AllConfigType } from 'src/config/config.type'; +import { MongooseModule } from '@nestjs/mongoose'; +import { FileSchema, FileSchemaClass } from './entities/file.schema'; +import { FilesDocumentService } from './files-document.service'; +import { FilesServiceAbstract } from './files-abstract.service'; +import databaseConfig from 'src/database/config/database.config'; +import { DatabaseConfig } from 'src/database/config/database-config.type'; @Module({ imports: [ - TypeOrmModule.forFeature([FileEntity]), + (databaseConfig() as DatabaseConfig).isDocumentDatabase + ? MongooseModule.forFeature([ + { name: FileSchemaClass.name, schema: FileSchema }, + ]) + : TypeOrmModule.forFeature([FileEntity]), MulterModule.registerAsync({ imports: [ConfigModule], inject: [ConfigService], @@ -97,6 +107,16 @@ import { AllConfigType } from 'src/config/config.type'; }), ], controllers: [FilesController], - providers: [ConfigModule, ConfigService, FilesService], + providers: [ + ConfigModule, + ConfigService, + { + provide: FilesServiceAbstract, + useClass: (databaseConfig() as DatabaseConfig).isDocumentDatabase + ? FilesDocumentService + : FilesRelationalService, + }, + ], + exports: [FilesServiceAbstract], }) export class FilesModule {} diff --git a/src/main.ts b/src/main.ts index d3663af8e..56a9454a8 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,3 +1,4 @@ +import 'dotenv/config'; import { ClassSerializerInterceptor, ValidationPipe, diff --git a/src/roles/dto/role.dto.ts b/src/roles/dto/role.dto.ts new file mode 100644 index 000000000..8121fd62b --- /dev/null +++ b/src/roles/dto/role.dto.ts @@ -0,0 +1,9 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsNumber } from 'class-validator'; +import { RoleType } from '../entities/role.type'; + +export class RoleDto implements RoleType { + @ApiProperty() + @IsNumber() + id: number; +} diff --git a/src/roles/entities/role.entity.ts b/src/roles/entities/role.entity.ts index b2859fc34..5bfaa4efd 100644 --- a/src/roles/entities/role.entity.ts +++ b/src/roles/entities/role.entity.ts @@ -1,10 +1,11 @@ import { Column, Entity, PrimaryColumn } from 'typeorm'; import { ApiProperty } from '@nestjs/swagger'; import { Allow, IsNumber } from 'class-validator'; -import { EntityHelper } from '../../utils/entity-helper'; +import { EntityRelationalHelper } from 'src/utils/relational-entity-helper'; +import { RoleType } from './role.type'; @Entity() -export class Role extends EntityHelper { +export class Role extends EntityRelationalHelper implements RoleType { @ApiProperty({ example: 1 }) @PrimaryColumn() @IsNumber() diff --git a/src/roles/entities/role.type.ts b/src/roles/entities/role.type.ts new file mode 100644 index 000000000..f7c9d8832 --- /dev/null +++ b/src/roles/entities/role.type.ts @@ -0,0 +1,4 @@ +export abstract class RoleType { + id: number; + name?: string; +} diff --git a/src/session/entities/session.entity.ts b/src/session/entities/session.entity.ts index cfc43d85c..64957e237 100644 --- a/src/session/entities/session.entity.ts +++ b/src/session/entities/session.entity.ts @@ -7,10 +7,11 @@ import { DeleteDateColumn, } from 'typeorm'; import { User } from '../../users/entities/user.entity'; -import { EntityHelper } from '../../utils/entity-helper'; +import { EntityRelationalHelper } from 'src/utils/relational-entity-helper'; +import { SessionType } from './session.type'; @Entity() -export class Session extends EntityHelper { +export class Session extends EntityRelationalHelper implements SessionType { @PrimaryGeneratedColumn() id: number; diff --git a/src/session/entities/session.schema.ts b/src/session/entities/session.schema.ts new file mode 100644 index 000000000..0285a2869 --- /dev/null +++ b/src/session/entities/session.schema.ts @@ -0,0 +1,36 @@ +import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose'; +import mongoose, { now, HydratedDocument } from 'mongoose'; +import { SessionType } from './session.type'; +import { UserSchemaClass } from 'src/users/entities/user.schema'; +import { EntityDocumentHelper } from 'src/utils/document-entity-helper'; + +export type SessionSchemaDocument = HydratedDocument; + +@Schema({ + timestamps: true, + toJSON: { + virtuals: true, + getters: true, + }, +}) +export class SessionSchemaClass + extends EntityDocumentHelper + implements SessionType +{ + id: string; + + @Prop({ type: mongoose.Schema.Types.ObjectId, ref: 'UserSchemaClass' }) + user: UserSchemaClass; + + @Prop({ default: now }) + createdAt: Date; + + @Prop() + deletedAt: Date; +} + +export const SessionSchema = SchemaFactory.createForClass(SessionSchemaClass); + +SessionSchema.virtual('id').get(function () { + return this._id.toString(); +}); diff --git a/src/session/entities/session.type.ts b/src/session/entities/session.type.ts new file mode 100644 index 000000000..22e0a375c --- /dev/null +++ b/src/session/entities/session.type.ts @@ -0,0 +1,9 @@ +import { UserType } from 'src/users/entities/user.type'; + +export abstract class SessionType { + _id?: string; + id: number | string; + user: UserType; + createdAt: Date; + deletedAt: Date; +} diff --git a/src/session/session-abstract.service.ts b/src/session/session-abstract.service.ts new file mode 100644 index 000000000..1725b34b3 --- /dev/null +++ b/src/session/session-abstract.service.ts @@ -0,0 +1,22 @@ +import { DeepPartial } from 'src/utils/types/deep-partial.type'; +import { NullableType } from '../utils/types/nullable.type'; +import { SessionType } from './entities/session.type'; +import { UserType } from 'src/users/entities/user.type'; +import { EntityCondition } from 'src/utils/types/entity-condition.type'; + +export abstract class SessionAbstractService { + abstract findOne( + options: EntityCondition, + ): Promise>; + + abstract create(data: DeepPartial): Promise; + + abstract softDelete({ + excludeId, + ...criteria + }: { + id?: SessionType['id']; + user?: Pick; + excludeId?: SessionType['id']; + }): Promise; +} diff --git a/src/session/session-document.service.ts b/src/session/session-document.service.ts new file mode 100644 index 000000000..beafa72df --- /dev/null +++ b/src/session/session-document.service.ts @@ -0,0 +1,58 @@ +import { Injectable } from '@nestjs/common'; +import { NullableType } from '../utils/types/nullable.type'; +import { SessionAbstractService } from './session-abstract.service'; +import { SessionType } from './entities/session.type'; +import { SessionSchemaClass } from './entities/session.schema'; +import { Model } from 'mongoose'; +import { InjectModel } from '@nestjs/mongoose'; +import { plainToInstance } from 'class-transformer'; +import { UserType } from 'src/users/entities/user.type'; +import { EntityCondition } from 'src/utils/types/entity-condition.type'; +import { DeepPartial } from 'src/utils/types/deep-partial.type'; + +@Injectable() +export class SessionDocumentService implements SessionAbstractService { + constructor( + @InjectModel(SessionSchemaClass.name) + private sessionModel: Model, + ) {} + + async findOne( + options: EntityCondition, + ): Promise> { + const clonedOptions = { ...options }; + if (clonedOptions.id) { + clonedOptions._id = clonedOptions.id.toString(); + delete clonedOptions.id; + } + + const sessionObject = await this.sessionModel.findOne(clonedOptions); + + return plainToInstance(SessionSchemaClass, sessionObject?.toJSON()); + } + + async create(data: DeepPartial): Promise { + const createdSession = new this.sessionModel(data); + const sessionObject = await createdSession.save(); + return plainToInstance(SessionSchemaClass, sessionObject.toJSON()); + } + + async softDelete({ + excludeId, + ...criteria + }: { + id?: SessionType['id']; + user?: Pick; + excludeId?: SessionType['id']; + }): Promise { + const transformedCriteria = { + user: criteria.user?.id, + _id: criteria.id + ? criteria.id + : excludeId + ? { $not: { $eq: excludeId } } + : undefined, + }; + await this.sessionModel.deleteMany(transformedCriteria); + } +} diff --git a/src/session/session-relational.service.ts b/src/session/session-relational.service.ts new file mode 100644 index 000000000..0ac98a550 --- /dev/null +++ b/src/session/session-relational.service.ts @@ -0,0 +1,54 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { FindOptionsWhere, Not, Repository } from 'typeorm'; +import { Session } from './entities/session.entity'; +import { NullableType } from '../utils/types/nullable.type'; +import { User } from 'src/users/entities/user.entity'; +import { SessionAbstractService } from './session-abstract.service'; +import { SessionType } from './entities/session.type'; +import { UserType } from 'src/users/entities/user.type'; +import { EntityCondition } from 'src/utils/types/entity-condition.type'; +import { DeepPartial } from 'src/utils/types/deep-partial.type'; + +@Injectable() +export class SessionRelationalService implements SessionAbstractService { + constructor( + @InjectRepository(Session) + private readonly sessionRepository: Repository, + ) {} + + async findOne( + options: EntityCondition, + ): Promise> { + return this.sessionRepository.findOne({ + where: options as FindOptionsWhere, + }); + } + + async create(data: DeepPartial): Promise { + return this.sessionRepository.save( + this.sessionRepository.create(data as DeepPartial), + ); + } + + async softDelete({ + excludeId, + ...criteria + }: { + id?: SessionType['id']; + user?: Pick; + excludeId?: SessionType['id']; + }): Promise { + await this.sessionRepository.softDelete({ + ...(criteria as { + id?: Session['id']; + user?: Pick; + }), + id: criteria.id + ? (criteria.id as Session['id']) + : excludeId + ? Not(excludeId as Session['id']) + : undefined, + }); + } +} diff --git a/src/session/session.module.ts b/src/session/session.module.ts index 449e77bbb..24c531279 100644 --- a/src/session/session.module.ts +++ b/src/session/session.module.ts @@ -1,11 +1,30 @@ import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; import { Session } from './entities/session.entity'; -import { SessionService } from './session.service'; +import { SessionRelationalService } from './session-relational.service'; +import { MongooseModule } from '@nestjs/mongoose'; +import { SessionSchema, SessionSchemaClass } from './entities/session.schema'; +import { SessionDocumentService } from './session-document.service'; +import { SessionAbstractService } from './session-abstract.service'; +import databaseConfig from 'src/database/config/database.config'; +import { DatabaseConfig } from 'src/database/config/database-config.type'; @Module({ - imports: [TypeOrmModule.forFeature([Session])], - providers: [SessionService], - exports: [SessionService], + imports: [ + (databaseConfig() as DatabaseConfig).isDocumentDatabase + ? MongooseModule.forFeature([ + { name: SessionSchemaClass.name, schema: SessionSchema }, + ]) + : TypeOrmModule.forFeature([Session]), + ], + providers: [ + { + provide: SessionAbstractService, + useClass: (databaseConfig() as DatabaseConfig).isDocumentDatabase + ? SessionDocumentService + : SessionRelationalService, + }, + ], + exports: [SessionAbstractService], }) export class SessionModule {} diff --git a/src/session/session.service.ts b/src/session/session.service.ts deleted file mode 100644 index e5b5fc41d..000000000 --- a/src/session/session.service.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { Injectable } from '@nestjs/common'; -import { InjectRepository } from '@nestjs/typeorm'; -import { FindOptions } from 'src/utils/types/find-options.type'; -import { DeepPartial, Not, Repository } from 'typeorm'; -import { Session } from './entities/session.entity'; -import { NullableType } from '../utils/types/nullable.type'; -import { User } from 'src/users/entities/user.entity'; - -@Injectable() -export class SessionService { - constructor( - @InjectRepository(Session) - private readonly sessionRepository: Repository, - ) {} - - async findOne(options: FindOptions): Promise> { - return this.sessionRepository.findOne({ - where: options.where, - }); - } - - async findMany(options: FindOptions): Promise { - return this.sessionRepository.find({ - where: options.where, - }); - } - - async create(data: DeepPartial): Promise { - return this.sessionRepository.save(this.sessionRepository.create(data)); - } - - async softDelete({ - excludeId, - ...criteria - }: { - id?: Session['id']; - user?: Pick; - excludeId?: Session['id']; - }): Promise { - await this.sessionRepository.softDelete({ - ...criteria, - id: criteria.id ? criteria.id : excludeId ? Not(excludeId) : undefined, - }); - } -} diff --git a/src/statuses/dto/status.dto.ts b/src/statuses/dto/status.dto.ts new file mode 100644 index 000000000..bd868617e --- /dev/null +++ b/src/statuses/dto/status.dto.ts @@ -0,0 +1,9 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { StatusType } from '../entities/status.type'; +import { IsNumber } from 'class-validator'; + +export class StatusDto implements StatusType { + @ApiProperty() + @IsNumber() + id: number; +} diff --git a/src/statuses/entities/status.entity.ts b/src/statuses/entities/status.entity.ts index aa5346b14..0d2e78710 100644 --- a/src/statuses/entities/status.entity.ts +++ b/src/statuses/entities/status.entity.ts @@ -1,10 +1,11 @@ import { Column, Entity, PrimaryColumn } from 'typeorm'; import { ApiProperty } from '@nestjs/swagger'; import { Allow } from 'class-validator'; -import { EntityHelper } from '../../utils/entity-helper'; +import { EntityRelationalHelper } from 'src/utils/relational-entity-helper'; +import { StatusType } from './status.type'; @Entity() -export class Status extends EntityHelper { +export class Status extends EntityRelationalHelper implements StatusType { @ApiProperty({ example: 1 }) @PrimaryColumn() id: number; diff --git a/src/statuses/entities/status.type.ts b/src/statuses/entities/status.type.ts new file mode 100644 index 000000000..21448070b --- /dev/null +++ b/src/statuses/entities/status.type.ts @@ -0,0 +1,4 @@ +export abstract class StatusType { + id: number; + name?: string; +} diff --git a/src/users/dto/create-user.dto.ts b/src/users/dto/create-user.dto.ts index b8ce0a2d2..7b8da5ff7 100644 --- a/src/users/dto/create-user.dto.ts +++ b/src/users/dto/create-user.dto.ts @@ -1,26 +1,15 @@ -import { Transform } from 'class-transformer'; +import { Transform, Type } from 'class-transformer'; import { ApiProperty } from '@nestjs/swagger'; -import { Role } from '../../roles/entities/role.entity'; -import { - IsEmail, - IsNotEmpty, - IsOptional, - MinLength, - Validate, -} from 'class-validator'; -import { Status } from '../../statuses/entities/status.entity'; -import { IsNotExist } from '../../utils/validators/is-not-exists.validator'; -import { FileEntity } from '../../files/entities/file.entity'; -import { IsExist } from '../../utils/validators/is-exists.validator'; -import { lowerCaseTransformer } from '../../utils/transformers/lower-case.transformer'; +import { IsEmail, IsNotEmpty, IsOptional, MinLength } from 'class-validator'; +import { lowerCaseTransformer } from 'src/utils/transformers/lower-case.transformer'; +import { RoleDto } from 'src/roles/dto/role.dto'; +import { StatusDto } from 'src/statuses/dto/status.dto'; +import { FileDto } from 'src/files/dto/file.dto'; export class CreateUserDto { @ApiProperty({ example: 'test1@example.com' }) @Transform(lowerCaseTransformer) @IsNotEmpty() - @Validate(IsNotExist, ['User'], { - message: 'emailAlreadyExists', - }) @IsEmail() email: string | null; @@ -40,24 +29,19 @@ export class CreateUserDto { @IsNotEmpty() lastName: string | null; - @ApiProperty({ type: () => FileEntity }) + @ApiProperty({ type: () => FileDto }) @IsOptional() - @Validate(IsExist, ['FileEntity', 'id'], { - message: 'imageNotExists', - }) - photo?: FileEntity | null; - - @ApiProperty({ type: Role }) - @Validate(IsExist, ['Role', 'id'], { - message: 'roleNotExists', - }) - role?: Role | null; - - @ApiProperty({ type: Status }) - @Validate(IsExist, ['Status', 'id'], { - message: 'statusNotExists', - }) - status?: Status; + photo?: FileDto | null; + + @ApiProperty({ type: RoleDto }) + @IsOptional() + @Type(() => RoleDto) + role?: RoleDto | null; + + @ApiProperty({ type: StatusDto }) + @IsOptional() + @Type(() => StatusDto) + status?: StatusDto; hash?: string | null; } diff --git a/src/users/dto/query-user.dto.ts b/src/users/dto/query-user.dto.ts index 829b210b1..2d8e44fe3 100644 --- a/src/users/dto/query-user.dto.ts +++ b/src/users/dto/query-user.dto.ts @@ -1,5 +1,4 @@ import { ApiProperty } from '@nestjs/swagger'; -import { Role } from '../../roles/entities/role.entity'; import { IsNumber, IsOptional, @@ -7,20 +6,21 @@ import { ValidateNested, } from 'class-validator'; import { Transform, Type, plainToInstance } from 'class-transformer'; -import { User } from '../entities/user.entity'; +import { UserType } from '../entities/user.type'; +import { RoleDto } from 'src/roles/dto/role.dto'; export class FilterUserDto { - @ApiProperty({ type: Role }) + @ApiProperty({ type: RoleDto }) @IsOptional() @ValidateNested({ each: true }) - @Type(() => Role) - roles?: Role[] | null; + @Type(() => RoleDto) + roles?: RoleDto[] | null; } export class SortUserDto { @ApiProperty() @IsString() - orderBy: keyof User; + orderBy: keyof UserType; @ApiProperty() @IsString() diff --git a/src/users/dto/update-user.dto.ts b/src/users/dto/update-user.dto.ts index 592540d91..b7cabf0c4 100644 --- a/src/users/dto/update-user.dto.ts +++ b/src/users/dto/update-user.dto.ts @@ -1,23 +1,18 @@ import { PartialType } from '@nestjs/swagger'; import { CreateUserDto } from './create-user.dto'; -import { Transform } from 'class-transformer'; +import { Transform, Type } from 'class-transformer'; import { ApiProperty } from '@nestjs/swagger'; -import { Role } from '../../roles/entities/role.entity'; -import { IsEmail, IsOptional, MinLength, Validate } from 'class-validator'; -import { Status } from '../../statuses/entities/status.entity'; -import { IsNotExist } from '../../utils/validators/is-not-exists.validator'; -import { FileEntity } from '../../files/entities/file.entity'; -import { IsExist } from '../../utils/validators/is-exists.validator'; -import { lowerCaseTransformer } from '../../utils/transformers/lower-case.transformer'; +import { IsEmail, IsOptional, MinLength } from 'class-validator'; +import { lowerCaseTransformer } from 'src/utils/transformers/lower-case.transformer'; +import { RoleDto } from 'src/roles/dto/role.dto'; +import { StatusDto } from 'src/statuses/dto/status.dto'; +import { FileDto } from 'src/files/dto/file.dto'; export class UpdateUserDto extends PartialType(CreateUserDto) { @ApiProperty({ example: 'test1@example.com' }) @Transform(lowerCaseTransformer) @IsOptional() - @Validate(IsNotExist, ['User'], { - message: 'emailAlreadyExists', - }) @IsEmail() email?: string | null; @@ -38,26 +33,19 @@ export class UpdateUserDto extends PartialType(CreateUserDto) { @IsOptional() lastName?: string | null; - @ApiProperty({ type: () => FileEntity }) + @ApiProperty({ type: FileDto }) @IsOptional() - @Validate(IsExist, ['FileEntity', 'id'], { - message: 'imageNotExists', - }) - photo?: FileEntity | null; + photo?: FileDto | null; - @ApiProperty({ type: Role }) + @ApiProperty({ type: RoleDto }) @IsOptional() - @Validate(IsExist, ['Role', 'id'], { - message: 'roleNotExists', - }) - role?: Role | null; + @Type(() => RoleDto) + role?: RoleDto | null; - @ApiProperty({ type: Status }) + @ApiProperty({ type: StatusDto }) @IsOptional() - @Validate(IsExist, ['Status', 'id'], { - message: 'statusNotExists', - }) - status?: Status; + @Type(() => StatusDto) + status?: StatusDto; hash?: string | null; } diff --git a/src/users/entities/user.entity.ts b/src/users/entities/user.entity.ts index 78bf825dc..ccd3655a9 100644 --- a/src/users/entities/user.entity.ts +++ b/src/users/entities/user.entity.ts @@ -8,19 +8,16 @@ import { ManyToOne, PrimaryGeneratedColumn, UpdateDateColumn, - BeforeInsert, - BeforeUpdate, } from 'typeorm'; import { Role } from '../../roles/entities/role.entity'; import { Status } from '../../statuses/entities/status.entity'; import { FileEntity } from '../../files/entities/file.entity'; -import bcrypt from 'bcryptjs'; -import { EntityHelper } from '../../utils/entity-helper'; -import { AuthProvidersEnum } from '../../auth/auth-providers.enum'; +import { EntityRelationalHelper } from 'src/utils/relational-entity-helper'; +import { AuthProvidersEnum } from 'src/auth/auth-providers.enum'; import { Exclude, Expose } from 'class-transformer'; @Entity() -export class User extends EntityHelper { +export class User extends EntityRelationalHelper { @PrimaryGeneratedColumn() id: number; @@ -42,15 +39,6 @@ export class User extends EntityHelper { this.previousPassword = this.password; } - @BeforeInsert() - @BeforeUpdate() - async setPassword() { - if (this.previousPassword !== this.password && this.password) { - const salt = await bcrypt.genSalt(); - this.password = await bcrypt.hash(this.password, salt); - } - } - @Column({ default: AuthProvidersEnum.email }) @Expose({ groups: ['me', 'admin'] }) provider: string; diff --git a/src/users/entities/user.schema.ts b/src/users/entities/user.schema.ts new file mode 100644 index 000000000..7c6aa77aa --- /dev/null +++ b/src/users/entities/user.schema.ts @@ -0,0 +1,96 @@ +import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose'; +import { now, HydratedDocument } from 'mongoose'; +import { UserType } from './user.type'; +import { RoleType } from 'src/roles/entities/role.type'; +import { StatusType } from 'src/statuses/entities/status.type'; +import { AuthProvidersEnum } from 'src/auth/auth-providers.enum'; +import { Exclude, Expose, Type } from 'class-transformer'; +import { EntityDocumentHelper } from 'src/utils/document-entity-helper'; +import { FileSchemaClass } from 'src/files/entities/file.schema'; + +export type UserSchemaDocument = HydratedDocument; + +@Schema({ + timestamps: true, + toJSON: { + virtuals: true, + getters: true, + }, +}) +export class UserSchemaClass extends EntityDocumentHelper implements UserType { + id: string; + + @Prop({ + type: String, + unique: true, + }) + @Expose({ groups: ['system', 'me', 'admin'], toPlainOnly: true }) + email: string | null; + + @Exclude({ toPlainOnly: true }) + @Prop() + password: string; + + @Exclude({ toPlainOnly: true }) + previousPassword: string; + + @Expose({ groups: ['system', 'me', 'admin'], toPlainOnly: true }) + @Prop({ + default: AuthProvidersEnum.email, + }) + provider: string; + + @Expose({ groups: ['system', 'me', 'admin'], toPlainOnly: true }) + @Prop({ + type: String, + default: null, + }) + socialId: string | null; + + @Prop({ + type: String, + }) + firstName: string | null; + + @Prop({ + type: String, + }) + lastName: string | null; + + @Prop({ + type: FileSchemaClass, + }) + @Type(() => FileSchemaClass) + photo?: FileSchemaClass | null; + + @Prop({ + type: RoleType, + }) + role?: RoleType | null; + + @Prop({ + type: StatusType, + }) + status?: StatusType; + + @Prop({ default: now }) + createdAt: Date; + + @Prop({ default: now }) + updatedAt: Date; + + @Prop() + deletedAt: Date; +} + +export const UserSchema = SchemaFactory.createForClass(UserSchemaClass); + +UserSchema.virtual('id').get(function () { + return this._id; +}); + +UserSchema.virtual('previousPassword').get(function () { + return this.password; +}); + +UserSchema.index({ 'role.id': 1 }); diff --git a/src/users/entities/user.type.ts b/src/users/entities/user.type.ts new file mode 100644 index 000000000..6b2a0ebc2 --- /dev/null +++ b/src/users/entities/user.type.ts @@ -0,0 +1,21 @@ +import { FileType } from 'src/files/entities/file.type'; +import { RoleType } from 'src/roles/entities/role.type'; +import { StatusType } from 'src/statuses/entities/status.type'; + +export abstract class UserType { + _id?: string; + id: number | string; + email: string | null; + password: string; + previousPassword: string; + provider: string; + socialId: string | null; + firstName: string | null; + lastName: string | null; + photo?: FileType | null; + role?: RoleType | null; + status?: StatusType; + createdAt: Date; + updatedAt: Date; + deletedAt: Date; +} diff --git a/src/users/users-abstract.service.ts b/src/users/users-abstract.service.ts new file mode 100644 index 000000000..36c8d6ccf --- /dev/null +++ b/src/users/users-abstract.service.ts @@ -0,0 +1,32 @@ +import { UserType } from './entities/user.type'; +import { NullableType } from 'src/utils/types/nullable.type'; +import { FilterUserDto, SortUserDto } from './dto/query-user.dto'; +import { IPaginationOptions } from 'src/utils/types/pagination-options'; +import { EntityCondition } from 'src/utils/types/entity-condition.type'; +import { CreateUserDto } from './dto/create-user.dto'; +import { DeepPartial } from 'src/utils/types/deep-partial.type'; + +export abstract class UsersServiceAbstract { + abstract create(createProfileDto: CreateUserDto): Promise; + + abstract findManyWithPagination({ + filterOptions, + sortOptions, + paginationOptions, + }: { + filterOptions?: FilterUserDto | null; + sortOptions?: SortUserDto[] | null; + paginationOptions: IPaginationOptions; + }): Promise; + + abstract findOne( + fields: EntityCondition, + ): Promise>; + + abstract update( + id: UserType['id'], + payload: DeepPartial, + ): Promise; + + abstract softDelete(id: UserType['id']): Promise; +} diff --git a/src/users/users-document.service.ts b/src/users/users-document.service.ts new file mode 100644 index 000000000..2ba31b4a2 --- /dev/null +++ b/src/users/users-document.service.ts @@ -0,0 +1,266 @@ +import { HttpException, HttpStatus, Injectable } from '@nestjs/common'; +import { EntityCondition } from 'src/utils/types/entity-condition.type'; +import { IPaginationOptions } from 'src/utils/types/pagination-options'; +import { CreateUserDto } from './dto/create-user.dto'; +import { NullableType } from '../utils/types/nullable.type'; +import { FilterUserDto, SortUserDto } from './dto/query-user.dto'; +import { UserType } from './entities/user.type'; +import { UsersServiceAbstract } from './users-abstract.service'; +import { UserSchemaClass } from './entities/user.schema'; +import { InjectModel } from '@nestjs/mongoose'; +import { Model } from 'mongoose'; +import bcrypt from 'bcryptjs'; +import { plainToInstance } from 'class-transformer'; +import { RoleEnum } from 'src/roles/roles.enum'; +import { StatusEnum } from 'src/statuses/statuses.enum'; +import { FilesServiceAbstract } from 'src/files/files-abstract.service'; +import { DeepPartial } from 'src/utils/types/deep-partial.type'; + +@Injectable() +export class UsersDocumentService implements UsersServiceAbstract { + constructor( + @InjectModel(UserSchemaClass.name) + private readonly usersModel: Model, + private readonly filesService: FilesServiceAbstract, + ) {} + + async create(createProfileDto: CreateUserDto): Promise { + const clonedPayload = { ...createProfileDto }; + + if (clonedPayload.password) { + const salt = await bcrypt.genSalt(); + clonedPayload.password = await bcrypt.hash(clonedPayload.password, salt); + } + + if (clonedPayload.email) { + const userObject = await this.usersModel.findOne({ + email: clonedPayload.email, + }); + if (userObject) { + throw new HttpException( + { + status: HttpStatus.UNPROCESSABLE_ENTITY, + errors: { + email: 'emailAlreadyExists', + }, + }, + HttpStatus.UNPROCESSABLE_ENTITY, + ); + } + } + + if (clonedPayload.photo?.id) { + const fileObject = await this.filesService.findOne({ + _id: clonedPayload.photo.id, + }); + if (!fileObject) { + throw new HttpException( + { + status: HttpStatus.UNPROCESSABLE_ENTITY, + errors: { + photo: 'imageNotExists', + }, + }, + HttpStatus.UNPROCESSABLE_ENTITY, + ); + } + } + + if (clonedPayload.role?.id) { + const roleObject = Object.values(RoleEnum).includes( + clonedPayload.role.id, + ); + if (!roleObject) { + throw new HttpException( + { + status: HttpStatus.UNPROCESSABLE_ENTITY, + errors: { + role: 'roleNotExists', + }, + }, + HttpStatus.UNPROCESSABLE_ENTITY, + ); + } + } + + if (clonedPayload.status?.id) { + const statusObject = Object.values(StatusEnum).includes( + clonedPayload.status.id, + ); + if (!statusObject) { + throw new HttpException( + { + status: HttpStatus.UNPROCESSABLE_ENTITY, + errors: { + status: 'statusNotExists', + }, + }, + HttpStatus.UNPROCESSABLE_ENTITY, + ); + } + } + + const createdUser = new this.usersModel(clonedPayload); + const userObject = await createdUser.save(); + return plainToInstance(UserSchemaClass, userObject.toJSON(), { + groups: ['system'], + }); + } + + async findManyWithPagination({ + filterOptions, + sortOptions, + paginationOptions, + }: { + filterOptions?: FilterUserDto | null; + sortOptions?: SortUserDto[] | null; + paginationOptions: IPaginationOptions; + }): Promise { + const where: EntityCondition = {}; + if (filterOptions?.roles?.length) { + where['role.id'] = { + $in: filterOptions.roles.map((role) => role.id), + }; + } + + console.log(where); + + const userObjects = await this.usersModel + .find(where) + .sort( + sortOptions?.reduce( + (accumulator, sort) => ({ + ...accumulator, + [sort.orderBy === 'id' ? '_id' : sort.orderBy]: + sort.order.toUpperCase() === 'ASC' ? 1 : -1, + }), + {}, + ), + ) + .skip((paginationOptions.page - 1) * paginationOptions.limit) + .limit(paginationOptions.limit); + + return userObjects.map((user) => + plainToInstance(UserSchemaClass, user.toJSON(), { + groups: ['system'], + }), + ); + } + + async findOne( + fields: EntityCondition, + ): Promise> { + if (fields.id) { + const userObject = await this.usersModel.findById(fields.id); + return plainToInstance(UserSchemaClass, userObject?.toJSON(), { + groups: ['system'], + }); + } + + const userObject = await this.usersModel.findOne(fields); + return plainToInstance(UserSchemaClass, userObject?.toJSON(), { + groups: ['system'], + }); + } + + async update( + id: UserType['id'], + payload: DeepPartial, + ): Promise { + const clonedPayload = { ...payload }; + delete clonedPayload.id; + delete clonedPayload._id; + + if ( + clonedPayload.password && + clonedPayload.previousPassword !== clonedPayload.password + ) { + const salt = await bcrypt.genSalt(); + clonedPayload.password = await bcrypt.hash(clonedPayload.password, salt); + } + + if (clonedPayload.email) { + const userObject = await this.usersModel.findOne({ + email: clonedPayload.email, + }); + + if (userObject?._id.toString() !== id) { + throw new HttpException( + { + status: HttpStatus.UNPROCESSABLE_ENTITY, + errors: { + email: 'emailAlreadyExists', + }, + }, + HttpStatus.UNPROCESSABLE_ENTITY, + ); + } + } + + if (clonedPayload.photo?.id) { + const fileObject = await this.filesService.findOne({ + _id: clonedPayload.photo.id, + }); + if (!fileObject) { + throw new HttpException( + { + status: HttpStatus.UNPROCESSABLE_ENTITY, + errors: { + photo: 'imageNotExists', + }, + }, + HttpStatus.UNPROCESSABLE_ENTITY, + ); + } + } + + if (clonedPayload.role?.id) { + const roleObject = Object.values(RoleEnum).includes( + clonedPayload.role.id, + ); + if (!roleObject) { + throw new HttpException( + { + status: HttpStatus.UNPROCESSABLE_ENTITY, + errors: { + role: 'roleNotExists', + }, + }, + HttpStatus.UNPROCESSABLE_ENTITY, + ); + } + } + + if (clonedPayload.status?.id) { + const statusObject = Object.values(StatusEnum).includes( + clonedPayload.status.id, + ); + if (!statusObject) { + throw new HttpException( + { + status: HttpStatus.UNPROCESSABLE_ENTITY, + errors: { + status: 'statusNotExists', + }, + }, + HttpStatus.UNPROCESSABLE_ENTITY, + ); + } + } + + const filter = { _id: id }; + const userObject = await this.usersModel.findOneAndUpdate( + filter, + clonedPayload, + ); + return plainToInstance(UserSchemaClass, userObject?.toJSON(), { + groups: ['system'], + }); + } + + async softDelete(id: UserType['id']): Promise { + await this.usersModel.deleteOne({ + _id: id, + }); + } +} diff --git a/src/users/users-relational.service.ts b/src/users/users-relational.service.ts new file mode 100644 index 000000000..fd8e78e3a --- /dev/null +++ b/src/users/users-relational.service.ts @@ -0,0 +1,247 @@ +import { HttpException, HttpStatus, Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { EntityCondition } from 'src/utils/types/entity-condition.type'; +import { IPaginationOptions } from 'src/utils/types/pagination-options'; +import { FindOptionsWhere, Repository } from 'typeorm'; +import { CreateUserDto } from './dto/create-user.dto'; +import { User } from './entities/user.entity'; +import { NullableType } from '../utils/types/nullable.type'; +import { FilterUserDto, SortUserDto } from './dto/query-user.dto'; +import { UserType } from './entities/user.type'; +import { UsersServiceAbstract } from './users-abstract.service'; +import bcrypt from 'bcryptjs'; +import { StatusEnum } from 'src/statuses/statuses.enum'; +import { RoleEnum } from 'src/roles/roles.enum'; +import { FilesServiceAbstract } from 'src/files/files-abstract.service'; +import { DeepPartial } from 'src/utils/types/deep-partial.type'; + +@Injectable() +export class UsersRelationalService implements UsersServiceAbstract { + constructor( + @InjectRepository(User) + private usersRepository: Repository, + private readonly filesService: FilesServiceAbstract, + ) {} + + async create(createProfileDto: CreateUserDto): Promise { + const clonedPayload = { ...createProfileDto }; + + if (clonedPayload.password) { + const salt = await bcrypt.genSalt(); + clonedPayload.password = await bcrypt.hash(clonedPayload.password, salt); + } + + if (clonedPayload.email) { + const userObject = await this.usersRepository.findOne({ + where: { + email: clonedPayload.email, + }, + }); + if (userObject) { + throw new HttpException( + { + status: HttpStatus.UNPROCESSABLE_ENTITY, + errors: { + email: 'emailAlreadyExists', + }, + }, + HttpStatus.UNPROCESSABLE_ENTITY, + ); + } + } + + if (clonedPayload.photo?.id) { + const fileObject = await this.filesService.findOne({ + id: clonedPayload.photo.id, + }); + + if (!fileObject) { + throw new HttpException( + { + status: HttpStatus.UNPROCESSABLE_ENTITY, + errors: { + photo: 'imageNotExists', + }, + }, + HttpStatus.UNPROCESSABLE_ENTITY, + ); + } + + clonedPayload.photo = fileObject; + } + + if (clonedPayload.role?.id) { + const roleObject = Object.values(RoleEnum).includes( + clonedPayload.role.id, + ); + if (!roleObject) { + throw new HttpException( + { + status: HttpStatus.UNPROCESSABLE_ENTITY, + errors: { + role: 'roleNotExists', + }, + }, + HttpStatus.UNPROCESSABLE_ENTITY, + ); + } + } + + if (clonedPayload.status?.id) { + const statusObject = Object.values(StatusEnum).includes( + clonedPayload.status.id, + ); + if (!statusObject) { + throw new HttpException( + { + status: HttpStatus.UNPROCESSABLE_ENTITY, + errors: { + status: 'statusNotExists', + }, + }, + HttpStatus.UNPROCESSABLE_ENTITY, + ); + } + } + + return this.usersRepository.save( + this.usersRepository.create(clonedPayload), + ); + } + + findManyWithPagination({ + filterOptions, + sortOptions, + paginationOptions, + }: { + filterOptions?: FilterUserDto | null; + sortOptions?: SortUserDto[] | null; + paginationOptions: IPaginationOptions; + }): Promise { + const where: FindOptionsWhere = {}; + if (filterOptions?.roles?.length) { + where.role = filterOptions.roles.map((role) => ({ + id: role.id, + })); + } + + return this.usersRepository.find({ + skip: (paginationOptions.page - 1) * paginationOptions.limit, + take: paginationOptions.limit, + where: where, + order: sortOptions?.reduce( + (accumulator, sort) => ({ + ...accumulator, + [sort.orderBy]: sort.order, + }), + {}, + ), + }); + } + + findOne(fields: EntityCondition): Promise> { + return this.usersRepository.findOne({ + where: fields as FindOptionsWhere, + }); + } + + async update( + id: UserType['id'], + payload: DeepPartial, + ): Promise { + const clonedPayload = { ...payload }; + + if ( + clonedPayload.password && + clonedPayload.previousPassword !== clonedPayload.password + ) { + const salt = await bcrypt.genSalt(); + clonedPayload.password = await bcrypt.hash(clonedPayload.password, salt); + } + + if (clonedPayload.email) { + const userObject = await this.usersRepository.findOne({ + where: { + email: clonedPayload.email, + }, + }); + + if (userObject?.id !== id) { + throw new HttpException( + { + status: HttpStatus.UNPROCESSABLE_ENTITY, + errors: { + email: 'emailAlreadyExists', + }, + }, + HttpStatus.UNPROCESSABLE_ENTITY, + ); + } + } + + if (clonedPayload.photo?.id) { + const fileObject = await this.filesService.findOne({ + id: clonedPayload.photo.id, + }); + + if (!fileObject) { + throw new HttpException( + { + status: HttpStatus.UNPROCESSABLE_ENTITY, + errors: { + photo: 'imageNotExists', + }, + }, + HttpStatus.UNPROCESSABLE_ENTITY, + ); + } + + clonedPayload.photo = fileObject; + } + + if (clonedPayload.role?.id) { + const roleObject = Object.values(RoleEnum).includes( + clonedPayload.role.id, + ); + if (!roleObject) { + throw new HttpException( + { + status: HttpStatus.UNPROCESSABLE_ENTITY, + errors: { + role: 'roleNotExists', + }, + }, + HttpStatus.UNPROCESSABLE_ENTITY, + ); + } + } + + if (clonedPayload.status?.id) { + const statusObject = Object.values(StatusEnum).includes( + clonedPayload.status.id, + ); + if (!statusObject) { + throw new HttpException( + { + status: HttpStatus.UNPROCESSABLE_ENTITY, + errors: { + status: 'statusNotExists', + }, + }, + HttpStatus.UNPROCESSABLE_ENTITY, + ); + } + } + + return this.usersRepository.save( + this.usersRepository.create({ + id: Number(id), + ...clonedPayload, + } as User), + ); + } + + async softDelete(id: UserType['id']): Promise { + await this.usersRepository.softDelete(id); + } +} diff --git a/src/users/users.controller.ts b/src/users/users.controller.ts index 64b2f781e..90ee68d8b 100644 --- a/src/users/users.controller.ts +++ b/src/users/users.controller.ts @@ -12,19 +12,19 @@ import { HttpCode, SerializeOptions, } from '@nestjs/common'; -import { UsersService } from './users.service'; import { CreateUserDto } from './dto/create-user.dto'; import { UpdateUserDto } from './dto/update-user.dto'; import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; import { Roles } from '../roles/roles.decorator'; import { RoleEnum } from '../roles/roles.enum'; import { AuthGuard } from '@nestjs/passport'; -import { RolesGuard } from '../roles/roles.guard'; -import { infinityPagination } from '../utils/infinity-pagination'; -import { User } from './entities/user.entity'; +import { RolesGuard } from 'src/roles/roles.guard'; +import { infinityPagination } from 'src/utils/infinity-pagination'; import { InfinityPaginationResultType } from '../utils/types/infinity-pagination-result.type'; import { NullableType } from '../utils/types/nullable.type'; import { QueryUserDto } from './dto/query-user.dto'; +import { UserType } from './entities/user.type'; +import { UsersServiceAbstract } from './users-abstract.service'; @ApiBearerAuth() @Roles(RoleEnum.admin) @@ -35,14 +35,14 @@ import { QueryUserDto } from './dto/query-user.dto'; version: '1', }) export class UsersController { - constructor(private readonly usersService: UsersService) {} + constructor(private readonly usersService: UsersServiceAbstract) {} @SerializeOptions({ groups: ['admin'], }) @Post() @HttpCode(HttpStatus.CREATED) - create(@Body() createProfileDto: CreateUserDto): Promise { + create(@Body() createProfileDto: CreateUserDto): Promise { return this.usersService.create(createProfileDto); } @@ -53,7 +53,7 @@ export class UsersController { @HttpCode(HttpStatus.OK) async findAll( @Query() query: QueryUserDto, - ): Promise> { + ): Promise> { const page = query?.page ?? 1; let limit = query?.limit ?? 10; if (limit > 50) { @@ -78,8 +78,8 @@ export class UsersController { }) @Get(':id') @HttpCode(HttpStatus.OK) - findOne(@Param('id') id: string): Promise> { - return this.usersService.findOne({ id: +id }); + findOne(@Param('id') id: UserType['id']): Promise> { + return this.usersService.findOne({ id }); } @SerializeOptions({ @@ -88,15 +88,15 @@ export class UsersController { @Patch(':id') @HttpCode(HttpStatus.OK) update( - @Param('id') id: number, + @Param('id') id: UserType['id'], @Body() updateProfileDto: UpdateUserDto, - ): Promise { + ): Promise { return this.usersService.update(id, updateProfileDto); } @Delete(':id') @HttpCode(HttpStatus.NO_CONTENT) - remove(@Param('id') id: number): Promise { + remove(@Param('id') id: UserType['id']): Promise { return this.usersService.softDelete(id); } } diff --git a/src/users/users.module.ts b/src/users/users.module.ts index 5dbcab9f4..16890550b 100644 --- a/src/users/users.module.ts +++ b/src/users/users.module.ts @@ -1,15 +1,35 @@ import { Module } from '@nestjs/common'; -import { UsersService } from './users.service'; + import { UsersController } from './users.controller'; import { TypeOrmModule } from '@nestjs/typeorm'; import { User } from './entities/user.entity'; -import { IsExist } from '../utils/validators/is-exists.validator'; -import { IsNotExist } from '../utils/validators/is-not-exists.validator'; +import { MongooseModule } from '@nestjs/mongoose'; +import { UserSchema, UserSchemaClass } from './entities/user.schema'; +import { UsersRelationalService } from './users-relational.service'; +import { UsersDocumentService } from './users-document.service'; +import { UsersServiceAbstract } from './users-abstract.service'; +import { FilesModule } from 'src/files/files.module'; +import databaseConfig from 'src/database/config/database.config'; +import { DatabaseConfig } from 'src/database/config/database-config.type'; @Module({ - imports: [TypeOrmModule.forFeature([User])], + imports: [ + (databaseConfig() as DatabaseConfig).isDocumentDatabase + ? MongooseModule.forFeature([ + { name: UserSchemaClass.name, schema: UserSchema }, + ]) + : TypeOrmModule.forFeature([User]), + FilesModule, + ], controllers: [UsersController], - providers: [IsExist, IsNotExist, UsersService], - exports: [UsersService], + providers: [ + { + provide: UsersServiceAbstract, + useClass: (databaseConfig() as DatabaseConfig).isDocumentDatabase + ? UsersDocumentService + : UsersRelationalService, + }, + ], + exports: [UsersServiceAbstract], }) export class UsersModule {} diff --git a/src/users/users.service.ts b/src/users/users.service.ts deleted file mode 100644 index 71b01ce0a..000000000 --- a/src/users/users.service.ts +++ /dev/null @@ -1,72 +0,0 @@ -import { Injectable } from '@nestjs/common'; -import { InjectRepository } from '@nestjs/typeorm'; -import { EntityCondition } from 'src/utils/types/entity-condition.type'; -import { IPaginationOptions } from 'src/utils/types/pagination-options'; -import { DeepPartial, FindOptionsWhere, Repository } from 'typeorm'; -import { CreateUserDto } from './dto/create-user.dto'; -import { User } from './entities/user.entity'; -import { NullableType } from '../utils/types/nullable.type'; -import { FilterUserDto, SortUserDto } from './dto/query-user.dto'; - -@Injectable() -export class UsersService { - constructor( - @InjectRepository(User) - private usersRepository: Repository, - ) {} - - create(createProfileDto: CreateUserDto): Promise { - return this.usersRepository.save( - this.usersRepository.create(createProfileDto), - ); - } - - findManyWithPagination({ - filterOptions, - sortOptions, - paginationOptions, - }: { - filterOptions?: FilterUserDto | null; - sortOptions?: SortUserDto[] | null; - paginationOptions: IPaginationOptions; - }): Promise { - const where: FindOptionsWhere = {}; - if (filterOptions?.roles?.length) { - where.role = filterOptions.roles.map((role) => ({ - id: role.id, - })); - } - - return this.usersRepository.find({ - skip: (paginationOptions.page - 1) * paginationOptions.limit, - take: paginationOptions.limit, - where: where, - order: sortOptions?.reduce( - (accumulator, sort) => ({ - ...accumulator, - [sort.orderBy]: sort.order, - }), - {}, - ), - }); - } - - findOne(fields: EntityCondition): Promise> { - return this.usersRepository.findOne({ - where: fields, - }); - } - - update(id: User['id'], payload: DeepPartial): Promise { - return this.usersRepository.save( - this.usersRepository.create({ - id, - ...payload, - }), - ); - } - - async softDelete(id: User['id']): Promise { - await this.usersRepository.softDelete(id); - } -} diff --git a/src/utils/document-entity-helper.ts b/src/utils/document-entity-helper.ts new file mode 100644 index 000000000..8a1e8b6d5 --- /dev/null +++ b/src/utils/document-entity-helper.ts @@ -0,0 +1,21 @@ +import { Expose, Transform } from 'class-transformer'; + +export class EntityDocumentHelper { + @Expose({ groups: ['system'] }) + // makes sure that when deserializing from a Mongoose Object, ObjectId is serialized into a string + @Transform( + (value) => { + if ('value' in value) { + // HACK: this is changed because of https://github.com/typestack/class-transformer/issues/879 + // return value.value.toString(); // because "toString" is also a wrapper for "toHexString" + return value.obj[value.key].toString(); + } + + return 'unknown value'; + }, + { + toPlainOnly: true, + }, + ) + public _id: string; +} diff --git a/src/utils/entity-helper.ts b/src/utils/relational-entity-helper.ts similarity index 82% rename from src/utils/entity-helper.ts rename to src/utils/relational-entity-helper.ts index 1e755bb64..ee0e17929 100644 --- a/src/utils/entity-helper.ts +++ b/src/utils/relational-entity-helper.ts @@ -1,7 +1,7 @@ import { instanceToPlain } from 'class-transformer'; import { AfterLoad, BaseEntity } from 'typeorm'; -export class EntityHelper extends BaseEntity { +export class EntityRelationalHelper extends BaseEntity { __entity?: string; @AfterLoad() diff --git a/src/utils/types/deep-partial.type.ts b/src/utils/types/deep-partial.type.ts new file mode 100644 index 000000000..4fff18893 --- /dev/null +++ b/src/utils/types/deep-partial.type.ts @@ -0,0 +1,3 @@ +export type DeepPartial = { + [P in keyof T]?: DeepPartial; +}; diff --git a/src/utils/types/entity-condition.type.ts b/src/utils/types/entity-condition.type.ts index 6297d29a7..4adb67cdd 100644 --- a/src/utils/types/entity-condition.type.ts +++ b/src/utils/types/entity-condition.type.ts @@ -1,3 +1,3 @@ -import { FindOptionsWhere } from 'typeorm'; - -export type EntityCondition = FindOptionsWhere; +export type EntityCondition = { + [P in keyof T]?: T[P] | T[P][] | undefined; +}; diff --git a/src/utils/types/find-options.type.ts b/src/utils/types/find-options.type.ts deleted file mode 100644 index ecdb75d11..000000000 --- a/src/utils/types/find-options.type.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { EntityCondition } from './entity-condition.type'; - -export type FindOptions = { - where: EntityCondition[] | EntityCondition; -}; diff --git a/src/utils/validators/is-exists.validator.ts b/src/utils/validators/is-exists.validator.ts deleted file mode 100644 index 08fa645bd..000000000 --- a/src/utils/validators/is-exists.validator.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { - ValidatorConstraint, - ValidatorConstraintInterface, -} from 'class-validator'; -import { DataSource } from 'typeorm'; -import { InjectDataSource } from '@nestjs/typeorm'; -import { ValidationArguments } from 'class-validator/types/validation/ValidationArguments'; -import { Injectable } from '@nestjs/common'; - -@Injectable() -@ValidatorConstraint({ name: 'IsExist', async: true }) -export class IsExist implements ValidatorConstraintInterface { - constructor( - @InjectDataSource() - private dataSource: DataSource, - ) {} - - async validate(value: string, validationArguments: ValidationArguments) { - const repository = validationArguments.constraints[0]; - const pathToProperty = validationArguments.constraints[1]; - const entity: unknown = await this.dataSource - .getRepository(repository) - .findOne({ - where: { - [pathToProperty ? pathToProperty : validationArguments.property]: - pathToProperty ? value?.[pathToProperty] : value, - }, - }); - - return Boolean(entity); - } -} diff --git a/src/utils/validators/is-not-exists.validator.ts b/src/utils/validators/is-not-exists.validator.ts deleted file mode 100644 index ca1581523..000000000 --- a/src/utils/validators/is-not-exists.validator.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { - ValidatorConstraint, - ValidatorConstraintInterface, -} from 'class-validator'; -import { DataSource } from 'typeorm'; -import { ValidationArguments } from 'class-validator/types/validation/ValidationArguments'; -import { Injectable } from '@nestjs/common'; -import { InjectDataSource } from '@nestjs/typeorm'; - -type ValidationEntity = - | { - id?: number | string; - } - | undefined; - -@Injectable() -@ValidatorConstraint({ name: 'IsNotExist', async: true }) -export class IsNotExist implements ValidatorConstraintInterface { - constructor( - @InjectDataSource() - private dataSource: DataSource, - ) {} - - async validate(value: string, validationArguments: ValidationArguments) { - const repository = validationArguments.constraints[0] as string; - const currentValue = validationArguments.object as ValidationEntity; - const entity = (await this.dataSource.getRepository(repository).findOne({ - where: { - [validationArguments.property]: value, - }, - })) as ValidationEntity; - - if (entity?.id === currentValue?.id) { - return true; - } - - return !entity; - } -} diff --git a/startup.document.ci.sh b/startup.document.ci.sh new file mode 100755 index 000000000..4751a47dd --- /dev/null +++ b/startup.document.ci.sh @@ -0,0 +1,10 @@ +#!/usr/bin/env bash +set -e + +/opt/wait-for-it.sh mongo:27017 +npm run seed:run:document +npm run start:prod > /dev/null 2>&1 & +/opt/wait-for-it.sh maildev:1080 +/opt/wait-for-it.sh localhost:3000 +npm run lint +npm run test:e2e -- --runInBand diff --git a/startup.document.dev.sh b/startup.document.dev.sh new file mode 100755 index 000000000..fdacb0763 --- /dev/null +++ b/startup.document.dev.sh @@ -0,0 +1,7 @@ +#!/usr/bin/env bash +set -e + +/opt/wait-for-it.sh mongo:27017 +cat .env +npm run seed:run:document +npm run start:prod diff --git a/startup.ci.sh b/startup.relational.ci.sh similarity index 89% rename from startup.ci.sh rename to startup.relational.ci.sh index 75557d62a..4a7ca41a2 100755 --- a/startup.ci.sh +++ b/startup.relational.ci.sh @@ -3,7 +3,7 @@ set -e /opt/wait-for-it.sh postgres:5432 npm run migration:run -npm run seed:run +npm run seed:run:relational npm run start:prod > /dev/null 2>&1 & /opt/wait-for-it.sh maildev:1080 /opt/wait-for-it.sh localhost:3000 diff --git a/startup.dev.sh b/startup.relational.dev.sh similarity index 78% rename from startup.dev.sh rename to startup.relational.dev.sh index 83b0bb3d3..1d2d05031 100755 --- a/startup.dev.sh +++ b/startup.relational.dev.sh @@ -3,5 +3,5 @@ set -e /opt/wait-for-it.sh postgres:5432 npm run migration:run -npm run seed:run +npm run seed:run:relational npm run start:prod