├── .nvmrc ├── .dockerignore ├── src ├── infrastructure │ ├── sms │ │ ├── sms.constants.ts │ │ ├── sms.port.ts │ │ └── adapters │ │ │ └── mock │ │ │ └── mock.sms.service.ts │ ├── mailer │ │ ├── mailer.constants.ts │ │ ├── mailer.module.ts │ │ ├── mailer.port.ts │ │ └── adapters │ │ │ └── mock │ │ │ └── mock.mailer.service.ts │ ├── repositories │ │ ├── modules │ │ │ ├── user │ │ │ │ ├── user.repository.constants.ts │ │ │ │ ├── user.repository.port.ts │ │ │ │ └── mock │ │ │ │ │ ├── mock.user.repository.test.ts │ │ │ │ │ └── mock.user.repository.ts │ │ │ └── session │ │ │ │ ├── session.repository.constants.ts │ │ │ │ ├── session.repository.port.ts │ │ │ │ └── mock │ │ │ │ ├── mock.session.repository.ts │ │ │ │ └── mock.session.repository.test.ts │ │ ├── presets │ │ │ ├── real-repositories.module.ts │ │ │ └── mock-repositories.module.ts │ │ ├── repositories.module.ts │ │ └── repository.port.ts │ ├── mail │ │ └── mail.module.ts │ ├── token │ │ ├── v1 │ │ │ ├── queries │ │ │ │ ├── verify-verification-token │ │ │ │ │ └── verify-verification-token.query.ts │ │ │ │ └── verify-reset-password-token │ │ │ │ │ └── verify-reset-password-token.query.ts │ │ │ ├── commands │ │ │ │ ├── generate-verification-token │ │ │ │ │ └── generate-verification-token.command.ts │ │ │ │ ├── generate-reset-password-token │ │ │ │ │ └── generate-reset-password-token.command.ts │ │ │ │ ├── generate-access-token │ │ │ │ │ └── generate-access-token.command.ts │ │ │ │ └── generate-refresh-token │ │ │ │ │ └── generate-refresh-token.command.ts │ │ │ └── v1-token.module.ts │ │ └── token.module.ts │ ├── config │ │ ├── configs │ │ │ ├── email.config.service.ts │ │ │ ├── jwt-config.service.ts │ │ │ ├── cache-config.service.ts │ │ │ ├── authentication-config.service.ts │ │ │ ├── throttler-config.service.ts │ │ │ ├── main-config.service.ts │ │ │ ├── redis-config.service.ts │ │ │ └── token-config.service.ts │ │ ├── config.module.ts │ │ └── config-schema.ts │ ├── jwt │ │ └── jwt.module.ts │ ├── throttler │ │ └── throttler.module.ts │ ├── cache │ │ └── cache.module.ts │ ├── cqrs │ │ └── cqrs.module.ts │ └── logger │ │ └── logger.module.ts ├── application │ ├── user │ │ ├── v1 │ │ │ ├── queries │ │ │ │ ├── find-all-users │ │ │ │ │ ├── find-all-users.query.ts │ │ │ │ │ ├── dto │ │ │ │ │ │ └── find-all-users.response.dto.ts │ │ │ │ │ ├── find-all-users.handler.ts │ │ │ │ │ ├── find-all-users.http.controller.ts │ │ │ │ │ └── find-all-users.handler.spec.ts │ │ │ │ ├── find-user-by-id │ │ │ │ │ ├── find-user-by-id.query.ts │ │ │ │ │ ├── dto │ │ │ │ │ │ └── find-user-by-id.response.dto.ts │ │ │ │ │ ├── find-user-by-id.handler.ts │ │ │ │ │ ├── find-user-by-id.http.controller.ts │ │ │ │ │ └── find-user-by-id.handler.spec.ts │ │ │ │ ├── find-user-by-email │ │ │ │ │ ├── find-user-by-email.query.ts │ │ │ │ │ ├── dto │ │ │ │ │ │ ├── find-user-by-email.param.dto.ts │ │ │ │ │ │ └── find-user-by-email.response.dto.ts │ │ │ │ │ ├── find-user-by-email.handler.ts │ │ │ │ │ ├── find-user-by-email.http.controller.ts │ │ │ │ │ └── find-user-by-email.handler.spec.ts │ │ │ │ └── find-current-user │ │ │ │ │ ├── dto │ │ │ │ │ └── find-current-user.response.dto.ts │ │ │ │ │ └── find-current-user.http.controller.ts │ │ │ ├── commands │ │ │ │ ├── create-user │ │ │ │ │ ├── create-user.command.ts │ │ │ │ │ ├── dto │ │ │ │ │ │ ├── create-user.request.dto.ts │ │ │ │ │ │ └── create-user.response.dto.ts │ │ │ │ │ ├── create-user.handler.ts │ │ │ │ │ ├── create-user.handler.spec.ts │ │ │ │ │ └── create-user.http.controller.ts │ │ │ │ ├── delete-user │ │ │ │ │ ├── delete-user.command.ts │ │ │ │ │ ├── dto │ │ │ │ │ │ └── delete-user.response.dto.ts │ │ │ │ │ └── delete-user.handler.ts │ │ │ │ └── update-user │ │ │ │ │ ├── update-user.command.ts │ │ │ │ │ ├── dto │ │ │ │ │ ├── update-user.response.dto.ts │ │ │ │ │ └── update-user.request.dto.ts │ │ │ │ │ └── update-user.handler.ts │ │ │ └── v1-user.module.ts │ │ └── user.module.ts │ ├── ping │ │ ├── events │ │ │ └── ping-ran.event.ts │ │ ├── v1 │ │ │ ├── v1-ping.module.ts │ │ │ └── queries │ │ │ │ └── ping │ │ │ │ ├── dto │ │ │ │ └── ping.response.dto.ts │ │ │ │ ├── ping.controller.ts │ │ │ │ └── ping.test.ts │ │ ├── ping.module.ts │ │ └── event-handlers │ │ │ ├── ping-ran.handler.ts │ │ │ └── ping-ran.handler.test.ts │ ├── session │ │ ├── v1 │ │ │ ├── queries │ │ │ │ ├── find-session-by-token │ │ │ │ │ ├── find-session-by-token.query.ts │ │ │ │ │ ├── dto │ │ │ │ │ │ └── find-session-by-token.response.dto.ts │ │ │ │ │ └── find-session-by-token.handler.ts │ │ │ │ ├── find-all-sessions-by-user │ │ │ │ │ ├── find-all-sessions-by-user.query.ts │ │ │ │ │ ├── dto │ │ │ │ │ │ └── find-all-sessions-by-user.response.dto.ts │ │ │ │ │ └── find-all-sessions-by-user.handler.ts │ │ │ │ ├── find-current-session │ │ │ │ │ ├── dto │ │ │ │ │ │ └── find-current-session.response.dto.ts │ │ │ │ │ └── find-current-session.http.controller.ts │ │ │ │ └── find-all-sessions-by-current-user │ │ │ │ │ ├── dto │ │ │ │ │ └── find-all-sessions-by-current-user.response.dto.ts │ │ │ │ │ └── find-all-sessions-by-current-user.http.controller.ts │ │ │ ├── commands │ │ │ │ ├── revoke-session │ │ │ │ │ ├── revoke-session.command.ts │ │ │ │ │ ├── dto │ │ │ │ │ │ └── revoke-session.response.dto.ts │ │ │ │ │ └── revoke-session.handler.ts │ │ │ │ └── create-session │ │ │ │ │ └── create-session.command.ts │ │ │ └── v1-session.module.ts │ │ └── session.module.ts │ ├── verification │ │ ├── v1 │ │ │ ├── commands │ │ │ │ ├── confirm-verification │ │ │ │ │ ├── confirm-verification.command.ts │ │ │ │ │ ├── dto │ │ │ │ │ │ └── confirm-verification.request.dto.ts │ │ │ │ │ ├── confirm-verification.http.controller.ts │ │ │ │ │ └── confirm-verification.handler.ts │ │ │ │ └── send-verification │ │ │ │ │ ├── send-verification.command.ts │ │ │ │ │ └── dto │ │ │ │ │ └── send-verification.request.dto.ts │ │ │ └── v1.verification.module.ts │ │ ├── verification.module.ts │ │ └── event-handlers │ │ │ └── on-verification-sent-add-to-counter.handler.ts │ ├── authentication │ │ ├── strategies │ │ │ ├── local │ │ │ │ ├── local.guard.ts │ │ │ │ └── local.strategy.ts │ │ │ └── refresh-token │ │ │ │ └── refresh-token.guard.ts │ │ ├── v1 │ │ │ ├── queries │ │ │ │ └── validate-credentials │ │ │ │ │ └── validate-credentials.query.ts │ │ │ ├── commands │ │ │ │ ├── login │ │ │ │ │ ├── login.command.ts │ │ │ │ │ ├── dto │ │ │ │ │ │ ├── login.request.dto.ts │ │ │ │ │ │ └── login.response.dto.ts │ │ │ │ │ └── login.http.controller.ts │ │ │ │ ├── register │ │ │ │ │ ├── register.command.ts │ │ │ │ │ ├── dto │ │ │ │ │ │ ├── register.request.dto.ts │ │ │ │ │ │ └── register.response.dto.ts │ │ │ │ │ └── register.http.controller.ts │ │ │ │ ├── confirm-forgot-password │ │ │ │ │ ├── confirm-forgot-password.command.ts │ │ │ │ │ ├── dto │ │ │ │ │ │ └── confirm-forgot-password.request.dto.ts │ │ │ │ │ └── confirm-forgot-password.http.controller.ts │ │ │ │ ├── forgot-password │ │ │ │ │ ├── forgot-password.command.ts │ │ │ │ │ └── dto │ │ │ │ │ │ └── forgot-password.request.dto.ts │ │ │ │ ├── refresh │ │ │ │ │ ├── dto │ │ │ │ │ │ └── refresh.response.dto.ts │ │ │ │ │ ├── refresh.command.ts │ │ │ │ │ ├── refresh.http.controller.ts │ │ │ │ │ └── refresh.handler.ts │ │ │ │ └── logout │ │ │ │ │ └── logout.http.controller.ts │ │ │ └── v1-authentication.module.ts │ │ ├── decorator │ │ │ ├── roles.decorator.ts │ │ │ ├── public.decorator.ts │ │ │ ├── current-user.decorator.ts │ │ │ ├── token.decorator.ts │ │ │ ├── current-user.decorator.test.ts │ │ │ └── token.decorator.test.ts │ │ ├── event-handlers │ │ │ ├── on-register-add-to-counter.handler.ts │ │ │ ├── on-login-when-success-add-to-counter.handler.ts │ │ │ ├── on-register-send-verification.handler.ts │ │ │ └── on-validate-credentials-when-password-incorrect-add-to-counter.handler.ts │ │ └── authentication.module.ts │ └── health │ │ ├── health.module.ts │ │ └── v1 │ │ ├── v1-health.module.ts │ │ └── queries │ │ └── check-health │ │ └── check-health.controller.ts ├── domain │ ├── user │ │ ├── events │ │ │ ├── on-user-created.event.ts │ │ │ ├── on-user-changed-password.event.ts │ │ │ ├── on-user-changed-email.event.ts │ │ │ ├── on-user-changed-last-name.event.ts │ │ │ ├── on-user-changed-first-name.event.ts │ │ │ ├── on-user-changed-verified-at.event.ts │ │ │ └── on-user-changed-role.event.ts │ │ ├── user-role.enum.ts │ │ ├── exceptions │ │ │ └── user-already-verified.exception.ts │ │ └── user.test.ts │ ├── session │ │ ├── events │ │ │ ├── on-session-created.event.ts │ │ │ ├── on-session-deleted.event.ts │ │ │ └── on-session-revoked.event.ts │ │ ├── session.dto.ts │ │ ├── session.spec.ts │ │ └── session.entity.ts │ ├── authentication │ │ ├── events │ │ │ ├── on-confirm-forgot-password.event.ts │ │ │ ├── on-login.event.ts │ │ │ ├── on-register.event.ts │ │ │ ├── on-login-unverified.event.ts │ │ │ ├── on-validate-credentials.event.ts │ │ │ ├── on-forgot-password.event.ts │ │ │ └── on-token-refresh.event.ts │ │ └── exceptions │ │ │ ├── no-email-match.exception.ts │ │ │ └── password-incorrect.exception.ts │ ├── verification │ │ └── events │ │ │ ├── on-verification-confirmed.event.ts │ │ │ └── on-verification-sent.event.ts │ ├── token │ │ ├── verification-token-payload.type.ts │ │ ├── reset-password-token-payload.type.ts │ │ ├── refresh-token-payload.type.ts │ │ ├── events │ │ │ ├── on-verification-token-generated.event.ts │ │ │ ├── on-reset-password-token-generated.event.ts │ │ │ ├── on-access-token-generated.event.ts │ │ │ └── on-refresh-token-generated.event.ts │ │ ├── base-token-payload.type.ts │ │ └── access-token-payload.type.ts │ └── base │ │ ├── value-object │ │ ├── value-object.base.ts │ │ └── value-object.base.test.ts │ │ └── entity │ │ └── entity.base.dto.ts ├── shared │ ├── decorator │ │ ├── non-standard-response.decorator.ts │ │ ├── api-operation-with-roles.decorator.ts │ │ └── validation │ │ │ ├── is-cuid.decorator.ts │ │ │ └── is-equal-to-property.decorator.ts │ ├── dto │ │ └── standard-http-response.dto.ts │ ├── exceptions │ │ ├── not-found.exception.ts │ │ ├── already-exists.exception.ts │ │ ├── internal-validation.exception.ts │ │ ├── unauthenticated.exception.ts │ │ └── no-permission.exception.ts │ ├── utilities │ │ └── tracing.ts │ ├── services │ │ └── hashing │ │ │ ├── hashing.service.ts │ │ │ └── hashing.test.ts │ └── interceptors │ │ ├── trace-user.interceptor.ts │ │ ├── role-class-serializer.interceptor.ts │ │ └── standard-response.interceptor.ts └── types │ └── express │ ├── index.d.ts │ └── request-with-user.ts ├── .lintstagedrc ├── .husky └── pre-commit ├── .snyk ├── .gitignore ├── .syncpackrc ├── tests └── utils │ └── create-mocks.ts ├── tsup.config.ts ├── Dockerfile ├── jest.config.cjs ├── .env.template ├── tsconfig.json ├── LICENSE ├── biome.json ├── .github └── workflows │ ├── codecov.yml │ └── ci.yml └── scripts └── template-remote.sh /.nvmrc: -------------------------------------------------------------------------------- 1 | 20 -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | dist/ 2 | node_modules/ 3 | .idea/ 4 | .vscode/ 5 | .env* -------------------------------------------------------------------------------- /src/infrastructure/sms/sms.constants.ts: -------------------------------------------------------------------------------- 1 | export const SMS = Symbol('SMS'); 2 | -------------------------------------------------------------------------------- /src/infrastructure/mailer/mailer.constants.ts: -------------------------------------------------------------------------------- 1 | export const MAILER = Symbol('Mailer'); 2 | -------------------------------------------------------------------------------- /src/application/user/v1/queries/find-all-users/find-all-users.query.ts: -------------------------------------------------------------------------------- 1 | export class V1FindAllUsersQuery {} 2 | -------------------------------------------------------------------------------- /.lintstagedrc: -------------------------------------------------------------------------------- 1 | { 2 | "*/**/*.{js,jsx,ts,tsx}": ["biome check"], 3 | "*/**/*.{json,css,md}": ["biome check"] 4 | } 5 | -------------------------------------------------------------------------------- /src/infrastructure/repositories/modules/user/user.repository.constants.ts: -------------------------------------------------------------------------------- 1 | export const USER_REPOSITORY = Symbol('USER_REPOSITORY'); 2 | -------------------------------------------------------------------------------- /src/infrastructure/repositories/modules/session/session.repository.constants.ts: -------------------------------------------------------------------------------- 1 | export const SESSION_REPOSITORY = Symbol('SESSION_REPOSITORY'); 2 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | echo "Running lint-staged..." 2 | npx lint-staged 3 | echo "Running typecheck..." 4 | npm run typecheck 5 | echo "Running tests..." 6 | npm run test -------------------------------------------------------------------------------- /src/application/user/v1/queries/find-user-by-id/find-user-by-id.query.ts: -------------------------------------------------------------------------------- 1 | export class V1FindUserByIDQuery { 2 | constructor(public readonly id: string) {} 3 | } 4 | -------------------------------------------------------------------------------- /src/application/user/v1/queries/find-user-by-email/find-user-by-email.query.ts: -------------------------------------------------------------------------------- 1 | export class V1FindUserByEmailQuery { 2 | constructor(public readonly email: string) {} 3 | } 4 | -------------------------------------------------------------------------------- /src/application/ping/events/ping-ran.event.ts: -------------------------------------------------------------------------------- 1 | import { IEvent } from '@nestjs/cqrs'; 2 | 3 | export class PingRanEvent implements IEvent { 4 | public readonly date: Date = new Date(); 5 | } 6 | -------------------------------------------------------------------------------- /src/domain/user/events/on-user-created.event.ts: -------------------------------------------------------------------------------- 1 | import type { User } from '../user.entity'; 2 | 3 | export class OnUserCreatedEvent { 4 | constructor(public readonly user: User) {} 5 | } 6 | -------------------------------------------------------------------------------- /src/application/session/v1/queries/find-session-by-token/find-session-by-token.query.ts: -------------------------------------------------------------------------------- 1 | export class V1FindSessionByTokenQuery { 2 | constructor(public readonly refreshToken: string) {} 3 | } 4 | -------------------------------------------------------------------------------- /src/infrastructure/mail/mail.module.ts: -------------------------------------------------------------------------------- 1 | import { Global, Module } from '@nestjs/common'; 2 | 3 | @Global() 4 | @Module({ 5 | imports: [], 6 | exports: [], 7 | }) 8 | export class MailModule {} 9 | -------------------------------------------------------------------------------- /src/application/verification/v1/commands/confirm-verification/confirm-verification.command.ts: -------------------------------------------------------------------------------- 1 | export class V1ConfirmVerificationCommand { 2 | constructor(public readonly verificationToken: string) {} 3 | } 4 | -------------------------------------------------------------------------------- /src/domain/user/events/on-user-changed-password.event.ts: -------------------------------------------------------------------------------- 1 | import type { User } from '../user.entity'; 2 | 3 | export class OnUserChangedPasswordEvent { 4 | constructor(public readonly user: User) {} 5 | } 6 | -------------------------------------------------------------------------------- /src/domain/session/events/on-session-created.event.ts: -------------------------------------------------------------------------------- 1 | import type { Session } from '../session.entity'; 2 | 3 | export class OnSessionCreatedEvent { 4 | constructor(public readonly session: Session) {} 5 | } 6 | -------------------------------------------------------------------------------- /src/domain/session/events/on-session-deleted.event.ts: -------------------------------------------------------------------------------- 1 | import type { Session } from '../session.entity'; 2 | 3 | export class OnSessionDeletedEvent { 4 | constructor(public readonly session: Session) {} 5 | } 6 | -------------------------------------------------------------------------------- /src/domain/session/events/on-session-revoked.event.ts: -------------------------------------------------------------------------------- 1 | import type { Session } from '../session.entity'; 2 | 3 | export class OnSessionRevokedEvent { 4 | constructor(public readonly session: Session) {} 5 | } 6 | -------------------------------------------------------------------------------- /src/infrastructure/token/v1/queries/verify-verification-token/verify-verification-token.query.ts: -------------------------------------------------------------------------------- 1 | export class V1VerifyVerificationTokenQuery { 2 | constructor(public readonly verificationToken: string) {} 3 | } 4 | -------------------------------------------------------------------------------- /src/application/user/v1/commands/create-user/create-user.command.ts: -------------------------------------------------------------------------------- 1 | import { User } from '@/domain/user/user.entity'; 2 | 3 | export class V1CreateUserCommand { 4 | constructor(public readonly user: User) {} 5 | } 6 | -------------------------------------------------------------------------------- /src/application/user/v1/commands/delete-user/delete-user.command.ts: -------------------------------------------------------------------------------- 1 | import { User } from '@/domain/user/user.entity'; 2 | 3 | export class V1DeleteUserCommand { 4 | constructor(public readonly user: User) {} 5 | } 6 | -------------------------------------------------------------------------------- /src/application/user/v1/commands/update-user/update-user.command.ts: -------------------------------------------------------------------------------- 1 | import { User } from '@/domain/user/user.entity'; 2 | 3 | export class V1UpdateUserCommand { 4 | constructor(public readonly user: User) {} 5 | } 6 | -------------------------------------------------------------------------------- /.snyk: -------------------------------------------------------------------------------- 1 | # Snyk (https://snyk.io) policy file, patches or ignores known vulnerabilities. 2 | version: v1.25.0 3 | ignore: {} 4 | patch: {} 5 | exclude: 6 | global: 7 | - './src/**/*.spec.*' 8 | - './src/**/*.test.*' 9 | -------------------------------------------------------------------------------- /src/infrastructure/token/v1/queries/verify-reset-password-token/verify-reset-password-token.query.ts: -------------------------------------------------------------------------------- 1 | export class V1VerifyResetPasswordTokenQuery { 2 | constructor(public readonly resetPasswordToken: string) {} 3 | } 4 | -------------------------------------------------------------------------------- /src/domain/authentication/events/on-confirm-forgot-password.event.ts: -------------------------------------------------------------------------------- 1 | import { User } from '@/domain/user/user.entity'; 2 | 3 | export class OnConfirmForgotPasswordEvent { 4 | constructor(public readonly user: User) {} 5 | } 6 | -------------------------------------------------------------------------------- /src/domain/verification/events/on-verification-confirmed.event.ts: -------------------------------------------------------------------------------- 1 | import { User } from '@/domain/user/user.entity'; 2 | 3 | export class OnVerificationConfirmedEvent { 4 | constructor(public readonly user: User) {} 5 | } 6 | -------------------------------------------------------------------------------- /src/domain/user/user-role.enum.ts: -------------------------------------------------------------------------------- 1 | export enum UserRoleEnum { 2 | USER = 'USER', 3 | ADMIN = 'ADMIN', 4 | DEVELOPER = 'DEVELOPER', 5 | } 6 | 7 | export const AllStaffRoles = [UserRoleEnum.ADMIN, UserRoleEnum.DEVELOPER]; 8 | -------------------------------------------------------------------------------- /src/application/authentication/strategies/local/local.guard.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { AuthGuard } from '@nestjs/passport'; 3 | 4 | @Injectable() 5 | export class LocalAuthGuard extends AuthGuard('local') {} 6 | -------------------------------------------------------------------------------- /src/application/verification/v1/commands/send-verification/send-verification.command.ts: -------------------------------------------------------------------------------- 1 | import { User } from '@/domain/user/user.entity'; 2 | 3 | export class V1SendVerificationCommand { 4 | constructor(public readonly user: User) {} 5 | } 6 | -------------------------------------------------------------------------------- /src/application/session/v1/commands/revoke-session/revoke-session.command.ts: -------------------------------------------------------------------------------- 1 | import { Session } from '@/domain/session/session.entity'; 2 | 3 | export class V1RevokeSessionCommand { 4 | constructor(public readonly session: Session) {} 5 | } 6 | -------------------------------------------------------------------------------- /src/application/session/v1/queries/find-all-sessions-by-user/find-all-sessions-by-user.query.ts: -------------------------------------------------------------------------------- 1 | import { User } from '@/domain/user/user.entity'; 2 | 3 | export class V1FindAllSessionsByUserQuery { 4 | constructor(public readonly user: User) {} 5 | } 6 | -------------------------------------------------------------------------------- /src/domain/authentication/events/on-login.event.ts: -------------------------------------------------------------------------------- 1 | import { User } from '@/domain/user/user.entity'; 2 | 3 | export class OnLoginEvent { 4 | constructor( 5 | public readonly user: User, 6 | public readonly ip?: string, 7 | ) {} 8 | } 9 | -------------------------------------------------------------------------------- /src/application/authentication/v1/queries/validate-credentials/validate-credentials.query.ts: -------------------------------------------------------------------------------- 1 | export class V1ValidateCredentialsQuery { 2 | constructor( 3 | public readonly email: string, 4 | public readonly password: string, 5 | ) {} 6 | } 7 | -------------------------------------------------------------------------------- /src/domain/authentication/events/on-register.event.ts: -------------------------------------------------------------------------------- 1 | import { User } from '@/domain/user/user.entity'; 2 | 3 | export class OnRegisterEvent { 4 | constructor( 5 | public readonly user: User, 6 | public readonly ip?: string, 7 | ) {} 8 | } 9 | -------------------------------------------------------------------------------- /src/application/authentication/strategies/refresh-token/refresh-token.guard.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { AuthGuard } from '@nestjs/passport'; 3 | 4 | @Injectable() 5 | export class RefreshTokenGuard extends AuthGuard('refreshToken') {} 6 | -------------------------------------------------------------------------------- /src/domain/token/verification-token-payload.type.ts: -------------------------------------------------------------------------------- 1 | import { BaseTokenPayload } from '@/domain/token/base-token-payload.type'; 2 | 3 | export type VerificationTokenPayload = BaseTokenPayload< 4 | 'verification', 5 | { 6 | sub: string; 7 | } 8 | >; 9 | -------------------------------------------------------------------------------- /src/infrastructure/token/v1/commands/generate-verification-token/generate-verification-token.command.ts: -------------------------------------------------------------------------------- 1 | import { User } from '@/domain/user/user.entity'; 2 | 3 | export class V1GenerateVerificationTokenCommand { 4 | constructor(public readonly user: User) {} 5 | } 6 | -------------------------------------------------------------------------------- /src/domain/token/reset-password-token-payload.type.ts: -------------------------------------------------------------------------------- 1 | import { BaseTokenPayload } from '@/domain/token/base-token-payload.type'; 2 | 3 | export type ResetPasswordTokenPayload = BaseTokenPayload< 4 | 'reset-password', 5 | { 6 | sub: string; 7 | } 8 | >; 9 | -------------------------------------------------------------------------------- /src/infrastructure/token/v1/commands/generate-reset-password-token/generate-reset-password-token.command.ts: -------------------------------------------------------------------------------- 1 | import { User } from '@/domain/user/user.entity'; 2 | 3 | export class V1GenerateResetPasswordTokenCommand { 4 | constructor(public readonly user: User) {} 5 | } 6 | -------------------------------------------------------------------------------- /src/application/authentication/v1/commands/login/login.command.ts: -------------------------------------------------------------------------------- 1 | import { User } from '@/domain/user/user.entity'; 2 | 3 | export class V1LoginCommand { 4 | constructor( 5 | public readonly user: User, 6 | public readonly ip?: string, 7 | ) {} 8 | } 9 | -------------------------------------------------------------------------------- /src/domain/authentication/events/on-login-unverified.event.ts: -------------------------------------------------------------------------------- 1 | import { User } from '@/domain/user/user.entity'; 2 | 3 | export class OnLoginUnverifiedEvent { 4 | constructor( 5 | public readonly user: User, 6 | public readonly ip?: string, 7 | ) {} 8 | } 9 | -------------------------------------------------------------------------------- /src/application/authentication/v1/commands/register/register.command.ts: -------------------------------------------------------------------------------- 1 | import { User } from '@/domain/user/user.entity'; 2 | 3 | export class V1RegisterCommand { 4 | constructor( 5 | public readonly user: User, 6 | public readonly ip?: string, 7 | ) {} 8 | } 9 | -------------------------------------------------------------------------------- /src/shared/decorator/non-standard-response.decorator.ts: -------------------------------------------------------------------------------- 1 | import { SetMetadata } from '@nestjs/common'; 2 | 3 | export const NonStandardResponseKey = Symbol('NonStandardResponse'); 4 | 5 | export const NonStandardResponse = () => 6 | SetMetadata(NonStandardResponseKey, true); 7 | -------------------------------------------------------------------------------- /src/application/authentication/v1/commands/confirm-forgot-password/confirm-forgot-password.command.ts: -------------------------------------------------------------------------------- 1 | export class V1ConfirmForgotPasswordCommand { 2 | constructor( 3 | public readonly resetPasswordToken: string, 4 | public readonly newPassword: string, 5 | ) {} 6 | } 7 | -------------------------------------------------------------------------------- /src/application/session/session.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import {} from '@nestjs/cqrs'; 3 | 4 | import { V1SessionModule } from './v1/v1-session.module'; 5 | 6 | @Module({ 7 | imports: [V1SessionModule], 8 | }) 9 | export class SessionModule {} 10 | -------------------------------------------------------------------------------- /src/application/session/v1/commands/create-session/create-session.command.ts: -------------------------------------------------------------------------------- 1 | import { User } from '@/domain/user/user.entity'; 2 | 3 | export class V1CreateSessionCommand { 4 | constructor( 5 | public readonly user: User, 6 | public readonly ip?: string, 7 | ) {} 8 | } 9 | -------------------------------------------------------------------------------- /src/application/user/v1/queries/find-user-by-email/dto/find-user-by-email.param.dto.ts: -------------------------------------------------------------------------------- 1 | import { IntersectionType } from '@nestjs/swagger'; 2 | 3 | import { UserEmailDto } from '@/domain/user/user.dto'; 4 | 5 | export class V1FindUserByEmailParamDto extends IntersectionType(UserEmailDto) {} 6 | -------------------------------------------------------------------------------- /src/domain/authentication/events/on-validate-credentials.event.ts: -------------------------------------------------------------------------------- 1 | export class OnValidateCredentialsEvent { 2 | constructor( 3 | public readonly email: string, 4 | public readonly emailExists: boolean, 5 | public readonly passwordMatches: boolean, 6 | ) {} 7 | } 8 | -------------------------------------------------------------------------------- /src/domain/verification/events/on-verification-sent.event.ts: -------------------------------------------------------------------------------- 1 | import { User } from '@/domain/user/user.entity'; 2 | 3 | export class OnVerificationSentEvent { 4 | constructor( 5 | public readonly user: User, 6 | public readonly verificationToken: string, 7 | ) {} 8 | } 9 | -------------------------------------------------------------------------------- /src/types/express/index.d.ts: -------------------------------------------------------------------------------- 1 | import { User as BaseUser } from '@/domain/user/user.entity'; 2 | 3 | declare global { 4 | namespace Express { 5 | export type User = BaseUser; 6 | 7 | interface Request { 8 | user?: User; 9 | } 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /src/domain/token/refresh-token-payload.type.ts: -------------------------------------------------------------------------------- 1 | import { BaseTokenPayload } from '@/domain/token/base-token-payload.type'; 2 | 3 | export type RefreshTokenPayload = BaseTokenPayload< 4 | 'refresh-token', 5 | { 6 | token: string; 7 | ip?: string; 8 | } 9 | >; 10 | -------------------------------------------------------------------------------- /src/application/authentication/v1/commands/forgot-password/forgot-password.command.ts: -------------------------------------------------------------------------------- 1 | import { User } from '@/domain/user/user.entity'; 2 | 3 | export class V1ForgotPasswordCommand { 4 | constructor( 5 | public readonly user: User, 6 | public readonly ip?: string, 7 | ) {} 8 | } 9 | -------------------------------------------------------------------------------- /src/domain/token/events/on-verification-token-generated.event.ts: -------------------------------------------------------------------------------- 1 | import { User } from '@/domain/user/user.entity'; 2 | 3 | export class OnVerificationTokenGeneratedEvent { 4 | constructor( 5 | public readonly verificationToken: string, 6 | public readonly user: User, 7 | ) {} 8 | } 9 | -------------------------------------------------------------------------------- /src/domain/token/base-token-payload.type.ts: -------------------------------------------------------------------------------- 1 | export type TokenType = 2 | | 'verification' 3 | | 'reset-password' 4 | | 'refresh-token' 5 | | 'access-token'; 6 | 7 | export interface BaseTokenPayload { 8 | type: Type | string; 9 | data: Data; 10 | } 11 | -------------------------------------------------------------------------------- /src/domain/token/events/on-reset-password-token-generated.event.ts: -------------------------------------------------------------------------------- 1 | import { User } from '@/domain/user/user.entity'; 2 | 3 | export class OnResetPasswordTokenGeneratedEvent { 4 | constructor( 5 | public readonly resetPasswordToken: string, 6 | public readonly user: User, 7 | ) {} 8 | } 9 | -------------------------------------------------------------------------------- /src/application/authentication/v1/commands/forgot-password/dto/forgot-password.request.dto.ts: -------------------------------------------------------------------------------- 1 | import { IntersectionType } from '@nestjs/swagger'; 2 | 3 | import { UserEmailDto } from '@/domain/user/user.dto'; 4 | 5 | export class V1ForgotPasswordRequestDto extends IntersectionType( 6 | UserEmailDto, 7 | ) {} 8 | -------------------------------------------------------------------------------- /src/application/health/health.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | 3 | import { V1HealthModule } from '@/application/health/v1/v1-health.module'; 4 | 5 | @Module({ 6 | imports: [V1HealthModule], 7 | controllers: [], 8 | providers: [], 9 | }) 10 | export class HealthModule {} 11 | -------------------------------------------------------------------------------- /src/application/verification/v1/commands/send-verification/dto/send-verification.request.dto.ts: -------------------------------------------------------------------------------- 1 | import { IntersectionType } from '@nestjs/swagger'; 2 | 3 | import { UserEmailDto } from '@/domain/user/user.dto'; 4 | 5 | export class V1SendVerificationRequestDto extends IntersectionType( 6 | UserEmailDto, 7 | ) {} 8 | -------------------------------------------------------------------------------- /src/types/express/request-with-user.ts: -------------------------------------------------------------------------------- 1 | import { Request } from 'express'; 2 | 3 | import { Session } from '@/domain/session/session.entity'; 4 | import { User } from '@/domain/user/user.entity'; 5 | 6 | export interface RequestWithUser extends Request { 7 | user: User; 8 | session: Session; 9 | } 10 | -------------------------------------------------------------------------------- /src/application/authentication/decorator/roles.decorator.ts: -------------------------------------------------------------------------------- 1 | import { SetMetadata } from '@nestjs/common'; 2 | 3 | import { UserRoleEnum } from '@/domain/user/user-role.enum'; 4 | 5 | export const ROLES_KEY = 'USER_USER_ROLES'; 6 | export const Roles = (...roles: UserRoleEnum[]) => 7 | SetMetadata(ROLES_KEY, roles); 8 | -------------------------------------------------------------------------------- /src/application/authentication/v1/commands/refresh/dto/refresh.response.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | 3 | export class V1RefreshTokenResponseDto { 4 | @ApiProperty({ 5 | description: 'Access token', 6 | type: String, 7 | }) 8 | accessToken!: string; 9 | } 10 | -------------------------------------------------------------------------------- /src/application/authentication/v1/commands/login/dto/login.request.dto.ts: -------------------------------------------------------------------------------- 1 | import { IntersectionType } from '@nestjs/swagger'; 2 | 3 | import { UserEmailDto, UserPasswordDto } from '@/domain/user/user.dto'; 4 | 5 | export class V1LoginRequestDto extends IntersectionType( 6 | UserEmailDto, 7 | UserPasswordDto, 8 | ) {} 9 | -------------------------------------------------------------------------------- /src/domain/token/access-token-payload.type.ts: -------------------------------------------------------------------------------- 1 | import { BaseTokenPayload } from '@/domain/token/base-token-payload.type'; 2 | 3 | export type AccessTokenPayload = BaseTokenPayload< 4 | 'access-token', 5 | { 6 | sub: string; 7 | refreshToken: string; 8 | ip?: string; 9 | } 10 | >; 11 | -------------------------------------------------------------------------------- /src/domain/user/events/on-user-changed-email.event.ts: -------------------------------------------------------------------------------- 1 | import type { User } from '../user.entity'; 2 | 3 | export class OnUserChangedEmailEvent { 4 | constructor( 5 | public readonly user: User, 6 | public readonly oldEmail: string, 7 | public readonly newEmail: string, 8 | ) {} 9 | } 10 | -------------------------------------------------------------------------------- /src/application/ping/v1/v1-ping.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | 3 | import { V1PingController } from '@/application/ping/v1/queries/ping/ping.controller'; 4 | 5 | @Module({ 6 | imports: [], 7 | controllers: [V1PingController], 8 | providers: [], 9 | }) 10 | export class V1PingModule {} 11 | -------------------------------------------------------------------------------- /src/application/authentication/decorator/public.decorator.ts: -------------------------------------------------------------------------------- 1 | import { SetMetadata, applyDecorators } from '@nestjs/common'; 2 | import { ApiSecurity } from '@nestjs/swagger'; 3 | 4 | export const IS_PUBLIC_KEY = 'isPublic'; 5 | export const Public = () => 6 | applyDecorators(SetMetadata(IS_PUBLIC_KEY, true), ApiSecurity({}, [])); 7 | -------------------------------------------------------------------------------- /src/application/user/user.module.ts: -------------------------------------------------------------------------------- 1 | import { Module, Type } from '@nestjs/common'; 2 | 3 | import { V1UserModule } from '@/application/user/v1/v1-user.module'; 4 | 5 | const EventHandlers: Type[] = []; 6 | 7 | @Module({ 8 | imports: [V1UserModule], 9 | providers: [...EventHandlers], 10 | }) 11 | export class UserModule {} 12 | -------------------------------------------------------------------------------- /src/domain/authentication/events/on-forgot-password.event.ts: -------------------------------------------------------------------------------- 1 | import { User } from '@/domain/user/user.entity'; 2 | 3 | export class OnForgotPasswordEvent { 4 | constructor( 5 | public readonly user: User, 6 | public readonly resetPasswordToken: string, 7 | public readonly ip?: string, 8 | ) {} 9 | } 10 | -------------------------------------------------------------------------------- /src/infrastructure/repositories/modules/user/user.repository.port.ts: -------------------------------------------------------------------------------- 1 | import type { User } from '@/domain/user/user.entity'; 2 | import { RepositoryPort } from '@/infrastructure/repositories/repository.port'; 3 | 4 | export interface UserRepositoryPort extends RepositoryPort { 5 | findOneByEmail: (email: string) => Promise; 6 | } 7 | -------------------------------------------------------------------------------- /src/application/verification/v1/commands/confirm-verification/dto/confirm-verification.request.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | 3 | export class V1ConfirmVerificationRequestDto { 4 | @ApiProperty({ 5 | description: 'Verification Token', 6 | type: String, 7 | }) 8 | verificationToken!: string; 9 | } 10 | -------------------------------------------------------------------------------- /src/domain/user/events/on-user-changed-last-name.event.ts: -------------------------------------------------------------------------------- 1 | import type { User } from '../user.entity'; 2 | 3 | export class OnUserChangedLastNameEvent { 4 | constructor( 5 | public readonly user: User, 6 | public readonly oldLastName: string | undefined, 7 | public readonly newLastName: string | undefined, 8 | ) {} 9 | } 10 | -------------------------------------------------------------------------------- /src/domain/user/events/on-user-changed-first-name.event.ts: -------------------------------------------------------------------------------- 1 | import type { User } from '../user.entity'; 2 | 3 | export class OnUserChangedFirstNameEvent { 4 | constructor( 5 | public readonly user: User, 6 | public readonly oldFirstName: string | undefined, 7 | public readonly newFirstName: string | undefined, 8 | ) {} 9 | } 10 | -------------------------------------------------------------------------------- /src/domain/user/events/on-user-changed-verified-at.event.ts: -------------------------------------------------------------------------------- 1 | import type { User } from '../user.entity'; 2 | 3 | export class OnUserChangedVerifiedAtEvent { 4 | constructor( 5 | public readonly user: User, 6 | public readonly oldVerifiedAt: Date | undefined, 7 | public readonly newVerifiedAt: Date | undefined, 8 | ) {} 9 | } 10 | -------------------------------------------------------------------------------- /src/infrastructure/token/token.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | 3 | import { JwtModule } from '@/infrastructure/jwt/jwt.module'; 4 | import { V1TokenModule } from '@/infrastructure/token/v1/v1-token.module'; 5 | 6 | @Module({ 7 | imports: [V1TokenModule, JwtModule], 8 | exports: [], 9 | }) 10 | export class TokenModule {} 11 | -------------------------------------------------------------------------------- /src/application/user/v1/commands/delete-user/dto/delete-user.response.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | 3 | import { User } from '@/domain/user/user.entity'; 4 | 5 | export class V1DeleteUserResponseDto { 6 | @ApiProperty({ 7 | description: 'The User that was Updated', 8 | type: User, 9 | }) 10 | user!: User; 11 | } 12 | -------------------------------------------------------------------------------- /src/application/user/v1/commands/update-user/dto/update-user.response.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | 3 | import { User } from '@/domain/user/user.entity'; 4 | 5 | export class V1UpdateUserResponseDto { 6 | @ApiProperty({ 7 | description: 'The User that was Updated', 8 | type: User, 9 | }) 10 | user!: User; 11 | } 12 | -------------------------------------------------------------------------------- /src/infrastructure/mailer/mailer.module.ts: -------------------------------------------------------------------------------- 1 | import { Global, Module } from '@nestjs/common'; 2 | 3 | import { MockMailerServiceProvider } from '@/infrastructure/mailer/adapters/mock/mock.mailer.service'; 4 | 5 | @Global() 6 | @Module({ 7 | providers: [MockMailerServiceProvider], 8 | exports: [MockMailerServiceProvider], 9 | }) 10 | export class MailerModule {} 11 | -------------------------------------------------------------------------------- /src/application/user/v1/queries/find-user-by-id/dto/find-user-by-id.response.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | 3 | import { User } from '@/domain/user/user.entity'; 4 | 5 | export class V1FindUserByIDResponseDto { 6 | @ApiProperty({ 7 | description: 'The User that was found by ID', 8 | type: User, 9 | }) 10 | user!: User; 11 | } 12 | -------------------------------------------------------------------------------- /src/application/user/v1/queries/find-current-user/dto/find-current-user.response.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | 3 | import { User } from '@/domain/user/user.entity'; 4 | 5 | export class V1FindCurrentUserResponseDto { 6 | @ApiProperty({ 7 | description: 'The User that was found', 8 | type: User, 9 | }) 10 | user!: User; 11 | } 12 | -------------------------------------------------------------------------------- /src/domain/user/events/on-user-changed-role.event.ts: -------------------------------------------------------------------------------- 1 | import type { UserRoleEnum } from '../user-role.enum'; 2 | import type { User } from '../user.entity'; 3 | 4 | export class OnUserChangedRoleEvent { 5 | constructor( 6 | public readonly user: User, 7 | public readonly oldRole: UserRoleEnum, 8 | public readonly newRole: UserRoleEnum, 9 | ) {} 10 | } 11 | -------------------------------------------------------------------------------- /src/domain/authentication/events/on-token-refresh.event.ts: -------------------------------------------------------------------------------- 1 | import { Session } from '@/domain/session/session.entity'; 2 | import { User } from '@/domain/user/user.entity'; 3 | 4 | export class OnTokenRefreshEvent { 5 | constructor( 6 | public readonly user: User, 7 | public readonly session: Session, 8 | public readonly newAccessToken: string, 9 | ) {} 10 | } 11 | -------------------------------------------------------------------------------- /src/application/authentication/v1/commands/refresh/refresh.command.ts: -------------------------------------------------------------------------------- 1 | import { Session } from '@/domain/session/session.entity'; 2 | import { User } from '@/domain/user/user.entity'; 3 | 4 | export class V1RefreshTokenCommand { 5 | constructor( 6 | public readonly user: User, 7 | public readonly session: Session, 8 | public readonly ip?: string, 9 | ) {} 10 | } 11 | -------------------------------------------------------------------------------- /src/application/user/v1/queries/find-user-by-email/dto/find-user-by-email.response.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | 3 | import { User } from '@/domain/user/user.entity'; 4 | 5 | export class V1FindUserByEmailResponseDto { 6 | @ApiProperty({ 7 | description: 'The User that was found by email', 8 | type: User, 9 | }) 10 | user!: User; 11 | } 12 | -------------------------------------------------------------------------------- /src/shared/dto/standard-http-response.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | import { Expose } from 'class-transformer'; 3 | 4 | export class StandardHttpResponseDto { 5 | @ApiProperty({ 6 | description: 'The status code of the response', 7 | }) 8 | @Expose() 9 | statusCode!: number; 10 | 11 | @Expose() 12 | data!: Data; 13 | } 14 | -------------------------------------------------------------------------------- /src/application/session/v1/commands/revoke-session/dto/revoke-session.response.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | 3 | import { Session } from '@/domain/session/session.entity'; 4 | 5 | export class V1RevokeSessionResponseDto { 6 | @ApiProperty({ 7 | description: 'The Session that was Revoked', 8 | type: Session, 9 | }) 10 | session!: Session; 11 | } 12 | -------------------------------------------------------------------------------- /src/application/user/v1/queries/find-all-users/dto/find-all-users.response.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | 3 | import { User } from '@/domain/user/user.entity'; 4 | 5 | export class V1FindAllUsersResponseDto { 6 | @ApiProperty({ 7 | description: 'All Users found.', 8 | type: User, 9 | isArray: true, 10 | }) 11 | users!: User[]; 12 | } 13 | -------------------------------------------------------------------------------- /src/infrastructure/sms/sms.port.ts: -------------------------------------------------------------------------------- 1 | export interface SMSOptions { 2 | from: string; 3 | to: string; 4 | text: string; 5 | } 6 | 7 | export interface SMSPort { 8 | sendSMS: (sms: SMSOptions, senderOptions: SenderOptions) => Promise; 9 | 10 | sendSMSs: ( 11 | sms: SMSOptions[], 12 | senderOptions: SenderOptions, 13 | ) => Promise; 14 | } 15 | -------------------------------------------------------------------------------- /src/application/user/v1/commands/create-user/dto/create-user.request.dto.ts: -------------------------------------------------------------------------------- 1 | import { IntersectionType } from '@nestjs/swagger'; 2 | 3 | import { 4 | UserEmailDto, 5 | UserFirstNameDto, 6 | UserLastNameDto, 7 | } from '@/domain/user/user.dto'; 8 | 9 | export class V1CreateUserRequestDto extends IntersectionType( 10 | UserEmailDto, 11 | UserFirstNameDto, 12 | UserLastNameDto, 13 | ) {} 14 | -------------------------------------------------------------------------------- /src/application/session/v1/queries/find-current-session/dto/find-current-session.response.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | 3 | import { Session } from '@/domain/session/session.entity'; 4 | 5 | export class V1FindCurrentSessionResponseDto { 6 | @ApiProperty({ 7 | description: 'The Session that was Found', 8 | type: Session, 9 | }) 10 | session!: Session; 11 | } 12 | -------------------------------------------------------------------------------- /src/application/session/v1/queries/find-session-by-token/dto/find-session-by-token.response.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | 3 | import { Session } from '@/domain/session/session.entity'; 4 | 5 | export class V1FindSessionByTokenResponseDto { 6 | @ApiProperty({ 7 | description: 'The Session that was Found', 8 | type: Session, 9 | }) 10 | session!: Session; 11 | } 12 | -------------------------------------------------------------------------------- /src/infrastructure/token/v1/commands/generate-access-token/generate-access-token.command.ts: -------------------------------------------------------------------------------- 1 | import { Session } from '@/domain/session/session.entity'; 2 | import { User } from '@/domain/user/user.entity'; 3 | 4 | export class V1GenerateAccessTokenCommand { 5 | constructor( 6 | public readonly user: User, 7 | public readonly session: Session, 8 | public readonly ip?: string, 9 | ) {} 10 | } 11 | -------------------------------------------------------------------------------- /src/infrastructure/token/v1/commands/generate-refresh-token/generate-refresh-token.command.ts: -------------------------------------------------------------------------------- 1 | import { Session } from '@/domain/session/session.entity'; 2 | import { User } from '@/domain/user/user.entity'; 3 | 4 | export class V1GenerateRefreshTokenCommand { 5 | constructor( 6 | public readonly user: User, 7 | public readonly session: Session, 8 | public readonly ip?: string, 9 | ) {} 10 | } 11 | -------------------------------------------------------------------------------- /src/application/session/v1/queries/find-all-sessions-by-user/dto/find-all-sessions-by-user.response.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | 3 | import { Session } from '@/domain/session/session.entity'; 4 | 5 | export class V1FindAllSessionsByUserResponseDto { 6 | @ApiProperty({ 7 | description: 'The Sessions that was Found', 8 | type: [Session], 9 | }) 10 | sessions!: Session[]; 11 | } 12 | -------------------------------------------------------------------------------- /src/domain/token/events/on-access-token-generated.event.ts: -------------------------------------------------------------------------------- 1 | import { Session } from '@/domain/session/session.entity'; 2 | import { User } from '@/domain/user/user.entity'; 3 | 4 | export class OnAccessTokenGeneratedEvent { 5 | constructor( 6 | public readonly accessToken: string, 7 | public readonly user: User, 8 | public readonly session: Session, 9 | public readonly ip?: string, 10 | ) {} 11 | } 12 | -------------------------------------------------------------------------------- /src/domain/token/events/on-refresh-token-generated.event.ts: -------------------------------------------------------------------------------- 1 | import { Session } from '@/domain/session/session.entity'; 2 | import { User } from '@/domain/user/user.entity'; 3 | 4 | export class OnRefreshTokenGeneratedEvent { 5 | constructor( 6 | public readonly refreshToken: string, 7 | public readonly user: User, 8 | public readonly session: Session, 9 | public readonly ip?: string, 10 | ) {} 11 | } 12 | -------------------------------------------------------------------------------- /src/application/authentication/v1/commands/login/dto/login.response.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | 3 | export class V1LoginResponseDto { 4 | @ApiProperty({ 5 | description: 'Access token', 6 | type: String, 7 | }) 8 | accessToken!: string; 9 | 10 | @ApiProperty({ 11 | description: 'Refresh token', 12 | type: String, 13 | }) 14 | refreshToken!: string; 15 | } 16 | -------------------------------------------------------------------------------- /src/application/ping/ping.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | 3 | import { PingRanHandler } from '@/application/ping/event-handlers/ping-ran.handler'; 4 | import { V1PingModule } from '@/application/ping/v1/v1-ping.module'; 5 | 6 | const EventHandlers = [PingRanHandler]; 7 | 8 | @Module({ 9 | imports: [V1PingModule], 10 | controllers: [], 11 | providers: [...EventHandlers], 12 | }) 13 | export class PingModule {} 14 | -------------------------------------------------------------------------------- /src/application/session/v1/queries/find-all-sessions-by-current-user/dto/find-all-sessions-by-current-user.response.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | 3 | import { Session } from '@/domain/session/session.entity'; 4 | 5 | export class V1FindAllSessionsByCurrentUserResponseDto { 6 | @ApiProperty({ 7 | description: 'The Sessions that was Found', 8 | type: [Session], 9 | }) 10 | sessions!: Session[]; 11 | } 12 | -------------------------------------------------------------------------------- /src/application/authentication/v1/commands/register/dto/register.request.dto.ts: -------------------------------------------------------------------------------- 1 | import { IntersectionType } from '@nestjs/swagger'; 2 | 3 | import { 4 | UserEmailDto, 5 | UserFirstNameDto, 6 | UserLastNameDto, 7 | UserPasswordDto, 8 | } from '@/domain/user/user.dto'; 9 | 10 | export class V1RegisterRequestDto extends IntersectionType( 11 | UserFirstNameDto, 12 | UserLastNameDto, 13 | UserEmailDto, 14 | UserPasswordDto, 15 | ) {} 16 | -------------------------------------------------------------------------------- /src/application/authentication/decorator/current-user.decorator.ts: -------------------------------------------------------------------------------- 1 | import { ExecutionContext, createParamDecorator } from '@nestjs/common'; 2 | 3 | import { RequestWithUser } from '@/types/express/request-with-user'; 4 | 5 | export const CurrentUserFactory = (data: unknown, ctx: ExecutionContext) => { 6 | const request = ctx.switchToHttp().getRequest(); 7 | return request.user; 8 | }; 9 | 10 | export const CurrentUser = createParamDecorator(CurrentUserFactory); 11 | -------------------------------------------------------------------------------- /src/application/health/v1/v1-health.module.ts: -------------------------------------------------------------------------------- 1 | import { HttpModule } from '@nestjs/axios'; 2 | import { Module } from '@nestjs/common'; 3 | import { TerminusModule } from '@nestjs/terminus'; 4 | 5 | import { V1CheckHealthController } from '@/application/health/v1/queries/check-health/check-health.controller'; 6 | 7 | @Module({ 8 | imports: [TerminusModule, HttpModule], 9 | controllers: [V1CheckHealthController], 10 | providers: [], 11 | }) 12 | export class V1HealthModule {} 13 | -------------------------------------------------------------------------------- /src/application/user/v1/commands/create-user/dto/create-user.response.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty, IntersectionType } from '@nestjs/swagger'; 2 | 3 | import { UserPasswordDto } from '@/domain/user/user.dto'; 4 | import { User } from '@/domain/user/user.entity'; 5 | 6 | export class V1CreateUserResponseDto extends IntersectionType(UserPasswordDto) { 7 | @ApiProperty({ 8 | description: 'The User that was created', 9 | type: User, 10 | }) 11 | user!: User; 12 | } 13 | -------------------------------------------------------------------------------- /src/shared/exceptions/not-found.exception.ts: -------------------------------------------------------------------------------- 1 | import { NotFoundException } from '@nestjs/common'; 2 | 3 | export class GenericNotFoundException extends NotFoundException { 4 | static readonly message = 'Entity not found'; 5 | 6 | constructor(cause?: Error | string) { 7 | super( 8 | cause 9 | ? `${GenericNotFoundException.message}: ${cause instanceof Error ? cause.message : cause}` 10 | : GenericNotFoundException.message, 11 | ); 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/application/authentication/v1/commands/register/dto/register.response.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | 3 | import { User } from '@/domain/user/user.entity'; 4 | 5 | export class V1RegisterResponseDto { 6 | @ApiProperty({ 7 | description: 'User that was registered', 8 | type: User, 9 | }) 10 | user!: User; 11 | 12 | @ApiProperty({ 13 | description: 'If Verification is required', 14 | type: Boolean, 15 | }) 16 | verificationRequired!: boolean; 17 | } 18 | -------------------------------------------------------------------------------- /src/shared/exceptions/already-exists.exception.ts: -------------------------------------------------------------------------------- 1 | import { ConflictException } from '@nestjs/common'; 2 | 3 | export class GenericAlreadyExistsException extends ConflictException { 4 | static readonly message = 'Entity already exists'; 5 | 6 | constructor(cause?: Error | string) { 7 | super( 8 | cause 9 | ? `${GenericAlreadyExistsException.message}: ${cause instanceof Error ? cause.message : cause}` 10 | : GenericAlreadyExistsException.message, 11 | ); 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/infrastructure/config/configs/email.config.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { ConfigService as NestConfigService } from '@nestjs/config'; 3 | 4 | import { Config } from '@/infrastructure/config/config-schema'; 5 | 6 | @Injectable() 7 | export class EmailConfigService { 8 | constructor( 9 | private readonly configService: NestConfigService, 10 | ) {} 11 | 12 | get from() { 13 | return this.configService.get('EMAIL_FROM'); 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/infrastructure/config/configs/jwt-config.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { ConfigService as NestConfigService } from '@nestjs/config'; 3 | 4 | import { Config } from '@/infrastructure/config/config-schema'; 5 | 6 | @Injectable() 7 | export class JwtConfigService { 8 | constructor( 9 | private readonly configService: NestConfigService, 10 | ) {} 11 | 12 | get secret() { 13 | return this.configService.get('JWT_SECRET'); 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/domain/user/exceptions/user-already-verified.exception.ts: -------------------------------------------------------------------------------- 1 | import { ForbiddenException } from '@nestjs/common'; 2 | 3 | export class UserAlreadyVerifiedException extends ForbiddenException { 4 | static readonly message = 'User is already verified'; 5 | 6 | constructor(cause?: Error | string) { 7 | super( 8 | cause 9 | ? `${UserAlreadyVerifiedException.message}: ${cause instanceof Error ? cause.message : cause}` 10 | : UserAlreadyVerifiedException.message, 11 | ); 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/shared/utilities/tracing.ts: -------------------------------------------------------------------------------- 1 | import { getNodeAutoInstrumentations } from '@opentelemetry/auto-instrumentations-node'; 2 | import { Resource } from '@opentelemetry/resources'; 3 | import { NodeSDK } from '@opentelemetry/sdk-node'; 4 | import { SEMRESATTRS_SERVICE_NAME } from '@opentelemetry/semantic-conventions'; 5 | 6 | export const otelSDK = new NodeSDK({ 7 | resource: new Resource({ 8 | [SEMRESATTRS_SERVICE_NAME]: process.env.APP_NAME ?? 'EnterpriseNest', 9 | }), 10 | 11 | instrumentations: [getNodeAutoInstrumentations()], 12 | }); 13 | -------------------------------------------------------------------------------- /src/application/user/v1/commands/update-user/dto/update-user.request.dto.ts: -------------------------------------------------------------------------------- 1 | import { IntersectionType, PartialType } from '@nestjs/swagger'; 2 | 3 | import { 4 | UserEmailDto, 5 | UserFirstNameDto, 6 | UserLastNameDto, 7 | UserPasswordDto, 8 | UserRoleDto, 9 | } from '@/domain/user/user.dto'; 10 | 11 | export class V1UpdateUserRequestDto extends PartialType( 12 | IntersectionType( 13 | UserFirstNameDto, 14 | UserLastNameDto, 15 | UserEmailDto, 16 | UserPasswordDto, 17 | UserRoleDto, 18 | ), 19 | ) {} 20 | -------------------------------------------------------------------------------- /src/application/verification/verification.module.ts: -------------------------------------------------------------------------------- 1 | import { Module, Type } from '@nestjs/common'; 2 | 3 | import { OnVerificationSentAddToCounterHandler } from '@/application/verification/event-handlers/on-verification-sent-add-to-counter.handler'; 4 | import { V1VerificationModule } from '@/application/verification/v1/v1.verification.module'; 5 | 6 | const EventHandlers: Type[] = [OnVerificationSentAddToCounterHandler]; 7 | 8 | @Module({ 9 | imports: [V1VerificationModule], 10 | 11 | providers: [...EventHandlers], 12 | }) 13 | export class VerificationModule {} 14 | -------------------------------------------------------------------------------- /src/shared/exceptions/internal-validation.exception.ts: -------------------------------------------------------------------------------- 1 | import { InternalServerErrorException } from '@nestjs/common'; 2 | 3 | export class GenericInternalValidationException extends InternalServerErrorException { 4 | static readonly message = 'Entity not valid'; 5 | 6 | constructor(cause?: Error | string) { 7 | super( 8 | cause 9 | ? `${GenericInternalValidationException.message}: ${cause instanceof Error ? cause.message : cause}` 10 | : GenericInternalValidationException.message, 11 | ); 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/domain/authentication/exceptions/no-email-match.exception.ts: -------------------------------------------------------------------------------- 1 | import { BadRequestException } from '@nestjs/common'; 2 | 3 | export class AuthenticationNoEmailMatchException extends BadRequestException { 4 | static readonly message = "Email doesn't match any user"; 5 | 6 | constructor(cause?: Error | string) { 7 | super( 8 | cause 9 | ? `${AuthenticationNoEmailMatchException.message}: ${cause instanceof Error ? cause.message : cause}` 10 | : AuthenticationNoEmailMatchException.message, 11 | ); 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/shared/exceptions/unauthenticated.exception.ts: -------------------------------------------------------------------------------- 1 | import { UnauthorizedException } from '@nestjs/common'; 2 | 3 | export class GenericUnauthenticatedException extends UnauthorizedException { 4 | static readonly message = "You're not authenticated to perform this action"; 5 | 6 | constructor(cause?: Error | string) { 7 | super( 8 | cause 9 | ? `${GenericUnauthenticatedException.message}: ${cause instanceof Error ? cause.message : cause}` 10 | : GenericUnauthenticatedException.message, 11 | ); 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/domain/authentication/exceptions/password-incorrect.exception.ts: -------------------------------------------------------------------------------- 1 | import { BadRequestException } from '@nestjs/common'; 2 | 3 | export class AuthenticationPasswordIncorrectException extends BadRequestException { 4 | static readonly message = 'Password is incorrect'; 5 | 6 | constructor(cause?: Error | string) { 7 | super( 8 | cause 9 | ? `${AuthenticationPasswordIncorrectException.message}: ${cause instanceof Error ? cause.message : cause}` 10 | : AuthenticationPasswordIncorrectException.message, 11 | ); 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/application/ping/event-handlers/ping-ran.handler.ts: -------------------------------------------------------------------------------- 1 | import { Injectable, Logger } from '@nestjs/common'; 2 | import { EventsHandler, IEventHandler } from '@nestjs/cqrs'; 3 | 4 | import { PingRanEvent } from '@/application/ping/events/ping-ran.event'; 5 | 6 | @EventsHandler(PingRanEvent) 7 | @Injectable() 8 | export class PingRanHandler implements IEventHandler { 9 | private readonly logger = new Logger(PingRanHandler.name); 10 | 11 | handle(event: PingRanEvent) { 12 | this.logger.log('PingRanEvent handled successfully'); 13 | return void 0; 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # Dependencies 4 | node_modules 5 | .pnp 6 | .pnp.js 7 | 8 | # Local env files 9 | .env 10 | .env.local 11 | .env.development.local 12 | .env.test.local 13 | .env.production.local 14 | 15 | # Testing 16 | coverage 17 | 18 | # Turbo 19 | .turbo 20 | 21 | # Vercel 22 | .vercel 23 | 24 | # Build Outputs 25 | .next/ 26 | out/ 27 | build 28 | dist 29 | 30 | .idea/ 31 | .vscode/ 32 | 33 | 34 | # Debug 35 | npm-debug.log* 36 | yarn-debug.log* 37 | yarn-error.log* 38 | 39 | # Misc 40 | .DS_Store 41 | *.pem 42 | -------------------------------------------------------------------------------- /.syncpackrc: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://unpkg.com/syncpack@11.2.1/dist/schema.json", 3 | "versionGroups": [ 4 | { 5 | "dependencies": ["@types/**"], 6 | "dependencyTypes": ["!dev"], 7 | "isBanned": true, 8 | "label": "@types packages should only be under devDependencies" 9 | } 10 | ], 11 | "semverGroups": [ 12 | { 13 | "label": "Use only Exact Versions for packages", 14 | "dependencies": ["**"], 15 | "dependencyTypes": ["dev", "prod"], 16 | "range": "", 17 | "packages": ["**"] 18 | } 19 | ] 20 | } -------------------------------------------------------------------------------- /src/domain/base/value-object/value-object.base.ts: -------------------------------------------------------------------------------- 1 | export type Primitives = string | number | boolean; 2 | export interface DomainPrimitive { 3 | value: T; 4 | } 5 | 6 | type ValueObjectProps = T extends Primitives | Date ? DomainPrimitive : T; 7 | 8 | // TODO: UNIT TEST 9 | export abstract class ValueObject { 10 | protected readonly props: ValueObjectProps; 11 | 12 | constructor(props: ValueObjectProps) { 13 | this.validate(props); 14 | this.props = props; 15 | } 16 | 17 | protected abstract validate(props: ValueObjectProps): void; 18 | } 19 | -------------------------------------------------------------------------------- /src/infrastructure/mailer/mailer.port.ts: -------------------------------------------------------------------------------- 1 | export interface MailerOptions { 2 | from: string; 3 | to: string | string[]; 4 | subject: string; 5 | cc?: string[]; 6 | bcc?: string[]; 7 | 8 | text?: string; 9 | html?: string; 10 | 11 | attachments?: { 12 | filename: string; 13 | content: Buffer; 14 | encoding: 'base64' | 'utf-8' | 'binary' | 'hex' | 'ascii'; 15 | contentType: string; 16 | }[]; 17 | } 18 | 19 | export interface MailerPort { 20 | sendEmail: (email: MailerOptions) => Promise; 21 | 22 | sendEmails: (emails: MailerOptions[]) => Promise; 23 | } 24 | -------------------------------------------------------------------------------- /src/shared/exceptions/no-permission.exception.ts: -------------------------------------------------------------------------------- 1 | import { ForbiddenException } from '@nestjs/common'; 2 | 3 | export class GenericNoPermissionException extends ForbiddenException { 4 | static readonly message = 5 | 'You are not authorized to perform this action or access this resource. Please make sure you have the necessary permissions.'; 6 | 7 | constructor(cause?: Error | string) { 8 | super( 9 | cause 10 | ? `${GenericNoPermissionException.message}: ${cause instanceof Error ? cause.message : cause}` 11 | : GenericNoPermissionException.message, 12 | ); 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /tests/utils/create-mocks.ts: -------------------------------------------------------------------------------- 1 | import { faker } from '@faker-js/faker'; 2 | 3 | import { Session } from '@/domain/session/session.entity'; 4 | import { User } from '@/domain/user/user.entity'; 5 | 6 | export const CreateMockUser = (hashedPassword = true): User => 7 | User.create({ 8 | email: faker.internet.email(), 9 | password: hashedPassword 10 | ? '$argon2id$v=19$m=16,t=2,p=1$RElXV3FhVHVuZFdXNTJGaw$qokKchd/Ye41cx3LajbZXQ' 11 | : 'Password123!', 12 | }); 13 | 14 | export const CreateMockSession = (user: User = CreateMockUser()): Session => 15 | Session.create({ 16 | userId: user.id, 17 | ip: faker.internet.ip(), 18 | }); 19 | -------------------------------------------------------------------------------- /tsup.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'tsup'; 2 | 3 | const isDev = process.env.NODE_ENV !== 'production'; 4 | const isWatch = process.argv.includes('--watch'); 5 | 6 | const tsupConfig = defineConfig({ 7 | entry: ['src/index.ts'], 8 | clean: true, 9 | sourcemap: isDev ? 'inline' : false, 10 | silent: !isWatch, 11 | watch: isWatch ? ['src', '.env'] : false, 12 | onSuccess: isWatch ? 'node dist/index.js' : undefined, 13 | tsconfig: 'tsconfig.json', 14 | minify: isDev ? false : 'terser', 15 | treeshake: true, 16 | terserOptions: { 17 | keep_classnames: true, 18 | compress: true, 19 | }, 20 | }); 21 | 22 | export default tsupConfig; 23 | -------------------------------------------------------------------------------- /src/infrastructure/config/configs/cache-config.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { ConfigService as NestConfigService } from '@nestjs/config'; 3 | 4 | import { Config } from '@/infrastructure/config/config-schema'; 5 | 6 | @Injectable() 7 | export class CacheConfigService { 8 | constructor( 9 | private readonly configService: NestConfigService, 10 | ) {} 11 | 12 | get ttl() { 13 | return this.configService.get('CACHE_TTL_MS'); 14 | } 15 | 16 | get useRedis() { 17 | return this.configService.get( 18 | 'CACHE_USE_REDIS', 19 | ); 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/shared/decorator/api-operation-with-roles.decorator.ts: -------------------------------------------------------------------------------- 1 | import { applyDecorators } from '@nestjs/common'; 2 | import { ApiOperation, ApiOperationOptions } from '@nestjs/swagger'; 3 | 4 | import { Roles } from '@/application/authentication/decorator/roles.decorator'; 5 | import { UserRoleEnum } from '@/domain/user/user-role.enum'; 6 | 7 | export const ApiOperationWithRoles = ( 8 | options: ApiOperationOptions, 9 | roles: UserRoleEnum[] = [...Object.values(UserRoleEnum)], 10 | ) => 11 | applyDecorators( 12 | ApiOperation({ 13 | ...options, 14 | summary: `${options.summary ?? 'Unknown'} - Roles: ${roles.join(', ')}`, 15 | }), 16 | Roles(...roles), 17 | ); 18 | -------------------------------------------------------------------------------- /src/infrastructure/repositories/modules/session/session.repository.port.ts: -------------------------------------------------------------------------------- 1 | import { Session } from '@/domain/session/session.entity'; 2 | import { User } from '@/domain/user/user.entity'; 3 | import { RepositoryPort } from '@/infrastructure/repositories/repository.port'; 4 | 5 | export interface SessionRepositoryPort extends RepositoryPort { 6 | findAllByUserID: (userID: string) => Promise; 7 | findAllByUser: (user: User) => Promise; 8 | 9 | findOneByToken: (token: string) => Promise; 10 | 11 | findAllRevoked: () => Promise; 12 | findAllNotRevoked: () => Promise; 13 | 14 | findAllByIP: (ip: string) => Promise; 15 | } 16 | -------------------------------------------------------------------------------- /src/application/ping/v1/queries/ping/dto/ping.response.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty } from '@nestjs/swagger'; 2 | import { Expose } from 'class-transformer'; 3 | 4 | export class PingResponseDto { 5 | @ApiProperty({ 6 | description: 'The message of the health check', 7 | example: 'OK', 8 | }) 9 | @Expose() 10 | message!: string; 11 | 12 | @ApiProperty({ 13 | description: 'The server time of the health check', 14 | example: new Date(), 15 | }) 16 | @Expose() 17 | serverTime!: Date; 18 | 19 | @ApiProperty({ 20 | description: 'The application name of the health check', 21 | example: 'EnterpriseNest', 22 | }) 23 | @Expose() 24 | appName!: string; 25 | } 26 | -------------------------------------------------------------------------------- /src/infrastructure/config/configs/authentication-config.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { ConfigService as NestConfigService } from '@nestjs/config'; 3 | 4 | import { Config } from '@/infrastructure/config/config-schema'; 5 | 6 | @Injectable() 7 | export class AuthenticationConfigService { 8 | constructor( 9 | private readonly configService: NestConfigService, 10 | ) {} 11 | 12 | get ipStrict() { 13 | return this.configService.get( 14 | 'AUTH_IP_STRICT', 15 | ); 16 | } 17 | 18 | get autoVerify() { 19 | return this.configService.get( 20 | 'AUTH_AUTO_VERIFY', 21 | ); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/infrastructure/jwt/jwt.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { JwtModule as BaseJwtModule } from '@nestjs/jwt'; 3 | 4 | import { ConfigModule } from '@/infrastructure/config/config.module'; 5 | import { JwtConfigService } from '@/infrastructure/config/configs/jwt-config.service'; 6 | 7 | @Module({ 8 | imports: [ 9 | BaseJwtModule.registerAsync({ 10 | global: true, 11 | imports: [ConfigModule], 12 | inject: [JwtConfigService], 13 | useFactory: (configService: JwtConfigService) => ({ 14 | secret: configService.secret, 15 | algorithms: ['HS512'], 16 | }), 17 | }), 18 | ], 19 | exports: [], 20 | }) 21 | export class JwtModule {} 22 | -------------------------------------------------------------------------------- /src/infrastructure/repositories/presets/real-repositories.module.ts: -------------------------------------------------------------------------------- 1 | import { type ClassProvider, Global, Module } from '@nestjs/common'; 2 | 3 | import { MockSessionRepositoryProvider } from '@/infrastructure/repositories/modules/session/mock/mock.session.repository'; 4 | import { MockUserRepositoryProvider } from '@/infrastructure/repositories/modules/user/mock/mock.user.repository'; 5 | import { HashingService } from '@/shared/services/hashing/hashing.service'; 6 | 7 | export const REPOSITORIES: ClassProvider[] = [ 8 | MockUserRepositoryProvider, 9 | MockSessionRepositoryProvider, 10 | ]; 11 | 12 | @Global() 13 | @Module({ 14 | providers: [...REPOSITORIES, HashingService], 15 | exports: REPOSITORIES.map((provider) => provider), 16 | }) 17 | export class RealRepositoriesModule {} 18 | -------------------------------------------------------------------------------- /src/infrastructure/repositories/repositories.module.ts: -------------------------------------------------------------------------------- 1 | import { Global, Module } from '@nestjs/common'; 2 | import { ConditionalModule } from '@nestjs/config'; 3 | 4 | import { MockRepositoriesModule } from '@/infrastructure/repositories/presets/mock-repositories.module'; 5 | import { RealRepositoriesModule } from '@/infrastructure/repositories/presets/real-repositories.module'; 6 | 7 | @Global() 8 | @Module({ 9 | imports: [ 10 | ConditionalModule.registerWhen( 11 | MockRepositoriesModule, 12 | (env) => env.NODE_ENV === 'development', 13 | ), 14 | ConditionalModule.registerWhen( 15 | RealRepositoriesModule, 16 | (env) => env.NODE_ENV === 'production', 17 | ), 18 | ], 19 | }) 20 | export class RepositoriesModule {} 21 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # Base Stage 2 | FROM node:20-alpine as base 3 | WORKDIR /app 4 | COPY . . 5 | ENV HUSKY=0 6 | 7 | # Dev Dependencies Stage 8 | FROM base as dev-deps 9 | RUN npm install -g pnpm 10 | RUN pnpm install --dev 11 | 12 | # Prod Dependencies Stage 13 | FROM base as prod-deps 14 | RUN npm install -g pnpm 15 | RUN pnpm install --prod 16 | 17 | # Build Stage 18 | FROM dev-deps as build 19 | RUN pnpm install 20 | RUN pnpm run build 21 | 22 | # Runner Stage 23 | FROM base as runner 24 | 25 | RUN apk add --no-cache curl 26 | 27 | COPY --from=build /app/dist ./dist 28 | COPY --from=prod-deps /app/node_modules ./node_modules 29 | USER node 30 | 31 | HEALTHCHECK --interval=30s --timeout=30s --start-period=5s --retries=3 CMD curl --fail http://localhost:3000/v1/ping || exit 1 32 | 33 | CMD ["node", "dist/index.js"] -------------------------------------------------------------------------------- /src/application/authentication/decorator/token.decorator.ts: -------------------------------------------------------------------------------- 1 | import { ExecutionContext, createParamDecorator } from '@nestjs/common'; 2 | 3 | import { CurrentUserFactory } from '@/application/authentication/decorator/current-user.decorator'; 4 | import { RequestWithUser } from '@/types/express/request-with-user'; 5 | 6 | export const TokenFactory = (data: unknown, ctx: ExecutionContext) => { 7 | const request = ctx.switchToHttp().getRequest(); 8 | 9 | const authorization = request.headers.authorization; 10 | 11 | if (!authorization) { 12 | return null; 13 | } 14 | 15 | const token = authorization.split(' ')[1]; 16 | 17 | if (!token) { 18 | return null; 19 | } 20 | 21 | return token; 22 | }; 23 | 24 | export const Token = createParamDecorator(CurrentUserFactory); 25 | -------------------------------------------------------------------------------- /src/infrastructure/repositories/presets/mock-repositories.module.ts: -------------------------------------------------------------------------------- 1 | import type { ClassProvider } from '@nestjs/common'; 2 | import { Global, Module } from '@nestjs/common'; 3 | 4 | import { MockSessionRepositoryProvider } from '@/infrastructure/repositories/modules/session/mock/mock.session.repository'; 5 | import { MockUserRepositoryProvider } from '@/infrastructure/repositories/modules/user/mock/mock.user.repository'; 6 | import { HashingService } from '@/shared/services/hashing/hashing.service'; 7 | 8 | export const MOCK_REPOSITORIES: ClassProvider[] = [ 9 | MockUserRepositoryProvider, 10 | MockSessionRepositoryProvider, 11 | ]; 12 | 13 | @Global() 14 | @Module({ 15 | providers: [...MOCK_REPOSITORIES, HashingService], 16 | exports: MOCK_REPOSITORIES.map((provider) => provider), 17 | }) 18 | export class MockRepositoriesModule {} 19 | -------------------------------------------------------------------------------- /src/infrastructure/config/configs/throttler-config.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { ConfigService as NestConfigService } from '@nestjs/config'; 3 | 4 | import { Config } from '@/infrastructure/config/config-schema'; 5 | 6 | @Injectable() 7 | export class ThrottlerConfigService { 8 | constructor( 9 | private readonly configService: NestConfigService, 10 | ) {} 11 | 12 | get ttl() { 13 | return this.configService.get( 14 | 'THROTTLER_DEFAULT_TTL_MS', 15 | ); 16 | } 17 | 18 | get limit() { 19 | return this.configService.get( 20 | 'THROTTLER_DEFAULT_LIMIT', 21 | ); 22 | } 23 | 24 | get useRedis() { 25 | return this.configService.get( 26 | 'THROTTLER_USE_REDIS', 27 | ); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/application/authentication/event-handlers/on-register-add-to-counter.handler.ts: -------------------------------------------------------------------------------- 1 | import { EventsHandler, IEventHandler } from '@nestjs/cqrs'; 2 | import { Counter } from '@opentelemetry/api'; 3 | import { MetricService } from 'nestjs-otel'; 4 | 5 | import { OnRegisterEvent } from '@/domain/authentication/events/on-register.event'; 6 | 7 | @EventsHandler(OnRegisterEvent) 8 | export class OnRegisterAddToCounterHandler 9 | implements IEventHandler 10 | { 11 | private readonly counter: Counter; 12 | constructor(private readonly metricService: MetricService) { 13 | this.counter = this.metricService.getCounter( 14 | 'authentication.user.register', 15 | { 16 | description: 'Count of successful user registrations', 17 | }, 18 | ); 19 | } 20 | 21 | handle(event: OnRegisterEvent) { 22 | this.counter.add(1, { 23 | email: event.user.email, 24 | }); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/infrastructure/config/configs/main-config.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { ConfigService as NestConfigService } from '@nestjs/config'; 3 | 4 | import { Config } from '@/infrastructure/config/config-schema'; 5 | 6 | @Injectable() 7 | export class MainConfigService { 8 | constructor( 9 | private readonly configService: NestConfigService, 10 | ) {} 11 | 12 | get NODE_ENV(): Config['NODE_ENV'] { 13 | return this.configService.get('NODE_ENV'); 14 | } 15 | 16 | get PORT(): Config['PORT'] { 17 | return this.configService.get('PORT'); 18 | } 19 | 20 | get APP_NAME(): Config['APP_NAME'] { 21 | return this.configService.get('APP_NAME'); 22 | } 23 | 24 | get BEHIND_PROXY(): Config['BEHIND_PROXY'] { 25 | return this.configService.get('BEHIND_PROXY'); 26 | } 27 | 28 | get DEBUG(): Config['DEBUG'] { 29 | return this.configService.get('DEBUG'); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/application/authentication/event-handlers/on-login-when-success-add-to-counter.handler.ts: -------------------------------------------------------------------------------- 1 | import { EventsHandler, IEventHandler } from '@nestjs/cqrs'; 2 | import { Counter } from '@opentelemetry/api'; 3 | import { MetricService } from 'nestjs-otel'; 4 | 5 | import { OnLoginEvent } from '@/domain/authentication/events/on-login.event'; 6 | 7 | @EventsHandler(OnLoginEvent) 8 | export class OnLoginWhenSuccessAddToCounterHandler 9 | implements IEventHandler 10 | { 11 | private readonly userLoginCounter: Counter; 12 | constructor(private readonly metricService: MetricService) { 13 | this.userLoginCounter = this.metricService.getCounter( 14 | 'authentication.user.login', 15 | { 16 | description: 'Count of successful user logins', 17 | }, 18 | ); 19 | } 20 | 21 | handle(event: OnLoginEvent) { 22 | this.userLoginCounter.add(1, { 23 | email: event.user.email, 24 | }); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/application/verification/event-handlers/on-verification-sent-add-to-counter.handler.ts: -------------------------------------------------------------------------------- 1 | import { EventsHandler, IEventHandler } from '@nestjs/cqrs'; 2 | import { Counter } from '@opentelemetry/api'; 3 | import { MetricService } from 'nestjs-otel'; 4 | 5 | import { OnVerificationSentEvent } from '@/domain/verification/events/on-verification-sent.event'; 6 | 7 | @EventsHandler(OnVerificationSentEvent) 8 | export class OnVerificationSentAddToCounterHandler 9 | implements IEventHandler 10 | { 11 | private readonly counter: Counter; 12 | constructor(private readonly metricService: MetricService) { 13 | this.counter = this.metricService.getCounter( 14 | 'verification.sent.total', 15 | { 16 | description: 'Count of verification emails sent to users', 17 | }, 18 | ); 19 | } 20 | 21 | handle(event: OnVerificationSentEvent) { 22 | this.counter.add(1, { 23 | email: event.user.email, 24 | }); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/shared/decorator/validation/is-cuid.decorator.ts: -------------------------------------------------------------------------------- 1 | import { isCuid } from '@paralleldrive/cuid2'; 2 | import { 3 | ValidationArguments, 4 | ValidationOptions, 5 | registerDecorator, 6 | } from 'class-validator'; 7 | 8 | export function IsCuid(validationOptions?: ValidationOptions) { 9 | return function _(object: object, propertyName: string) { 10 | registerDecorator({ 11 | name: 'isCuid', 12 | target: object.constructor, 13 | propertyName, 14 | options: validationOptions, 15 | validator: { 16 | // biome-ignore lint/suspicious/noExplicitAny: Required by class-validator 17 | validate(value: any, args: ValidationArguments) { 18 | return isCuid(value); 19 | }, 20 | defaultMessage(args: ValidationArguments) { 21 | return `${propertyName} is not a valid CUID.`; 22 | }, 23 | }, 24 | }); 25 | } as PropertyDecorator; 26 | } 27 | -------------------------------------------------------------------------------- /src/infrastructure/sms/adapters/mock/mock.sms.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable, Logger, Provider } from '@nestjs/common'; 2 | 3 | import { SMS } from '@/infrastructure/sms/sms.constants'; 4 | import { SMSOptions, SMSPort } from '@/infrastructure/sms/sms.port'; 5 | 6 | @Injectable() 7 | class MockSMS implements SMSPort { 8 | private readonly logger = new Logger(MockSMS.name); 9 | 10 | sendSMS(sms: SMSOptions, _: undefined): Promise { 11 | this.logger.log( 12 | `Sending SMS to ${JSON.stringify(sms.to)}, body ${sms.text}`, 13 | ); 14 | 15 | return Promise.resolve(); 16 | } 17 | 18 | sendSMSs(smss: SMSOptions[], _: undefined): Promise { 19 | for (const sms of smss) { 20 | this.logger.log( 21 | `Sending SMS to ${JSON.stringify(sms.to)}, body ${sms.text}`, 22 | ); 23 | } 24 | 25 | return Promise.resolve(); 26 | } 27 | } 28 | 29 | export const MockSMSService: Provider = { 30 | provide: SMS, 31 | useClass: MockSMS, 32 | }; 33 | -------------------------------------------------------------------------------- /src/domain/base/value-object/value-object.base.test.ts: -------------------------------------------------------------------------------- 1 | import { 2 | DomainPrimitive, 3 | ValueObject, 4 | } from '@/domain/base/value-object/value-object.base'; 5 | 6 | class TestValueObject extends ValueObject { 7 | protected validate(props: DomainPrimitive): void { 8 | if (!props.value) { 9 | throw new Error('Value cannot be empty'); 10 | } 11 | } 12 | public getProps(): DomainPrimitive { 13 | return this.props; 14 | } 15 | } 16 | 17 | describe('base value object', () => { 18 | it('should throw error when value is not provided', () => { 19 | const props = {} as DomainPrimitive; // don't do this in your actual code 20 | expect(() => new TestValueObject(props)).toThrow(Error); 21 | }); 22 | 23 | it('should properly set the props', () => { 24 | const props: DomainPrimitive = { value: 'Test string' }; 25 | const myValueObject = new TestValueObject(props); 26 | expect(myValueObject.getProps()).toEqual(props); 27 | }); 28 | }); 29 | -------------------------------------------------------------------------------- /jest.config.cjs: -------------------------------------------------------------------------------- 1 | /** @type {import('jest').Config} */ 2 | const config = { 3 | verbose: true, 4 | testEnvironment: 'node', 5 | transform: { 6 | '^.+\\.tsx?$': [ 7 | '@swc/jest', 8 | { 9 | jsc: { 10 | target: 'es2022', 11 | parser: { 12 | syntax: 'typescript', 13 | decorators: true, 14 | }, 15 | 16 | transform: { 17 | legacyDecorator: true, 18 | decoratorMetadata: true, 19 | }, 20 | }, 21 | }, 22 | ], 23 | }, 24 | transformIgnorePatterns: ['node_modules'], 25 | testRegex: '(test|spec)\\.(jsx?|tsx?)$', 26 | extensionsToTreatAsEsm: ['.ts'], 27 | moduleNameMapper: { 28 | '^@/(.*)$': '/src/$1', 29 | '^@tests/(.*)$': '/tests/$1', 30 | }, 31 | collectCoverageFrom: ['src/**/*.{ts,tsx}', '!src/**/*.d.ts'], 32 | }; 33 | 34 | module.exports = config; 35 | -------------------------------------------------------------------------------- /.env.template: -------------------------------------------------------------------------------- 1 | # Main Settings 2 | APP_NAME=EnterpriseNest 3 | NODE_ENV=development 4 | PORT=3000 5 | BEHIND_PROXY=false 6 | DEBUG=false 7 | 8 | # Redis Settings 9 | REDIS_HOST="localhost" 10 | REDIS_PORT=6379 11 | REDIS_USERNAME="" 12 | REDIS_PASSWORD="" 13 | REDIS_DB=0 14 | 15 | # Cache Settings 16 | CACHE_TTL_MS=60000 17 | CACHE_USE_REDIS=true 18 | 19 | # Throttler Settings 20 | THROTTLER_DEFAULT_TTL_MS=3600000 21 | THROTTLER_DEFAULT_LIMIT=100 22 | THROTTLER_USE_REDIS=true 23 | 24 | # Authentication Settings 25 | AUTH_IP_STRICT=true 26 | AUTH_AUTO_VERIFY=false 27 | 28 | # JWT Settings 29 | JWT_SECRET="secret" 30 | 31 | # Token Settings 32 | TOKEN_ACCESS_SECRET="secret" 33 | TOKEN_REFRESH_SECRET="secret" 34 | TOKEN_VERIFICATION_SECRET="secret" 35 | TOKEN_RESET_PASSWORD_SECRET="secret" 36 | 37 | TOKEN_ACCESS_TOKEN_EXPIRATION=3600 # 1 hour (in seconds) Optional 38 | TOKEN_REFRESH_TOKEN_EXPIRATION=604800 # 7 days (in seconds) Optional 39 | TOKEN_VERIFICATION_TOKEN_EXPIRATION=43600 # 12 hours (in seconds) Optional 40 | TOKEN_RESET_PASSWORD_TOKEN_EXPIRATION=43600 # 12 hours (in seconds) Optional -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "declaration": true, 4 | "declarationMap": true, 5 | "esModuleInterop": true, 6 | "incremental": true, 7 | "isolatedModules": true, 8 | "experimentalDecorators": true, 9 | "emitDecoratorMetadata": true, 10 | "lib": ["ESNext"], 11 | "module": "ESNext", 12 | "moduleDetection": "force", 13 | "moduleResolution": "Bundler", 14 | "noUncheckedIndexedAccess": true, 15 | "allowJs": true, 16 | "resolveJsonModule": true, 17 | "skipLibCheck": true, 18 | "strict": true, 19 | "target": "ES2022", 20 | "outDir": "dist", 21 | "tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json", 22 | "paths": { 23 | "@/*": ["./src/*"], 24 | "@tests/*": ["./tests/*"] 25 | }, 26 | "typeRoots": ["./src/types/**/*", "node_modules/@types"] 27 | }, 28 | "include": ["src/**/*.ts", "tests/**/*", "generators/**/*"], 29 | "exclude": ["node_modules", "dist", "coverage"] 30 | } 31 | -------------------------------------------------------------------------------- /src/shared/services/hashing/hashing.service.ts: -------------------------------------------------------------------------------- 1 | import crypto from 'node:crypto'; 2 | 3 | import { Injectable } from '@nestjs/common'; 4 | import { argon2Verify, argon2id } from 'hash-wasm'; 5 | 6 | @Injectable() 7 | export class HashingService { 8 | generateSalt(): string { 9 | // Generate a random 32-byte Uint8Array and convert it to a 64-character hexadecimal string. 10 | return crypto 11 | .getRandomValues(new Uint8Array(128)) 12 | .reduce((memo, i) => memo + i.toString(16).padStart(2, '0'), ''); 13 | } 14 | 15 | async hash(password: string): Promise { 16 | return argon2id({ 17 | password, 18 | salt: this.generateSalt(), 19 | iterations: 64, 20 | memorySize: 1024, 21 | parallelism: 4, 22 | hashLength: 256, 23 | outputType: 'encoded', 24 | }); 25 | } 26 | 27 | async compare(plain: string, hashed: string): Promise { 28 | return argon2Verify({ 29 | password: plain, 30 | hash: hashed, 31 | }); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/application/ping/event-handlers/ping-ran.handler.test.ts: -------------------------------------------------------------------------------- 1 | // ping-ran.handler.spec.ts 2 | import { Logger } from '@nestjs/common'; 3 | import { Test, TestingModule } from '@nestjs/testing'; 4 | 5 | import { PingRanHandler } from '@/application/ping/event-handlers/ping-ran.handler'; 6 | import { PingRanEvent } from '@/application/ping/events/ping-ran.event'; 7 | 8 | describe('pingRanHandler', () => { 9 | let handler: PingRanHandler; 10 | 11 | beforeEach(async () => { 12 | const module: TestingModule = await Test.createTestingModule({ 13 | providers: [PingRanHandler], 14 | }).compile(); 15 | 16 | handler = module.get(PingRanHandler); 17 | }); 18 | 19 | it('should be defined', () => { 20 | expect(handler).toBeDefined(); 21 | }); 22 | 23 | it('should handle PingRanEvent', () => { 24 | const logSpy = jest.spyOn(Logger.prototype, 'log'); 25 | 26 | handler.handle(new PingRanEvent()); 27 | 28 | expect(logSpy).toHaveBeenCalledWith( 29 | 'PingRanEvent handled successfully', 30 | ); 31 | }); 32 | }); 33 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Thomas B 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/application/verification/v1/v1.verification.module.ts: -------------------------------------------------------------------------------- 1 | import { Module, Type } from '@nestjs/common'; 2 | 3 | import { V1ConfirmVerificationCommandHandler } from '@/application/verification/v1/commands/confirm-verification/confirm-verification.handler'; 4 | import { V1ConfirmVerificationController } from '@/application/verification/v1/commands/confirm-verification/confirm-verification.http.controller'; 5 | import { V1SendVerificationCommandHandler } from '@/application/verification/v1/commands/send-verification/send-verification.handler'; 6 | import { V1SendVerificationController } from '@/application/verification/v1/commands/send-verification/send-verification.http.controller'; 7 | 8 | const CommandHandlers: Type[] = [ 9 | V1SendVerificationCommandHandler, 10 | V1ConfirmVerificationCommandHandler, 11 | ]; 12 | const QueryHandlers: Type[] = []; 13 | 14 | const CommandControllers: Type[] = [ 15 | V1SendVerificationController, 16 | V1ConfirmVerificationController, 17 | ]; 18 | const QueryControllers: Type[] = []; 19 | 20 | @Module({ 21 | imports: [], 22 | controllers: [...CommandControllers, ...QueryControllers], 23 | providers: [...CommandHandlers, ...QueryHandlers], 24 | }) 25 | export class V1VerificationModule {} 26 | -------------------------------------------------------------------------------- /src/infrastructure/config/configs/redis-config.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { ConfigService as NestConfigService } from '@nestjs/config'; 3 | 4 | import { Config } from '@/infrastructure/config/config-schema'; 5 | 6 | @Injectable() 7 | export class RedisConfigService { 8 | constructor( 9 | private readonly configService: NestConfigService, 10 | ) {} 11 | 12 | get url() { 13 | return `redis://${this.username}:${this.password}@${this.host}:${this.port.toString()}/${this.db.toString()}`; 14 | } 15 | 16 | get host() { 17 | return this.configService.get('REDIS_HOST'); 18 | } 19 | 20 | get port() { 21 | return this.configService.get('REDIS_PORT'); 22 | } 23 | 24 | get password() { 25 | return this.configService.get( 26 | 'REDIS_PASSWORD', 27 | ); 28 | } 29 | 30 | get db() { 31 | return this.configService.get('REDIS_DB'); 32 | } 33 | 34 | get username() { 35 | return this.configService.get( 36 | 'REDIS_USERNAME', 37 | ); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/infrastructure/repositories/modules/user/mock/mock.user.repository.test.ts: -------------------------------------------------------------------------------- 1 | import { CreateMockUser } from '@tests/utils/create-mocks'; 2 | 3 | import { User } from '@/domain/user/user.entity'; 4 | import { HashingService } from '@/shared/services/hashing/hashing.service'; 5 | 6 | import { MockUserRepository } from './mock.user.repository'; 7 | 8 | describe('mockUserRepository', () => { 9 | let mockUserRepository: MockUserRepository; 10 | let hashingService: HashingService; 11 | let testUser: User; 12 | 13 | beforeEach(async () => { 14 | hashingService = new HashingService(); 15 | mockUserRepository = new MockUserRepository(hashingService); 16 | 17 | testUser = CreateMockUser(); 18 | 19 | await mockUserRepository.create(testUser); 20 | }); 21 | 22 | test('should find a User by email', async () => { 23 | expect(await mockUserRepository.findOneByEmail(testUser.email)).toEqual( 24 | testUser, 25 | ); 26 | }); 27 | 28 | test('should return undefined if find user by email doesnt exist', async () => { 29 | expect( 30 | await mockUserRepository.findOneByEmail('idont@exist.com'), 31 | ).toEqual(undefined); 32 | }); 33 | }); 34 | -------------------------------------------------------------------------------- /src/application/authentication/v1/commands/confirm-forgot-password/dto/confirm-forgot-password.request.dto.ts: -------------------------------------------------------------------------------- 1 | import { ApiProperty, IntersectionType } from '@nestjs/swagger'; 2 | import { IsJWT, IsNotEmpty } from 'class-validator'; 3 | 4 | import { UserPasswordDto } from '@/domain/user/user.dto'; 5 | import { IsEqualToProperty } from '@/shared/decorator/validation/is-equal-to-property.decorator'; 6 | 7 | export class V1ConfirmForgotPasswordRequestDto extends IntersectionType( 8 | UserPasswordDto, 9 | ) { 10 | @ApiProperty({ 11 | example: 'Password123!', 12 | description: 13 | 'Password must contain at least one uppercase letter, one lowercase letter, one number and one special character and be at least 8 characters long.', 14 | }) 15 | @IsEqualToProperty('password', { 16 | message: 'Password confirmation does not match password', 17 | }) 18 | passwordConfirmation!: string; 19 | 20 | @ApiProperty({ 21 | description: 'Reset Password Token', 22 | example: 23 | 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ0eXBlIjoicmVzZXQtcGFzc3dvcmQifQ.1lJ4FzQ2Z6VY7B6r1Y1J9zL4b9i0f5ZGzI4t5J8z0zg', 24 | }) 25 | @IsJWT() 26 | @IsNotEmpty() 27 | resetPasswordToken!: string; 28 | } 29 | -------------------------------------------------------------------------------- /src/shared/interceptors/trace-user.interceptor.ts: -------------------------------------------------------------------------------- 1 | import { 2 | CallHandler, 3 | ExecutionContext, 4 | Injectable, 5 | NestInterceptor, 6 | } from '@nestjs/common'; 7 | import { TraceService } from 'nestjs-otel'; 8 | import { Observable } from 'rxjs'; 9 | 10 | import { User } from '@/domain/user/user.entity'; 11 | import { RequestWithUser } from '@/types/express/request-with-user'; 12 | 13 | @Injectable() 14 | export class TraceUserInterceptor implements NestInterceptor { 15 | constructor(private readonly traceService: TraceService) {} 16 | 17 | intercept( 18 | context: ExecutionContext, 19 | next: CallHandler, 20 | ): Observable { 21 | let user: User | undefined = undefined; 22 | 23 | if (context.getType() === 'http') { 24 | const httpContext = context.switchToHttp(); 25 | const request = httpContext.getRequest(); 26 | user = request.user; 27 | } 28 | 29 | if (user) { 30 | const span = this.traceService.getSpan(); 31 | 32 | span?.setAttribute('userId', user.id); 33 | span?.setAttribute('userEmail', user.email); 34 | } 35 | 36 | return next.handle(); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/infrastructure/mailer/adapters/mock/mock.mailer.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable, Logger, Provider } from '@nestjs/common'; 2 | 3 | import { MAILER } from '@/infrastructure/mailer/mailer.constants'; 4 | import { MailerOptions, MailerPort } from '@/infrastructure/mailer/mailer.port'; 5 | 6 | @Injectable() 7 | class MockMailerService implements MailerPort { 8 | private readonly logger = new Logger(MockMailerService.name); 9 | 10 | sendEmail(email: MailerOptions): Promise { 11 | const content = email.text ?? email.html ?? ''; 12 | 13 | this.logger.log( 14 | `Sending email to ${JSON.stringify(email.to)} with subject ${email.subject}, body ${content}`, 15 | ); 16 | 17 | return Promise.resolve(); 18 | } 19 | 20 | sendEmails(emails: MailerOptions[]): Promise { 21 | for (const email of emails) { 22 | const content = email.text ?? email.html ?? ''; 23 | 24 | this.logger.log( 25 | `Sending email to ${JSON.stringify(email.to)} with subject ${email.subject}, body ${content}`, 26 | ); 27 | } 28 | 29 | return Promise.resolve(); 30 | } 31 | } 32 | 33 | export const MockMailerServiceProvider: Provider = { 34 | provide: MAILER, 35 | useClass: MockMailerService, 36 | }; 37 | -------------------------------------------------------------------------------- /src/application/authentication/event-handlers/on-register-send-verification.handler.ts: -------------------------------------------------------------------------------- 1 | import { Logger } from '@nestjs/common'; 2 | import { CommandBus, EventsHandler, IEventHandler } from '@nestjs/cqrs'; 3 | 4 | import { V1SendVerificationCommandHandler } from '@/application/verification/v1/commands/send-verification/send-verification.handler'; 5 | import { OnRegisterEvent } from '@/domain/authentication/events/on-register.event'; 6 | 7 | @EventsHandler(OnRegisterEvent) 8 | export class OnRegisterSendVerificationHandler 9 | implements IEventHandler 10 | { 11 | private readonly logger = new Logger( 12 | OnRegisterSendVerificationHandler.name, 13 | ); 14 | 15 | constructor(private readonly commandBus: CommandBus) {} 16 | 17 | async handle(event: OnRegisterEvent) { 18 | if (event.user.verifiedAt) { 19 | this.logger.log( 20 | `User ${event.user.id} has already been verified, skipping verification email`, 21 | ); 22 | return; 23 | } 24 | 25 | this.logger.log( 26 | `Sending verification email to ${event.user.id} as part of registration process`, 27 | ); 28 | 29 | await V1SendVerificationCommandHandler.runHandler(this.commandBus, { 30 | user: event.user, 31 | }); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/application/user/v1/queries/find-current-user/find-current-user.http.controller.ts: -------------------------------------------------------------------------------- 1 | import { Controller, Get } from '@nestjs/common'; 2 | import { QueryBus } from '@nestjs/cqrs'; 3 | import { ApiTags } from '@nestjs/swagger'; 4 | 5 | import { CurrentUser } from '@/application/authentication/decorator/current-user.decorator'; 6 | import { User } from '@/domain/user/user.entity'; 7 | import { ApiOperationWithRoles } from '@/shared/decorator/api-operation-with-roles.decorator'; 8 | import { ApiStandardisedResponse } from '@/shared/decorator/api-standardised-response.decorator'; 9 | 10 | import { V1FindCurrentUserResponseDto } from './dto/find-current-user.response.dto'; 11 | 12 | @ApiTags('User') 13 | @Controller({ 14 | version: '1', 15 | }) 16 | export class V1FindCurrentUserController { 17 | constructor(private readonly queryBus: QueryBus) {} 18 | 19 | @ApiOperationWithRoles({ 20 | summary: 'Find Current', 21 | }) 22 | @ApiStandardisedResponse( 23 | { 24 | status: 200, 25 | description: 'The User has been successfully found.', 26 | }, 27 | V1FindCurrentUserResponseDto, 28 | ) 29 | @Get('/user/me') 30 | findCurrentUser( 31 | @CurrentUser() user: User, 32 | ): Promise { 33 | return Promise.resolve({ user }); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/shared/interceptors/role-class-serializer.interceptor.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | CallHandler, 3 | ExecutionContext, 4 | PlainLiteralObject, 5 | } from '@nestjs/common'; 6 | import { ClassSerializerInterceptor, Injectable } from '@nestjs/common'; 7 | import type { Observable } from 'rxjs'; 8 | import { map } from 'rxjs'; 9 | 10 | import { UserRoleEnum } from '@/domain/user/user-role.enum'; 11 | import { RequestWithUser } from '@/types/express/request-with-user'; 12 | 13 | @Injectable() 14 | export class RolesClassSerializerInterceptor extends ClassSerializerInterceptor { 15 | intercept( 16 | context: ExecutionContext, 17 | next: CallHandler, 18 | ): Observable { 19 | const user = context.switchToHttp().getRequest() 20 | .user as RequestWithUser['user'] | undefined; 21 | 22 | const userRole = user?.role ?? UserRoleEnum.USER; 23 | 24 | const contextOptions = this.getContextOptions(context); 25 | const options = { 26 | ...this.defaultOptions, 27 | ...contextOptions, 28 | groups: [userRole], 29 | }; 30 | 31 | return next 32 | .handle() 33 | .pipe( 34 | map((res: PlainLiteralObject | PlainLiteralObject[]) => 35 | this.serialize(res, options), 36 | ), 37 | ); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/application/health/v1/queries/check-health/check-health.controller.ts: -------------------------------------------------------------------------------- 1 | import { Controller, Get, HttpCode, Logger } from '@nestjs/common'; 2 | import { ApiOperation, ApiTags } from '@nestjs/swagger'; 3 | import { 4 | DiskHealthIndicator, 5 | HealthCheck, 6 | HealthCheckService, 7 | HttpHealthIndicator, 8 | } from '@nestjs/terminus'; 9 | 10 | import { Public } from '../../../../authentication/decorator/public.decorator'; 11 | 12 | @ApiTags('Health') 13 | @Controller({ 14 | version: '1', 15 | }) 16 | export class V1CheckHealthController { 17 | private readonly logger = new Logger(V1CheckHealthController.name); 18 | 19 | constructor( 20 | private readonly health: HealthCheckService, 21 | private readonly http: HttpHealthIndicator, 22 | private readonly disk: DiskHealthIndicator, 23 | ) {} 24 | 25 | @ApiOperation({ 26 | summary: 'Health Check', 27 | }) 28 | @Get('/health') 29 | @HealthCheck() 30 | @HttpCode(200) 31 | @Public() 32 | healthCheck() { 33 | this.logger.log('Checking health'); 34 | 35 | return this.health.check([ 36 | () => this.http.pingCheck('internet', 'https://google.com'), 37 | () => 38 | this.disk.checkStorage('storage', { 39 | path: '/', 40 | thresholdPercent: 0.5, 41 | }), 42 | ]); 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/infrastructure/throttler/throttler.module.ts: -------------------------------------------------------------------------------- 1 | import { Global, Module } from '@nestjs/common'; 2 | import { ThrottlerModule as BaseThrottlerModule } from '@nestjs/throttler'; 3 | import { ThrottlerStorageRedisService } from 'nestjs-throttler-storage-redis'; 4 | 5 | import { ConfigModule } from '@/infrastructure/config/config.module'; 6 | import { RedisConfigService } from '@/infrastructure/config/configs/redis-config.service'; 7 | import { ThrottlerConfigService } from '@/infrastructure/config/configs/throttler-config.service'; 8 | 9 | @Global() 10 | @Module({ 11 | imports: [ 12 | BaseThrottlerModule.forRootAsync({ 13 | imports: [ConfigModule], 14 | inject: [ThrottlerConfigService, RedisConfigService], 15 | useFactory: ( 16 | throttlerConfig: ThrottlerConfigService, 17 | redisConfig: RedisConfigService, 18 | ) => ({ 19 | throttlers: [ 20 | { 21 | ttl: throttlerConfig.ttl, 22 | limit: throttlerConfig.limit, 23 | }, 24 | ], 25 | 26 | storage: throttlerConfig.useRedis 27 | ? new ThrottlerStorageRedisService(redisConfig.url) 28 | : undefined, 29 | }), 30 | }), 31 | ], 32 | exports: [BaseThrottlerModule], 33 | }) 34 | export class ThrottlerModule {} 35 | -------------------------------------------------------------------------------- /src/shared/decorator/validation/is-equal-to-property.decorator.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ValidationArguments, 3 | ValidationOptions, 4 | registerDecorator, 5 | } from 'class-validator'; 6 | 7 | /* eslint-disable -- This is just annoying to type */ 8 | // TODO: Fix this 9 | 10 | export function IsEqualToProperty( 11 | property: string, 12 | validationOptions?: ValidationOptions, 13 | ) { 14 | return function _(object: object, propertyName: string) { 15 | registerDecorator({ 16 | name: 'isEqualToProperty', 17 | target: object.constructor, 18 | propertyName, 19 | constraints: [property], 20 | options: validationOptions, 21 | validator: { 22 | // biome-ignore lint/suspicious/noExplicitAny: Required by class-validator 23 | validate(value: any, args: ValidationArguments) { 24 | const [relatedPropertyName] = args.constraints; 25 | 26 | const relatedValue = ( 27 | args.object as Record 28 | )[relatedPropertyName]; 29 | return value === relatedValue; 30 | }, 31 | defaultMessage(args: ValidationArguments) { 32 | return `${propertyName} does not match ${args.constraints[0]}`; 33 | }, 34 | }, 35 | }); 36 | }; 37 | } 38 | -------------------------------------------------------------------------------- /biome.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://biomejs.dev/schemas/1.8.3/schema.json", 3 | 4 | "organizeImports": { 5 | "enabled": true 6 | }, 7 | "linter": { 8 | "enabled": true, 9 | "rules": { 10 | "recommended": true, 11 | "style": { 12 | "useImportType": { 13 | "fix": "none", 14 | "level": "info" 15 | } 16 | } 17 | } 18 | }, 19 | "formatter": { 20 | "enabled": true, 21 | "formatWithErrors": false, 22 | "ignore": [], 23 | "indentWidth": 4, 24 | "indentStyle": "space", 25 | "lineWidth": 80, 26 | "attributePosition": "multiline", 27 | "lineEnding": "lf" 28 | }, 29 | "javascript": { 30 | "parser": { 31 | "unsafeParameterDecoratorsEnabled": true 32 | }, 33 | "formatter": { 34 | "bracketSpacing": true, 35 | "jsxQuoteStyle": "double", 36 | "quoteStyle": "single", 37 | "semicolons": "always", 38 | "trailingCommas": "all" 39 | } 40 | }, 41 | "json": { 42 | "formatter": { 43 | "trailingCommas": "none" 44 | } 45 | }, 46 | "vcs": { 47 | "enabled": true, 48 | "clientKind": "git", 49 | "useIgnoreFile": true, 50 | "defaultBranch": "main" 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/shared/services/hashing/hashing.test.ts: -------------------------------------------------------------------------------- 1 | import type { TestingModule } from '@nestjs/testing'; 2 | import { Test } from '@nestjs/testing'; 3 | 4 | import { HashingService } from './hashing.service'; 5 | 6 | describe('hashingService', () => { 7 | let service: HashingService; 8 | 9 | beforeEach(async () => { 10 | const module: TestingModule = await Test.createTestingModule({ 11 | providers: [HashingService], 12 | }).compile(); 13 | 14 | service = module.get(HashingService); 15 | }); 16 | 17 | it('should generate a salt of correct length', () => { 18 | const salt = service.generateSalt(); 19 | expect(salt).toHaveLength(256); // A 32-byte Uint8Array will generate a 64-character hexadecimal string. 20 | }); 21 | 22 | it('should hash password correctly', async () => { 23 | const password = 'testpassword'; 24 | const hash = await service.hash(password); 25 | expect(hash).not.toEqual(password); 26 | }); 27 | 28 | it('should compare password correctly', async () => { 29 | const password = 'testpassword'; 30 | const hash = await service.hash(password); 31 | const isCorrect = await service.compare(password, hash); 32 | expect(isCorrect).toBe(true); 33 | 34 | const isIncorrect = await service.compare('wrongpassword', hash); 35 | expect(isIncorrect).toBe(false); 36 | }); 37 | }); 38 | -------------------------------------------------------------------------------- /src/application/session/v1/queries/find-current-session/find-current-session.http.controller.ts: -------------------------------------------------------------------------------- 1 | import { Controller, Get, Logger, Request } from '@nestjs/common'; 2 | import { QueryBus } from '@nestjs/cqrs'; 3 | import { ApiTags } from '@nestjs/swagger'; 4 | 5 | import { ApiOperationWithRoles } from '@/shared/decorator/api-operation-with-roles.decorator'; 6 | import { ApiStandardisedResponse } from '@/shared/decorator/api-standardised-response.decorator'; 7 | import type { RequestWithUser } from '@/types/express/request-with-user'; 8 | 9 | import { V1FindCurrentSessionResponseDto } from './dto/find-current-session.response.dto'; 10 | 11 | @ApiTags('Session') 12 | @Controller({ 13 | version: '1', 14 | }) 15 | export class V1FindCurrentSessionController { 16 | private readonly logger = new Logger(V1FindCurrentSessionController.name); 17 | 18 | constructor(private readonly queryBus: QueryBus) {} 19 | 20 | @ApiOperationWithRoles({ 21 | summary: 'Find Current Session', 22 | }) 23 | @ApiStandardisedResponse( 24 | { 25 | status: 200, 26 | description: 'The Session has been successfully found.', 27 | }, 28 | V1FindCurrentSessionResponseDto, 29 | ) 30 | @Get('/session/me') 31 | findCurrentSession( 32 | @Request() req: RequestWithUser, 33 | ): Promise { 34 | return Promise.resolve({ session: req.session }); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/domain/base/entity/entity.base.dto.ts: -------------------------------------------------------------------------------- 1 | import { applyDecorators } from '@nestjs/common'; 2 | import { ApiProperty } from '@nestjs/swagger'; 3 | import { IsDate } from 'class-validator'; 4 | 5 | import { IsCuid } from '@/shared/decorator/validation/is-cuid.decorator'; 6 | 7 | export const ID = (name?: string): PropertyDecorator => 8 | applyDecorators( 9 | ApiProperty({ 10 | description: name ? `${name} ID` : 'Entity ID', 11 | example: 'x7m4vrctwbuduy18g5jj6lk3', 12 | type: String, 13 | required: true, 14 | }), 15 | IsCuid(), 16 | ); 17 | 18 | export class EntityIDDto { 19 | @ID() 20 | id!: string; 21 | } 22 | 23 | export const CreatedAt = (): PropertyDecorator => 24 | applyDecorators( 25 | ApiProperty({ 26 | description: 'Entity creation date', 27 | example: '2021-01-01T00:00:00.000Z', 28 | type: Date, 29 | required: true, 30 | }), 31 | IsDate(), 32 | ); 33 | 34 | export class EntityCreatedAtDto { 35 | @CreatedAt() 36 | createdAt!: Date; 37 | } 38 | 39 | export const UpdatedAt = (): PropertyDecorator => 40 | applyDecorators( 41 | ApiProperty({ 42 | description: 'Entity update date', 43 | example: '2021-01-01T00:00:00.000Z', 44 | type: Date, 45 | required: true, 46 | }), 47 | IsDate(), 48 | ); 49 | 50 | export class EntityUpdatedAtDto { 51 | @UpdatedAt() 52 | updatedAt!: Date; 53 | } 54 | -------------------------------------------------------------------------------- /src/application/user/v1/queries/find-all-users/find-all-users.handler.ts: -------------------------------------------------------------------------------- 1 | import { Inject, Logger } from '@nestjs/common'; 2 | import type { IQueryHandler, QueryBus } from '@nestjs/cqrs'; 3 | import { QueryHandler } from '@nestjs/cqrs'; 4 | 5 | import { User } from '@/domain/user/user.entity'; 6 | import { USER_REPOSITORY } from '@/infrastructure/repositories/modules/user/user.repository.constants'; 7 | import type { UserRepositoryPort } from '@/infrastructure/repositories/modules/user/user.repository.port'; 8 | 9 | import { V1FindAllUsersQuery } from './find-all-users.query'; 10 | 11 | type V1FindAllUsersQueryHandlerResponse = User[]; 12 | 13 | @QueryHandler(V1FindAllUsersQuery) 14 | export class V1FindAllUsersQueryHandler 15 | implements 16 | IQueryHandler 17 | { 18 | private readonly logger = new Logger(V1FindAllUsersQueryHandler.name); 19 | 20 | constructor( 21 | @Inject(USER_REPOSITORY) 22 | private readonly userRepository: UserRepositoryPort, 23 | ) {} 24 | 25 | static runHandler( 26 | bus: QueryBus, 27 | ): Promise { 28 | return bus.execute< 29 | V1FindAllUsersQuery, 30 | V1FindAllUsersQueryHandlerResponse 31 | >(new V1FindAllUsersQuery()); 32 | } 33 | 34 | execute( 35 | query: V1FindAllUsersQuery, 36 | ): Promise { 37 | this.logger.log('Finding all users'); 38 | 39 | return this.userRepository.findAll(); 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /.github/workflows/codecov.yml: -------------------------------------------------------------------------------- 1 | # .github/workflows/ci.yml 2 | name: Codecov 3 | 4 | on: 5 | push: 6 | branches: [ main ] 7 | 8 | jobs: 9 | build: 10 | runs-on: ubuntu-latest 11 | 12 | steps: 13 | - name: Checkout code 14 | uses: actions/checkout@v2 15 | 16 | - uses: pnpm/action-setup@v4 17 | name: Install pnpm 18 | with: 19 | version: 9 20 | run_install: false 21 | 22 | - name: Install Node.js 23 | uses: actions/setup-node@v4 24 | with: 25 | node-version: 20 26 | cache: 'pnpm' 27 | 28 | - name: Get pnpm store directory 29 | shell: bash 30 | run: | 31 | echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV 32 | 33 | - uses: actions/cache@v4 34 | name: Setup pnpm cache 35 | with: 36 | path: ${{ env.STORE_PATH }} 37 | key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }} 38 | restore-keys: | 39 | ${{ runner.os }}-pnpm-store- 40 | 41 | - name: Install dependencies 42 | run: pnpm install 43 | 44 | 45 | - name: Run tests 46 | run: pnpm test:coverage 47 | env: 48 | JWT_SECRET: "TESTING" 49 | TOKEN_ACCESS_SECRET: "TESTING" 50 | TOKEN_REFRESH_SECRET: "TESTING" 51 | TOKEN_VERIFICATION_SECRET: "TESTING" 52 | TOKEN_RESET_PASSWORD_SECRET: "TESTING" 53 | 54 | - name: Upload coverage reports to Codecov 55 | uses: codecov/codecov-action@v4.0.1 56 | with: 57 | token: ${{ secrets.CODECOV_TOKEN }} 58 | -------------------------------------------------------------------------------- /scripts/template-remote.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Check if the project is a git repository 4 | if [ ! -d .git ]; then 5 | echo "This script should be run at the root of a git repository" 6 | exit 1 7 | fi 8 | 9 | # Check if the template remote already exists 10 | if git remote get-url template > /dev/null 2>&1; then 11 | echo "Template remote already exists" 12 | git remote remove template 13 | fi 14 | 15 | # Delete the template branch if it already exists 16 | if git branch --list template > /dev/null 2>&1; then 17 | git branch -D template 18 | git push origin --delete template 19 | fi 20 | 21 | # Save current branch 22 | current_branch=$(git branch --show-current) 23 | 24 | # Setup Template Remote for the project 25 | git remote add template git@github.com:TheGoatedDev/EnterpriseNest.git 26 | 27 | # Fetch the template remote 28 | git fetch template 29 | 30 | # Create a new branch from the template remote 31 | git checkout -b template $current_branch 32 | 33 | # Push the new branch to the origin remote 34 | git push origin template 35 | 36 | # Merge the template branch into the saved branch 37 | git merge template/main 38 | 39 | # Push the saved branch to the origin remote 40 | git push origin template 41 | 42 | # Checkout the saved branch 43 | git checkout $current_branch 44 | 45 | # Delete the template remote 46 | git remote remove template 47 | 48 | # Setup PR for the saved branch 49 | gh pr create --base $current_branch --head template --title "Merge template changes" --body "This PR merges the latest changes from the template repository" -d --auto 50 | 51 | # Print success message 52 | echo "Template remote setup successfully" -------------------------------------------------------------------------------- /src/application/user/v1/queries/find-user-by-id/find-user-by-id.handler.ts: -------------------------------------------------------------------------------- 1 | import { Inject, Logger } from '@nestjs/common'; 2 | import type { IQueryHandler, QueryBus } from '@nestjs/cqrs'; 3 | import { QueryHandler } from '@nestjs/cqrs'; 4 | 5 | import { User } from '@/domain/user/user.entity'; 6 | import { USER_REPOSITORY } from '@/infrastructure/repositories/modules/user/user.repository.constants'; 7 | import type { UserRepositoryPort } from '@/infrastructure/repositories/modules/user/user.repository.port'; 8 | 9 | import { V1FindUserByIDQuery } from './find-user-by-id.query'; 10 | 11 | type V1FindUserByIDQueryHandlerResponse = User | undefined; 12 | 13 | @QueryHandler(V1FindUserByIDQuery) 14 | export class V1FindUserByIDQueryHandler 15 | implements 16 | IQueryHandler 17 | { 18 | private readonly logger = new Logger(V1FindUserByIDQueryHandler.name); 19 | 20 | constructor( 21 | @Inject(USER_REPOSITORY) 22 | private readonly userRepository: UserRepositoryPort, 23 | ) {} 24 | 25 | static runHandler( 26 | bus: QueryBus, 27 | query: V1FindUserByIDQuery, 28 | ): Promise { 29 | return bus.execute< 30 | V1FindUserByIDQuery, 31 | V1FindUserByIDQueryHandlerResponse 32 | >(new V1FindUserByIDQuery(query.id)); 33 | } 34 | 35 | execute( 36 | query: V1FindUserByIDQuery, 37 | ): Promise { 38 | this.logger.log(`Finding user by id ${query.id}`); 39 | return this.userRepository.findOneById(query.id); 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/application/ping/v1/queries/ping/ping.controller.ts: -------------------------------------------------------------------------------- 1 | import { Controller, Get, HttpCode, Logger } from '@nestjs/common'; 2 | import { EventBus } from '@nestjs/cqrs'; 3 | import { ApiOperation, ApiTags } from '@nestjs/swagger'; 4 | 5 | import { Public } from '@/application/authentication/decorator/public.decorator'; 6 | import { PingRanEvent } from '@/application/ping/events/ping-ran.event'; 7 | import { PingResponseDto } from '@/application/ping/v1/queries/ping/dto/ping.response.dto'; 8 | import { MainConfigService } from '@/infrastructure/config/configs/main-config.service'; 9 | import { ApiStandardisedResponse } from '@/shared/decorator/api-standardised-response.decorator'; 10 | 11 | @ApiTags('Ping') 12 | @Controller({ 13 | version: '1', 14 | }) 15 | export class V1PingController { 16 | private readonly logger = new Logger(V1PingController.name); 17 | 18 | constructor( 19 | private readonly mainConfig: MainConfigService, 20 | private readonly eventBus: EventBus, 21 | ) {} 22 | 23 | @ApiOperation({ 24 | summary: 'Ping', 25 | }) 26 | @ApiStandardisedResponse( 27 | { 28 | status: 200, 29 | description: 'The Ping is successful', 30 | }, 31 | PingResponseDto, 32 | ) 33 | @Get('/ping') 34 | @HttpCode(200) 35 | @Public() 36 | healthCheck(): PingResponseDto { 37 | this.logger.log('Health Check is successful'); 38 | 39 | this.eventBus.publish(new PingRanEvent()); 40 | 41 | return { 42 | message: 'OK', 43 | serverTime: new Date(), 44 | appName: this.mainConfig.APP_NAME, 45 | }; 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/shared/interceptors/standard-response.interceptor.ts: -------------------------------------------------------------------------------- 1 | // NestJS imports 2 | import { 3 | CallHandler, 4 | ExecutionContext, 5 | Injectable, 6 | NestInterceptor, 7 | } from '@nestjs/common'; 8 | import { Reflector } from '@nestjs/core'; 9 | import { Response } from 'express'; 10 | import { Observable } from 'rxjs'; 11 | import { map } from 'rxjs/operators'; 12 | 13 | import { NonStandardResponseKey } from '@/shared/decorator/non-standard-response.decorator'; 14 | import { StandardHttpResponseDto } from '@/shared/dto/standard-http-response.dto'; 15 | 16 | @Injectable() 17 | export class StandardResponseInterceptor implements NestInterceptor { 18 | constructor(private readonly reflector: Reflector) {} 19 | 20 | intercept( 21 | context: ExecutionContext, 22 | next: CallHandler, 23 | ): Observable { 24 | const shouldSkip = this.reflector.getAllAndOverride( 25 | NonStandardResponseKey, 26 | [context.getHandler(), context.getClass()], 27 | ); 28 | 29 | // Check if the response should be skipped 30 | if (shouldSkip) { 31 | return next.handle(); 32 | } 33 | 34 | const response = context.switchToHttp().getResponse(); 35 | 36 | const statusCode = response.statusCode; 37 | 38 | return next.handle().pipe( 39 | map((data: T) => { 40 | const responseDto = new StandardHttpResponseDto(); 41 | responseDto.statusCode = statusCode; 42 | responseDto.data = data; 43 | return responseDto; 44 | }), 45 | ); 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/domain/session/session.dto.ts: -------------------------------------------------------------------------------- 1 | import { applyDecorators } from '@nestjs/common'; 2 | import { ApiProperty } from '@nestjs/swagger'; 3 | import { IsIP, IsOptional } from 'class-validator'; 4 | 5 | import { ID } from '@/domain/base/entity/entity.base.dto'; 6 | import { IsCuid } from '@/shared/decorator/validation/is-cuid.decorator'; 7 | 8 | export const SessionUserId = () => ID('User'); 9 | 10 | export class SessionUserIdDto { 11 | @SessionUserId() 12 | userId!: string; 13 | } 14 | 15 | export const SessionToken = () => 16 | applyDecorators( 17 | ApiProperty({ 18 | description: 'The token of the session', 19 | example: 'jokemgtxc2pb7flis7v6ln8l', 20 | type: String, 21 | required: true, 22 | }), 23 | IsCuid(), 24 | ); 25 | 26 | export class SessionTokenDto { 27 | @SessionToken() 28 | token!: string; 29 | } 30 | 31 | export const SessionIsRevoked = () => 32 | applyDecorators( 33 | ApiProperty({ 34 | description: 'If the session is revoked', 35 | example: false, 36 | type: Boolean, 37 | required: true, 38 | }), 39 | ); 40 | 41 | export class SessionIsRevokedDto { 42 | @SessionIsRevoked() 43 | isRevoked!: boolean; 44 | } 45 | 46 | export const SessionIp = () => 47 | applyDecorators( 48 | ApiProperty({ 49 | description: 'The IP of the session (if available)', 50 | example: '1.1.1.1', 51 | type: String, 52 | }), 53 | IsIP(), 54 | IsOptional(), 55 | ); 56 | 57 | export class SessionIpDto { 58 | @SessionIp() 59 | ip?: string; 60 | } 61 | -------------------------------------------------------------------------------- /src/application/user/v1/queries/find-all-users/find-all-users.http.controller.ts: -------------------------------------------------------------------------------- 1 | import { Controller, Get } from '@nestjs/common'; 2 | import { QueryBus } from '@nestjs/cqrs'; 3 | import { ApiTags } from '@nestjs/swagger'; 4 | 5 | import { V1FindAllUsersQueryHandler } from '@/application/user/v1/queries/find-all-users/find-all-users.handler'; 6 | import { AllStaffRoles } from '@/domain/user/user-role.enum'; 7 | import { ApiOperationWithRoles } from '@/shared/decorator/api-operation-with-roles.decorator'; 8 | import { ApiStandardisedResponse } from '@/shared/decorator/api-standardised-response.decorator'; 9 | 10 | import { V1FindAllUsersResponseDto } from './dto/find-all-users.response.dto'; 11 | 12 | @ApiTags('User') 13 | @Controller({ 14 | version: '1', 15 | }) 16 | export class V1FindAllUsersController { 17 | constructor(private readonly queryBus: QueryBus) {} 18 | 19 | @ApiOperationWithRoles( 20 | { 21 | summary: 'Find all Users', 22 | description: 'Requires the user to be an read admin.', 23 | }, 24 | AllStaffRoles, 25 | ) 26 | @ApiStandardisedResponse( 27 | { 28 | status: 200, 29 | description: 'The User has been successfully found.', 30 | }, 31 | V1FindAllUsersResponseDto, 32 | ) 33 | @ApiStandardisedResponse({ 34 | status: 404, 35 | description: 'The User could not be found.', 36 | }) 37 | @Get('/user') 38 | async findAllUsers(): Promise { 39 | const users = await V1FindAllUsersQueryHandler.runHandler( 40 | this.queryBus, 41 | ); 42 | 43 | return { users }; 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/application/authentication/decorator/current-user.decorator.test.ts: -------------------------------------------------------------------------------- 1 | import { createMock } from '@golevelup/ts-jest'; 2 | import { ExecutionContext } from '@nestjs/common'; 3 | 4 | import { CurrentUserFactory } from '@/application/authentication/decorator/current-user.decorator'; 5 | 6 | describe('current user decorator', () => { 7 | it('should return the current user', () => { 8 | // Mock user object 9 | const user = { userId: '123', username: 'testUser' }; 10 | 11 | // Mock request object 12 | const request = { 13 | user, 14 | }; 15 | 16 | // Mock ExecutionContext 17 | const context = createMock(); 18 | context.switchToHttp = jest.fn().mockReturnValue({ 19 | getRequest: () => request, 20 | }); 21 | 22 | // Invoke CurrentUser decorator with mocked context 23 | const result = CurrentUserFactory(null, context); 24 | 25 | // Assert that the decorator returns the expected user object 26 | expect(result).toBe(user); 27 | }); 28 | 29 | it('should return undefined if the user is not present', () => { 30 | // Mock request object 31 | const request = {}; 32 | 33 | // Mock ExecutionContext 34 | const context = createMock(); 35 | context.switchToHttp = jest.fn().mockReturnValue({ 36 | getRequest: () => request, 37 | }); 38 | 39 | // Invoke CurrentUser decorator with mocked context 40 | const result = CurrentUserFactory(null, context); 41 | 42 | // Assert that the decorator returns null 43 | expect(result).toBeUndefined(); 44 | }); 45 | }); 46 | -------------------------------------------------------------------------------- /src/infrastructure/token/v1/v1-token.module.ts: -------------------------------------------------------------------------------- 1 | import { Module, Type } from '@nestjs/common'; 2 | 3 | import { V1GenerateAccessTokenCommandHandler } from '@/infrastructure/token/v1/commands/generate-access-token/generate-access-token.handler'; 4 | import { V1GenerateRefreshTokenCommandHandler } from '@/infrastructure/token/v1/commands/generate-refresh-token/generate-refresh-token.handler'; 5 | import { V1GenerateResetPasswordTokenCommandHandler } from '@/infrastructure/token/v1/commands/generate-reset-password-token/generate-reset-password-token.handler'; 6 | import { V1GenerateVerificationTokenCommandHandler } from '@/infrastructure/token/v1/commands/generate-verification-token/generate-verification-token.handler'; 7 | import { V1VerifyResetPasswordTokenQueryHandler } from '@/infrastructure/token/v1/queries/verify-reset-password-token/verify-reset-password-token.handler'; 8 | import { V1VerifyVerificationTokenQueryHandler } from '@/infrastructure/token/v1/queries/verify-verification-token/verify-verification-token.handler'; 9 | 10 | const QueryHandlers: Type[] = [ 11 | V1VerifyVerificationTokenQueryHandler, 12 | V1VerifyResetPasswordTokenQueryHandler, 13 | ]; 14 | const QueryControllers: Type[] = []; 15 | 16 | const CommandHandlers: Type[] = [ 17 | V1GenerateAccessTokenCommandHandler, 18 | V1GenerateRefreshTokenCommandHandler, 19 | V1GenerateVerificationTokenCommandHandler, 20 | V1GenerateResetPasswordTokenCommandHandler, 21 | ]; 22 | const CommandControllers: Type[] = []; 23 | 24 | @Module({ 25 | imports: [], 26 | controllers: [...CommandControllers, ...QueryControllers], 27 | providers: [...QueryHandlers, ...CommandHandlers], 28 | }) 29 | export class V1TokenModule {} 30 | -------------------------------------------------------------------------------- /src/application/authentication/event-handlers/on-validate-credentials-when-password-incorrect-add-to-counter.handler.ts: -------------------------------------------------------------------------------- 1 | import { EventsHandler, IEventHandler } from '@nestjs/cqrs'; 2 | import { Counter } from '@opentelemetry/api'; 3 | import { MetricService } from 'nestjs-otel'; 4 | 5 | import { OnValidateCredentialsEvent } from '@/domain/authentication/events/on-validate-credentials.event'; 6 | 7 | @EventsHandler(OnValidateCredentialsEvent) 8 | export class OnValidateCredentialsWhenPasswordIncorrectAddToCounterHandler 9 | implements IEventHandler 10 | { 11 | private readonly incorrectPasswordCounter: Counter; 12 | private readonly totalAttemptsCounter: Counter; 13 | 14 | constructor(private readonly metricService: MetricService) { 15 | this.incorrectPasswordCounter = this.metricService.getCounter( 16 | 'authentication.password.incorrect', 17 | { 18 | description: 'Count of incorrect password attempts', 19 | }, 20 | ); 21 | 22 | this.totalAttemptsCounter = this.metricService.getCounter( 23 | 'authentication.attempts.total', 24 | { 25 | description: 'Count of total authentication attempts', 26 | }, 27 | ); 28 | } 29 | 30 | handle(event: OnValidateCredentialsEvent) { 31 | this.totalAttemptsCounter.add(1, { 32 | email: event.email, 33 | }); 34 | 35 | if (!event.emailExists) { 36 | return; 37 | } 38 | 39 | if (!event.passwordMatches) { 40 | this.incorrectPasswordCounter.add(1, { 41 | email: event.email, 42 | }); 43 | } 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/application/authentication/v1/commands/logout/logout.http.controller.ts: -------------------------------------------------------------------------------- 1 | import { Controller, HttpCode, Post, Req, UseGuards } from '@nestjs/common'; 2 | import { CommandBus } from '@nestjs/cqrs'; 3 | import { ApiSecurity, ApiTags } from '@nestjs/swagger'; 4 | 5 | import { Public } from '@/application/authentication/decorator/public.decorator'; 6 | import { RefreshTokenGuard } from '@/application/authentication/strategies/refresh-token/refresh-token.guard'; 7 | import { V1RevokeSessionCommandHandler } from '@/application/session/v1/commands/revoke-session/revoke-session.handler'; 8 | import { ApiOperationWithRoles } from '@/shared/decorator/api-operation-with-roles.decorator'; 9 | import { ApiStandardisedResponse } from '@/shared/decorator/api-standardised-response.decorator'; 10 | import type { RequestWithUser } from '@/types/express/request-with-user'; 11 | 12 | @ApiTags('Authentication') 13 | @Controller({ 14 | version: '1', 15 | }) 16 | export class V1LogoutController { 17 | constructor(private readonly commandBus: CommandBus) {} 18 | 19 | @ApiOperationWithRoles({ 20 | summary: 'Logout a User Account and invalidate the refresh token', 21 | }) // This is to bypass the AccessTokenGuard 22 | @ApiSecurity('refresh-token') 23 | @ApiStandardisedResponse({ 24 | status: 200, 25 | description: 'User Logged Out Successfully', 26 | }) 27 | @HttpCode(200) 28 | @Post('/authentication/logout') 29 | @Public() 30 | @UseGuards(RefreshTokenGuard) 31 | async refreshToken(@Req() request: RequestWithUser): Promise { 32 | await V1RevokeSessionCommandHandler.runHandler(this.commandBus, { 33 | session: request.session, 34 | }); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/infrastructure/cache/cache.module.ts: -------------------------------------------------------------------------------- 1 | import { 2 | CacheOptions, 3 | CacheModule as NestCacheModule, 4 | } from '@nestjs/cache-manager'; 5 | import { Global, Logger, Module } from '@nestjs/common'; 6 | import * as redisStore from 'cache-manager-redis-yet'; 7 | import type { RedisClientOptions } from 'redis'; 8 | 9 | import { CacheConfigService } from '@/infrastructure/config/configs/cache-config.service'; 10 | import { RedisConfigService } from '@/infrastructure/config/configs/redis-config.service'; 11 | 12 | @Global() 13 | @Module({ 14 | imports: [ 15 | NestCacheModule.registerAsync({ 16 | useFactory: ( 17 | redisConfig: RedisConfigService, 18 | cacheConfig: CacheConfigService, 19 | ) => { 20 | const logger = new Logger('CacheModule'); 21 | 22 | logger.debug('Configuring CacheModule'); 23 | 24 | if (!cacheConfig.useRedis) { 25 | logger.warn('Using memory server for CacheModule'); 26 | 27 | return { 28 | ttl: cacheConfig.ttl, 29 | } as unknown as CacheOptions; 30 | } 31 | 32 | logger.warn('Using redis server for CacheModule'); 33 | 34 | return { 35 | store: redisStore, 36 | 37 | url: redisConfig.url, 38 | 39 | ttl: cacheConfig.ttl, 40 | } as unknown as CacheOptions; 41 | }, 42 | inject: [RedisConfigService, CacheConfigService], 43 | isGlobal: true, 44 | }), 45 | ], 46 | exports: [], 47 | }) 48 | export class CacheModule {} 49 | -------------------------------------------------------------------------------- /src/application/user/v1/queries/find-user-by-email/find-user-by-email.handler.ts: -------------------------------------------------------------------------------- 1 | import { Inject, Logger } from '@nestjs/common'; 2 | import type { IQueryHandler, QueryBus } from '@nestjs/cqrs'; 3 | import { QueryHandler } from '@nestjs/cqrs'; 4 | 5 | import { User } from '@/domain/user/user.entity'; 6 | import { USER_REPOSITORY } from '@/infrastructure/repositories/modules/user/user.repository.constants'; 7 | import type { UserRepositoryPort } from '@/infrastructure/repositories/modules/user/user.repository.port'; 8 | 9 | import { V1FindUserByEmailQuery } from './find-user-by-email.query'; 10 | 11 | type V1FindUserByEmailQueryHandlerResponse = User | undefined; 12 | 13 | @QueryHandler(V1FindUserByEmailQuery) 14 | export class V1FindUserByEmailQueryHandler 15 | implements 16 | IQueryHandler< 17 | V1FindUserByEmailQuery, 18 | V1FindUserByEmailQueryHandlerResponse 19 | > 20 | { 21 | private readonly logger = new Logger(V1FindUserByEmailQueryHandler.name); 22 | 23 | constructor( 24 | @Inject(USER_REPOSITORY) 25 | private readonly userRepository: UserRepositoryPort, 26 | ) {} 27 | 28 | static runHandler( 29 | bus: QueryBus, 30 | query: V1FindUserByEmailQuery, 31 | ): Promise { 32 | return bus.execute< 33 | V1FindUserByEmailQuery, 34 | V1FindUserByEmailQueryHandlerResponse 35 | >(new V1FindUserByEmailQuery(query.email)); 36 | } 37 | 38 | execute( 39 | query: V1FindUserByEmailQuery, 40 | ): Promise { 41 | this.logger.log(`Finding user by email ${query.email}`); 42 | return this.userRepository.findOneByEmail(query.email); 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/application/authentication/authentication.module.ts: -------------------------------------------------------------------------------- 1 | import { Module, Type } from '@nestjs/common'; 2 | import { PassportModule } from '@nestjs/passport'; 3 | 4 | import { OnLoginWhenSuccessAddToCounterHandler } from '@/application/authentication/event-handlers/on-login-when-success-add-to-counter.handler'; 5 | import { OnRegisterAddToCounterHandler } from '@/application/authentication/event-handlers/on-register-add-to-counter.handler'; 6 | import { OnRegisterSendVerificationHandler } from '@/application/authentication/event-handlers/on-register-send-verification.handler'; 7 | import { OnValidateCredentialsWhenPasswordIncorrectAddToCounterHandler } from '@/application/authentication/event-handlers/on-validate-credentials-when-password-incorrect-add-to-counter.handler'; 8 | import { AccessTokenStrategy } from '@/application/authentication/strategies/access-token/access-token.strategy'; 9 | import { LocalStrategy } from '@/application/authentication/strategies/local/local.strategy'; 10 | import { RefreshTokenStrategy } from '@/application/authentication/strategies/refresh-token/refresh-token.strategy'; 11 | import { V1AuthenticationModule } from '@/application/authentication/v1/v1-authentication.module'; 12 | import { JwtModule } from '@/infrastructure/jwt/jwt.module'; 13 | 14 | const EventHandlers: Type[] = [ 15 | OnValidateCredentialsWhenPasswordIncorrectAddToCounterHandler, 16 | OnLoginWhenSuccessAddToCounterHandler, 17 | OnRegisterAddToCounterHandler, 18 | OnRegisterSendVerificationHandler, 19 | ]; 20 | 21 | @Module({ 22 | imports: [PassportModule, V1AuthenticationModule, JwtModule], 23 | 24 | providers: [ 25 | ...EventHandlers, 26 | LocalStrategy, 27 | AccessTokenStrategy, 28 | RefreshTokenStrategy, 29 | ], 30 | }) 31 | export class AuthenticationModule {} 32 | -------------------------------------------------------------------------------- /src/application/ping/v1/queries/ping/ping.test.ts: -------------------------------------------------------------------------------- 1 | import { EventBus } from '@nestjs/cqrs'; 2 | import { NestExpressApplication } from '@nestjs/platform-express'; 3 | import { Test, TestingModule } from '@nestjs/testing'; 4 | import request from 'supertest'; 5 | 6 | import { V1PingController } from '@/application/ping/v1/queries/ping/ping.controller'; 7 | import { MainConfigService } from '@/infrastructure/config/configs/main-config.service'; 8 | 9 | describe('pingController (e2e)', () => { 10 | let app: NestExpressApplication; 11 | 12 | beforeEach(async () => { 13 | const moduleFixture: TestingModule = await Test.createTestingModule({ 14 | controllers: [V1PingController], 15 | providers: [ 16 | { 17 | provide: MainConfigService, 18 | useValue: { 19 | APP_NAME: 'Test App', 20 | }, 21 | }, 22 | { 23 | provide: EventBus, 24 | useValue: { 25 | publish: jest.fn(), 26 | }, 27 | }, 28 | ], 29 | }).compile(); 30 | 31 | app = moduleFixture.createNestApplication(); 32 | 33 | await app.init(); 34 | }); 35 | 36 | it('/ping (GET)', () => { 37 | return request(app.getHttpServer()) 38 | .get('/ping') 39 | .expect(200) 40 | .then((response) => { 41 | expect(response.body).toEqual({ 42 | message: 'OK', 43 | serverTime: expect.any(String), 44 | appName: 'Test App', 45 | }); 46 | }); 47 | }); 48 | 49 | afterAll(async () => { 50 | await app.close(); 51 | }); 52 | }); 53 | -------------------------------------------------------------------------------- /src/infrastructure/cqrs/cqrs.module.ts: -------------------------------------------------------------------------------- 1 | import { Logger, Module, OnModuleDestroy, OnModuleInit } from '@nestjs/common'; 2 | import { 3 | CqrsModule as BaseCqrsModule, 4 | CommandBus, 5 | EventBus, 6 | QueryBus, 7 | } from '@nestjs/cqrs'; 8 | import { Subject, takeUntil } from 'rxjs'; 9 | 10 | @Module({ 11 | imports: [BaseCqrsModule.forRoot()], 12 | exports: [BaseCqrsModule], 13 | }) 14 | export class CqrsModule implements OnModuleDestroy, OnModuleInit { 15 | private destroy$ = new Subject(); 16 | 17 | private readonly eventLogger = new Logger('EventBus'); 18 | private readonly commandLogger = new Logger('CommandBus'); 19 | private readonly queryLogger = new Logger('QueryBus'); 20 | 21 | constructor( 22 | private readonly eventBus: EventBus, 23 | private readonly commandBus: CommandBus, 24 | private readonly queryBus: QueryBus, 25 | ) {} 26 | 27 | onModuleInit() { 28 | // Log all events, commands, and queries 29 | this.eventBus.pipe(takeUntil(this.destroy$)).subscribe((event) => { 30 | this.eventLogger.debug( 31 | `${event.constructor.name} - ${JSON.stringify(event)}`, 32 | ); 33 | }); 34 | 35 | this.commandBus.pipe(takeUntil(this.destroy$)).subscribe((command) => { 36 | this.commandLogger.debug( 37 | `${command.constructor.name} - ${JSON.stringify(command)}`, 38 | ); 39 | }); 40 | 41 | this.queryBus.pipe(takeUntil(this.destroy$)).subscribe((query) => { 42 | this.queryLogger.debug( 43 | `${query.constructor.name} - ${JSON.stringify(query)}`, 44 | ); 45 | }); 46 | } 47 | 48 | onModuleDestroy() { 49 | this.destroy$.next(); 50 | this.destroy$.complete(); 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/application/user/v1/commands/delete-user/delete-user.handler.ts: -------------------------------------------------------------------------------- 1 | import { Inject, Logger } from '@nestjs/common'; 2 | import type { CommandBus, ICommandHandler } from '@nestjs/cqrs'; 3 | import { CommandHandler, EventPublisher } from '@nestjs/cqrs'; 4 | 5 | import { User } from '@/domain/user/user.entity'; 6 | import { USER_REPOSITORY } from '@/infrastructure/repositories/modules/user/user.repository.constants'; 7 | import type { UserRepositoryPort } from '@/infrastructure/repositories/modules/user/user.repository.port'; 8 | 9 | import { V1DeleteUserCommand } from './delete-user.command'; 10 | 11 | type V1DeleteUserCommandHandlerResponse = User; 12 | 13 | @CommandHandler(V1DeleteUserCommand) 14 | export class V1DeleteUserCommandHandler 15 | implements 16 | ICommandHandler 17 | { 18 | private readonly logger = new Logger(V1DeleteUserCommandHandler.name); 19 | 20 | constructor( 21 | @Inject(USER_REPOSITORY) 22 | private readonly userRepository: UserRepositoryPort, 23 | private readonly eventPublisher: EventPublisher, 24 | ) {} 25 | 26 | static runHandler( 27 | bus: CommandBus, 28 | command: V1DeleteUserCommand, 29 | ): Promise { 30 | return bus.execute< 31 | V1DeleteUserCommand, 32 | V1DeleteUserCommandHandlerResponse 33 | >(new V1DeleteUserCommand(command.user)); 34 | } 35 | 36 | async execute({ 37 | user, 38 | }: V1DeleteUserCommand): Promise { 39 | this.logger.log(`Deleting user ${user.id}`); 40 | 41 | const entity = this.eventPublisher.mergeObjectContext(user); 42 | 43 | await this.userRepository.delete(entity); 44 | 45 | entity.commit(); 46 | 47 | return entity; 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/domain/session/session.spec.ts: -------------------------------------------------------------------------------- 1 | import { CreateMockUser } from '@tests/utils/create-mocks'; 2 | 3 | import { User } from '@/domain/user/user.entity'; 4 | import { GenericInternalValidationException } from '@/shared/exceptions/internal-validation.exception'; 5 | 6 | import { Session } from './session.entity'; 7 | 8 | describe('session', () => { 9 | let session: Session; 10 | let user: User; 11 | 12 | beforeEach(() => { 13 | user = CreateMockUser(); 14 | 15 | session = Session.create({ 16 | userId: user.id, 17 | ip: '1.1.1.1', 18 | }); 19 | }); 20 | 21 | test('create() should create a valid session instance', () => { 22 | expect(session.id).toBeDefined(); 23 | expect(session.isRevoked).toBeFalsy(); 24 | expect(session.userId).toEqual(user.id); 25 | expect(session.token).toBeDefined(); 26 | expect(session.ip).toBeDefined(); 27 | }); 28 | 29 | test('create() should not have a ip is not provided one', () => { 30 | const tempSession = Session.create({ 31 | userId: user.id, 32 | }); 33 | expect(tempSession.getData().ip).toBeUndefined(); 34 | }); 35 | 36 | test('revoke() should mark the session as revoked', () => { 37 | expect(session.getData().isRevoked).toBeFalsy(); 38 | session.revoke(); 39 | expect(session.getData().isRevoked).toBeTruthy(); 40 | }); 41 | 42 | test('validate() should not throw exception for a valid session', () => { 43 | expect(() => { 44 | session.validate(); 45 | }).not.toThrow(); 46 | }); 47 | 48 | test('validate() should throw exception for an invalid ip', () => { 49 | expect(() => { 50 | Session.create({ userId: user.id, ip: 'invalid-ip' }); 51 | }).toThrow(GenericInternalValidationException); 52 | }); 53 | }); 54 | -------------------------------------------------------------------------------- /src/application/session/v1/queries/find-session-by-token/find-session-by-token.handler.ts: -------------------------------------------------------------------------------- 1 | import { Inject, Logger } from '@nestjs/common'; 2 | import type { IQueryHandler, QueryBus } from '@nestjs/cqrs'; 3 | import { QueryHandler } from '@nestjs/cqrs'; 4 | 5 | import { Session } from '@/domain/session/session.entity'; 6 | import { SESSION_REPOSITORY } from '@/infrastructure/repositories/modules/session/session.repository.constants'; 7 | import type { SessionRepositoryPort } from '@/infrastructure/repositories/modules/session/session.repository.port'; 8 | 9 | import { V1FindSessionByTokenQuery } from './find-session-by-token.query'; 10 | 11 | type V1FindSessionByTokenQueryHandlerResponse = Session | undefined; 12 | 13 | @QueryHandler(V1FindSessionByTokenQuery) 14 | export class V1FindSessionByTokenQueryHandler 15 | implements 16 | IQueryHandler< 17 | V1FindSessionByTokenQuery, 18 | V1FindSessionByTokenQueryHandlerResponse 19 | > 20 | { 21 | private readonly logger = new Logger(V1FindSessionByTokenQueryHandler.name); 22 | 23 | constructor( 24 | @Inject(SESSION_REPOSITORY) 25 | private readonly sessionRepository: SessionRepositoryPort, 26 | ) {} 27 | 28 | static runHandler( 29 | bus: QueryBus, 30 | query: V1FindSessionByTokenQuery, 31 | ): Promise { 32 | return bus.execute< 33 | V1FindSessionByTokenQuery, 34 | V1FindSessionByTokenQueryHandlerResponse 35 | >(new V1FindSessionByTokenQuery(query.refreshToken)); 36 | } 37 | 38 | execute( 39 | query: V1FindSessionByTokenQuery, 40 | ): Promise { 41 | this.logger.log(`Finding session by token ${query.refreshToken}`); 42 | return this.sessionRepository.findOneByToken(query.refreshToken); 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/application/user/v1/commands/update-user/update-user.handler.ts: -------------------------------------------------------------------------------- 1 | import { Inject, Logger } from '@nestjs/common'; 2 | import type { CommandBus, ICommandHandler } from '@nestjs/cqrs'; 3 | import { CommandHandler, EventPublisher } from '@nestjs/cqrs'; 4 | 5 | import { User } from '@/domain/user/user.entity'; 6 | import { USER_REPOSITORY } from '@/infrastructure/repositories/modules/user/user.repository.constants'; 7 | import type { UserRepositoryPort } from '@/infrastructure/repositories/modules/user/user.repository.port'; 8 | 9 | import { V1UpdateUserCommand } from './update-user.command'; 10 | 11 | type V1UpdateUserCommandHandlerResponse = User; 12 | 13 | @CommandHandler(V1UpdateUserCommand) 14 | export class V1UpdateUserCommandHandler 15 | implements 16 | ICommandHandler 17 | { 18 | private readonly logger = new Logger(V1UpdateUserCommandHandler.name); 19 | 20 | constructor( 21 | @Inject(USER_REPOSITORY) 22 | private readonly userRepository: UserRepositoryPort, 23 | private readonly eventPublisher: EventPublisher, 24 | ) {} 25 | 26 | static runHandler( 27 | bus: CommandBus, 28 | command: V1UpdateUserCommand, 29 | ): Promise { 30 | return bus.execute< 31 | V1UpdateUserCommand, 32 | V1UpdateUserCommandHandlerResponse 33 | >(new V1UpdateUserCommand(command.user)); 34 | } 35 | 36 | async execute({ 37 | user, 38 | }: V1UpdateUserCommand): Promise { 39 | this.logger.log(`Updating user ${user.id}`); 40 | 41 | const entity = this.eventPublisher.mergeObjectContext(user); 42 | 43 | const updatedEntity = await this.userRepository.update(entity); 44 | 45 | entity.commit(); 46 | 47 | return updatedEntity; 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/application/session/v1/queries/find-all-sessions-by-user/find-all-sessions-by-user.handler.ts: -------------------------------------------------------------------------------- 1 | import { Inject, Logger } from '@nestjs/common'; 2 | import type { IQueryHandler, QueryBus } from '@nestjs/cqrs'; 3 | import { QueryHandler } from '@nestjs/cqrs'; 4 | 5 | import { Session } from '@/domain/session/session.entity'; 6 | import { SESSION_REPOSITORY } from '@/infrastructure/repositories/modules/session/session.repository.constants'; 7 | import type { SessionRepositoryPort } from '@/infrastructure/repositories/modules/session/session.repository.port'; 8 | 9 | import { V1FindAllSessionsByUserQuery } from './find-all-sessions-by-user.query'; 10 | 11 | type V1FindAllSessionsByUserQueryHandlerResponse = Session[]; 12 | 13 | @QueryHandler(V1FindAllSessionsByUserQuery) 14 | export class V1FindAllSessionsByUserQueryHandler 15 | implements 16 | IQueryHandler< 17 | V1FindAllSessionsByUserQuery, 18 | V1FindAllSessionsByUserQueryHandlerResponse 19 | > 20 | { 21 | private readonly logger = new Logger( 22 | V1FindAllSessionsByUserQueryHandler.name, 23 | ); 24 | 25 | constructor( 26 | @Inject(SESSION_REPOSITORY) 27 | private readonly sessionRepository: SessionRepositoryPort, 28 | ) {} 29 | 30 | static runHandler( 31 | bus: QueryBus, 32 | query: V1FindAllSessionsByUserQuery, 33 | ): Promise { 34 | return bus.execute< 35 | V1FindAllSessionsByUserQuery, 36 | V1FindAllSessionsByUserQueryHandlerResponse 37 | >(new V1FindAllSessionsByUserQuery(query.user)); 38 | } 39 | 40 | execute( 41 | query: V1FindAllSessionsByUserQuery, 42 | ): Promise { 43 | this.logger.log(`Finding all sessions by user ${query.user.id}`); 44 | return this.sessionRepository.findAllByUser(query.user); 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/infrastructure/config/config.module.ts: -------------------------------------------------------------------------------- 1 | import { Global, Module } from '@nestjs/common'; 2 | import { ConfigModule as NestConfigModule } from '@nestjs/config'; 3 | 4 | import { ConfigSchema } from '@/infrastructure/config/config-schema'; 5 | import { AuthenticationConfigService } from '@/infrastructure/config/configs/authentication-config.service'; 6 | import { CacheConfigService } from '@/infrastructure/config/configs/cache-config.service'; 7 | import { EmailConfigService } from '@/infrastructure/config/configs/email.config.service'; 8 | import { JwtConfigService } from '@/infrastructure/config/configs/jwt-config.service'; 9 | import { MainConfigService } from '@/infrastructure/config/configs/main-config.service'; 10 | import { RedisConfigService } from '@/infrastructure/config/configs/redis-config.service'; 11 | import { ThrottlerConfigService } from '@/infrastructure/config/configs/throttler-config.service'; 12 | import { TokenConfigService } from '@/infrastructure/config/configs/token-config.service'; 13 | 14 | @Global() 15 | @Module({ 16 | imports: [ 17 | NestConfigModule.forRoot({ 18 | cache: true, 19 | validate: (config) => ConfigSchema.parse(config), 20 | 21 | envFilePath: ['.env', '.env.local'], 22 | }), 23 | ], 24 | controllers: [], 25 | providers: [ 26 | MainConfigService, 27 | RedisConfigService, 28 | CacheConfigService, 29 | ThrottlerConfigService, 30 | AuthenticationConfigService, 31 | EmailConfigService, 32 | TokenConfigService, 33 | JwtConfigService, 34 | ], 35 | exports: [ 36 | MainConfigService, 37 | RedisConfigService, 38 | CacheConfigService, 39 | ThrottlerConfigService, 40 | AuthenticationConfigService, 41 | EmailConfigService, 42 | TokenConfigService, 43 | JwtConfigService, 44 | ], 45 | }) 46 | export class ConfigModule {} 47 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | pull_request: 5 | branches: [develop, main] 6 | 7 | jobs: 8 | ci: 9 | runs-on: ubuntu-latest 10 | outputs: 11 | store-path: ${{ steps.store-path.outputs.path }} 12 | steps: 13 | - name: Checkout code 14 | uses: actions/checkout@v4 15 | 16 | - name: Install pnpm 17 | uses: pnpm/action-setup@v4 18 | with: 19 | version: 9 20 | run_install: false 21 | 22 | - name: Install Node.js 23 | uses: actions/setup-node@v4 24 | with: 25 | node-version: 20 26 | cache: 'pnpm' 27 | 28 | - name: Get pnpm store directory 29 | shell: bash 30 | run: | 31 | echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV 32 | 33 | - uses: actions/cache@v4 34 | name: Setup pnpm cache 35 | with: 36 | path: ${{ env.STORE_PATH }} 37 | key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }} 38 | restore-keys: | 39 | ${{ runner.os }}-pnpm-store- 40 | 41 | - name: Install dependencies 42 | run: pnpm install 43 | 44 | - name: Run typecheck 45 | run: pnpm typecheck 46 | 47 | - name: Run linting 48 | run: pnpm lint 49 | 50 | - name: Run tests 51 | run: pnpm test 52 | env: 53 | JWT_SECRET: 'TESTING' 54 | TOKEN_ACCESS_SECRET: 'TESTING' 55 | TOKEN_REFRESH_SECRET: 'TESTING' 56 | TOKEN_VERIFICATION_SECRET: 'TESTING' 57 | TOKEN_RESET_PASSWORD_SECRET: 'TESTING' 58 | 59 | - name: Run dry build 60 | run: pnpm build 61 | -------------------------------------------------------------------------------- /src/application/verification/v1/commands/confirm-verification/confirm-verification.http.controller.ts: -------------------------------------------------------------------------------- 1 | import { Body, Controller, HttpCode, Post } from '@nestjs/common'; 2 | import { CommandBus } from '@nestjs/cqrs'; 3 | import { ApiOperation, ApiTags } from '@nestjs/swagger'; 4 | import { Throttle } from '@nestjs/throttler'; 5 | 6 | import { Public } from '@/application/authentication/decorator/public.decorator'; 7 | import { V1ConfirmVerificationCommandHandler } from '@/application/verification/v1/commands/confirm-verification/confirm-verification.handler'; 8 | import { ApiStandardisedResponse } from '@/shared/decorator/api-standardised-response.decorator'; 9 | 10 | import { V1ConfirmVerificationRequestDto } from './dto/confirm-verification.request.dto'; 11 | 12 | @ApiTags('Verification') 13 | @Controller({ 14 | version: '1', 15 | }) 16 | export class V1ConfirmVerificationController { 17 | constructor(private readonly commandBus: CommandBus) {} 18 | 19 | @ApiOperation({ 20 | summary: 'Confirm Verification Email', 21 | }) 22 | // Throttle the confirm-verification endpoint to prevent brute force attacks (5 Requests per 1 hour) 23 | @ApiStandardisedResponse({ 24 | status: 200, 25 | description: 'Verification Completed Successfully', 26 | }) 27 | @ApiStandardisedResponse({ 28 | status: 403, 29 | description: 'User not found or Token is invalid', 30 | }) 31 | @HttpCode(200) 32 | @Post('/verification/confirm') 33 | @Public() 34 | @Throttle({ 35 | default: { 36 | limit: 5, 37 | ttl: 60 * 60 * 1000, 38 | }, 39 | }) 40 | async confirmVerification( 41 | @Body() body: V1ConfirmVerificationRequestDto, 42 | ): Promise { 43 | await V1ConfirmVerificationCommandHandler.runHandler(this.commandBus, { 44 | verificationToken: body.verificationToken, 45 | }); 46 | 47 | return Promise.resolve(); 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/application/session/v1/queries/find-all-sessions-by-current-user/find-all-sessions-by-current-user.http.controller.ts: -------------------------------------------------------------------------------- 1 | import { Controller, Get, Logger } from '@nestjs/common'; 2 | import { QueryBus } from '@nestjs/cqrs'; 3 | import { ApiTags } from '@nestjs/swagger'; 4 | 5 | import { CurrentUser } from '@/application/authentication/decorator/current-user.decorator'; 6 | import { V1FindAllSessionsByUserQueryHandler } from '@/application/session/v1/queries/find-all-sessions-by-user/find-all-sessions-by-user.handler'; 7 | import { User } from '@/domain/user/user.entity'; 8 | import { ApiOperationWithRoles } from '@/shared/decorator/api-operation-with-roles.decorator'; 9 | import { ApiStandardisedResponse } from '@/shared/decorator/api-standardised-response.decorator'; 10 | 11 | import { V1FindAllSessionsByCurrentUserResponseDto } from './dto/find-all-sessions-by-current-user.response.dto'; 12 | 13 | @ApiTags('Session') 14 | @Controller({ 15 | version: '1', 16 | }) 17 | export class V1FindAllSessionsByCurrentUserController { 18 | private readonly logger = new Logger( 19 | V1FindAllSessionsByCurrentUserController.name, 20 | ); 21 | 22 | constructor(private readonly queryBus: QueryBus) {} 23 | 24 | @ApiOperationWithRoles({ 25 | summary: 'Find all Sessions By current user', 26 | }) 27 | @ApiStandardisedResponse( 28 | { 29 | status: 200, 30 | description: 'The Sessions has been successfully found.', 31 | }, 32 | V1FindAllSessionsByCurrentUserResponseDto, 33 | ) 34 | @Get('/session/user/me') 35 | async findSessionByCurrentUser( 36 | @CurrentUser() currentUser: User, 37 | ): Promise { 38 | const sessions = await V1FindAllSessionsByUserQueryHandler.runHandler( 39 | this.queryBus, 40 | { 41 | user: currentUser, 42 | }, 43 | ); 44 | 45 | return { sessions }; 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/application/user/v1/queries/find-user-by-id/find-user-by-id.http.controller.ts: -------------------------------------------------------------------------------- 1 | import { Controller, Get, Param } from '@nestjs/common'; 2 | import { QueryBus } from '@nestjs/cqrs'; 3 | import { ApiTags } from '@nestjs/swagger'; 4 | 5 | import { AllStaffRoles } from '@/domain/user/user-role.enum'; 6 | import { ApiOperationWithRoles } from '@/shared/decorator/api-operation-with-roles.decorator'; 7 | import { ApiStandardisedResponse } from '@/shared/decorator/api-standardised-response.decorator'; 8 | import { GenericNotFoundException } from '@/shared/exceptions/not-found.exception'; 9 | 10 | import { V1FindUserByIDResponseDto } from './dto/find-user-by-id.response.dto'; 11 | import { V1FindUserByIDQueryHandler } from './find-user-by-id.handler'; 12 | import { V1FindUserByIDQuery } from './find-user-by-id.query'; 13 | 14 | @ApiTags('User') 15 | @Controller({ 16 | version: '1', 17 | }) 18 | export class V1FindUserByIDController { 19 | constructor(private readonly queryBus: QueryBus) {} 20 | 21 | @ApiOperationWithRoles( 22 | { 23 | summary: 'Find User by ID', 24 | description: 'Requires the user to be an read admin.', 25 | }, 26 | AllStaffRoles, 27 | ) 28 | @ApiStandardisedResponse( 29 | { 30 | status: 200, 31 | description: 'The User has been successfully found.', 32 | }, 33 | V1FindUserByIDResponseDto, 34 | ) 35 | @ApiStandardisedResponse({ 36 | status: 404, 37 | description: 'The User could not be found.', 38 | }) 39 | @Get('/user/:id') 40 | async findUserById( 41 | @Param('id') id: string, 42 | ): Promise { 43 | const user = await V1FindUserByIDQueryHandler.runHandler( 44 | this.queryBus, 45 | new V1FindUserByIDQuery(id), 46 | ); 47 | 48 | if (!user) { 49 | throw new GenericNotFoundException('User not found'); 50 | } 51 | 52 | return { user }; 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/infrastructure/config/configs/token-config.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { ConfigService as NestConfigService } from '@nestjs/config'; 3 | 4 | import { Config } from '@/infrastructure/config/config-schema'; 5 | 6 | @Injectable() 7 | export class TokenConfigService { 8 | constructor( 9 | private readonly configService: NestConfigService, 10 | ) {} 11 | 12 | get accessTokenSecret() { 13 | return this.configService.get( 14 | 'TOKEN_ACCESS_SECRET', 15 | ); 16 | } 17 | 18 | get refreshTokenSecret() { 19 | return this.configService.get( 20 | 'TOKEN_REFRESH_SECRET', 21 | ); 22 | } 23 | 24 | get verificationTokenSecret() { 25 | return this.configService.get( 26 | 'TOKEN_VERIFICATION_SECRET', 27 | ); 28 | } 29 | 30 | get resetPasswordTokenSecret() { 31 | return this.configService.get( 32 | 'TOKEN_RESET_PASSWORD_SECRET', 33 | ); 34 | } 35 | 36 | get verificationTokenExpiration() { 37 | return this.configService.get< 38 | Config['TOKEN_VERIFICATION_TOKEN_EXPIRATION'] 39 | >('TOKEN_VERIFICATION_TOKEN_EXPIRATION'); 40 | } 41 | 42 | get accessTokenExpiration() { 43 | return this.configService.get( 44 | 'TOKEN_ACCESS_TOKEN_EXPIRATION', 45 | ); 46 | } 47 | 48 | get refreshTokenExpiration() { 49 | return this.configService.get( 50 | 'TOKEN_REFRESH_TOKEN_EXPIRATION', 51 | ); 52 | } 53 | 54 | get resetPasswordTokenExpiration() { 55 | return this.configService.get< 56 | Config['TOKEN_RESET_PASSWORD_TOKEN_EXPIRATION'] 57 | >('TOKEN_RESET_PASSWORD_TOKEN_EXPIRATION'); 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /src/infrastructure/repositories/modules/session/mock/mock.session.repository.ts: -------------------------------------------------------------------------------- 1 | import { type ClassProvider, Injectable } from '@nestjs/common'; 2 | 3 | import { Session } from '@/domain/session/session.entity'; 4 | import { User } from '@/domain/user/user.entity'; 5 | import { AbstractMockRepository } from '@/infrastructure/repositories/modules/mock/abstracts/mock.repository'; 6 | import { SESSION_REPOSITORY } from '@/infrastructure/repositories/modules/session/session.repository.constants'; 7 | import { SessionRepositoryPort } from '@/infrastructure/repositories/modules/session/session.repository.port'; 8 | 9 | @Injectable() 10 | export class MockSessionRepository 11 | extends AbstractMockRepository 12 | implements SessionRepositoryPort 13 | { 14 | findAllByUserID: (userID: string) => Promise = async ( 15 | userID: string, 16 | ) => { 17 | return Promise.resolve( 18 | this.data.filter((session) => session.userId === userID), 19 | ); 20 | }; 21 | findAllByUser: (user: User) => Promise = async (user: User) => { 22 | return Promise.resolve( 23 | this.data.filter((session) => session.userId === user.id), 24 | ); 25 | }; 26 | 27 | findOneByToken: (token: string) => Promise = async ( 28 | token: string, 29 | ) => { 30 | return Promise.resolve( 31 | this.data.find((session) => session.token === token), 32 | ); 33 | }; 34 | findAllRevoked: () => Promise = async () => 35 | Promise.resolve(this.data.filter((session) => session.isRevoked)); 36 | findAllNotRevoked: () => Promise = async () => 37 | Promise.resolve(this.data.filter((session) => !session.isRevoked)); 38 | findAllByIP: (ip: string) => Promise = async (ip: string) => 39 | Promise.resolve(this.data.filter((session) => session.ip === ip)); 40 | } 41 | 42 | export const MockSessionRepositoryProvider: ClassProvider = { 43 | provide: SESSION_REPOSITORY, 44 | useClass: MockSessionRepository, 45 | }; 46 | -------------------------------------------------------------------------------- /src/domain/session/session.entity.ts: -------------------------------------------------------------------------------- 1 | import { IntersectionType } from '@nestjs/swagger'; 2 | import { createId } from '@paralleldrive/cuid2'; 3 | import { Expose, plainToInstance } from 'class-transformer'; 4 | 5 | import { Entity } from '@/domain/base/entity/entity.base'; 6 | import { OnSessionRevokedEvent } from '@/domain/session/events/on-session-revoked.event'; 7 | import { 8 | SessionIp, 9 | SessionIpDto, 10 | SessionIsRevoked, 11 | SessionIsRevokedDto, 12 | SessionToken, 13 | SessionTokenDto, 14 | SessionUserId, 15 | SessionUserIdDto, 16 | } from '@/domain/session/session.dto'; 17 | import { AllStaffRoles } from '@/domain/user/user-role.enum'; 18 | 19 | export class SessionData extends IntersectionType( 20 | SessionUserIdDto, 21 | SessionTokenDto, 22 | SessionIsRevokedDto, 23 | SessionIpDto, 24 | ) {} 25 | 26 | export interface CreateSessionProps { 27 | userId: string; 28 | ip?: string; 29 | } 30 | 31 | export class Session extends Entity { 32 | static create(props: CreateSessionProps): Session { 33 | const id = createId(); 34 | 35 | const data: SessionData = plainToInstance(SessionData, { 36 | ...props, 37 | isRevoked: false, 38 | token: createId(), 39 | }); 40 | 41 | return new Session({ id, data }); 42 | } 43 | 44 | @Expose() 45 | @SessionUserId() 46 | get userId(): string { 47 | return this.data.userId; 48 | } 49 | 50 | @Expose() 51 | @SessionToken() 52 | get token(): string { 53 | return this.data.token; 54 | } 55 | 56 | @Expose() 57 | @SessionIsRevoked() 58 | get isRevoked(): boolean { 59 | return this.data.isRevoked; 60 | } 61 | 62 | @Expose({ 63 | groups: [...AllStaffRoles], 64 | }) 65 | @SessionIp() 66 | get ip(): string | undefined { 67 | return this.data.ip; 68 | } 69 | 70 | public revoke(): void { 71 | this.data.isRevoked = true; 72 | this.apply(new OnSessionRevokedEvent(this)); 73 | this.updated(); 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /src/application/session/v1/v1-session.module.ts: -------------------------------------------------------------------------------- 1 | import { Module, Type } from '@nestjs/common'; 2 | 3 | import { V1CreateSessionCommandHandler } from '@/application/session/v1/commands/create-session/create-session.handler'; 4 | import { V1RevokeSessionCommandHandler } from '@/application/session/v1/commands/revoke-session/revoke-session.handler'; 5 | import { V1RevokeSessionController } from '@/application/session/v1/commands/revoke-session/revoke-session.http.controller'; 6 | import { V1FindAllSessionsByCurrentUserController } from '@/application/session/v1/queries/find-all-sessions-by-current-user/find-all-sessions-by-current-user.http.controller'; 7 | import { V1FindAllSessionsByUserQueryHandler } from '@/application/session/v1/queries/find-all-sessions-by-user/find-all-sessions-by-user.handler'; 8 | import { V1FindAllSessionsByUserController } from '@/application/session/v1/queries/find-all-sessions-by-user/find-all-sessions-by-user.http.controller'; 9 | import { V1FindCurrentSessionController } from '@/application/session/v1/queries/find-current-session/find-current-session.http.controller'; 10 | import { V1FindSessionByTokenQueryHandler } from '@/application/session/v1/queries/find-session-by-token/find-session-by-token.handler'; 11 | import { V1FindSessionByTokenController } from '@/application/session/v1/queries/find-session-by-token/find-session-by-token.http.controller'; 12 | 13 | const commandHandlers: Type[] = [ 14 | V1CreateSessionCommandHandler, 15 | V1RevokeSessionCommandHandler, 16 | ]; 17 | 18 | const commandControllers: Type[] = [V1RevokeSessionController]; 19 | 20 | const queryHandlers: Type[] = [ 21 | V1FindSessionByTokenQueryHandler, 22 | V1FindAllSessionsByUserQueryHandler, 23 | ]; 24 | 25 | const queryControllers: Type[] = [ 26 | V1FindCurrentSessionController, 27 | V1FindAllSessionsByCurrentUserController, 28 | V1FindAllSessionsByUserController, 29 | V1FindSessionByTokenController, 30 | ]; 31 | 32 | @Module({ 33 | controllers: [...queryControllers, ...commandControllers], 34 | providers: [...commandHandlers, ...queryHandlers], 35 | }) 36 | export class V1SessionModule {} 37 | -------------------------------------------------------------------------------- /src/application/user/v1/commands/create-user/create-user.handler.ts: -------------------------------------------------------------------------------- 1 | import { Inject, Logger } from '@nestjs/common'; 2 | import { 3 | CommandBus, 4 | CommandHandler, 5 | EventBus, 6 | EventPublisher, 7 | ICommandHandler, 8 | } from '@nestjs/cqrs'; 9 | 10 | import { OnUserCreatedEvent } from '@/domain/user/events/on-user-created.event'; 11 | import { User } from '@/domain/user/user.entity'; 12 | import { USER_REPOSITORY } from '@/infrastructure/repositories/modules/user/user.repository.constants'; 13 | import type { UserRepositoryPort } from '@/infrastructure/repositories/modules/user/user.repository.port'; 14 | 15 | import { V1CreateUserCommand } from './create-user.command'; 16 | 17 | type V1CreateUserCommandHandlerResponse = User; 18 | 19 | @CommandHandler(V1CreateUserCommand) 20 | export class V1CreateUserCommandHandler 21 | implements 22 | ICommandHandler 23 | { 24 | private readonly logger = new Logger(V1CreateUserCommandHandler.name); 25 | 26 | constructor( 27 | @Inject(USER_REPOSITORY) 28 | private readonly userRepository: UserRepositoryPort, 29 | private readonly eventPublisher: EventPublisher, 30 | private readonly eventBus: EventBus, 31 | ) {} 32 | 33 | static runHandler( 34 | bus: CommandBus, 35 | command: V1CreateUserCommand, 36 | ): Promise { 37 | return bus.execute< 38 | V1CreateUserCommand, 39 | V1CreateUserCommandHandlerResponse 40 | >(new V1CreateUserCommand(command.user)); 41 | } 42 | 43 | async execute( 44 | command: V1CreateUserCommand, 45 | ): Promise { 46 | this.logger.log(`Creating user ${command.user.email}`); 47 | 48 | const userEntity = this.eventPublisher.mergeObjectContext(command.user); 49 | 50 | await this.userRepository.create(userEntity); 51 | 52 | this.eventBus.publish(new OnUserCreatedEvent(userEntity)); 53 | 54 | userEntity.commit(); 55 | 56 | return userEntity; 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/application/session/v1/commands/revoke-session/revoke-session.handler.ts: -------------------------------------------------------------------------------- 1 | import { Inject, Logger } from '@nestjs/common'; 2 | import type { CommandBus, ICommandHandler } from '@nestjs/cqrs'; 3 | import { CommandHandler, EventPublisher } from '@nestjs/cqrs'; 4 | 5 | import { Session } from '@/domain/session/session.entity'; 6 | import { SESSION_REPOSITORY } from '@/infrastructure/repositories/modules/session/session.repository.constants'; 7 | import type { SessionRepositoryPort } from '@/infrastructure/repositories/modules/session/session.repository.port'; 8 | 9 | import { V1RevokeSessionCommand } from './revoke-session.command'; 10 | 11 | type V1RevokeSessionCommandHandlerResponse = Session; 12 | 13 | @CommandHandler(V1RevokeSessionCommand) 14 | export class V1RevokeSessionCommandHandler 15 | implements 16 | ICommandHandler< 17 | V1RevokeSessionCommand, 18 | V1RevokeSessionCommandHandlerResponse 19 | > 20 | { 21 | private readonly logger = new Logger(V1RevokeSessionCommandHandler.name); 22 | constructor( 23 | @Inject(SESSION_REPOSITORY) 24 | private readonly sessionRepository: SessionRepositoryPort, 25 | private readonly eventPublisher: EventPublisher, 26 | ) {} 27 | 28 | static runHandler( 29 | bus: CommandBus, 30 | command: V1RevokeSessionCommand, 31 | ): Promise { 32 | return bus.execute< 33 | V1RevokeSessionCommand, 34 | V1RevokeSessionCommandHandlerResponse 35 | >(new V1RevokeSessionCommand(command.session)); 36 | } 37 | 38 | async execute( 39 | command: V1RevokeSessionCommand, 40 | ): Promise { 41 | this.logger.log(`Revoking session ${command.session.token}`); 42 | 43 | const session = this.eventPublisher.mergeObjectContext(command.session); 44 | 45 | session.revoke(); 46 | 47 | await this.sessionRepository.update(session); 48 | 49 | session.commit(); 50 | 51 | this.logger.log(`Revoked Session ${session.token}`); 52 | 53 | return session; 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/application/authentication/v1/commands/confirm-forgot-password/confirm-forgot-password.http.controller.ts: -------------------------------------------------------------------------------- 1 | import { Body, Controller, HttpCode, Post } from '@nestjs/common'; 2 | import { CommandBus } from '@nestjs/cqrs'; 3 | import { ApiOperation, ApiTags } from '@nestjs/swagger'; 4 | import { Throttle } from '@nestjs/throttler'; 5 | 6 | import { Public } from '@/application/authentication/decorator/public.decorator'; 7 | import { V1ConfirmForgotPasswordCommandHandler } from '@/application/authentication/v1/commands/confirm-forgot-password/confirm-forgot-password.handler'; 8 | import { ApiStandardisedResponse } from '@/shared/decorator/api-standardised-response.decorator'; 9 | 10 | import { V1ConfirmForgotPasswordRequestDto } from './dto/confirm-forgot-password.request.dto'; 11 | 12 | @ApiTags('Authentication') 13 | @Controller({ 14 | version: '1', 15 | }) 16 | export class V1ConfirmForgotPasswordController { 17 | constructor(private readonly commandBus: CommandBus) {} 18 | 19 | @ApiOperation({ 20 | summary: 21 | 'Confirm Forgot Password - Confirm the forgot password request and reset the password', 22 | }) 23 | // Throttle the confirm-forgot-password endpoint to prevent brute force attacks (5 Requests per 1 minute) 24 | @ApiStandardisedResponse({ 25 | status: 200, 26 | description: 'Password has been reset successfully', 27 | }) 28 | @ApiStandardisedResponse({ 29 | status: 404, 30 | description: 'User is not found', 31 | }) 32 | @HttpCode(200) 33 | @Post('/authentication/confirm-forgot-password') 34 | @Public() 35 | @Throttle({ 36 | default: { 37 | limit: 5, 38 | ttl: 60 * 1000, 39 | }, 40 | }) 41 | async confirmForgotPassword( 42 | @Body() body: V1ConfirmForgotPasswordRequestDto, 43 | ): Promise { 44 | await V1ConfirmForgotPasswordCommandHandler.runHandler( 45 | this.commandBus, 46 | { 47 | resetPasswordToken: body.resetPasswordToken, 48 | newPassword: body.password, 49 | }, 50 | ); 51 | 52 | return Promise.resolve(); 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/application/user/v1/queries/find-user-by-email/find-user-by-email.http.controller.ts: -------------------------------------------------------------------------------- 1 | import { Controller, Get, Param } from '@nestjs/common'; 2 | import { QueryBus } from '@nestjs/cqrs'; 3 | import { ApiTags } from '@nestjs/swagger'; 4 | 5 | import { V1FindUserByEmailParamDto } from '@/application/user/v1/queries/find-user-by-email/dto/find-user-by-email.param.dto'; 6 | import { V1FindUserByEmailQueryHandler } from '@/application/user/v1/queries/find-user-by-email/find-user-by-email.handler'; 7 | import { AllStaffRoles } from '@/domain/user/user-role.enum'; 8 | import { ApiOperationWithRoles } from '@/shared/decorator/api-operation-with-roles.decorator'; 9 | import { ApiStandardisedResponse } from '@/shared/decorator/api-standardised-response.decorator'; 10 | import { GenericNotFoundException } from '@/shared/exceptions/not-found.exception'; 11 | 12 | import { V1FindUserByEmailResponseDto } from './dto/find-user-by-email.response.dto'; 13 | 14 | @ApiTags('User') 15 | @Controller({ 16 | version: '1', 17 | }) 18 | export class V1FindUserByEmailController { 19 | constructor(private readonly queryBus: QueryBus) {} 20 | 21 | @ApiOperationWithRoles( 22 | { 23 | summary: 'Find User by Email', 24 | description: 'Requires the user to be an read admin.', 25 | }, 26 | AllStaffRoles, 27 | ) 28 | @ApiStandardisedResponse( 29 | { 30 | status: 200, 31 | description: 'The User has been successfully found.', 32 | }, 33 | V1FindUserByEmailResponseDto, 34 | ) 35 | @ApiStandardisedResponse({ 36 | status: 404, 37 | description: 'The User could not be found.', 38 | }) 39 | @Get('/user/email/:email') 40 | async findUserById( 41 | @Param() params: V1FindUserByEmailParamDto, 42 | ): Promise { 43 | const user = await V1FindUserByEmailQueryHandler.runHandler( 44 | this.queryBus, 45 | { 46 | email: params.email, 47 | }, 48 | ); 49 | 50 | if (!user) { 51 | throw new GenericNotFoundException('User not found'); 52 | } 53 | 54 | return { user }; 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/infrastructure/config/config-schema.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod'; 2 | 3 | const StringToNumber = z.preprocess((x) => Number(x), z.number()); 4 | const StringToNumberOptional = z.preprocess( 5 | (x) => Number(x), 6 | z.number().optional(), 7 | ); 8 | const StringToBoolean = z.preprocess((x) => x === 'true', z.boolean()); 9 | 10 | export const ConfigSchema = z.object({ 11 | // Main 12 | NODE_ENV: z 13 | .enum(['development', 'test', 'production']) 14 | .default('development'), 15 | PORT: StringToNumber.default(3000), 16 | APP_NAME: z.string().default('EnterpriseNest'), 17 | BEHIND_PROXY: StringToBoolean.default('false'), 18 | DEBUG: StringToBoolean.default('false'), 19 | 20 | // Redis 21 | REDIS_HOST: z.string().default('localhost'), 22 | REDIS_PORT: StringToNumber.default(6379), 23 | REDIS_DB: StringToNumber.default(0), 24 | REDIS_USERNAME: z.string().default(''), 25 | REDIS_PASSWORD: z.string().default(''), 26 | 27 | // Cache 28 | CACHE_TTL_MS: StringToNumber.default(60000), 29 | CACHE_USE_REDIS: StringToBoolean.default('false'), 30 | 31 | // Throttler / Rate Limiter 32 | THROTTLER_DEFAULT_TTL_MS: StringToNumber.default(60000), 33 | THROTTLER_DEFAULT_LIMIT: StringToNumber.default(100), 34 | THROTTLER_USE_REDIS: StringToBoolean.default('false'), 35 | 36 | // Authentication 37 | AUTH_IP_STRICT: StringToBoolean.default('false'), 38 | AUTH_AUTO_VERIFY: StringToBoolean.default('false'), 39 | 40 | // JWT 41 | JWT_SECRET: z.string(), 42 | 43 | // Token 44 | TOKEN_ACCESS_SECRET: z.string(), 45 | TOKEN_REFRESH_SECRET: z.string(), 46 | TOKEN_VERIFICATION_SECRET: z.string(), 47 | TOKEN_RESET_PASSWORD_SECRET: z.string(), 48 | 49 | TOKEN_ACCESS_TOKEN_EXPIRATION: StringToNumberOptional.optional(), 50 | TOKEN_REFRESH_TOKEN_EXPIRATION: StringToNumberOptional.optional(), 51 | TOKEN_VERIFICATION_TOKEN_EXPIRATION: StringToNumberOptional.optional(), 52 | TOKEN_RESET_PASSWORD_TOKEN_EXPIRATION: StringToNumberOptional.optional(), 53 | 54 | // Email 55 | EMAIL_FROM: z.string().email().default('no-reply@test.com'), 56 | }); 57 | 58 | export type Config = z.infer; 59 | -------------------------------------------------------------------------------- /src/application/user/v1/queries/find-all-users/find-all-users.handler.spec.ts: -------------------------------------------------------------------------------- 1 | import { CqrsModule, QueryBus } from '@nestjs/cqrs'; 2 | import { JwtModule } from '@nestjs/jwt'; 3 | import type { TestingModule } from '@nestjs/testing'; 4 | import { Test } from '@nestjs/testing'; 5 | import { CreateMockUser } from '@tests/utils/create-mocks'; 6 | 7 | import { User } from '@/domain/user/user.entity'; 8 | import { USER_REPOSITORY } from '@/infrastructure/repositories/modules/user/user.repository.constants'; 9 | import { UserRepositoryPort } from '@/infrastructure/repositories/modules/user/user.repository.port'; 10 | import { MockRepositoriesModule } from '@/infrastructure/repositories/presets/mock-repositories.module'; 11 | 12 | import { V1FindAllUsersQueryHandler } from './find-all-users.handler'; 13 | 14 | describe('findAllUsersQueryHandler', () => { 15 | let mockUserRepository: UserRepositoryPort; 16 | let testUsers: User[]; 17 | let queryBus: QueryBus; 18 | 19 | beforeEach(async () => { 20 | const module: TestingModule = await Test.createTestingModule({ 21 | imports: [ 22 | CqrsModule, 23 | JwtModule.register({ 24 | secret: 'test', 25 | }), 26 | MockRepositoriesModule, 27 | ], 28 | providers: [V1FindAllUsersQueryHandler], 29 | }).compile(); 30 | 31 | await module.init(); 32 | 33 | mockUserRepository = module.get(USER_REPOSITORY); 34 | queryBus = module.get(QueryBus); 35 | 36 | testUsers = [...Array.from({ length: 100 }, () => CreateMockUser())]; 37 | 38 | await Promise.all( 39 | testUsers.map((user) => mockUserRepository.create(user)), 40 | ); 41 | }); 42 | 43 | it('should find the users', async () => { 44 | const user = 45 | testUsers[Math.floor(Math.random() * testUsers.length)] ?? 46 | CreateMockUser(); 47 | 48 | const result = await V1FindAllUsersQueryHandler.runHandler(queryBus); 49 | 50 | expect(result.length).toEqual(100); 51 | expect(result).toContain(user); 52 | expect(result).toEqual(expect.arrayContaining(testUsers)); 53 | }); 54 | }); 55 | -------------------------------------------------------------------------------- /src/application/user/v1/queries/find-user-by-id/find-user-by-id.handler.spec.ts: -------------------------------------------------------------------------------- 1 | import { CqrsModule, QueryBus } from '@nestjs/cqrs'; 2 | import { JwtModule } from '@nestjs/jwt'; 3 | import type { TestingModule } from '@nestjs/testing'; 4 | import { Test } from '@nestjs/testing'; 5 | import { CreateMockUser } from '@tests/utils/create-mocks'; 6 | 7 | import { User } from '@/domain/user/user.entity'; 8 | import { USER_REPOSITORY } from '@/infrastructure/repositories/modules/user/user.repository.constants'; 9 | import { UserRepositoryPort } from '@/infrastructure/repositories/modules/user/user.repository.port'; 10 | import { MockRepositoriesModule } from '@/infrastructure/repositories/presets/mock-repositories.module'; 11 | 12 | import { V1FindUserByIDQueryHandler } from './find-user-by-id.handler'; 13 | import { V1FindUserByIDQuery } from './find-user-by-id.query'; 14 | 15 | describe('findUserByIDQueryHandler', () => { 16 | let mockUserRepository: UserRepositoryPort; 17 | let testUsers: User[]; 18 | let queryBus: QueryBus; 19 | 20 | beforeEach(async () => { 21 | const module: TestingModule = await Test.createTestingModule({ 22 | imports: [ 23 | CqrsModule, 24 | JwtModule.register({ 25 | secret: 'test', 26 | }), 27 | MockRepositoriesModule, 28 | ], 29 | providers: [V1FindUserByIDQueryHandler], 30 | }).compile(); 31 | 32 | await module.init(); 33 | 34 | mockUserRepository = module.get(USER_REPOSITORY); 35 | queryBus = module.get(QueryBus); 36 | 37 | testUsers = [...Array.from({ length: 100 }, () => CreateMockUser())]; 38 | 39 | await Promise.all( 40 | testUsers.map((user) => mockUserRepository.create(user)), 41 | ); 42 | }); 43 | 44 | it('should find the user', async () => { 45 | const user = 46 | testUsers[Math.floor(Math.random() * testUsers.length)] ?? 47 | CreateMockUser(); 48 | 49 | const result = await V1FindUserByIDQueryHandler.runHandler( 50 | queryBus, 51 | new V1FindUserByIDQuery(user.id), 52 | ); 53 | 54 | expect(result).toEqual(user); 55 | }); 56 | }); 57 | -------------------------------------------------------------------------------- /src/application/user/v1/queries/find-user-by-email/find-user-by-email.handler.spec.ts: -------------------------------------------------------------------------------- 1 | import { CqrsModule, QueryBus } from '@nestjs/cqrs'; 2 | import { JwtModule } from '@nestjs/jwt'; 3 | import type { TestingModule } from '@nestjs/testing'; 4 | import { Test } from '@nestjs/testing'; 5 | import { CreateMockUser } from '@tests/utils/create-mocks'; 6 | 7 | import { User } from '@/domain/user/user.entity'; 8 | import { USER_REPOSITORY } from '@/infrastructure/repositories/modules/user/user.repository.constants'; 9 | import { UserRepositoryPort } from '@/infrastructure/repositories/modules/user/user.repository.port'; 10 | import { MockRepositoriesModule } from '@/infrastructure/repositories/presets/mock-repositories.module'; 11 | 12 | import { V1FindUserByEmailQueryHandler } from './find-user-by-email.handler'; 13 | import { V1FindUserByEmailQuery } from './find-user-by-email.query'; 14 | 15 | describe('findUserByEmailQueryHandler', () => { 16 | let mockUserRepository: UserRepositoryPort; 17 | let testUsers: User[]; 18 | let queryBus: QueryBus; 19 | 20 | beforeEach(async () => { 21 | const module: TestingModule = await Test.createTestingModule({ 22 | imports: [ 23 | CqrsModule, 24 | JwtModule.register({ 25 | secret: 'test', 26 | }), 27 | MockRepositoriesModule, 28 | ], 29 | providers: [V1FindUserByEmailQueryHandler], 30 | }).compile(); 31 | 32 | await module.init(); 33 | 34 | mockUserRepository = module.get(USER_REPOSITORY); 35 | queryBus = module.get(QueryBus); 36 | 37 | testUsers = [...Array.from({ length: 100 }, () => CreateMockUser())]; 38 | 39 | await Promise.all( 40 | testUsers.map((user) => mockUserRepository.create(user)), 41 | ); 42 | }); 43 | 44 | it('should find the user', async () => { 45 | const user = 46 | testUsers[Math.floor(Math.random() * testUsers.length)] ?? 47 | CreateMockUser(); 48 | 49 | const result = await V1FindUserByEmailQueryHandler.runHandler( 50 | queryBus, 51 | new V1FindUserByEmailQuery(user.email), 52 | ); 53 | 54 | expect(result).toEqual(user); 55 | }); 56 | }); 57 | -------------------------------------------------------------------------------- /src/application/user/v1/commands/create-user/create-user.handler.spec.ts: -------------------------------------------------------------------------------- 1 | import { CommandBus, CqrsModule } from '@nestjs/cqrs'; 2 | import { JwtModule } from '@nestjs/jwt'; 3 | import type { TestingModule } from '@nestjs/testing'; 4 | import { Test } from '@nestjs/testing'; 5 | 6 | import { User } from '@/domain/user/user.entity'; 7 | import { MockRepositoriesModule } from '@/infrastructure/repositories/presets/mock-repositories.module'; 8 | import { GenericAlreadyExistsException } from '@/shared/exceptions/already-exists.exception'; 9 | 10 | import { V1CreateUserCommand } from './create-user.command'; 11 | import { V1CreateUserCommandHandler } from './create-user.handler'; 12 | 13 | describe('createUserCommandHandler', () => { 14 | let testUser: User; 15 | let commandBus: CommandBus; 16 | 17 | beforeEach(async () => { 18 | const module: TestingModule = await Test.createTestingModule({ 19 | imports: [ 20 | CqrsModule, 21 | JwtModule.register({ 22 | secret: 'test', 23 | }), 24 | MockRepositoriesModule, 25 | ], 26 | providers: [V1CreateUserCommandHandler], 27 | }).compile(); 28 | 29 | await module.init(); 30 | 31 | commandBus = module.get(CommandBus); 32 | 33 | testUser = User.create({ 34 | email: 'john.doe@mail.com', 35 | password: 36 | '$argon2id$v=19$m=16,t=2,p=1$b29mYXNkZmFzZGZh$TXcefCmDL26dVuPHuMfCrg', 37 | }); 38 | }); 39 | 40 | it('should create user', async () => { 41 | const result = await V1CreateUserCommandHandler.runHandler( 42 | commandBus, 43 | new V1CreateUserCommand(testUser), 44 | ); 45 | 46 | expect(result).toEqual(testUser); 47 | }); 48 | 49 | it('should return undefined if user not found', async () => { 50 | await V1CreateUserCommandHandler.runHandler( 51 | commandBus, 52 | new V1CreateUserCommand(testUser), 53 | ); 54 | 55 | await expect( 56 | V1CreateUserCommandHandler.runHandler( 57 | commandBus, 58 | new V1CreateUserCommand(testUser), 59 | ), 60 | ).rejects.toThrow(GenericAlreadyExistsException); 61 | }); 62 | }); 63 | -------------------------------------------------------------------------------- /src/application/authentication/v1/commands/refresh/refresh.http.controller.ts: -------------------------------------------------------------------------------- 1 | import { Controller, HttpCode, Post, Req, UseGuards } from '@nestjs/common'; 2 | import { CommandBus } from '@nestjs/cqrs'; 3 | import { ApiSecurity, ApiTags } from '@nestjs/swagger'; 4 | 5 | import { CurrentUser } from '@/application/authentication/decorator/current-user.decorator'; 6 | import { Public } from '@/application/authentication/decorator/public.decorator'; 7 | import { RefreshTokenGuard } from '@/application/authentication/strategies/refresh-token/refresh-token.guard'; 8 | import { V1RefreshTokenCommandHandler } from '@/application/authentication/v1/commands/refresh/refresh.handler'; 9 | import { User } from '@/domain/user/user.entity'; 10 | import { ApiOperationWithRoles } from '@/shared/decorator/api-operation-with-roles.decorator'; 11 | import { ApiStandardisedResponse } from '@/shared/decorator/api-standardised-response.decorator'; 12 | import type { RequestWithUser } from '@/types/express/request-with-user'; 13 | 14 | import { V1RefreshTokenResponseDto } from './dto/refresh.response.dto'; 15 | 16 | @ApiTags('Authentication') 17 | @Controller({ 18 | version: '1', 19 | }) 20 | export class V1RefreshTokenController { 21 | constructor(private readonly commandBus: CommandBus) {} 22 | 23 | @ApiOperationWithRoles({ 24 | summary: 25 | 'RefreshToken to a User Account and get access and refresh token', 26 | }) // This is to bypass the AccessTokenGuard 27 | @ApiSecurity('refresh-token') 28 | @ApiStandardisedResponse( 29 | { 30 | status: 201, 31 | description: 'Tokens Refreshed Successfully', 32 | }, 33 | V1RefreshTokenResponseDto, 34 | ) 35 | @ApiStandardisedResponse({ 36 | status: 401, 37 | description: 'User is Not Verified or Email or Password is Incorrect', 38 | }) 39 | @HttpCode(201) 40 | @Post('/authentication/refresh') 41 | @Public() 42 | @UseGuards(RefreshTokenGuard) 43 | async refreshToken( 44 | @Req() request: RequestWithUser, 45 | @CurrentUser() user: User, 46 | ): Promise { 47 | return V1RefreshTokenCommandHandler.runHandler(this.commandBus, { 48 | user, 49 | session: request.session, 50 | ip: request.ip, 51 | }); 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/application/authentication/v1/commands/login/login.http.controller.ts: -------------------------------------------------------------------------------- 1 | import { Controller, HttpCode, Post, Req, UseGuards } from '@nestjs/common'; 2 | import { CommandBus } from '@nestjs/cqrs'; 3 | import { ApiBody, ApiOperation, ApiTags } from '@nestjs/swagger'; 4 | import { Throttle } from '@nestjs/throttler'; 5 | 6 | import { CurrentUser } from '@/application/authentication/decorator/current-user.decorator'; 7 | import { Public } from '@/application/authentication/decorator/public.decorator'; 8 | import { LocalAuthGuard } from '@/application/authentication/strategies/local/local.guard'; 9 | import { V1LoginCommandHandler } from '@/application/authentication/v1/commands/login/login.handler'; 10 | import { User } from '@/domain/user/user.entity'; 11 | import { ApiStandardisedResponse } from '@/shared/decorator/api-standardised-response.decorator'; 12 | import type { RequestWithUser } from '@/types/express/request-with-user'; 13 | 14 | import { V1LoginRequestDto } from './dto/login.request.dto'; 15 | import { V1LoginResponseDto } from './dto/login.response.dto'; 16 | 17 | @ApiTags('Authentication') 18 | @Controller({ 19 | version: '1', 20 | }) 21 | export class V1LoginController { 22 | constructor(private readonly commandBus: CommandBus) {} 23 | 24 | @ApiBody({ type: V1LoginRequestDto }) 25 | // Throttle the login endpoint to prevent brute force attacks (5 Requests per 1 minute) 26 | @ApiOperation({ 27 | summary: 'Login to a User Account and get access and refresh token', 28 | }) 29 | @ApiStandardisedResponse( 30 | { 31 | status: 200, 32 | description: 'User Logged In Successfully', 33 | }, 34 | V1LoginResponseDto, 35 | ) 36 | @ApiStandardisedResponse({ 37 | status: 401, 38 | description: 'User is Not Verified or Email or Password is Incorrect', 39 | }) 40 | @HttpCode(200) 41 | @Post('/authentication/login') 42 | @Public() 43 | @Throttle({ 44 | default: { 45 | limit: 5, 46 | ttl: 60 * 1000, 47 | }, 48 | }) 49 | @UseGuards(LocalAuthGuard) 50 | async login( 51 | @Req() request: RequestWithUser, 52 | @CurrentUser() user: User, 53 | ): Promise { 54 | return V1LoginCommandHandler.runHandler(this.commandBus, { 55 | user, 56 | ip: request.ip, 57 | }); 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /src/application/authentication/v1/commands/register/register.http.controller.ts: -------------------------------------------------------------------------------- 1 | import { Body, Controller, Post, Req } from '@nestjs/common'; 2 | import { CommandBus } from '@nestjs/cqrs'; 3 | import { ApiOperation, ApiTags } from '@nestjs/swagger'; 4 | import { Throttle } from '@nestjs/throttler'; 5 | 6 | import { Public } from '@/application/authentication/decorator/public.decorator'; 7 | import { V1RegisterCommandHandler } from '@/application/authentication/v1/commands/register/register.handler'; 8 | import { User } from '@/domain/user/user.entity'; 9 | import { ApiStandardisedResponse } from '@/shared/decorator/api-standardised-response.decorator'; 10 | import type { RequestWithUser } from '@/types/express/request-with-user'; 11 | 12 | import { V1RegisterRequestDto } from './dto/register.request.dto'; 13 | import { V1RegisterResponseDto } from './dto/register.response.dto'; 14 | 15 | @ApiTags('Authentication') 16 | @Controller({ 17 | version: '1', 18 | }) 19 | export class V1RegisterController { 20 | constructor(private readonly commandBus: CommandBus) {} 21 | 22 | @ApiOperation({ 23 | summary: 'Register to a User', 24 | }) 25 | // Throttle the register endpoint to prevent brute force attacks (10 Requests per minute) 26 | @ApiStandardisedResponse( 27 | { 28 | status: 201, 29 | description: 'User Was Registered Successfully', 30 | }, 31 | V1RegisterResponseDto, 32 | ) 33 | @ApiStandardisedResponse({ 34 | status: 409, 35 | description: 'User Already Exists', 36 | }) 37 | @ApiStandardisedResponse({ 38 | status: 400, 39 | description: 'Validation Error', 40 | }) 41 | @Post('/authentication/register') 42 | @Public() 43 | @Throttle({ 44 | default: { 45 | limit: 10, 46 | ttl: 60 * 1000, 47 | }, 48 | }) 49 | async register( 50 | @Req() request: RequestWithUser, 51 | @Body() body: V1RegisterRequestDto, 52 | ): Promise { 53 | const user = User.create({ 54 | firstName: body.firstName, 55 | lastName: body.lastName, 56 | email: body.email, 57 | password: body.password, 58 | }); 59 | 60 | return V1RegisterCommandHandler.runHandler(this.commandBus, { 61 | user, 62 | ip: request.ip, 63 | }); 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /src/domain/user/user.test.ts: -------------------------------------------------------------------------------- 1 | import { CreateMockUser } from '@tests/utils/create-mocks'; 2 | 3 | import { GenericInternalValidationException } from '@/shared/exceptions/internal-validation.exception'; 4 | 5 | import { UserRoleEnum } from './user-role.enum'; 6 | import { CreateUserProps, User } from './user.entity'; 7 | 8 | describe('user', () => { 9 | let user: User; 10 | 11 | beforeEach(() => { 12 | user = CreateMockUser(); 13 | }); 14 | 15 | test('user can be created', () => { 16 | expect(user.isPasswordHashed).toBe(true); 17 | expect(user.id).toBeDefined(); 18 | }); 19 | 20 | test('user first name can be updated', () => { 21 | user.firstName = 'Jane'; 22 | expect(user.firstName).toBe('Jane'); 23 | }); 24 | 25 | test('user last name can be updated', () => { 26 | user.lastName = 'Smith'; 27 | expect(user.lastName).toBe('Smith'); 28 | }); 29 | 30 | test('user email can be updated', () => { 31 | user.email = 'oof@email.com'; 32 | expect(user.email).toBe('oof@email.com'); 33 | }); 34 | 35 | test('user verifiedAt can be updated', () => { 36 | expect(user.verifiedAt).toBeUndefined(); 37 | const date = new Date(); 38 | user.verifiedAt = date; 39 | expect(user.verifiedAt).toBe(date); 40 | }); 41 | 42 | test('user password can be updated', () => { 43 | user.password = 'Password123!'; 44 | expect(user.password).toBe('Password123!'); 45 | expect(user.isPasswordHashed).toBe(false); 46 | }); 47 | 48 | test('user User Role can be admin and reviewer and user and admin read-only', () => { 49 | user.makeAdmin(); 50 | expect(user.role).toBe(UserRoleEnum.ADMIN); 51 | user.makeUser(); 52 | expect(user.role).toBe(UserRoleEnum.USER); 53 | }); 54 | 55 | test('validationException thrown when invalid data provided', () => { 56 | const invalidUserDataMock: CreateUserProps = { 57 | email: 'john.doe', 58 | password: 'password', 59 | }; 60 | 61 | expect(() => { 62 | User.create(invalidUserDataMock); 63 | }).toThrow(GenericInternalValidationException); 64 | }); 65 | 66 | // added 67 | test('user User Role can be updated', () => { 68 | user.role = UserRoleEnum.DEVELOPER; 69 | expect(user.role).toBe(UserRoleEnum.DEVELOPER); 70 | }); 71 | }); 72 | -------------------------------------------------------------------------------- /src/infrastructure/repositories/modules/session/mock/mock.session.repository.test.ts: -------------------------------------------------------------------------------- 1 | import { CreateMockUser } from '@tests/utils/create-mocks'; 2 | 3 | import { Session } from '@/domain/session/session.entity'; 4 | import { User } from '@/domain/user/user.entity'; 5 | import { MockSessionRepository } from '@/infrastructure/repositories/modules/session/mock/mock.session.repository'; 6 | 7 | describe('mockSessionRepository', () => { 8 | let mockSessionRepository: MockSessionRepository; 9 | let testSession: Session; 10 | let user: User; 11 | 12 | beforeEach(() => { 13 | mockSessionRepository = new MockSessionRepository(); 14 | user = CreateMockUser(); 15 | 16 | testSession = Session.create({ 17 | userId: user.id, 18 | ip: '1.1.1.1', 19 | }); 20 | }); 21 | 22 | test('should find a Session by userID', async () => { 23 | await mockSessionRepository.create(testSession); 24 | expect(await mockSessionRepository.findAllByUserID(user.id)).toEqual([ 25 | testSession, 26 | ]); 27 | }); 28 | 29 | test('should find a Session by user', async () => { 30 | const tempSession = Session.create({ userId: user.id, ip: '1.1.1.1' }); 31 | 32 | await mockSessionRepository.create(tempSession); 33 | expect(await mockSessionRepository.findAllByUser(user)).toEqual([ 34 | tempSession, 35 | ]); 36 | }); 37 | 38 | test('should find a Session by token', async () => { 39 | await mockSessionRepository.create(testSession); 40 | expect( 41 | await mockSessionRepository.findOneByToken(testSession.token), 42 | ).toEqual(testSession); 43 | }); 44 | 45 | test('should find all revoked Sessions', async () => { 46 | await mockSessionRepository.create(testSession); 47 | expect(await mockSessionRepository.findAllRevoked()).toHaveLength(0); 48 | }); 49 | 50 | test('should find all not revoked Sessions', async () => { 51 | await mockSessionRepository.create(testSession); 52 | expect(await mockSessionRepository.findAllNotRevoked()).toHaveLength(1); 53 | }); 54 | 55 | test('should find all Sessions by IP', async () => { 56 | await mockSessionRepository.create(testSession); 57 | expect( 58 | await mockSessionRepository.findAllByIP(testSession.ip ?? ''), 59 | ).toHaveLength(1); 60 | }); 61 | }); 62 | -------------------------------------------------------------------------------- /src/application/user/v1/commands/create-user/create-user.http.controller.ts: -------------------------------------------------------------------------------- 1 | import { Body, Controller, Post } from '@nestjs/common'; 2 | import { CommandBus } from '@nestjs/cqrs'; 3 | import { ApiTags } from '@nestjs/swagger'; 4 | import { generate as generatePassword } from 'generate-password'; 5 | 6 | import { AllStaffRoles } from '@/domain/user/user-role.enum'; 7 | import { User } from '@/domain/user/user.entity'; 8 | import { ApiOperationWithRoles } from '@/shared/decorator/api-operation-with-roles.decorator'; 9 | import { ApiStandardisedResponse } from '@/shared/decorator/api-standardised-response.decorator'; 10 | 11 | import { V1CreateUserCommand } from './create-user.command'; 12 | import { V1CreateUserCommandHandler } from './create-user.handler'; 13 | import { V1CreateUserRequestDto } from './dto/create-user.request.dto'; 14 | import { V1CreateUserResponseDto } from './dto/create-user.response.dto'; 15 | 16 | @ApiTags('User') 17 | @Controller({ 18 | version: '1', 19 | }) 20 | export class V1CreateUserController { 21 | constructor(private readonly commandBus: CommandBus) {} 22 | 23 | @ApiOperationWithRoles( 24 | { 25 | summary: 'Creates a User', 26 | description: 27 | 'Requires the user to be an write admin. Creates a new User with the provided data. The password is randomly generated and returned in the response.', 28 | }, 29 | AllStaffRoles, 30 | ) 31 | @ApiStandardisedResponse( 32 | { 33 | status: 201, 34 | description: 'The User has been successfully created.', 35 | }, 36 | V1CreateUserResponseDto, 37 | ) 38 | @Post('/user') 39 | async createUser( 40 | @Body() body: V1CreateUserRequestDto, 41 | ): Promise { 42 | const password = generatePassword({ 43 | length: 10, 44 | numbers: true, 45 | symbols: true, 46 | uppercase: true, 47 | strict: true, 48 | }); 49 | 50 | const createdUser = await V1CreateUserCommandHandler.runHandler( 51 | this.commandBus, 52 | new V1CreateUserCommand( 53 | User.create({ 54 | ...body, 55 | password, 56 | }), 57 | ), 58 | ); 59 | 60 | return { 61 | user: createdUser, 62 | password, 63 | }; 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /src/application/authentication/v1/v1-authentication.module.ts: -------------------------------------------------------------------------------- 1 | import { Module, Type } from '@nestjs/common'; 2 | 3 | import { V1ConfirmForgotPasswordCommandHandler } from '@/application/authentication/v1/commands/confirm-forgot-password/confirm-forgot-password.handler'; 4 | import { V1ConfirmForgotPasswordController } from '@/application/authentication/v1/commands/confirm-forgot-password/confirm-forgot-password.http.controller'; 5 | import { V1ForgotPasswordCommandHandler } from '@/application/authentication/v1/commands/forgot-password/forgot-password.handler'; 6 | import { V1ForgotPasswordController } from '@/application/authentication/v1/commands/forgot-password/forgot-password.http.controller'; 7 | import { V1LoginCommandHandler } from '@/application/authentication/v1/commands/login/login.handler'; 8 | import { V1LoginController } from '@/application/authentication/v1/commands/login/login.http.controller'; 9 | import { V1LogoutController } from '@/application/authentication/v1/commands/logout/logout.http.controller'; 10 | import { V1RefreshTokenCommandHandler } from '@/application/authentication/v1/commands/refresh/refresh.handler'; 11 | import { V1RefreshTokenController } from '@/application/authentication/v1/commands/refresh/refresh.http.controller'; 12 | import { V1RegisterCommandHandler } from '@/application/authentication/v1/commands/register/register.handler'; 13 | import { V1RegisterController } from '@/application/authentication/v1/commands/register/register.http.controller'; 14 | import { V1ValidateCredentialsQueryHandler } from '@/application/authentication/v1/queries/validate-credentials/validate-credentials.handler'; 15 | import { HashingService } from '@/shared/services/hashing/hashing.service'; 16 | 17 | const QueryHandlers: Type[] = [V1ValidateCredentialsQueryHandler]; 18 | 19 | const CommandHandlers: Type[] = [ 20 | V1LoginCommandHandler, 21 | V1RegisterCommandHandler, 22 | V1RefreshTokenCommandHandler, 23 | V1ForgotPasswordCommandHandler, 24 | V1ConfirmForgotPasswordCommandHandler, 25 | ]; 26 | const CommandControllers: Type[] = [ 27 | V1LoginController, 28 | V1RegisterController, 29 | V1RefreshTokenController, 30 | V1LogoutController, 31 | V1ForgotPasswordController, 32 | V1ConfirmForgotPasswordController, 33 | ]; 34 | 35 | @Module({ 36 | imports: [], 37 | controllers: [...CommandControllers], 38 | providers: [...QueryHandlers, ...CommandHandlers, HashingService], 39 | }) 40 | export class V1AuthenticationModule {} 41 | -------------------------------------------------------------------------------- /src/application/authentication/strategies/local/local.strategy.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { EventBus, QueryBus } from '@nestjs/cqrs'; 3 | import { PassportStrategy } from '@nestjs/passport'; 4 | import { Strategy } from 'passport-local'; 5 | 6 | import { V1ValidateCredentialsQueryHandler } from '@/application/authentication/v1/queries/validate-credentials/validate-credentials.handler'; 7 | import { OnLoginUnverifiedEvent } from '@/domain/authentication/events/on-login-unverified.event'; 8 | import { AuthenticationNoEmailMatchException } from '@/domain/authentication/exceptions/no-email-match.exception'; 9 | import { AuthenticationPasswordIncorrectException } from '@/domain/authentication/exceptions/password-incorrect.exception'; 10 | import { GenericNoPermissionException } from '@/shared/exceptions/no-permission.exception'; 11 | import { GenericUnauthenticatedException } from '@/shared/exceptions/unauthenticated.exception'; 12 | import { RequestWithUser } from '@/types/express/request-with-user'; 13 | 14 | @Injectable() 15 | export class LocalStrategy extends PassportStrategy(Strategy) { 16 | constructor( 17 | private readonly queryBus: QueryBus, 18 | private readonly eventBus: EventBus, 19 | ) { 20 | super({ 21 | usernameField: 'email', 22 | passwordField: 'password', 23 | passReqToCallback: true, 24 | }); 25 | } 26 | 27 | async validate( 28 | request: RequestWithUser, 29 | email: string, 30 | password: string, 31 | ): Promise { 32 | const user = await V1ValidateCredentialsQueryHandler.runHandler( 33 | this.queryBus, 34 | { 35 | email, 36 | password, 37 | }, 38 | ).catch((error: unknown) => { 39 | if ( 40 | error instanceof AuthenticationNoEmailMatchException || 41 | error instanceof AuthenticationPasswordIncorrectException 42 | ) { 43 | throw new GenericUnauthenticatedException( 44 | "Email or password doesn't match", 45 | ); 46 | } 47 | 48 | throw error; 49 | }); 50 | 51 | if (!user.verifiedAt) { 52 | this.eventBus.publish(new OnLoginUnverifiedEvent(user, request.ip)); 53 | throw new GenericNoPermissionException('User is not verified'); 54 | } 55 | 56 | return user; 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/application/user/v1/v1-user.module.ts: -------------------------------------------------------------------------------- 1 | import { Module, Type } from '@nestjs/common'; 2 | 3 | import { V1CreateUserCommandHandler } from '@/application/user/v1/commands/create-user/create-user.handler'; 4 | import { V1CreateUserController } from '@/application/user/v1/commands/create-user/create-user.http.controller'; 5 | import { V1DeleteUserCommandHandler } from '@/application/user/v1/commands/delete-user/delete-user.handler'; 6 | import { V1DeleteUserController } from '@/application/user/v1/commands/delete-user/delete-user.http.controller'; 7 | import { V1UpdateUserCommandHandler } from '@/application/user/v1/commands/update-user/update-user.handler'; 8 | import { V1UpdateUserController } from '@/application/user/v1/commands/update-user/update-user.http.controller'; 9 | import { V1FindAllUsersQueryHandler } from '@/application/user/v1/queries/find-all-users/find-all-users.handler'; 10 | import { V1FindAllUsersController } from '@/application/user/v1/queries/find-all-users/find-all-users.http.controller'; 11 | import { V1FindCurrentUserController } from '@/application/user/v1/queries/find-current-user/find-current-user.http.controller'; 12 | import { V1FindUserByEmailQueryHandler } from '@/application/user/v1/queries/find-user-by-email/find-user-by-email.handler'; 13 | import { V1FindUserByEmailController } from '@/application/user/v1/queries/find-user-by-email/find-user-by-email.http.controller'; 14 | import { V1FindUserByIDQueryHandler } from '@/application/user/v1/queries/find-user-by-id/find-user-by-id.handler'; 15 | import { V1FindUserByIDController } from '@/application/user/v1/queries/find-user-by-id/find-user-by-id.http.controller'; 16 | 17 | const QueryHandlers: Type[] = [ 18 | V1FindUserByEmailQueryHandler, 19 | V1FindUserByIDQueryHandler, 20 | V1FindAllUsersQueryHandler, 21 | ]; 22 | const QueryControllers: Type[] = [ 23 | V1FindUserByEmailController, 24 | V1FindCurrentUserController, 25 | V1FindUserByIDController, 26 | V1FindAllUsersController, 27 | ]; 28 | 29 | const CommandHandlers: Type[] = [ 30 | V1CreateUserCommandHandler, 31 | V1UpdateUserCommandHandler, 32 | V1DeleteUserCommandHandler, 33 | ]; 34 | const CommandControllers: Type[] = [ 35 | V1CreateUserController, 36 | V1UpdateUserController, 37 | V1DeleteUserController, 38 | ]; 39 | 40 | @Module({ 41 | imports: [], 42 | controllers: [...CommandControllers, ...QueryControllers], 43 | providers: [...QueryHandlers, ...CommandHandlers], 44 | }) 45 | export class V1UserModule {} 46 | -------------------------------------------------------------------------------- /src/infrastructure/repositories/modules/user/mock/mock.user.repository.ts: -------------------------------------------------------------------------------- 1 | import { type ClassProvider, Injectable } from '@nestjs/common'; 2 | 3 | import { User } from '@/domain/user/user.entity'; 4 | import { AbstractMockRepository } from '@/infrastructure/repositories/modules/mock/abstracts/mock.repository'; 5 | import { USER_REPOSITORY } from '@/infrastructure/repositories/modules/user/user.repository.constants'; 6 | import { UserRepositoryPort } from '@/infrastructure/repositories/modules/user/user.repository.port'; 7 | import { GenericAlreadyExistsException } from '@/shared/exceptions/already-exists.exception'; 8 | import { GenericNotFoundException } from '@/shared/exceptions/not-found.exception'; 9 | import { HashingService } from '@/shared/services/hashing/hashing.service'; 10 | 11 | @Injectable() 12 | export class MockUserRepository 13 | extends AbstractMockRepository 14 | implements UserRepositoryPort 15 | { 16 | constructor(private readonly hashingService: HashingService) { 17 | super(); 18 | } 19 | 20 | findOneByEmail: (email: string) => Promise = async ( 21 | email: string, 22 | ) => { 23 | return Promise.resolve(this.data.find((user) => user.email === email)); 24 | }; 25 | 26 | create: (entity: User) => Promise = async (entity: User) => { 27 | if (await this.findOneById(entity.id)) { 28 | throw new GenericAlreadyExistsException(); 29 | } 30 | 31 | if (await this.findOneByEmail(entity.email)) { 32 | throw new GenericAlreadyExistsException(); 33 | } 34 | 35 | if (!entity.isPasswordHashed) { 36 | entity.password = await this.hashingService.hash(entity.password); 37 | } 38 | 39 | this.data.push(entity); 40 | return Promise.resolve(entity); 41 | }; 42 | update: (entity: User) => Promise = async (entity: User) => { 43 | const index = this.data.findIndex((user) => user.id === entity.id); 44 | 45 | if (index === -1) { 46 | throw new GenericNotFoundException(); 47 | } 48 | 49 | if (!entity.isPasswordHashed) { 50 | entity.password = await this.hashingService.hash(entity.password); 51 | } 52 | 53 | this.data[index] = entity; 54 | return Promise.resolve(entity); 55 | }; 56 | } 57 | 58 | export const MockUserRepositoryProvider: ClassProvider = { 59 | provide: USER_REPOSITORY, 60 | useClass: MockUserRepository, 61 | }; 62 | -------------------------------------------------------------------------------- /src/infrastructure/repositories/repository.port.ts: -------------------------------------------------------------------------------- 1 | import { Entity } from '@/domain/base/entity/entity.base'; 2 | 3 | /** 4 | * RepositoryPort for managing database operations for a particular Entity type. 5 | */ 6 | export interface RepositoryPort> { 7 | /** 8 | * Creates an Entity. 9 | * @param entity - The entity to create. 10 | * @returns A promise containing the created entity. 11 | * @throws Error If the creation fails. 12 | */ 13 | create: (entity: EntityType) => Promise; 14 | 15 | /** 16 | * Updates an Entity. 17 | * @param entity - The entity to update. 18 | * @returns A promise containing the updated entity. 19 | * @throws Error If the update fails. 20 | */ 21 | update: (entity: EntityType) => Promise; 22 | 23 | /** 24 | * Deletes an Entity by id. 25 | * @param id - The id of the entity to delete. 26 | * @returns A promise containing a boolean showing the deletion success status. 27 | * @throws Error If the deletion fails. 28 | */ 29 | delete: (entity: EntityType) => Promise; 30 | 31 | /** 32 | * Finds an Entity by id. 33 | * @param id - The id of the entity to find. 34 | * @returns A promise containing the entity, or undefined if not found. 35 | * @throws Error If the search process fails. 36 | */ 37 | findOneById: (id: string) => Promise; 38 | 39 | /** 40 | * Finds all Entities. 41 | * @returns A promise containing an array of all entities. 42 | * @throws Error If the search process fails. 43 | */ 44 | findAll: () => Promise; 45 | 46 | /** 47 | * Finds all Entities paginated. 48 | * @param page - The page number. 49 | * @param limit - The number of items per page. 50 | * @returns A promise containing an array of all entities. 51 | * @throws Error If the search process fails. 52 | */ 53 | findAllPaginated: ( 54 | page: number, 55 | limit: number, 56 | ) => Promise<{ 57 | entities: EntityType[]; 58 | totalPages: number; 59 | totalItems: number; 60 | }>; 61 | 62 | /** 63 | * Implements a transaction operation. 64 | * @param handler - The handler for the transaction. 65 | * @returns A promise containing the transaction result. 66 | * @throws Error If the transaction fails. 67 | */ 68 | transaction: (handler: () => Promise) => Promise; 69 | } 70 | -------------------------------------------------------------------------------- /src/application/authentication/decorator/token.decorator.test.ts: -------------------------------------------------------------------------------- 1 | import { createMock } from '@golevelup/ts-jest'; 2 | import { ExecutionContext } from '@nestjs/common'; 3 | 4 | import { TokenFactory } from '@/application/authentication/decorator/token.decorator'; 5 | 6 | describe('token decorator', () => { 7 | it('should return the token', () => { 8 | // Mock user object 9 | const token = 'Bearer TestToken'; 10 | 11 | // Mock request object 12 | const request = { 13 | headers: { 14 | authorization: token, 15 | }, 16 | }; 17 | 18 | // Mock ExecutionContext 19 | const context = createMock(); 20 | context.switchToHttp = jest.fn().mockReturnValue({ 21 | getRequest: () => request, 22 | }); 23 | 24 | // Invoke CurrentUser decorator with mocked context 25 | const result = TokenFactory(null, context); 26 | 27 | // Assert that the decorator returns the expected user object 28 | expect(result).toBe('TestToken'); 29 | }); 30 | 31 | it('should return null if the token is not present', () => { 32 | // Mock request object 33 | const request = { 34 | headers: { 35 | authorization: null, 36 | }, 37 | }; 38 | 39 | // Mock ExecutionContext 40 | const context = createMock(); 41 | context.switchToHttp = jest.fn().mockReturnValue({ 42 | getRequest: () => request, 43 | }); 44 | 45 | // Invoke CurrentUser decorator with mocked context 46 | const result = TokenFactory(null, context); 47 | 48 | // Assert that the decorator returns the expected user object 49 | expect(result).toBeNull(); 50 | }); 51 | 52 | it('should return null if the token is not valid', () => { 53 | // Mock request object 54 | const request = { 55 | headers: { 56 | authorization: 'Bearer ', 57 | }, 58 | }; 59 | 60 | // Mock ExecutionContext 61 | const context = createMock(); 62 | context.switchToHttp = jest.fn().mockReturnValue({ 63 | getRequest: () => request, 64 | }); 65 | 66 | // Invoke CurrentUser decorator with mocked context 67 | const result = TokenFactory(null, context); 68 | 69 | // Assert that the decorator returns the expected user object 70 | expect(result).toBeNull(); 71 | }); 72 | }); 73 | -------------------------------------------------------------------------------- /src/infrastructure/logger/logger.module.ts: -------------------------------------------------------------------------------- 1 | import { Global, Module } from '@nestjs/common'; 2 | import { Span, context, trace } from '@opentelemetry/api'; 3 | import { LoggerModule as BaseLoggerModule } from 'nestjs-pino'; 4 | import PinoPretty from 'pino-pretty'; 5 | 6 | import { MainConfigService } from '@/infrastructure/config/configs/main-config.service'; 7 | 8 | @Global() 9 | @Module({ 10 | imports: [ 11 | BaseLoggerModule.forRootAsync({ 12 | inject: [MainConfigService], 13 | useFactory: (config: MainConfigService) => ({ 14 | pinoHttp: { 15 | level: config.DEBUG ? 'trace' : 'info', 16 | formatters: { 17 | log: (object) => { 18 | const span = trace.getSpan(context.active()) as 19 | | (Span & { 20 | attributes: Record; 21 | }) 22 | | undefined; 23 | 24 | if (!span) return { ...object }; 25 | 26 | const spanContext = trace 27 | .getSpan(context.active()) 28 | ?.spanContext(); 29 | 30 | if (!spanContext) return { ...object }; 31 | 32 | const { spanId, traceId } = spanContext; 33 | 34 | const { userId, userEmail } = span.attributes; 35 | 36 | return { 37 | ...object, 38 | spanId, 39 | traceId, 40 | userId, 41 | userEmail, 42 | }; 43 | }, 44 | }, 45 | transport: 46 | config.NODE_ENV === 'development' 47 | ? { 48 | target: 'pino-pretty', 49 | 50 | options: { 51 | ignore: 'context,hostname,pid,res,req,spanId,traceId', 52 | messageFormat: '{context} - {msg}', 53 | } as PinoPretty.PrettyOptions, 54 | } 55 | : undefined, 56 | }, 57 | }), 58 | }), 59 | ], 60 | exports: [], 61 | }) 62 | export class LoggerModule {} 63 | -------------------------------------------------------------------------------- /src/application/verification/v1/commands/confirm-verification/confirm-verification.handler.ts: -------------------------------------------------------------------------------- 1 | import { Logger } from '@nestjs/common'; 2 | import { 3 | CommandBus, 4 | CommandHandler, 5 | EventBus, 6 | ICommandHandler, 7 | QueryBus, 8 | } from '@nestjs/cqrs'; 9 | 10 | import { V1UpdateUserCommandHandler } from '@/application/user/v1/commands/update-user/update-user.handler'; 11 | import { OnVerificationConfirmedEvent } from '@/domain/verification/events/on-verification-confirmed.event'; 12 | import { V1VerifyVerificationTokenQueryHandler } from '@/infrastructure/token/v1/queries/verify-verification-token/verify-verification-token.handler'; 13 | 14 | import { V1ConfirmVerificationCommand } from './confirm-verification.command'; 15 | 16 | type V1ConfirmVerificationCommandHandlerResponse = true; 17 | 18 | @CommandHandler(V1ConfirmVerificationCommand) 19 | export class V1ConfirmVerificationCommandHandler 20 | implements 21 | ICommandHandler< 22 | V1ConfirmVerificationCommand, 23 | V1ConfirmVerificationCommandHandlerResponse 24 | > 25 | { 26 | private readonly logger = new Logger( 27 | V1ConfirmVerificationCommandHandler.name, 28 | ); 29 | 30 | constructor( 31 | private readonly eventBus: EventBus, 32 | private readonly commandBus: CommandBus, 33 | private readonly queryBus: QueryBus, 34 | ) {} 35 | 36 | static runHandler( 37 | bus: CommandBus, 38 | command: V1ConfirmVerificationCommand, 39 | ): Promise { 40 | return bus.execute< 41 | V1ConfirmVerificationCommand, 42 | V1ConfirmVerificationCommandHandlerResponse 43 | >(new V1ConfirmVerificationCommand(command.verificationToken)); 44 | } 45 | 46 | async execute( 47 | command: V1ConfirmVerificationCommand, 48 | ): Promise { 49 | this.logger.log( 50 | `Confirming verification for ${command.verificationToken}`, 51 | ); 52 | 53 | const { user } = await V1VerifyVerificationTokenQueryHandler.runHandler( 54 | this.queryBus, 55 | { 56 | verificationToken: command.verificationToken, 57 | }, 58 | ); 59 | 60 | user.verifiedAt = new Date(); 61 | 62 | await V1UpdateUserCommandHandler.runHandler(this.commandBus, { 63 | user, 64 | }); 65 | 66 | this.eventBus.publish(new OnVerificationConfirmedEvent(user)); 67 | 68 | return Promise.resolve(true); 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /src/application/authentication/v1/commands/refresh/refresh.handler.ts: -------------------------------------------------------------------------------- 1 | import { Logger } from '@nestjs/common'; 2 | import { 3 | CommandBus, 4 | CommandHandler, 5 | EventBus, 6 | ICommandHandler, 7 | } from '@nestjs/cqrs'; 8 | 9 | import { V1RefreshTokenResponseDto } from '@/application/authentication/v1/commands/refresh/dto/refresh.response.dto'; 10 | import { OnTokenRefreshEvent } from '@/domain/authentication/events/on-token-refresh.event'; 11 | import { V1GenerateAccessTokenCommandHandler } from '@/infrastructure/token/v1/commands/generate-access-token/generate-access-token.handler'; 12 | 13 | import { V1RefreshTokenCommand } from './refresh.command'; 14 | 15 | type V1RefreshTokenCommandHandlerResponse = V1RefreshTokenResponseDto; 16 | 17 | @CommandHandler(V1RefreshTokenCommand) 18 | export class V1RefreshTokenCommandHandler 19 | implements 20 | ICommandHandler< 21 | V1RefreshTokenCommand, 22 | V1RefreshTokenCommandHandlerResponse 23 | > 24 | { 25 | private readonly logger = new Logger(V1RefreshTokenCommandHandler.name); 26 | 27 | constructor( 28 | private readonly eventBus: EventBus, 29 | private readonly commandBus: CommandBus, 30 | ) {} 31 | 32 | static runHandler( 33 | bus: CommandBus, 34 | command: V1RefreshTokenCommand, 35 | ): Promise { 36 | return bus.execute< 37 | V1RefreshTokenCommand, 38 | V1RefreshTokenCommandHandlerResponse 39 | >(new V1RefreshTokenCommand(command.user, command.session, command.ip)); 40 | } 41 | 42 | async execute( 43 | command: V1RefreshTokenCommand, 44 | ): Promise { 45 | this.logger.log( 46 | `User ${command.user.id} has Refreshed Access Token with Session: ${command.session.id}, IP: ${command.ip ?? 'unknown'}`, 47 | ); 48 | 49 | const accessToken = 50 | await V1GenerateAccessTokenCommandHandler.runHandler( 51 | this.commandBus, 52 | { 53 | user: command.user, 54 | session: command.session, 55 | ip: command.ip, 56 | }, 57 | ); 58 | 59 | this.eventBus.publish( 60 | new OnTokenRefreshEvent( 61 | command.user, 62 | command.session, 63 | accessToken.accessToken, 64 | ), 65 | ); 66 | 67 | return Promise.resolve({ 68 | accessToken: accessToken.accessToken, 69 | }); 70 | } 71 | } 72 | --------------------------------------------------------------------------------