@stjoseph/prumo 0.0.5 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (155) hide show
  1. package/.prumo-templates/api/auth.md +51 -95
  2. package/.prumo-templates/api/config.md +30 -56
  3. package/.prumo-templates/api/errors.md +64 -44
  4. package/.prumo-templates/api/logging.md +27 -44
  5. package/.prumo-templates/api/observability.md +36 -62
  6. package/.prumo-templates/api/pagination.md +23 -38
  7. package/.prumo-templates/api/routes.md +67 -48
  8. package/.prumo-templates/api/structure.md +130 -0
  9. package/.prumo-templates/client/data.md +4 -3
  10. package/.prumo-templates/core/conventions.md +10 -7
  11. package/.prumo-templates/core/testing.md +43 -26
  12. package/.prumo-templates/core/tooling.md +33 -30
  13. package/.prumo-templates/database/entities.md +53 -61
  14. package/.prumo-templates/database/queries.md +34 -47
  15. package/.prumo-templates/database/transactions.md +28 -38
  16. package/.prumo-templates/mcp/server.md +54 -0
  17. package/.prumo-templates/monorepo/dependencies.md +3 -3
  18. package/.prumo-templates/monorepo/layout.md +1 -1
  19. package/README.md +12 -6
  20. package/dist/asker.js +4 -0
  21. package/dist/clean.js +2 -2
  22. package/dist/cli.js +4 -0
  23. package/dist/commands.js +2 -0
  24. package/dist/compose.js +12 -8
  25. package/dist/context.js +2 -0
  26. package/dist/generate.js +8 -0
  27. package/dist/mcp.js +100 -0
  28. package/dist/questions.js +21 -1
  29. package/package.json +4 -3
  30. package/templates/api/.swcrc +13 -0
  31. package/templates/api/README.md +10 -1
  32. package/templates/api/biome.jsonc +5 -3
  33. package/templates/api/package.json +22 -28
  34. package/templates/api/pnpm-workspace.yaml +0 -2
  35. package/templates/api/scripts/ports.mjs +508 -0
  36. package/templates/api/src/domain/shared/errors/domain-error.ts +5 -0
  37. package/templates/api/src/domain/shared/transactions/transaction-manager.ts +5 -0
  38. package/templates/api/src/domain/users/dto/profile-response.dto.ts +14 -0
  39. package/templates/api/src/domain/users/dto/update-profile.dto.ts +10 -0
  40. package/templates/api/src/domain/users/entities/profile.entity.ts +26 -0
  41. package/templates/api/src/domain/users/errors/profile-not-found.error.ts +9 -0
  42. package/templates/api/src/domain/users/repositories/profile.repository.ts +8 -0
  43. package/templates/api/src/domain/users/use-cases/create-profile.use-case.ts +19 -0
  44. package/templates/api/src/domain/users/use-cases/find-profile.use-case.ts +22 -0
  45. package/templates/api/src/domain/users/use-cases/update-profile.use-case.ts +26 -0
  46. package/templates/api/src/infra/auth/auth.cli.ts +5 -0
  47. package/templates/api/src/{auth → infra/auth}/auth.factory.ts +26 -1
  48. package/templates/api/src/infra/config/env.ts +47 -0
  49. package/templates/api/src/infra/database/mikroorm/entities/auth-user.schema.ts +16 -0
  50. package/templates/api/src/infra/database/mikroorm/entities/profile.schema.ts +28 -0
  51. package/templates/api/src/{mikro-orm.config.ts → infra/database/mikroorm/mikro-orm.config.ts} +1 -1
  52. package/templates/api/src/infra/database/mikroorm/mikro-orm.factory.ts +17 -0
  53. package/templates/api/src/infra/database/mikroorm/repositories/mikroorm-profile.repository.ts +17 -0
  54. package/templates/api/src/infra/database/mikroorm/transactions/mikroorm-transaction-manager.ts +14 -0
  55. package/templates/api/src/infra/di/index.ts +32 -0
  56. package/templates/api/src/infra/di/users.di.ts +7 -0
  57. package/templates/api/src/infra/http/app.ts +99 -0
  58. package/templates/api/src/infra/http/controllers/auth.controller.ts +36 -0
  59. package/templates/api/src/infra/http/controllers/health.controller.ts +19 -0
  60. package/templates/api/src/infra/http/controllers/mcp.controller.ts +40 -0
  61. package/templates/api/src/infra/http/controllers/users.controller.ts +35 -0
  62. package/templates/api/src/infra/http/errors/error-handler.ts +116 -0
  63. package/templates/api/src/infra/http/errors/http-error.ts +8 -0
  64. package/templates/api/src/infra/http/fastify.d.ts +17 -0
  65. package/templates/api/src/infra/http/fetch-bridge.ts +42 -0
  66. package/templates/api/src/infra/http/hooks/auth.hook.ts +32 -0
  67. package/templates/api/src/infra/http/hooks/request-context.hook.ts +9 -0
  68. package/templates/api/src/infra/http/server.ts +34 -0
  69. package/templates/api/src/infra/mcp/mcp.server.ts +20 -0
  70. package/templates/api/src/infra/mcp/tools/users.tool.ts +39 -0
  71. package/templates/api/test/domain/users/use-cases/find-profile.use-case.spec.ts +22 -0
  72. package/templates/api/test/domain/users/use-cases/update-profile.use-case.spec.ts +26 -0
  73. package/templates/api/{src → test/infra}/auth/auth.factory.spec.ts +4 -11
  74. package/templates/api/test/infra/database/mikroorm/mikro-orm.factory.spec.ts +8 -0
  75. package/templates/api/test/infra/database/mikroorm/repositories/mikroorm-profile.repository.spec.ts +51 -0
  76. package/templates/api/test/infra/database/mikroorm/transactions/mikroorm-transaction-manager.spec.ts +29 -0
  77. package/templates/api/test/infra/http/app.spec.ts +21 -0
  78. package/templates/api/test/infra/http/controllers/mcp.controller.spec.ts +152 -0
  79. package/templates/api/test/infra/http/controllers/users.controller.spec.ts +105 -0
  80. package/templates/api/test/support/factories/profile.factory.ts +25 -0
  81. package/templates/api/test/{factories → support/factories}/user.factory.ts +1 -1
  82. package/templates/api/test/support/fakes/in-memory-profile.repository.ts +14 -0
  83. package/templates/api/test/{setup.ts → support/setup.ts} +26 -14
  84. package/templates/api/tsconfig.json +3 -6
  85. package/templates/api/vitest.config.mts +4 -4
  86. package/templates/mobile/.env.example +3 -0
  87. package/templates/mobile/README.md +10 -0
  88. package/templates/mobile/jest.config.js +5 -1
  89. package/templates/mobile/package.json +3 -3
  90. package/templates/mobile/scripts/ports.mjs +508 -0
  91. package/templates/mobile/{src → test}/features/auth/authenticated-layout.spec.tsx +1 -1
  92. package/templates/mobile/{src → test}/features/auth/sign-in-form.spec.tsx +2 -2
  93. package/templates/mobile/{src → test}/features/auth/sign-out-button.spec.tsx +2 -2
  94. package/templates/mobile/{src → test}/features/profile/profile-form.spec.tsx +2 -2
  95. package/templates/mobile/tsconfig.json +2 -1
  96. package/templates/site/.env.example +2 -0
  97. package/templates/site/README.md +12 -1
  98. package/templates/site/gitignore +1 -0
  99. package/templates/site/package.json +1 -1
  100. package/templates/site/scripts/ports.mjs +508 -0
  101. package/templates/site/{src → test}/app/page.spec.ts +1 -1
  102. package/templates/site/tsconfig.json +2 -1
  103. package/templates/site/vitest.config.mts +4 -2
  104. package/templates/web/.env.example +3 -0
  105. package/templates/web/README.md +10 -1
  106. package/templates/web/package.json +5 -3
  107. package/templates/web/scripts/ports.mjs +508 -0
  108. package/templates/web/src/features/auth/auth-client.ts +5 -0
  109. package/templates/web/src/features/auth/sign-in-form.tsx +10 -3
  110. package/templates/web/src/features/oauth/consent-form.tsx +63 -0
  111. package/templates/web/src/routeTree.gen.ts +21 -2
  112. package/templates/web/src/routes/_authenticated/consent.tsx +21 -0
  113. package/templates/web/{src → test}/app.spec.tsx +2 -2
  114. package/templates/web/{src → test}/features/auth/sign-in-form.spec.tsx +2 -2
  115. package/templates/web/test/features/oauth/consent-form.spec.tsx +45 -0
  116. package/templates/web/{src → test}/features/profile/profile-form.spec.tsx +2 -2
  117. package/templates/web/test/{render-app.tsx → support/render-app.tsx} +1 -1
  118. package/templates/web/tsconfig.json +2 -1
  119. package/templates/web/vite.config.ts +3 -3
  120. package/templates/workspace/README.md +7 -3
  121. package/templates/workspace/scripts/ports.mjs +508 -0
  122. package/.prumo-templates/api/modules.md +0 -117
  123. package/templates/api/nest-cli.json +0 -10
  124. package/templates/api/src/app.module.ts +0 -46
  125. package/templates/api/src/auth/auth.constants.ts +0 -2
  126. package/templates/api/src/auth/auth.controller.ts +0 -19
  127. package/templates/api/src/auth/auth.guard.ts +0 -43
  128. package/templates/api/src/auth/auth.module.ts +0 -30
  129. package/templates/api/src/auth/auth.ts +0 -4
  130. package/templates/api/src/auth/current-user.decorator.ts +0 -9
  131. package/templates/api/src/auth/express.d.ts +0 -10
  132. package/templates/api/src/auth/public.decorator.ts +0 -4
  133. package/templates/api/src/config/env.module.ts +0 -11
  134. package/templates/api/src/config/env.ts +0 -81
  135. package/templates/api/src/errors/problem-details.filter.ts +0 -86
  136. package/templates/api/src/errors/validation-exception.factory.ts +0 -30
  137. package/templates/api/src/health/health.controller.ts +0 -32
  138. package/templates/api/src/health/health.module.ts +0 -9
  139. package/templates/api/src/main.ts +0 -43
  140. package/templates/api/src/mikro-orm.factory.ts +0 -14
  141. package/templates/api/src/request-context/request-context.module.ts +0 -9
  142. package/templates/api/src/request-context/request-context.service.ts +0 -17
  143. package/templates/api/src/request-context/request-id.middleware.ts +0 -19
  144. package/templates/api/src/users/dto/update-profile.dto.ts +0 -20
  145. package/templates/api/src/users/entities/profile.entity.ts +0 -27
  146. package/templates/api/src/users/users.controller.ts +0 -23
  147. package/templates/api/src/users/users.module.ts +0 -10
  148. package/templates/api/src/users/users.service.spec.ts +0 -44
  149. package/templates/api/src/users/users.service.ts +0 -36
  150. package/templates/api/test/factories/profile.factory.ts +0 -26
  151. package/templates/api/tsconfig.build.json +0 -8
  152. /package/templates/api/test/{global-setup.ts → support/global-setup.ts} +0 -0
  153. /package/templates/mobile/test/{fake-transport.ts → support/fake-transport.ts} +0 -0
  154. /package/templates/web/test/{fake-transport.ts → support/fake-transport.ts} +0 -0
  155. /package/templates/web/test/{setup.ts → support/setup.ts} +0 -0
@@ -1,43 +0,0 @@
1
- import {
2
- type CanActivate,
3
- type ExecutionContext,
4
- Inject,
5
- Injectable,
6
- UnauthorizedException,
7
- } from '@nestjs/common'
8
- import { Reflector } from '@nestjs/core'
9
- import { fromNodeHeaders } from 'better-auth/node'
10
- import type { Request } from 'express'
11
- import { AUTH, IS_PUBLIC } from './auth.constants'
12
- import type { Auth } from './auth.factory'
13
-
14
- @Injectable()
15
- export class AuthGuard implements CanActivate {
16
- constructor(
17
- private readonly reflector: Reflector,
18
- @Inject(AUTH) private readonly auth: Auth,
19
- ) {}
20
-
21
- async canActivate(context: ExecutionContext): Promise<boolean> {
22
- const request = context.switchToHttp().getRequest<Request>()
23
- const session = await this.auth.api.getSession({ headers: fromNodeHeaders(request.headers) })
24
-
25
- request.user = session?.user
26
- request.session = session?.session
27
-
28
- const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC, [
29
- context.getHandler(),
30
- context.getClass(),
31
- ])
32
-
33
- if (isPublic === true) {
34
- return true
35
- }
36
-
37
- if (session === null) {
38
- throw new UnauthorizedException()
39
- }
40
-
41
- return true
42
- }
43
- }
@@ -1,30 +0,0 @@
1
- import { Global, Module } from '@nestjs/common'
2
- import { APP_GUARD } from '@nestjs/core'
3
- import { Env } from '@/config/env'
4
- import { UsersModule } from '@/users/users.module'
5
- import { UsersService } from '@/users/users.service'
6
- import { AUTH } from './auth.constants'
7
- import { AuthController } from './auth.controller'
8
- import { createAuth } from './auth.factory'
9
- import { AuthGuard } from './auth.guard'
10
-
11
- @Global()
12
- @Module({
13
- imports: [UsersModule],
14
- controllers: [AuthController],
15
- providers: [
16
- {
17
- provide: AUTH,
18
- inject: [Env, UsersService],
19
- useFactory: (env: Env, users: UsersService) =>
20
- createAuth(env, {
21
- onUserCreated: async (user) => {
22
- await users.createProfile(user.id, user.name)
23
- },
24
- }),
25
- },
26
- { provide: APP_GUARD, useClass: AuthGuard },
27
- ],
28
- exports: [AUTH],
29
- })
30
- export class AuthModule {}
@@ -1,4 +0,0 @@
1
- import { loadEnv } from '@/config/env'
2
- import { createAuth } from './auth.factory'
3
-
4
- export const auth = createAuth(loadEnv())
@@ -1,9 +0,0 @@
1
- import { createParamDecorator, type ExecutionContext } from '@nestjs/common'
2
- import type { Request } from 'express'
3
- import type { AuthUser } from './auth.factory'
4
-
5
- export const CurrentUser = createParamDecorator(
6
- (_data: unknown, context: ExecutionContext): AuthUser | undefined => {
7
- return context.switchToHttp().getRequest<Request>().user
8
- },
9
- )
@@ -1,10 +0,0 @@
1
- import type { AuthSession, AuthUser } from './auth.factory'
2
-
3
- declare global {
4
- namespace Express {
5
- interface Request {
6
- user?: AuthUser
7
- session?: NonNullable<AuthSession>['session']
8
- }
9
- }
10
- }
@@ -1,4 +0,0 @@
1
- import { SetMetadata } from '@nestjs/common'
2
- import { IS_PUBLIC } from './auth.constants'
3
-
4
- export const Public = () => SetMetadata(IS_PUBLIC, true)
@@ -1,11 +0,0 @@
1
- import { Global, Module } from '@nestjs/common'
2
- import { ConfigModule } from '@nestjs/config'
3
- import { Env, loadEnv } from './env'
4
-
5
- @Global()
6
- @Module({
7
- imports: [ConfigModule.forRoot({ cache: true, ignoreEnvFile: true, validate: loadEnv })],
8
- providers: [{ provide: Env, useFactory: loadEnv }],
9
- exports: [Env],
10
- })
11
- export class EnvModule {}
@@ -1,81 +0,0 @@
1
- import 'reflect-metadata'
2
- import { existsSync } from 'node:fs'
3
- import { plainToInstance } from 'class-transformer'
4
- import {
5
- IsEnum,
6
- IsInt,
7
- IsOptional,
8
- IsString,
9
- Matches,
10
- Max,
11
- Min,
12
- MinLength,
13
- validateSync,
14
- } from 'class-validator'
15
-
16
- export type NodeEnv = 'development' | 'test' | 'production'
17
- export type LogLevel = 'error' | 'warn' | 'info' | 'debug'
18
-
19
- export class Env {
20
- @IsEnum(['development', 'test', 'production'])
21
- NODE_ENV: NodeEnv
22
-
23
- @IsInt()
24
- @Min(1)
25
- @Max(65535)
26
- PORT: number
27
-
28
- @IsString()
29
- @MinLength(1)
30
- DATABASE_URL: string
31
-
32
- @IsString()
33
- @MinLength(1)
34
- AUTH_DATABASE_URL: string
35
-
36
- @IsString()
37
- @MinLength(32)
38
- BETTER_AUTH_SECRET: string
39
-
40
- @IsString()
41
- @MinLength(1)
42
- BETTER_AUTH_URL: string
43
-
44
- @IsString()
45
- @MinLength(1)
46
- WEB_ORIGIN: string
47
-
48
- @IsOptional()
49
- @Matches(/^[a-z][a-z0-9+.-]*$/)
50
- MOBILE_APP_SCHEME?: string
51
-
52
- @IsEnum(['error', 'warn', 'info', 'debug'])
53
- LOG_LEVEL: LogLevel
54
- }
55
-
56
- export function validate(raw: Record<string, unknown>): Env {
57
- const env = plainToInstance(Env, raw, { enableImplicitConversion: true })
58
- const errors = validateSync(env, { whitelist: true, forbidUnknownValues: false })
59
-
60
- if (errors.length > 0) {
61
- const named = errors.map((error) => error.property).join(', ')
62
- throw new Error(`Invalid environment: ${named}`)
63
- }
64
-
65
- loaded = env
66
- return env
67
- }
68
-
69
- let loaded: Env | undefined
70
-
71
- export function loadEnv(): Env {
72
- if (loaded !== undefined) {
73
- return loaded
74
- }
75
-
76
- if (process.env.NODE_ENV !== 'production' && existsSync('.env')) {
77
- process.loadEnvFile('.env')
78
- }
79
-
80
- return validate(process.env)
81
- }
@@ -1,86 +0,0 @@
1
- import {
2
- type ArgumentsHost,
3
- Catch,
4
- type ExceptionFilter,
5
- HttpException,
6
- HttpStatus,
7
- Logger,
8
- } from '@nestjs/common'
9
- import type { Request, Response } from 'express'
10
- import { RequestContextService } from '@/request-context/request-context.service'
11
- import { ValidationFailedException } from './validation-exception.factory'
12
-
13
- type ProblemDocument = {
14
- type: string
15
- title: string
16
- status: number
17
- detail: string
18
- instance: string
19
- requestId?: string
20
- errors?: Record<string, string[]>
21
- }
22
-
23
- const TITLES: Record<number, string> = {
24
- 400: 'Bad Request',
25
- 401: 'Unauthorized',
26
- 403: 'Forbidden',
27
- 404: 'Not Found',
28
- 409: 'Conflict',
29
- 422: 'Unprocessable Entity',
30
- 500: 'Internal Server Error',
31
- 503: 'Service Unavailable',
32
- }
33
-
34
- function detailOf(exception: HttpException): string {
35
- const response = exception.getResponse()
36
-
37
- if (typeof response === 'string') {
38
- return response
39
- }
40
-
41
- const message = (response as { message?: unknown }).message
42
-
43
- if (typeof message === 'string') {
44
- return message
45
- }
46
-
47
- return exception.message
48
- }
49
-
50
- @Catch()
51
- export class ProblemDetailsFilter implements ExceptionFilter {
52
- private readonly logger = new Logger(ProblemDetailsFilter.name)
53
-
54
- constructor(private readonly context: RequestContextService) {}
55
-
56
- catch(exception: unknown, host: ArgumentsHost): void {
57
- const http = host.switchToHttp()
58
- const request = http.getRequest<Request>()
59
- const response = http.getResponse<Response>()
60
-
61
- const status =
62
- exception instanceof HttpException ? exception.getStatus() : HttpStatus.INTERNAL_SERVER_ERROR
63
-
64
- const problem: ProblemDocument = {
65
- type: 'about:blank',
66
- title: TITLES[status] ?? 'Error',
67
- status,
68
- detail:
69
- exception instanceof HttpException && status < HttpStatus.INTERNAL_SERVER_ERROR
70
- ? detailOf(exception)
71
- : 'An unexpected error occurred.',
72
- instance: request.originalUrl,
73
- requestId: this.context.requestId,
74
- }
75
-
76
- if (exception instanceof ValidationFailedException) {
77
- problem.errors = exception.fields
78
- }
79
-
80
- if (status >= HttpStatus.INTERNAL_SERVER_ERROR) {
81
- this.logger.error(exception)
82
- }
83
-
84
- response.status(status).type('application/problem+json').json(problem)
85
- }
86
- }
@@ -1,30 +0,0 @@
1
- import { BadRequestException, type ValidationError } from '@nestjs/common'
2
-
3
- export type FieldErrors = Record<string, string[]>
4
-
5
- function collect(errors: ValidationError[], prefix: string, into: FieldErrors): void {
6
- for (const error of errors) {
7
- const key = prefix === '' ? error.property : `${prefix}.${error.property}`
8
- const messages = Object.values(error.constraints ?? {})
9
-
10
- if (messages.length > 0) {
11
- into[key] = messages
12
- }
13
-
14
- if (error.children !== undefined && error.children.length > 0) {
15
- collect(error.children, key, into)
16
- }
17
- }
18
- }
19
-
20
- export class ValidationFailedException extends BadRequestException {
21
- constructor(readonly fields: FieldErrors) {
22
- super('Validation failed')
23
- }
24
- }
25
-
26
- export function validationExceptionFactory(errors: ValidationError[]): ValidationFailedException {
27
- const fields: FieldErrors = {}
28
- collect(errors, '', fields)
29
- return new ValidationFailedException(fields)
30
- }
@@ -1,32 +0,0 @@
1
- import { Controller, Get, VERSION_NEUTRAL } from '@nestjs/common'
2
- import { ApiTags } from '@nestjs/swagger'
3
- import {
4
- HealthCheck,
5
- type HealthCheckResult,
6
- HealthCheckService,
7
- MikroOrmHealthIndicator,
8
- } from '@nestjs/terminus'
9
- import { Public } from '@/auth/public.decorator'
10
-
11
- @ApiTags('health')
12
- @Controller({ path: 'health', version: VERSION_NEUTRAL })
13
- export class HealthController {
14
- constructor(
15
- private readonly health: HealthCheckService,
16
- private readonly database: MikroOrmHealthIndicator,
17
- ) {}
18
-
19
- @Public()
20
- @Get('live')
21
- @HealthCheck()
22
- live(): Promise<HealthCheckResult> {
23
- return this.health.check([])
24
- }
25
-
26
- @Public()
27
- @Get('ready')
28
- @HealthCheck()
29
- ready(): Promise<HealthCheckResult> {
30
- return this.health.check([() => this.database.pingCheck('database')])
31
- }
32
- }
@@ -1,9 +0,0 @@
1
- import { Module } from '@nestjs/common'
2
- import { TerminusModule } from '@nestjs/terminus'
3
- import { HealthController } from './health.controller'
4
-
5
- @Module({
6
- imports: [TerminusModule],
7
- controllers: [HealthController],
8
- })
9
- export class HealthModule {}
@@ -1,43 +0,0 @@
1
- import { MikroORM } from '@mikro-orm/postgresql'
2
- import { ClassSerializerInterceptor, ValidationPipe, VersioningType } from '@nestjs/common'
3
- import { NestFactory, Reflector } from '@nestjs/core'
4
- import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger'
5
- import { Logger } from 'nestjs-pino'
6
- import { AppModule } from './app.module'
7
- import { Env } from './config/env'
8
- import { validationExceptionFactory } from './errors/validation-exception.factory'
9
-
10
- async function bootstrap(): Promise<void> {
11
- const app = await NestFactory.create(AppModule, { bufferLogs: true })
12
- const env = app.get(Env)
13
-
14
- app.useLogger(app.get(Logger))
15
- app.setGlobalPrefix('api')
16
- app.enableCors({ origin: env.WEB_ORIGIN, credentials: true, exposedHeaders: ['X-Request-Id'] })
17
- app.enableVersioning({ type: VersioningType.URI })
18
-
19
- app.useGlobalPipes(
20
- new ValidationPipe({
21
- whitelist: true,
22
- forbidNonWhitelisted: true,
23
- transform: true,
24
- exceptionFactory: validationExceptionFactory,
25
- }),
26
- )
27
- app.useGlobalInterceptors(new ClassSerializerInterceptor(app.get(Reflector)))
28
-
29
- if (env.NODE_ENV !== 'production') {
30
- const document = SwaggerModule.createDocument(
31
- app,
32
- new DocumentBuilder().setTitle('API').setVersion('1').build(),
33
- )
34
- SwaggerModule.setup('api/docs', app, document)
35
- }
36
-
37
- // MikroORM 7 no longer connects on init, so readiness would report the database down until a first query.
38
- await app.get(MikroORM).connect()
39
-
40
- await app.listen(env.PORT)
41
- }
42
-
43
- void bootstrap()
@@ -1,14 +0,0 @@
1
- import { Migrator } from '@mikro-orm/migrations'
2
- import { defineConfig } from '@mikro-orm/postgresql'
3
- import type { Env } from './config/env'
4
- import { Profile } from './users/entities/profile.entity'
5
-
6
- export function createOrmConfig(env: Pick<Env, 'DATABASE_URL' | 'NODE_ENV'>) {
7
- return defineConfig({
8
- clientUrl: env.DATABASE_URL,
9
- entities: [Profile],
10
- extensions: [Migrator],
11
- migrations: { path: './migrations', snapshot: false },
12
- debug: env.NODE_ENV === 'development',
13
- })
14
- }
@@ -1,9 +0,0 @@
1
- import { Global, Module } from '@nestjs/common'
2
- import { RequestContextService } from './request-context.service'
3
-
4
- @Global()
5
- @Module({
6
- providers: [RequestContextService],
7
- exports: [RequestContextService],
8
- })
9
- export class RequestContextModule {}
@@ -1,17 +0,0 @@
1
- import { AsyncLocalStorage } from 'node:async_hooks'
2
- import { Injectable } from '@nestjs/common'
3
-
4
- type Store = { requestId: string }
5
-
6
- @Injectable()
7
- export class RequestContextService {
8
- private readonly storage = new AsyncLocalStorage<Store>()
9
-
10
- run<T>(requestId: string, callback: () => T): T {
11
- return this.storage.run({ requestId }, callback)
12
- }
13
-
14
- get requestId(): string | undefined {
15
- return this.storage.getStore()?.requestId
16
- }
17
- }
@@ -1,19 +0,0 @@
1
- import { randomUUID } from 'node:crypto'
2
- import { Injectable, type NestMiddleware } from '@nestjs/common'
3
- import type { NextFunction, Request, Response } from 'express'
4
- import { RequestContextService } from './request-context.service'
5
-
6
- export const REQUEST_ID_HEADER = 'x-request-id'
7
-
8
- @Injectable()
9
- export class RequestIdMiddleware implements NestMiddleware {
10
- constructor(private readonly context: RequestContextService) {}
11
-
12
- use(request: Request, response: Response, next: NextFunction): void {
13
- const inbound = request.header(REQUEST_ID_HEADER)
14
- const requestId = inbound && inbound.length > 0 ? inbound : randomUUID()
15
-
16
- response.setHeader(REQUEST_ID_HEADER, requestId)
17
- this.context.run(requestId, next)
18
- }
19
- }
@@ -1,20 +0,0 @@
1
- import { IsIn, IsOptional, IsString, MaxLength, MinLength } from 'class-validator'
2
-
3
- const LOCALES = ['en', 'pt-BR'] as const
4
-
5
- export class UpdateProfileDto {
6
- @IsOptional()
7
- @IsString()
8
- @MinLength(1)
9
- @MaxLength(80)
10
- displayName?: string
11
-
12
- @IsOptional()
13
- @IsIn(LOCALES)
14
- locale?: (typeof LOCALES)[number]
15
-
16
- @IsOptional()
17
- @IsString()
18
- @MaxLength(64)
19
- timezone?: string
20
- }
@@ -1,27 +0,0 @@
1
- import { defineEntity, p } from '@mikro-orm/core'
2
-
3
- const ProfileSchema = defineEntity({
4
- name: 'Profile',
5
- properties: {
6
- id: p.uuid().primary().defaultRaw('uuidv7()'),
7
- userId: p.uuid().unique(),
8
- displayName: p.text(),
9
- locale: p.text().default('en'),
10
- timezone: p.text().default('UTC'),
11
- createdAt: p
12
- .datetime()
13
- .onCreate(() => new Date())
14
- .defaultRaw('now()'),
15
- updatedAt: p
16
- .datetime()
17
- .onCreate(() => new Date())
18
- .onUpdate(() => new Date())
19
- .defaultRaw('now()'),
20
- },
21
- })
22
-
23
- export class Profile extends ProfileSchema.class {}
24
-
25
- ProfileSchema.setClass(Profile)
26
-
27
- export { ProfileSchema }
@@ -1,23 +0,0 @@
1
- import { Body, Controller, Get, Patch } from '@nestjs/common'
2
- import { ApiTags } from '@nestjs/swagger'
3
- import type { AuthUser } from '@/auth/auth.factory'
4
- import { CurrentUser } from '@/auth/current-user.decorator'
5
- import { UpdateProfileDto } from './dto/update-profile.dto'
6
- import type { Profile } from './entities/profile.entity'
7
- import { UsersService } from './users.service'
8
-
9
- @ApiTags('users')
10
- @Controller({ path: 'users', version: '1' })
11
- export class UsersController {
12
- constructor(private readonly users: UsersService) {}
13
-
14
- @Get('me')
15
- findMe(@CurrentUser() user: AuthUser): Promise<Profile> {
16
- return this.users.findProfile(user.id)
17
- }
18
-
19
- @Patch('me')
20
- updateMe(@CurrentUser() user: AuthUser, @Body() changes: UpdateProfileDto): Promise<Profile> {
21
- return this.users.updateProfile(user.id, changes)
22
- }
23
- }
@@ -1,10 +0,0 @@
1
- import { Module } from '@nestjs/common'
2
- import { UsersController } from './users.controller'
3
- import { UsersService } from './users.service'
4
-
5
- @Module({
6
- controllers: [UsersController],
7
- providers: [UsersService],
8
- exports: [UsersService],
9
- })
10
- export class UsersModule {}
@@ -1,44 +0,0 @@
1
- import { NotFoundException } from '@nestjs/common'
2
- import { plainToInstance } from 'class-transformer'
3
- import { beforeEach, describe, expect, it } from 'vitest'
4
- import { createProfile } from '../../test/factories/profile.factory'
5
- import { testOrm } from '../../test/setup'
6
- import { UpdateProfileDto } from './dto/update-profile.dto'
7
- import { UsersService } from './users.service'
8
-
9
- describe('UsersService', () => {
10
- let service: UsersService
11
-
12
- beforeEach(() => {
13
- service = new UsersService(testOrm().em.fork())
14
- })
15
-
16
- it('finds the profile belonging to a user', async () => {
17
- const em = testOrm().em.fork()
18
- const created = await createProfile(em, { displayName: 'Ana' })
19
-
20
- await expect(service.findProfile(created.userId)).resolves.toMatchObject({
21
- displayName: 'Ana',
22
- })
23
- })
24
-
25
- it('refuses a user without a profile', async () => {
26
- await expect(service.findProfile(crypto.randomUUID())).rejects.toBeInstanceOf(NotFoundException)
27
- })
28
-
29
- it('applies only the fields the request carries', async () => {
30
- const em = testOrm().em.fork()
31
- const created = await createProfile(em, {
32
- displayName: 'Ana',
33
- timezone: 'UTC',
34
- })
35
-
36
- const changes = plainToInstance(UpdateProfileDto, { timezone: 'America/Sao_Paulo' })
37
- const updated = await service.updateProfile(created.userId, changes)
38
-
39
- expect(updated).toMatchObject({
40
- displayName: 'Ana',
41
- timezone: 'America/Sao_Paulo',
42
- })
43
- })
44
- })
@@ -1,36 +0,0 @@
1
- import { EntityManager } from '@mikro-orm/postgresql'
2
- import { Injectable, NotFoundException } from '@nestjs/common'
3
- import type { UpdateProfileDto } from './dto/update-profile.dto'
4
- import { Profile } from './entities/profile.entity'
5
-
6
- @Injectable()
7
- export class UsersService {
8
- constructor(private readonly em: EntityManager) {}
9
-
10
- async findProfile(userId: string): Promise<Profile> {
11
- const profile = await this.em.findOne(Profile, { userId })
12
-
13
- if (profile === null) {
14
- throw new NotFoundException('Profile not found')
15
- }
16
-
17
- return profile
18
- }
19
-
20
- async updateProfile(userId: string, changes: UpdateProfileDto): Promise<Profile> {
21
- const profile = await this.findProfile(userId)
22
-
23
- this.em.assign(profile, changes, { ignoreUndefined: true })
24
- await this.em.flush()
25
-
26
- return profile
27
- }
28
-
29
- async createProfile(userId: string, displayName: string): Promise<Profile> {
30
- const profile = this.em.create(Profile, { userId, displayName })
31
-
32
- await this.em.flush()
33
-
34
- return profile
35
- }
36
- }
@@ -1,26 +0,0 @@
1
- import type { EntityManager } from '@mikro-orm/postgresql'
2
- import { Profile } from '@/users/entities/profile.entity'
3
- import { createUser } from './user.factory'
4
-
5
- type ProfileOverrides = Partial<{
6
- userId: string
7
- displayName: string
8
- locale: string
9
- timezone: string
10
- }>
11
-
12
- export async function createProfile(
13
- em: EntityManager,
14
- overrides: ProfileOverrides = {},
15
- ): Promise<Profile> {
16
- const profile = em.create(Profile, {
17
- userId: overrides.userId ?? (await createUser(em)),
18
- displayName: overrides.displayName ?? 'Test Person',
19
- locale: overrides.locale ?? 'en',
20
- timezone: overrides.timezone ?? 'UTC',
21
- })
22
-
23
- await em.flush()
24
-
25
- return profile
26
- }
@@ -1,8 +0,0 @@
1
- {
2
- "extends": "./tsconfig.json",
3
- "compilerOptions": {
4
- "rootDir": "./src"
5
- },
6
- "include": ["src"],
7
- "exclude": ["node_modules", "dist", "test", "**/*.spec.ts"]
8
- }