@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.
- package/.prumo-templates/api/auth.md +51 -95
- package/.prumo-templates/api/config.md +30 -56
- package/.prumo-templates/api/errors.md +64 -44
- package/.prumo-templates/api/logging.md +27 -44
- package/.prumo-templates/api/observability.md +36 -62
- package/.prumo-templates/api/pagination.md +23 -38
- package/.prumo-templates/api/routes.md +67 -48
- package/.prumo-templates/api/structure.md +130 -0
- package/.prumo-templates/client/data.md +4 -3
- package/.prumo-templates/core/conventions.md +10 -7
- package/.prumo-templates/core/testing.md +43 -26
- package/.prumo-templates/core/tooling.md +33 -30
- package/.prumo-templates/database/entities.md +53 -61
- package/.prumo-templates/database/queries.md +34 -47
- package/.prumo-templates/database/transactions.md +28 -38
- package/.prumo-templates/mcp/server.md +54 -0
- package/.prumo-templates/monorepo/dependencies.md +3 -3
- package/.prumo-templates/monorepo/layout.md +1 -1
- package/README.md +12 -6
- package/dist/asker.js +4 -0
- package/dist/clean.js +2 -2
- package/dist/cli.js +4 -0
- package/dist/commands.js +2 -0
- package/dist/compose.js +12 -8
- package/dist/context.js +2 -0
- package/dist/generate.js +8 -0
- package/dist/mcp.js +100 -0
- package/dist/questions.js +21 -1
- package/package.json +4 -3
- package/templates/api/.swcrc +13 -0
- package/templates/api/README.md +10 -1
- package/templates/api/biome.jsonc +5 -3
- package/templates/api/package.json +22 -28
- package/templates/api/pnpm-workspace.yaml +0 -2
- package/templates/api/scripts/ports.mjs +508 -0
- package/templates/api/src/domain/shared/errors/domain-error.ts +5 -0
- package/templates/api/src/domain/shared/transactions/transaction-manager.ts +5 -0
- package/templates/api/src/domain/users/dto/profile-response.dto.ts +14 -0
- package/templates/api/src/domain/users/dto/update-profile.dto.ts +10 -0
- package/templates/api/src/domain/users/entities/profile.entity.ts +26 -0
- package/templates/api/src/domain/users/errors/profile-not-found.error.ts +9 -0
- package/templates/api/src/domain/users/repositories/profile.repository.ts +8 -0
- package/templates/api/src/domain/users/use-cases/create-profile.use-case.ts +19 -0
- package/templates/api/src/domain/users/use-cases/find-profile.use-case.ts +22 -0
- package/templates/api/src/domain/users/use-cases/update-profile.use-case.ts +26 -0
- package/templates/api/src/infra/auth/auth.cli.ts +5 -0
- package/templates/api/src/{auth → infra/auth}/auth.factory.ts +26 -1
- package/templates/api/src/infra/config/env.ts +47 -0
- package/templates/api/src/infra/database/mikroorm/entities/auth-user.schema.ts +16 -0
- package/templates/api/src/infra/database/mikroorm/entities/profile.schema.ts +28 -0
- package/templates/api/src/{mikro-orm.config.ts → infra/database/mikroorm/mikro-orm.config.ts} +1 -1
- package/templates/api/src/infra/database/mikroorm/mikro-orm.factory.ts +17 -0
- package/templates/api/src/infra/database/mikroorm/repositories/mikroorm-profile.repository.ts +17 -0
- package/templates/api/src/infra/database/mikroorm/transactions/mikroorm-transaction-manager.ts +14 -0
- package/templates/api/src/infra/di/index.ts +32 -0
- package/templates/api/src/infra/di/users.di.ts +7 -0
- package/templates/api/src/infra/http/app.ts +99 -0
- package/templates/api/src/infra/http/controllers/auth.controller.ts +36 -0
- package/templates/api/src/infra/http/controllers/health.controller.ts +19 -0
- package/templates/api/src/infra/http/controllers/mcp.controller.ts +40 -0
- package/templates/api/src/infra/http/controllers/users.controller.ts +35 -0
- package/templates/api/src/infra/http/errors/error-handler.ts +116 -0
- package/templates/api/src/infra/http/errors/http-error.ts +8 -0
- package/templates/api/src/infra/http/fastify.d.ts +17 -0
- package/templates/api/src/infra/http/fetch-bridge.ts +42 -0
- package/templates/api/src/infra/http/hooks/auth.hook.ts +32 -0
- package/templates/api/src/infra/http/hooks/request-context.hook.ts +9 -0
- package/templates/api/src/infra/http/server.ts +34 -0
- package/templates/api/src/infra/mcp/mcp.server.ts +20 -0
- package/templates/api/src/infra/mcp/tools/users.tool.ts +39 -0
- package/templates/api/test/domain/users/use-cases/find-profile.use-case.spec.ts +22 -0
- package/templates/api/test/domain/users/use-cases/update-profile.use-case.spec.ts +26 -0
- package/templates/api/{src → test/infra}/auth/auth.factory.spec.ts +4 -11
- package/templates/api/test/infra/database/mikroorm/mikro-orm.factory.spec.ts +8 -0
- package/templates/api/test/infra/database/mikroorm/repositories/mikroorm-profile.repository.spec.ts +51 -0
- package/templates/api/test/infra/database/mikroorm/transactions/mikroorm-transaction-manager.spec.ts +29 -0
- package/templates/api/test/infra/http/app.spec.ts +21 -0
- package/templates/api/test/infra/http/controllers/mcp.controller.spec.ts +152 -0
- package/templates/api/test/infra/http/controllers/users.controller.spec.ts +105 -0
- package/templates/api/test/support/factories/profile.factory.ts +25 -0
- package/templates/api/test/{factories → support/factories}/user.factory.ts +1 -1
- package/templates/api/test/support/fakes/in-memory-profile.repository.ts +14 -0
- package/templates/api/test/{setup.ts → support/setup.ts} +26 -14
- package/templates/api/tsconfig.json +3 -6
- package/templates/api/vitest.config.mts +4 -4
- package/templates/mobile/.env.example +3 -0
- package/templates/mobile/README.md +10 -0
- package/templates/mobile/jest.config.js +5 -1
- package/templates/mobile/package.json +3 -3
- package/templates/mobile/scripts/ports.mjs +508 -0
- package/templates/mobile/{src → test}/features/auth/authenticated-layout.spec.tsx +1 -1
- package/templates/mobile/{src → test}/features/auth/sign-in-form.spec.tsx +2 -2
- package/templates/mobile/{src → test}/features/auth/sign-out-button.spec.tsx +2 -2
- package/templates/mobile/{src → test}/features/profile/profile-form.spec.tsx +2 -2
- package/templates/mobile/tsconfig.json +2 -1
- package/templates/site/.env.example +2 -0
- package/templates/site/README.md +12 -1
- package/templates/site/gitignore +1 -0
- package/templates/site/package.json +1 -1
- package/templates/site/scripts/ports.mjs +508 -0
- package/templates/site/{src → test}/app/page.spec.ts +1 -1
- package/templates/site/tsconfig.json +2 -1
- package/templates/site/vitest.config.mts +4 -2
- package/templates/web/.env.example +3 -0
- package/templates/web/README.md +10 -1
- package/templates/web/package.json +5 -3
- package/templates/web/scripts/ports.mjs +508 -0
- package/templates/web/src/features/auth/auth-client.ts +5 -0
- package/templates/web/src/features/auth/sign-in-form.tsx +10 -3
- package/templates/web/src/features/oauth/consent-form.tsx +63 -0
- package/templates/web/src/routeTree.gen.ts +21 -2
- package/templates/web/src/routes/_authenticated/consent.tsx +21 -0
- package/templates/web/{src → test}/app.spec.tsx +2 -2
- package/templates/web/{src → test}/features/auth/sign-in-form.spec.tsx +2 -2
- package/templates/web/test/features/oauth/consent-form.spec.tsx +45 -0
- package/templates/web/{src → test}/features/profile/profile-form.spec.tsx +2 -2
- package/templates/web/test/{render-app.tsx → support/render-app.tsx} +1 -1
- package/templates/web/tsconfig.json +2 -1
- package/templates/web/vite.config.ts +3 -3
- package/templates/workspace/README.md +7 -3
- package/templates/workspace/scripts/ports.mjs +508 -0
- package/.prumo-templates/api/modules.md +0 -117
- package/templates/api/nest-cli.json +0 -10
- package/templates/api/src/app.module.ts +0 -46
- package/templates/api/src/auth/auth.constants.ts +0 -2
- package/templates/api/src/auth/auth.controller.ts +0 -19
- package/templates/api/src/auth/auth.guard.ts +0 -43
- package/templates/api/src/auth/auth.module.ts +0 -30
- package/templates/api/src/auth/auth.ts +0 -4
- package/templates/api/src/auth/current-user.decorator.ts +0 -9
- package/templates/api/src/auth/express.d.ts +0 -10
- package/templates/api/src/auth/public.decorator.ts +0 -4
- package/templates/api/src/config/env.module.ts +0 -11
- package/templates/api/src/config/env.ts +0 -81
- package/templates/api/src/errors/problem-details.filter.ts +0 -86
- package/templates/api/src/errors/validation-exception.factory.ts +0 -30
- package/templates/api/src/health/health.controller.ts +0 -32
- package/templates/api/src/health/health.module.ts +0 -9
- package/templates/api/src/main.ts +0 -43
- package/templates/api/src/mikro-orm.factory.ts +0 -14
- package/templates/api/src/request-context/request-context.module.ts +0 -9
- package/templates/api/src/request-context/request-context.service.ts +0 -17
- package/templates/api/src/request-context/request-id.middleware.ts +0 -19
- package/templates/api/src/users/dto/update-profile.dto.ts +0 -20
- package/templates/api/src/users/entities/profile.entity.ts +0 -27
- package/templates/api/src/users/users.controller.ts +0 -23
- package/templates/api/src/users/users.module.ts +0 -10
- package/templates/api/src/users/users.service.spec.ts +0 -44
- package/templates/api/src/users/users.service.ts +0 -36
- package/templates/api/test/factories/profile.factory.ts +0 -26
- package/templates/api/tsconfig.build.json +0 -8
- /package/templates/api/test/{global-setup.ts → support/global-setup.ts} +0 -0
- /package/templates/mobile/test/{fake-transport.ts → support/fake-transport.ts} +0 -0
- /package/templates/web/test/{fake-transport.ts → support/fake-transport.ts} +0 -0
- /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,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,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,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,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
|
-
}
|
|
File without changes
|