diff --git a/.gitignore b/.gitignore index 70d8effb..d3b918fa 100644 --- a/.gitignore +++ b/.gitignore @@ -30,6 +30,7 @@ scripts/satellite/sync-config.json # ✅ IDE / AI 工具配置 .qoder/ .codex-backups/ +/.codegraph/ # ✅ 补丁与压缩包 *.patch diff --git a/apps/api/package.json b/apps/api/package.json index b37466b5..1a787e55 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -10,11 +10,20 @@ "check": "tsc -p tsconfig.json --noEmit" }, "dependencies": { + "@nestjs/common": "^11.1.28", + "@nestjs/core": "^11.1.28", + "@nestjs/platform-fastify": "^11.1.28", + "@nestjs/swagger": "^11.4.6", + "@scalar/nestjs-api-reference": "^1.0.31", "@supabase/storage-js": "^2.108.2", "ali-oss": "^6.23.0", + "class-transformer": "^0.5.1", + "class-validator": "^0.14.4", "jose": "^6.2.3", "pg": "^8.16.3", - "read-excel-file": "^9.2.0" + "read-excel-file": "^9.2.0", + "reflect-metadata": "^0.2.2", + "rxjs": "^7.8.2" }, "devDependencies": { "@types/node": "^24.0.4", diff --git a/apps/api/src/core/http.ts b/apps/api/src/core/http.ts index 94d34165..16b3a680 100644 --- a/apps/api/src/core/http.ts +++ b/apps/api/src/core/http.ts @@ -10,6 +10,7 @@ export interface RequestContext { res: ServerResponse; url: URL; requestId: string; + parsedBody?: unknown; } export type Handler = (ctx: RequestContext) => Promise; diff --git a/apps/api/src/core/request.ts b/apps/api/src/core/request.ts index c4473b99..ad7bc569 100644 --- a/apps/api/src/core/request.ts +++ b/apps/api/src/core/request.ts @@ -90,6 +90,16 @@ export interface ReadJsonBodyOptions { export async function readJsonBody(ctx: RequestContext, options: ReadJsonBodyOptions = {}): Promise { const maxBytes = options.maxBytes ?? config.maxJsonBodyBytes; + if (ctx.parsedBody !== undefined) { + const serializedBytes = Buffer.byteLength(JSON.stringify(ctx.parsedBody)); + if (serializedBytes > maxBytes) { + throw new HttpError(413, `JSON body is too large. Max ${maxBytes} bytes.`, 'JSON_BODY_TOO_LARGE'); + } + if (!ctx.parsedBody || typeof ctx.parsedBody !== 'object' || Array.isArray(ctx.parsedBody)) { + throw new HttpError(400, 'JSON body must be an object', 'INVALID_JSON_BODY'); + } + return ctx.parsedBody as JsonObject; + } const contentLength = Number(getHeader(ctx.req, 'content-length') || 0); if (Number.isFinite(contentLength) && contentLength > maxBytes) { throw new HttpError(413, `JSON body is too large. Max ${maxBytes} bytes.`, 'JSON_BODY_TOO_LARGE'); diff --git a/apps/api/src/core/router.ts b/apps/api/src/core/router.ts index e0e4102e..2e2c93c0 100644 --- a/apps/api/src/core/router.ts +++ b/apps/api/src/core/router.ts @@ -28,7 +28,7 @@ export function createRouter(definitions: RouteDefinition[] = allRoutes) { return routes; } -const allRoutes: RouteDefinition[] = [ +export const allRoutes: RouteDefinition[] = [ ...healthRoutes, ...authRoutes, ...tenantRoutes, diff --git a/apps/api/src/nest/ai.module.ts b/apps/api/src/nest/ai.module.ts new file mode 100644 index 00000000..17323073 --- /dev/null +++ b/apps/api/src/nest/ai.module.ts @@ -0,0 +1,48 @@ +import { Body, Controller, Get, HttpCode, Inject, Injectable, Module, Post, Query, Req, Res } from '@nestjs/common'; +import { ApiBearerAuth, ApiBody, ApiOperation, ApiTags } from '@nestjs/swagger'; +import type { FastifyReply, FastifyRequest } from 'fastify'; +import * as routes from '../features/ai/routes.js'; +import { ApiStandardResponses } from './api-doc.decorators.js'; +import { DomainRouteService } from './domain-route.service.js'; +import { GenerateRecommendationDto } from './request.dto.js'; +import { RecommendationDetailQueryDto, RecommendationExportQueryDto, RecommendationListQueryDto } from './query.dto.js'; +import { RequestContextFactory } from './request-context.factory.js'; + +const AI_HANDLERS = Symbol('AI_HANDLERS'); +const aiHandlers = { + list: routes.schoolRecommendationReportsRoute, + detail: routes.schoolRecommendationReportDetailRoute, + export: routes.schoolRecommendationReportExportRoute, + generate: routes.generateSchoolRecommendationRoute, +}; + +@Injectable() +class AiService extends DomainRouteService { + constructor(factory: RequestContextFactory, @Inject(AI_HANDLERS) injectedHandlers: typeof aiHandlers) { + super(factory, injectedHandlers); + } +} + +@ApiTags('AI 推荐') +@ApiBearerAuth() +@Controller('/api/ai/school-recommendations') +class AiController { + constructor(private readonly service: AiService) {} + private run(name: string, req: FastifyRequest, res: FastifyReply) { return this.service.execute(name, req, res); } + + @Get() @ApiOperation({ summary: '查询院校推荐报告' }) + @ApiStandardResponses('items') + list(@Query() _q: RecommendationListQueryDto, @Req() req: FastifyRequest, @Res({ passthrough: true }) res: FastifyReply) { return this.run('list', req, res); } + @Get('detail') @ApiOperation({ summary: '获取院校推荐报告详情' }) + @ApiStandardResponses('item') + detail(@Query() _q: RecommendationDetailQueryDto, @Req() req: FastifyRequest, @Res({ passthrough: true }) res: FastifyReply) { return this.run('detail', req, res); } + @Get('export') @ApiOperation({ summary: '导出院校推荐报告' }) + @ApiStandardResponses('item') + exportReport(@Query() _q: RecommendationExportQueryDto, @Req() req: FastifyRequest, @Res({ passthrough: true }) res: FastifyReply) { return this.run('export', req, res); } + @Post('generate') @HttpCode(200) @ApiBody({ type: GenerateRecommendationDto }) @ApiOperation({ summary: '生成院校推荐报告', description: '根据学生成绩、地区、专业偏好等条件生成并保存推荐报告。' }) + @ApiStandardResponses('item') + generate(@Body() _b: GenerateRecommendationDto, @Req() req: FastifyRequest, @Res({ passthrough: true }) res: FastifyReply) { return this.run('generate', req, res); } +} + +@Module({ controllers: [AiController], providers: [AiService, { provide: AI_HANDLERS, useValue: aiHandlers }] }) +export class AiModule {} diff --git a/apps/api/src/nest/api-doc.decorators.ts b/apps/api/src/nest/api-doc.decorators.ts new file mode 100644 index 00000000..087f140b --- /dev/null +++ b/apps/api/src/nest/api-doc.decorators.ts @@ -0,0 +1,38 @@ +import { applyDecorators } from '@nestjs/common'; +import { ApiBadRequestResponse, ApiExtraModels, ApiForbiddenResponse, ApiNotFoundResponse, ApiOkResponse, ApiUnauthorizedResponse, getSchemaPath } from '@nestjs/swagger'; +import { ApiErrorResponseDto, ApiResponseMetaDto, GenericItemDto, envelopeSchema, itemEnvelopeSchema, itemsEnvelopeSchema, okEnvelopeSchema } from './api-response.dto.js'; + +type ResponseKind = 'item' | 'items' | 'ok' | 'object'; + +export function ApiStandardResponses(kind: ResponseKind = 'object', description = '请求成功') { + const schema = kind === 'item' + ? itemEnvelopeSchema + : kind === 'items' + ? itemsEnvelopeSchema + : kind === 'ok' + ? okEnvelopeSchema + : envelopeSchema({ data: { type: 'object', additionalProperties: true, description: '接口业务数据' } }); + + return applyDecorators( + ApiExtraModels(ApiResponseMetaDto, ApiErrorResponseDto, GenericItemDto), + ApiOkResponse({ description, schema }), + ApiBadRequestResponse({ description: '请求参数或业务输入不合法', type: ApiErrorResponseDto }), + ApiUnauthorizedResponse({ description: '未登录、会话无效或已过期', type: ApiErrorResponseDto }), + ApiForbiddenResponse({ description: '当前用户无权访问该租户或资源', type: ApiErrorResponseDto }), + ApiNotFoundResponse({ description: '请求的业务资源不存在', type: ApiErrorResponseDto }), + ); +} + +export function ApiEnvelopeProperties(properties: Record, description = '请求成功') { + return applyDecorators( + ApiExtraModels(ApiResponseMetaDto, ApiErrorResponseDto, GenericItemDto), + ApiOkResponse({ description, schema: envelopeSchema(properties) }), + ApiBadRequestResponse({ description: '请求参数或业务输入不合法', type: ApiErrorResponseDto }), + ApiUnauthorizedResponse({ description: '未登录、会话无效或已过期', type: ApiErrorResponseDto }), + ApiForbiddenResponse({ description: '当前用户无权访问该租户或资源', type: ApiErrorResponseDto }), + ApiNotFoundResponse({ description: '请求的业务资源不存在', type: ApiErrorResponseDto }), + ); +} + +export const itemProperty = { $ref: getSchemaPath(GenericItemDto) }; +export const itemsProperty = { type: 'array', items: itemProperty }; diff --git a/apps/api/src/nest/api-exception.filter.ts b/apps/api/src/nest/api-exception.filter.ts new file mode 100644 index 00000000..42d3cd5a --- /dev/null +++ b/apps/api/src/nest/api-exception.filter.ts @@ -0,0 +1,35 @@ +import type { ArgumentsHost, ExceptionFilter } from '@nestjs/common'; +import { Catch, HttpException } from '@nestjs/common'; +import type { FastifyReply } from 'fastify'; +import { config } from '../core/config.js'; +import { HttpError } from '../core/errors.js'; +import { withResponseMeta } from '../core/http.js'; + +@Catch() +export class ApiExceptionFilter implements ExceptionFilter { + catch(error: unknown, host: ArgumentsHost) { + const reply = host.switchToHttp().getResponse(); + const requestId = String(reply.getHeader('x-request-id') || ''); + let statusCode = 500; + let message = config.isProduction ? 'Internal server error' : error instanceof Error ? error.message : 'Unknown error'; + let code = 'INTERNAL_ERROR'; + + if (error instanceof HttpError) { + statusCode = error.statusCode; + message = error.message; + code = error.code; + } else if (error instanceof HttpException) { + statusCode = error.getStatus(); + const response = error.getResponse(); + if (typeof response === 'string') message = response; + else if (response && typeof response === 'object') { + const body = response as Record; + const responseMessage = body.message; + message = Array.isArray(responseMessage) ? responseMessage.join('; ') : String(responseMessage || message); + code = typeof body.code === 'string' ? body.code : statusCode === 400 ? 'VALIDATION_ERROR' : 'HTTP_ERROR'; + } + } + + reply.status(statusCode).send(withResponseMeta({ error: message, code, requestId }, requestId)); + } +} diff --git a/apps/api/src/nest/api-response.dto.ts b/apps/api/src/nest/api-response.dto.ts new file mode 100644 index 00000000..f611c17c --- /dev/null +++ b/apps/api/src/nest/api-response.dto.ts @@ -0,0 +1,69 @@ +import { ApiProperty, ApiPropertyOptional, getSchemaPath } from '@nestjs/swagger'; + +export class ApiResponseMetaDto { + @ApiProperty({ description: '请求追踪 ID,排查问题时请提供该值', example: '8e25cb25-2347-4c41-a734-2dd7c35df90f' }) + requestId!: string; +} + +export class ApiErrorResponseDto { + @ApiProperty({ description: '面向调用方的错误信息', example: 'questionId is required' }) + error!: string; + + @ApiProperty({ description: '稳定的业务错误码', example: 'REQUIRED_FIELD' }) + code!: string; + + @ApiProperty({ description: '请求追踪 ID' }) + requestId!: string; + + @ApiProperty({ type: ApiResponseMetaDto }) + meta!: ApiResponseMetaDto; +} + +export class GenericItemDto { + @ApiPropertyOptional({ description: '资源 ID', format: 'uuid' }) id?: string; + @ApiPropertyOptional({ description: '租户 ID', format: 'uuid' }) tenantId?: string; + @ApiPropertyOptional({ description: '用户 ID', format: 'uuid' }) userId?: string; + @ApiPropertyOptional({ description: '题目 ID', format: 'uuid' }) questionId?: string; + @ApiPropertyOptional({ description: '练习会话 ID', format: 'uuid' }) practiceSessionId?: string; + @ApiPropertyOptional({ description: '单词 ID', format: 'uuid' }) wordId?: string; + @ApiPropertyOptional({ description: '资源名称或标题' }) name?: string; + @ApiPropertyOptional({ description: '标题' }) title?: string; + @ApiPropertyOptional({ description: '资源类型' }) type?: string; + @ApiPropertyOptional({ description: '资源状态' }) status?: string; + @ApiPropertyOptional({ description: '是否操作成功' }) ok?: boolean; + @ApiPropertyOptional({ description: '是否收藏' }) favorite?: boolean; + @ApiPropertyOptional({ description: '积分或数量值' }) score?: number; + @ApiPropertyOptional({ description: '统计数量' }) count?: number; + @ApiPropertyOptional({ description: '扩展元数据', type: 'object', additionalProperties: true }) metadata?: Record; + @ApiPropertyOptional({ description: '创建时间', format: 'date-time' }) createdAt?: string; + @ApiPropertyOptional({ description: '更新时间', format: 'date-time' }) updatedAt?: string; +} + +export class BooleanResultDto { + @ApiProperty({ description: '操作是否成功', example: true }) ok!: boolean; + @ApiPropertyOptional({ description: '当前是否收藏', example: true }) favorite?: boolean; +} + +export function envelopeSchema(payload: Record) { + return { + type: 'object', + properties: { + ...payload, + meta: { $ref: getSchemaPath(ApiResponseMetaDto) }, + }, + required: ['meta'], + }; +} + +export const itemEnvelopeSchema = envelopeSchema({ + item: { $ref: getSchemaPath(GenericItemDto), description: '接口返回的单个业务对象;具体字段见接口说明。' }, +}); + +export const itemsEnvelopeSchema = envelopeSchema({ + items: { type: 'array', items: { $ref: getSchemaPath(GenericItemDto) }, description: '业务对象列表' }, +}); + +export const okEnvelopeSchema = envelopeSchema({ + ok: { type: 'boolean', example: true }, + favorite: { type: 'boolean', description: '收藏接口返回当前收藏状态' }, +}); diff --git a/apps/api/src/nest/api.interceptor.ts b/apps/api/src/nest/api.interceptor.ts new file mode 100644 index 00000000..d207439c --- /dev/null +++ b/apps/api/src/nest/api.interceptor.ts @@ -0,0 +1,14 @@ +import type { CallHandler, ExecutionContext, NestInterceptor } from '@nestjs/common'; +import { Injectable } from '@nestjs/common'; +import type { FastifyReply } from 'fastify'; +import type { Observable } from 'rxjs'; +import { map } from 'rxjs/operators'; +import { withResponseMeta } from '../core/http.js'; + +@Injectable() +export class ApiEnvelopeInterceptor implements NestInterceptor { + intercept(context: ExecutionContext, next: CallHandler): Observable { + const reply = context.switchToHttp().getResponse(); + return next.handle().pipe(map(body => withResponseMeta(body, String(reply.getHeader('x-request-id') || '')))); + } +} diff --git a/apps/api/src/nest/app.module.ts b/apps/api/src/nest/app.module.ts new file mode 100644 index 00000000..604b4dbd --- /dev/null +++ b/apps/api/src/nest/app.module.ts @@ -0,0 +1,21 @@ +import { Global, Module } from '@nestjs/common'; +import { AiModule } from './ai.module.js'; +import { AuthModule } from './auth.module.js'; +import { DatabaseLifecycle } from './database.provider.js'; +import { HealthModule } from './health.module.js'; +import { LearningModule } from './learning.module.js'; +import { ProfileModule } from './profile.module.js'; +import { RequestContextFactory } from './request-context.factory.js'; +import { TenantModule } from './tenant.module.js'; + +@Global() +@Module({ + providers: [RequestContextFactory, DatabaseLifecycle], + exports: [RequestContextFactory], +}) +class CoreModule {} + +@Module({ + imports: [CoreModule, HealthModule, TenantModule, AuthModule, ProfileModule, LearningModule, AiModule], +}) +export class AppModule {} diff --git a/apps/api/src/nest/auth.module.ts b/apps/api/src/nest/auth.module.ts new file mode 100644 index 00000000..d4ed48b6 --- /dev/null +++ b/apps/api/src/nest/auth.module.ts @@ -0,0 +1,57 @@ +import { Body, Controller, Get, HttpCode, Inject, Injectable, Module, Post, Req, Res } from '@nestjs/common'; +import { ApiBearerAuth, ApiBody, ApiOperation, ApiTags } from '@nestjs/swagger'; +import type { FastifyReply, FastifyRequest } from 'fastify'; +import * as handlers from '../features/auth/routes.js'; +import { DomainRouteService } from './domain-route.service.js'; +import { BindPhoneDto, OAuthCodeDto, SmsSendDto, SmsVerifyDto } from './dto.js'; +import { RequestContextFactory } from './request-context.factory.js'; +import { ApiEnvelopeProperties, ApiStandardResponses } from './api-doc.decorators.js'; + +const AUTH_HANDLERS = Symbol('AUTH_HANDLERS'); +const authHandlers = { + sendSms: handlers.sendSmsCodeRoute, verifySms: handlers.verifySmsCodeRoute, me: handlers.meRoute, + logout: handlers.logoutRoute, bindPhone: handlers.bindPhoneRoute, wechat: handlers.wechatWebLoginRoute, + miniapp: handlers.wechatMiniappLoginRoute, qq: handlers.qqLoginRoute, +}; + +@Injectable() +class AuthService extends DomainRouteService { + constructor(factory: RequestContextFactory, @Inject(AUTH_HANDLERS) injectedHandlers: typeof authHandlers) { + super(factory, injectedHandlers); + } +} + +@ApiTags('认证与登录') +@Controller('/api/auth') +class AuthController { + constructor(private readonly service: AuthService) {} + private run(name: string, req: FastifyRequest, res: FastifyReply) { return this.service.execute(name, req, res); } + + @Post('sms/send') @HttpCode(200) @ApiBody({ type: SmsSendDto }) @ApiOperation({ summary: '发送短信验证码', description: '根据用途发送登录、绑定手机号或重置密码验证码,并应用租户级限流。' }) + @ApiEnvelopeProperties({ item: { type: 'object', properties: { id: { type: 'string', format: 'uuid' }, phone: { type: 'string' }, purpose: { type: 'string' }, provider: { type: 'string' }, status: { type: 'string' }, expiresAt: { type: 'string', format: 'date-time' } } }, expireIn: { type: 'integer', description: '验证码有效秒数' }, cooldown: { type: 'integer', description: '再次发送冷却秒数' }, debugCode: { type: 'string', description: '仅开发环境 mock provider 返回' } }) + sendSms(@Body() _body: SmsSendDto, @Req() req: FastifyRequest, @Res({ passthrough: true }) res: FastifyReply) { return this.run('sendSms', req, res); } + @Post('sms/verify') @HttpCode(200) @ApiBody({ type: SmsVerifyDto }) @ApiOperation({ summary: '校验短信验证码并登录', description: '校验验证码;登录用途会创建或更新用户,并签发应用会话。' }) + @ApiEnvelopeProperties({ ok: { type: 'boolean' }, verified: { type: 'boolean' }, purpose: { type: 'string' }, phone: { type: 'string' }, user: { type: 'object', description: '登录用户;非 login 用途可能不返回', additionalProperties: true }, isNewUser: { type: 'boolean' }, session: { type: 'object', description: '应用会话 token 与过期时间', properties: { token: { type: 'string' }, expiresAt: { type: 'string', format: 'date-time' } } } }) + verifySms(@Body() _body: SmsVerifyDto, @Req() req: FastifyRequest, @Res({ passthrough: true }) res: FastifyReply) { return this.run('verifySms', req, res); } + @Get('me') @ApiBearerAuth() @ApiOperation({ summary: '获取当前登录用户', description: '根据 Bearer Token 返回当前用户与会话有效期。' }) + @ApiEnvelopeProperties({ user: { type: 'object', description: '当前用户信息', additionalProperties: true }, session: { type: 'object', description: '当前会话信息', additionalProperties: true } }) + me(@Req() req: FastifyRequest, @Res({ passthrough: true }) res: FastifyReply) { return this.run('me', req, res); } + @Post('logout') @HttpCode(200) @ApiBearerAuth() @ApiOperation({ summary: '退出登录', description: '撤销当前应用会话;Supabase JWT 会话由其认证服务管理。' }) + @ApiStandardResponses('ok') + logout(@Req() req: FastifyRequest, @Res({ passthrough: true }) res: FastifyReply) { return this.run('logout', req, res); } + @Post('phone/bind') @HttpCode(200) @ApiBearerAuth() @ApiBody({ type: BindPhoneDto }) @ApiOperation({ summary: '绑定手机号', description: '使用 bind_phone 用途的短信验证码为当前用户绑定中国大陆手机号。' }) + @ApiEnvelopeProperties({ ok: { type: 'boolean' }, phone: { type: 'string' }, user: { type: 'object', additionalProperties: true } }) + bindPhone(@Body() _body: BindPhoneDto, @Req() req: FastifyRequest, @Res({ passthrough: true }) res: FastifyReply) { return this.run('bindPhone', req, res); } + @Post('oauth/wechat') @HttpCode(200) @ApiBody({ type: OAuthCodeDto }) @ApiOperation({ summary: '微信网页 OAuth 登录', description: '使用微信网页授权 code 换取用户身份并创建应用会话。' }) + @ApiEnvelopeProperties({ provider: { type: 'string' }, user: { type: 'object', additionalProperties: true }, isNewUser: { type: 'boolean' }, session: { type: 'object', additionalProperties: true }, identity: { type: 'object', additionalProperties: true } }) + wechat(@Body() _body: OAuthCodeDto, @Req() req: FastifyRequest, @Res({ passthrough: true }) res: FastifyReply) { return this.run('wechat', req, res); } + @Post('oauth/wechat-miniapp') @HttpCode(200) @ApiBody({ type: OAuthCodeDto }) @ApiOperation({ summary: '微信小程序登录', description: '使用小程序 wx.login 返回的 code 换取 openid 并创建应用会话。' }) + @ApiEnvelopeProperties({ provider: { type: 'string' }, user: { type: 'object', additionalProperties: true }, isNewUser: { type: 'boolean' }, session: { type: 'object', additionalProperties: true }, identity: { type: 'object', additionalProperties: true } }) + miniapp(@Body() _body: OAuthCodeDto, @Req() req: FastifyRequest, @Res({ passthrough: true }) res: FastifyReply) { return this.run('miniapp', req, res); } + @Post('oauth/qq') @HttpCode(200) @ApiBody({ type: OAuthCodeDto }) @ApiOperation({ summary: 'QQ OAuth 登录', description: '使用 QQ OAuth 授权 code 和回调地址换取用户身份并创建应用会话。' }) + @ApiEnvelopeProperties({ provider: { type: 'string' }, user: { type: 'object', additionalProperties: true }, isNewUser: { type: 'boolean' }, session: { type: 'object', additionalProperties: true }, identity: { type: 'object', additionalProperties: true } }) + qq(@Body() _body: OAuthCodeDto, @Req() req: FastifyRequest, @Res({ passthrough: true }) res: FastifyReply) { return this.run('qq', req, res); } +} + +@Module({ controllers: [AuthController], providers: [AuthService, { provide: AUTH_HANDLERS, useValue: authHandlers }] }) +export class AuthModule {} diff --git a/apps/api/src/nest/database.provider.ts b/apps/api/src/nest/database.provider.ts new file mode 100644 index 00000000..6e33ac99 --- /dev/null +++ b/apps/api/src/nest/database.provider.ts @@ -0,0 +1,9 @@ +import { Injectable, OnApplicationShutdown } from '@nestjs/common'; +import { closePool } from '../core/db.js'; + +@Injectable() +export class DatabaseLifecycle implements OnApplicationShutdown { + async onApplicationShutdown() { + await closePool(); + } +} diff --git a/apps/api/src/nest/domain-route.service.ts b/apps/api/src/nest/domain-route.service.ts new file mode 100644 index 00000000..7422c6ba --- /dev/null +++ b/apps/api/src/nest/domain-route.service.ts @@ -0,0 +1,16 @@ +import type { FastifyReply, FastifyRequest } from 'fastify'; +import type { Handler } from '../core/http.js'; +import { RequestContextFactory } from './request-context.factory.js'; + +export abstract class DomainRouteService { + protected constructor( + protected readonly contextFactory: RequestContextFactory, + private readonly handlers: Record, + ) {} + + execute(name: string, request: FastifyRequest, reply: FastifyReply) { + const handler = this.handlers[name]; + if (!handler) throw new Error(`Missing domain route handler: ${name}`); + return handler(this.contextFactory.create(request, reply)); + } +} diff --git a/apps/api/src/nest/dto.ts b/apps/api/src/nest/dto.ts new file mode 100644 index 00000000..029de571 --- /dev/null +++ b/apps/api/src/nest/dto.ts @@ -0,0 +1,55 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { IsIn, IsInt, IsObject, IsOptional, IsString, Max, Min } from 'class-validator'; +import { Type } from 'class-transformer'; + +export class TenantResolveQueryDto { + @ApiPropertyOptional({ description: '要解析的访问域名,例如 student.example.com' }) @IsOptional() @IsString() host?: string; + @ApiPropertyOptional({ description: '租户编码;本地开发或无独立域名时使用', example: 'master' }) @IsOptional() @IsString() tenantCode?: string; +} + +export class PaginationQueryDto { + @ApiPropertyOptional({ minimum: 1, maximum: 500 }) + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + @Max(500) + limit?: number; +} + +export class FlexibleQueryDto extends PaginationQueryDto { + @ApiPropertyOptional() @IsOptional() @IsString() id?: string; + @ApiPropertyOptional() @IsOptional() @IsString() sessionId?: string; + @ApiPropertyOptional() @IsOptional() @IsString() questionId?: string; + @ApiPropertyOptional() @IsOptional() @IsString() unitId?: string; + @ApiPropertyOptional() @IsOptional() @IsString() reportId?: string; + @ApiPropertyOptional() @IsOptional() @IsString() days?: string; +} + +export class JsonObjectDto { + [key: string]: unknown; +} + +const SMS_PURPOSES = ['login', 'bind_phone', 'reset_password'] as const; + +export class SmsSendDto { + @ApiProperty({ description: '中国大陆手机号,允许 +86 前缀和空格', example: '13800138000' }) @IsString() phone!: string; + @ApiPropertyOptional({ description: '验证码用途', enum: SMS_PURPOSES, default: 'login' }) @IsOptional() @IsIn(SMS_PURPOSES) purpose?: string; + @ApiPropertyOptional({ description: '客户端设备标识,用于风控限流' }) @IsOptional() @IsString() deviceId?: string; + @ApiPropertyOptional({ description: '短信请求扩展元数据', type: 'object', additionalProperties: true }) @IsOptional() @IsObject() metadata?: Record; +} + +export class SmsVerifyDto { + @ApiProperty({ description: '中国大陆手机号', example: '13800138000' }) @IsString() phone!: string; + @ApiProperty({ description: '收到的短信验证码', example: '123456' }) @IsString() code!: string; + @ApiPropertyOptional({ description: '验证码用途', enum: SMS_PURPOSES, default: 'login' }) @IsOptional() @IsIn(SMS_PURPOSES) purpose?: string; +} + +export class BindPhoneDto extends SmsVerifyDto {} + +export class OAuthCodeDto { + @ApiProperty({ description: 'OAuth 平台返回的一次性授权 code' }) @IsString() code!: string; + @ApiPropertyOptional({ description: '客户端可提供的公开用户资料,不允许包含 token/secret', type: 'object', additionalProperties: true }) @IsOptional() @IsObject() profile?: Record; + @ApiPropertyOptional({ description: '微信用户资料语言', example: 'zh_CN' }) @IsOptional() @IsString() lang?: string; + @ApiPropertyOptional({ description: 'QQ OAuth 回调地址;租户未配置时必填' }) @IsOptional() @IsString() redirectUri?: string; +} diff --git a/apps/api/src/nest/health.module.ts b/apps/api/src/nest/health.module.ts new file mode 100644 index 00000000..90044c25 --- /dev/null +++ b/apps/api/src/nest/health.module.ts @@ -0,0 +1,31 @@ +import { Controller, Get, Inject, Injectable, Module } from '@nestjs/common'; +import { ApiOperation, ApiTags } from '@nestjs/swagger'; +import { healthRoute } from '../features/health/routes.js'; +import { ApiEnvelopeProperties } from './api-doc.decorators.js'; + +const HEALTH_CHECK = Symbol('HEALTH_CHECK'); + +@Injectable() +class HealthService { + constructor(@Inject(HEALTH_CHECK) private readonly healthCheck: typeof healthRoute) {} + check() { return this.healthCheck(); } +} + +@ApiTags('健康检查') +@Controller() +class HealthController { + constructor(private readonly service: HealthService) {} + + @Get('/health') + @ApiOperation({ summary: '检查 API 与数据库健康状态', description: '用于部署探针和人工排查,返回 API、PostgreSQL 连接及服务器时间。' }) + @ApiEnvelopeProperties({ + ok: { type: 'boolean', example: true }, + service: { type: 'string', example: 'tiku-saas-api' }, + db: { type: 'string', example: 'ok' }, + time: { type: 'string', format: 'date-time' }, + }) + check() { return this.service.check(); } +} + +@Module({ controllers: [HealthController], providers: [HealthService, { provide: HEALTH_CHECK, useValue: healthRoute }] }) +export class HealthModule {} diff --git a/apps/api/src/nest/http-hooks.ts b/apps/api/src/nest/http-hooks.ts new file mode 100644 index 00000000..98e9d9ae --- /dev/null +++ b/apps/api/src/nest/http-hooks.ts @@ -0,0 +1,63 @@ +import type { FastifyInstance, FastifyRequest } from 'fastify'; +import { config } from '../core/config.js'; +import { authorizeCorsRequest, CorsPolicy } from '../core/cors.js'; +import { requestIdFrom } from '../core/request-id.js'; +import { withResponseMeta } from '../core/http.js'; + +function writeLog(event: Record, error = false) { + const line = JSON.stringify({ timestamp: new Date().toISOString(), service: 'tiku-saas-api', ...event }); + if (error) console.error(line); + else console.log(line); +} + +function requestPath(request: FastifyRequest) { + try { + return new URL(request.raw.url || '/', 'http://localhost').pathname; + } catch { + return '/'; + } +} + +export function registerHttpHooks(instance: FastifyInstance) { + // 使用 Fastify hook 覆盖原生与兼容路由,避免两套路由出现不同的 CORS 和访问日志行为。 + const corsPolicy = new CorsPolicy({ + staticOrigins: config.corsOrigins, + tenantDomainsEnabled: config.corsTenantDomainsEnabled, + positiveCacheTtlMs: config.corsTenantDomainCacheTtlMs, + negativeCacheTtlMs: config.corsTenantDomainNegativeCacheTtlMs, + maxCacheEntries: config.corsTenantDomainCacheMaxEntries, + onLookupError(error, host) { + writeLog({ event: 'cors_tenant_domain_lookup_failed', host, error: error instanceof Error ? error.message : 'unknown' }, true); + }, + }); + + instance.addHook('onRequest', async (request, reply) => { + const requestId = requestIdFrom(request.raw); + reply.header('x-request-id', requestId); + (request as FastifyRequest & { startedAt?: bigint }).startedAt = process.hrtime.bigint(); + const corsDecision = await authorizeCorsRequest(request.raw, reply.raw, corsPolicy); + if (!corsDecision.allowed) { + reply.status(403).send(withResponseMeta({ error: 'Request origin is not allowed', code: 'CORS_ORIGIN_DENIED', requestId }, requestId)); + return reply; + } + if (request.method === 'OPTIONS') { + reply.status(204).send(); + return reply; + } + }); + + instance.addHook('onResponse', async (request, reply) => { + const startedAt = (request as FastifyRequest & { startedAt?: bigint }).startedAt || process.hrtime.bigint(); + const durationMs = Number(process.hrtime.bigint() - startedAt) / 1_000_000; + writeLog({ + event: 'http_request', + requestId: String(reply.getHeader('x-request-id') || ''), + method: request.method, + path: requestPath(request), + status: reply.statusCode, + durationMs: Number(durationMs.toFixed(2)), + }, reply.statusCode >= 500); + }); +} + +export { writeLog }; diff --git a/apps/api/src/nest/learning.module.ts b/apps/api/src/nest/learning.module.ts new file mode 100644 index 00000000..8a915f32 --- /dev/null +++ b/apps/api/src/nest/learning.module.ts @@ -0,0 +1,87 @@ +import { Body, Controller, Get, HttpCode, Inject, Injectable, Module, Post, Query, Req, Res } from '@nestjs/common'; +import { ApiBearerAuth, ApiBody, ApiOperation, ApiTags } from '@nestjs/swagger'; +import type { FastifyReply, FastifyRequest } from 'fastify'; +import * as routes from '../features/learning/routes.js'; +import { learningLeaderboardRoute } from '../features/learning/leaderboard.js'; +import { ApiEnvelopeProperties, ApiStandardResponses, itemsProperty } from './api-doc.decorators.js'; +import { DomainRouteService } from './domain-route.service.js'; +import { CreatePracticeSessionDto, FavoriteWordDto, QuestionActionDto, SubmitAnswerDto, SubmitPracticeSessionDto, WordProgressDto, WordReviewDto } from './request.dto.js'; +import { LeaderboardQueryDto, LearningWindowQueryDto, LimitQueryDto, PracticeSessionQueryDto, UnitQueryDto, WordProgressQueryDto, WordReviewPlanQueryDto, WrongQuestionQueryDto, WrongReviewPlanQueryDto } from './query.dto.js'; +import { RequestContextFactory } from './request-context.factory.js'; + +const learningHandlers = { + leaderboard: learningLeaderboardRoute, createSession: routes.createPracticeSessionRoute, + sessionDetail: routes.practiceSessionDetailRoute, submitSession: routes.submitPracticeSessionRoute, + sessionReport: routes.practiceSessionReportRoute, history: routes.practiceHistoryRoute, + reports: routes.practiceReportsRoute, stats: routes.learningStatsRoute, trend: routes.learningTrendRoute, + answer: routes.submitAnswerRoute, favoriteQuestions: routes.favoriteQuestionsRoute, + toggleFavoriteQuestion: routes.toggleFavoriteQuestionRoute, wrongQuestions: routes.wrongQuestionsRoute, + wrongPlan: routes.wrongQuestionReviewPlanRoute, resolveWrong: routes.resolveWrongQuestionRoute, + wordProgress: routes.wordProgressRoute, updateWordProgress: routes.updateWordProgressRoute, + wordPlan: routes.wordReviewPlanRoute, reviewWord: routes.reviewWordRoute, favoriteWords: routes.favoriteWordsRoute, + toggleFavoriteWord: routes.toggleFavoriteWordRoute, wordStats: routes.wordStatsRoute, +}; +const LEARNING_HANDLERS = Symbol('LEARNING_HANDLERS'); + +@Injectable() +class LearningService extends DomainRouteService { + constructor(factory: RequestContextFactory, @Inject(LEARNING_HANDLERS) injectedHandlers: typeof learningHandlers) { + super(factory, injectedHandlers); + } +} + +@ApiTags('学习与刷题') +@ApiBearerAuth() +@Controller('/api/learning') +class LearningController { + constructor(private readonly service: LearningService) {} + private run(name: string, req: FastifyRequest, res: FastifyReply) { return this.service.execute(name, req, res); } + + @Get('leaderboard') @ApiOperation({ summary: '查询学习排行榜' }) @ApiEnvelopeProperties({ metric: { type: 'string' }, label: { type: 'string' }, unit: { type: 'string' }, period: { type: 'string' }, scope: { type: 'object', additionalProperties: true }, page: { type: 'integer' }, pageSize: { type: 'integer' }, items: itemsProperty, currentUser: { type: 'object', nullable: true, additionalProperties: true }, generatedAt: { type: 'string', format: 'date-time' } }) + leaderboard(@Query() _q: LeaderboardQueryDto, @Req() req: FastifyRequest, @Res({ passthrough: true }) res: FastifyReply) { return this.run('leaderboard', req, res); } + @Post('practice-sessions') @HttpCode(200) @ApiBody({ type: CreatePracticeSessionDto }) @ApiOperation({ summary: '创建练习会话', description: '可通过蓝图、题集、内容节点或练习模式组装题目,并校验练习权益。' }) @ApiStandardResponses('item') + createSession(@Body() _b: CreatePracticeSessionDto, @Req() req: FastifyRequest, @Res({ passthrough: true }) res: FastifyReply) { return this.run('createSession', req, res); } + @Get('practice-sessions/detail') @ApiOperation({ summary: '获取练习会话详情' }) @ApiStandardResponses('item') + sessionDetail(@Query() _q: PracticeSessionQueryDto, @Req() req: FastifyRequest, @Res({ passthrough: true }) res: FastifyReply) { return this.run('sessionDetail', req, res); } + @Post('practice-sessions/submit') @HttpCode(200) @ApiBody({ type: SubmitPracticeSessionDto }) @ApiOperation({ summary: '提交练习会话', description: '结束练习、生成成绩报告并触发自动徽章判定。' }) @ApiStandardResponses('item') + submitSession(@Body() _b: SubmitPracticeSessionDto, @Req() req: FastifyRequest, @Res({ passthrough: true }) res: FastifyReply) { return this.run('submitSession', req, res); } + @Get('practice-sessions/report') @ApiOperation({ summary: '获取练习会话报告' }) @ApiStandardResponses('item') + sessionReport(@Query() _q: PracticeSessionQueryDto, @Req() req: FastifyRequest, @Res({ passthrough: true }) res: FastifyReply) { return this.run('sessionReport', req, res); } + @Get('practice-sessions/history') @ApiOperation({ summary: '查询练习历史' }) @ApiStandardResponses('items') + history(@Query() _q: LimitQueryDto, @Req() req: FastifyRequest, @Res({ passthrough: true }) res: FastifyReply) { return this.run('history', req, res); } + @Get('practice-reports') @ApiOperation({ summary: '查询练习报告列表' }) @ApiStandardResponses('items') + reports(@Query() _q: LimitQueryDto, @Req() req: FastifyRequest, @Res({ passthrough: true }) res: FastifyReply) { return this.run('reports', req, res); } + @Get('stats') @ApiOperation({ summary: '查询学习统计' }) @ApiStandardResponses('item') + stats(@Query() _q: LearningWindowQueryDto, @Req() req: FastifyRequest, @Res({ passthrough: true }) res: FastifyReply) { return this.run('stats', req, res); } + @Get('trend') @ApiOperation({ summary: '查询学习趋势' }) @ApiStandardResponses('items') + trend(@Query() _q: LearningWindowQueryDto, @Req() req: FastifyRequest, @Res({ passthrough: true }) res: FastifyReply) { return this.run('trend', req, res); } + @Post('answers') @HttpCode(200) @ApiBody({ type: SubmitAnswerDto }) @ApiOperation({ summary: '提交题目答案', description: '支持选择题、主观题自评和复合题子题答案;可关联练习会话。' }) @ApiStandardResponses('item') + answer(@Body() _b: SubmitAnswerDto, @Req() req: FastifyRequest, @Res({ passthrough: true }) res: FastifyReply) { return this.run('answer', req, res); } + @Get('favorites/questions') @ApiOperation({ summary: '查询收藏题目' }) @ApiStandardResponses('items') + favoriteQuestions(@Query() _q: LimitQueryDto, @Req() req: FastifyRequest, @Res({ passthrough: true }) res: FastifyReply) { return this.run('favoriteQuestions', req, res); } + @Post('favorites/questions') @HttpCode(200) @ApiBody({ type: QuestionActionDto }) @ApiOperation({ summary: '收藏或取消收藏题目' }) @ApiStandardResponses('ok') + toggleFavoriteQuestion(@Body() _b: QuestionActionDto, @Req() req: FastifyRequest, @Res({ passthrough: true }) res: FastifyReply) { return this.run('toggleFavoriteQuestion', req, res); } + @Get('wrong-questions') @ApiOperation({ summary: '查询错题列表' }) @ApiStandardResponses('items') + wrongQuestions(@Query() _q: WrongQuestionQueryDto, @Req() req: FastifyRequest, @Res({ passthrough: true }) res: FastifyReply) { return this.run('wrongQuestions', req, res); } + @Get('wrong-questions/review-plan') @ApiOperation({ summary: '生成错题复习计划' }) @ApiEnvelopeProperties({ items: itemsProperty, nextAction: { type: 'object', description: '下一步复习建议', additionalProperties: true } }) + wrongPlan(@Query() _q: WrongReviewPlanQueryDto, @Req() req: FastifyRequest, @Res({ passthrough: true }) res: FastifyReply) { return this.run('wrongPlan', req, res); } + @Post('wrong-questions/resolve') @HttpCode(200) @ApiBody({ type: QuestionActionDto }) @ApiOperation({ summary: '将错题标记为已解决' }) @ApiStandardResponses('ok') + resolveWrong(@Body() _b: QuestionActionDto, @Req() req: FastifyRequest, @Res({ passthrough: true }) res: FastifyReply) { return this.run('resolveWrong', req, res); } + @Get('vocabulary/progress') @ApiOperation({ summary: '查询单词学习进度' }) @ApiStandardResponses('items') + wordProgress(@Query() _q: WordProgressQueryDto, @Req() req: FastifyRequest, @Res({ passthrough: true }) res: FastifyReply) { return this.run('wordProgress', req, res); } + @Post('vocabulary/progress') @HttpCode(200) @ApiBody({ type: WordProgressDto }) @ApiOperation({ summary: '更新单词学习进度' }) @ApiStandardResponses('item') + updateWordProgress(@Body() _b: WordProgressDto, @Req() req: FastifyRequest, @Res({ passthrough: true }) res: FastifyReply) { return this.run('updateWordProgress', req, res); } + @Get('vocabulary/review-plan') @ApiOperation({ summary: '生成单词复习计划' }) @ApiStandardResponses('item') + wordPlan(@Query() _q: WordReviewPlanQueryDto, @Req() req: FastifyRequest, @Res({ passthrough: true }) res: FastifyReply) { return this.run('wordPlan', req, res); } + @Post('vocabulary/review') @HttpCode(200) @ApiBody({ type: WordReviewDto }) @ApiOperation({ summary: '提交单词复习结果' }) @ApiStandardResponses('item') + reviewWord(@Body() _b: WordReviewDto, @Req() req: FastifyRequest, @Res({ passthrough: true }) res: FastifyReply) { return this.run('reviewWord', req, res); } + @Get('vocabulary/favorites') @ApiOperation({ summary: '查询收藏单词' }) @ApiStandardResponses('items') + favoriteWords(@Query() _q: UnitQueryDto, @Req() req: FastifyRequest, @Res({ passthrough: true }) res: FastifyReply) { return this.run('favoriteWords', req, res); } + @Post('vocabulary/favorites') @HttpCode(200) @ApiBody({ type: FavoriteWordDto }) @ApiOperation({ summary: '收藏或取消收藏单词' }) @ApiStandardResponses('ok') + toggleFavoriteWord(@Body() _b: FavoriteWordDto, @Req() req: FastifyRequest, @Res({ passthrough: true }) res: FastifyReply) { return this.run('toggleFavoriteWord', req, res); } + @Get('vocabulary/stats') @ApiOperation({ summary: '查询单词学习统计' }) @ApiStandardResponses('item') + wordStats(@Query() _q: UnitQueryDto, @Req() req: FastifyRequest, @Res({ passthrough: true }) res: FastifyReply) { return this.run('wordStats', req, res); } +} + +@Module({ controllers: [LearningController], providers: [LearningService, { provide: LEARNING_HANDLERS, useValue: learningHandlers }] }) +export class LearningModule {} diff --git a/apps/api/src/nest/legacy-bridge.ts b/apps/api/src/nest/legacy-bridge.ts new file mode 100644 index 00000000..b451b71a --- /dev/null +++ b/apps/api/src/nest/legacy-bridge.ts @@ -0,0 +1,79 @@ +import type { FastifyInstance, HTTPMethods } from 'fastify'; +import { publicErrorBody, withResponseMeta } from '../core/http.js'; +import { allRoutes, type RouteDefinition } from '../core/router.js'; +import { RequestContextFactory } from './request-context.factory.js'; + +export const NATIVE_ROUTE_KEYS = new Set([ + ...['GET /health', 'GET /api/tenant/resolve'], + ...[ + 'POST /api/auth/sms/send', 'POST /api/auth/sms/verify', 'GET /api/auth/me', 'POST /api/auth/logout', + 'POST /api/auth/phone/bind', 'POST /api/auth/oauth/wechat', 'POST /api/auth/oauth/wechat-miniapp', 'POST /api/auth/oauth/qq', + ], + ...[ + 'GET /api/profile/me', 'PATCH /api/profile/me', 'POST /api/profile/check-in', 'GET /api/profile/score-events', + 'GET /api/profile/activity-tasks', 'POST /api/profile/activity-tasks/claim', 'GET /api/profile/exchange-items', + 'POST /api/profile/exchange-items/redeem', 'GET /api/profile/notifications', 'POST /api/profile/notifications/status', + 'GET /api/profile/badges', 'GET /api/profile/feedbacks', 'POST /api/profile/feedbacks', 'GET /api/profile/exam-countdowns', + ], + ...[ + 'GET /api/learning/leaderboard', 'POST /api/learning/practice-sessions', 'GET /api/learning/practice-sessions/detail', + 'POST /api/learning/practice-sessions/submit', 'GET /api/learning/practice-sessions/report', + 'GET /api/learning/practice-sessions/history', 'GET /api/learning/practice-reports', 'GET /api/learning/stats', + 'GET /api/learning/trend', 'POST /api/learning/answers', 'GET /api/learning/favorites/questions', + 'POST /api/learning/favorites/questions', 'GET /api/learning/wrong-questions', + 'GET /api/learning/wrong-questions/review-plan', 'POST /api/learning/wrong-questions/resolve', + 'GET /api/learning/vocabulary/progress', 'POST /api/learning/vocabulary/progress', + 'GET /api/learning/vocabulary/review-plan', 'POST /api/learning/vocabulary/review', + 'GET /api/learning/vocabulary/favorites', 'POST /api/learning/vocabulary/favorites', + 'GET /api/learning/vocabulary/stats', + ], + ...[ + 'GET /api/ai/school-recommendations', 'GET /api/ai/school-recommendations/detail', + 'GET /api/ai/school-recommendations/export', 'POST /api/ai/school-recommendations/generate', + ], +]); + +export function legacyRoutes(definitions: RouteDefinition[] = allRoutes) { + return definitions.filter(([method, path]) => !NATIVE_ROUTE_KEYS.has(`${method} ${path}`)); +} + +export function registerLegacyRoutes(instance: FastifyInstance, contextFactory: RequestContextFactory) { + // 未迁移模块仍由 Fastify 承载,但请求上下文、错误结构和 requestId 与 Nest 原生路由保持一致。 + const seen = new Set(); + for (const [method, path, handler] of legacyRoutes()) { + const key = `${method} ${path}`; + if (seen.has(key) || NATIVE_ROUTE_KEYS.has(key)) throw new Error(`Duplicate API route: ${key}`); + seen.add(key); + instance.route({ + method: method as HTTPMethods, + url: path, + handler: async (request, reply) => { + const requestId = String(reply.getHeader('x-request-id') || ''); + try { + const result = await handler(contextFactory.create(request, reply)); + return reply.send(withResponseMeta(result, requestId)); + } catch (error) { + const { statusCode, body } = publicErrorBody(error); + return reply.status(statusCode).send(withResponseMeta({ ...body, requestId }, requestId)); + } + }, + }); + } + + instance.get('/api/questions/:questionId/videos', async (request, reply) => { + const route = allRoutes.find(([method, path]) => method === 'GET' && path === '/api/questions/videos'); + if (!route) return reply.status(404).send(); + const params = request.params as { questionId: string }; + const url = new URL(request.raw.url || '/', `http://${request.headers.host || 'localhost'}`); + url.searchParams.set('questionId', params.questionId); + const ctx = contextFactory.create(request, reply); + ctx.url = url; + const requestId = ctx.requestId; + try { + return reply.send(withResponseMeta(await route[2](ctx), requestId)); + } catch (error) { + const { statusCode, body } = publicErrorBody(error); + return reply.status(statusCode).send(withResponseMeta({ ...body, requestId }, requestId)); + } + }); +} diff --git a/apps/api/src/nest/openapi.ts b/apps/api/src/nest/openapi.ts new file mode 100644 index 00000000..3d6c3764 --- /dev/null +++ b/apps/api/src/nest/openapi.ts @@ -0,0 +1,38 @@ +import type { INestApplication } from '@nestjs/common'; +import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger'; +import { apiReference } from '@scalar/nestjs-api-reference'; +import type { FastifyInstance } from 'fastify'; +import { allRoutes } from '../core/router.js'; +import { legacyRoutes } from './legacy-bridge.js'; + +export function registerOpenApi(app: INestApplication, instance: FastifyInstance) { + const document = SwaggerModule.createDocument(app, new DocumentBuilder() + .setTitle('Tiku SaaS API') + .setDescription('NestJS migration API reference') + .setVersion('0.1.0') + .addBearerAuth() + .addApiKey({ type: 'apiKey', in: 'header', name: 'x-tenant-id' }, 'tenant-id') + .build()); + + for (const [method, path] of legacyRoutes(allRoutes)) { + const lowerMethod = method.toLowerCase() as 'get' | 'post' | 'put' | 'patch' | 'delete'; + const pathItem = document.paths[path] || {}; + if (!pathItem[lowerMethod]) { + pathItem[lowerMethod] = { + tags: ['legacy'], + summary: `待迁移接口:${method} ${path}`, + description: '该接口仍通过兼容桥运行,后续按业务模块迁移为原生 Nest Controller 和强类型 DTO。', + responses: { '200': { description: '兼容接口响应;具体字段暂以现有调用契约为准。' } }, + 'x-migration-status': 'legacy', + } as never; + } + document.paths[path] = pathItem; + } + + instance.get('/openapi.json', async (_request, reply) => reply.send(document)); + const scalar = apiReference({ withFastify: true, content: document }) as (request: unknown, response: unknown) => void; + instance.get('/docs', async (request, reply) => { + scalar(request, reply.raw); + return reply; + }); +} diff --git a/apps/api/src/nest/profile.module.ts b/apps/api/src/nest/profile.module.ts new file mode 100644 index 00000000..97f0f966 --- /dev/null +++ b/apps/api/src/nest/profile.module.ts @@ -0,0 +1,83 @@ +import { Body, Controller, Get, HttpCode, Inject, Injectable, Module, Patch, Post, Query, Req, Res } from '@nestjs/common'; +import { ApiBearerAuth, ApiBody, ApiOperation, ApiTags } from '@nestjs/swagger'; +import type { FastifyReply, FastifyRequest } from 'fastify'; +import * as routes from '../features/profile/routes.js'; +import * as points from '../features/profile/points.js'; +import * as notifications from '../features/profile/notifications.js'; +import { ApiEnvelopeProperties, ApiStandardResponses, itemProperty, itemsProperty } from './api-doc.decorators.js'; +import { DomainRouteService } from './domain-route.service.js'; +import { PaginationQueryDto } from './dto.js'; +import { ActivityTaskClaimDto, ExchangeRedeemDto, NotificationStatusDto, SubmitFeedbackDto, UpdateProfileDto } from './request.dto.js'; +import { BadgeQueryDto, FeedbackQueryDto, LimitQueryDto, ProfileNotificationQueryDto } from './query.dto.js'; +import { RequestContextFactory } from './request-context.factory.js'; + +const PROFILE_HANDLERS = Symbol('PROFILE_HANDLERS'); +const profileHandlers = { + me: routes.profileMeRoute, updateMe: routes.updateProfileMeRoute, checkIn: routes.checkInRoute, + scoreEvents: routes.scoreEventsRoute, tasks: points.activityTasksRoute, claimTask: points.claimActivityTaskRoute, + exchangeItems: points.exchangeItemsRoute, redeem: points.redeemExchangeItemRoute, + notifications: notifications.profileNotificationsRoute, notificationStatus: notifications.updateProfileNotificationStatusRoute, + badges: routes.profileBadgesRoute, feedbacks: routes.feedbacksRoute, submitFeedback: routes.submitFeedbackRoute, + countdowns: routes.examCountdownRoute, +}; + +@Injectable() +class ProfileService extends DomainRouteService { + constructor(factory: RequestContextFactory, @Inject(PROFILE_HANDLERS) injectedHandlers: typeof profileHandlers) { + super(factory, injectedHandlers); + } +} + +@ApiTags('个人中心') +@ApiBearerAuth() +@Controller('/api/profile') +class ProfileController { + constructor(private readonly service: ProfileService) {} + private run(name: string, req: FastifyRequest, res: FastifyReply) { return this.service.execute(name, req, res); } + + @Get('me') @ApiOperation({ summary: '获取当前学生资料' }) + @ApiStandardResponses('item') + me(@Req() req: FastifyRequest, @Res({ passthrough: true }) res: FastifyReply) { return this.run('me', req, res); } + @Patch('me') @ApiBody({ type: UpdateProfileDto }) @ApiOperation({ summary: '更新当前学生资料', description: '更新姓名、预设头像、地区、意向院校专业及学习扩展数据。' }) + @ApiStandardResponses('item') + updateMe(@Body() _body: UpdateProfileDto, @Req() req: FastifyRequest, @Res({ passthrough: true }) res: FastifyReply) { return this.run('updateMe', req, res); } + @Post('check-in') @HttpCode(200) @ApiOperation({ summary: '每日签到', description: '完成当日签到并返回积分奖励及连续签到状态。' }) + @ApiStandardResponses('item') + checkIn(@Req() req: FastifyRequest, @Res({ passthrough: true }) res: FastifyReply) { return this.run('checkIn', req, res); } + @Get('score-events') @ApiOperation({ summary: '查询积分流水' }) + @ApiStandardResponses('items') + scoreEvents(@Query() _query: LimitQueryDto, @Req() req: FastifyRequest, @Res({ passthrough: true }) res: FastifyReply) { return this.run('scoreEvents', req, res); } + @Get('activity-tasks') @ApiOperation({ summary: '查询积分活动任务' }) + @ApiStandardResponses('items') + tasks(@Query() _query: LimitQueryDto, @Req() req: FastifyRequest, @Res({ passthrough: true }) res: FastifyReply) { return this.run('tasks', req, res); } + @Post('activity-tasks/claim') @HttpCode(200) @ApiBody({ type: ActivityTaskClaimDto }) @ApiOperation({ summary: '领取活动任务奖励', description: '使用任务 ID 或编码领取奖励,部分任务还需要 sourceId 作为完成证据。' }) + @ApiStandardResponses('item') + claimTask(@Body() _body: ActivityTaskClaimDto, @Req() req: FastifyRequest, @Res({ passthrough: true }) res: FastifyReply) { return this.run('claimTask', req, res); } + @Get('exchange-items') @ApiOperation({ summary: '查询积分兑换商品' }) + @ApiStandardResponses('items') + exchangeItems(@Query() _query: LimitQueryDto, @Req() req: FastifyRequest, @Res({ passthrough: true }) res: FastifyReply) { return this.run('exchangeItems', req, res); } + @Post('exchange-items/redeem') @HttpCode(200) @ApiBody({ type: ExchangeRedeemDto }) @ApiOperation({ summary: '兑换积分商品', description: '使用商品 ID 或编码创建积分兑换订单;建议提供客户端幂等键。' }) + @ApiStandardResponses('item') + redeem(@Body() _body: ExchangeRedeemDto, @Req() req: FastifyRequest, @Res({ passthrough: true }) res: FastifyReply) { return this.run('redeem', req, res); } + @Get('notifications') @ApiOperation({ summary: '查询用户通知' }) + @ApiEnvelopeProperties({ items: itemsProperty, summary: { type: 'object', description: '按状态统计的通知数量', additionalProperties: { type: 'integer' } } }) + notificationList(@Query() _query: ProfileNotificationQueryDto, @Req() req: FastifyRequest, @Res({ passthrough: true }) res: FastifyReply) { return this.run('notifications', req, res); } + @Post('notifications/status') @HttpCode(200) @ApiBody({ type: NotificationStatusDto }) @ApiOperation({ summary: '更新通知状态', description: '批量将通知更新为已读、忽略或归档,最多 100 条。' }) + @ApiStandardResponses('item') + notificationStatus(@Body() _body: NotificationStatusDto, @Req() req: FastifyRequest, @Res({ passthrough: true }) res: FastifyReply) { return this.run('notificationStatus', req, res); } + @Get('badges') @ApiOperation({ summary: '查询徽章列表' }) + @ApiEnvelopeProperties({ items: itemsProperty, summary: { type: 'object', properties: { total: { type: 'integer' }, unlocked: { type: 'integer' }, includeLocked: { type: 'boolean' } } } }) + badges(@Query() _query: BadgeQueryDto, @Req() req: FastifyRequest, @Res({ passthrough: true }) res: FastifyReply) { return this.run('badges', req, res); } + @Get('feedbacks') @ApiOperation({ summary: '查询反馈记录' }) + @ApiStandardResponses('items') + feedbacks(@Query() _query: FeedbackQueryDto, @Req() req: FastifyRequest, @Res({ passthrough: true }) res: FastifyReply) { return this.run('feedbacks', req, res); } + @Post('feedbacks') @HttpCode(200) @ApiBody({ type: SubmitFeedbackDto }) @ApiOperation({ summary: '提交意见反馈', description: '提交题目错误或产品建议,可附带题目 ID、联系方式和附件引用。' }) + @ApiStandardResponses('item') + submitFeedback(@Body() _body: SubmitFeedbackDto, @Req() req: FastifyRequest, @Res({ passthrough: true }) res: FastifyReply) { return this.run('submitFeedback', req, res); } + @Get('exam-countdowns') @ApiOperation({ summary: '查询考试倒计时' }) + @ApiStandardResponses('items') + countdowns(@Query() _query: LimitQueryDto, @Req() req: FastifyRequest, @Res({ passthrough: true }) res: FastifyReply) { return this.run('countdowns', req, res); } +} + +@Module({ controllers: [ProfileController], providers: [ProfileService, { provide: PROFILE_HANDLERS, useValue: profileHandlers }] }) +export class ProfileModule {} diff --git a/apps/api/src/nest/query.dto.ts b/apps/api/src/nest/query.dto.ts new file mode 100644 index 00000000..b2956912 --- /dev/null +++ b/apps/api/src/nest/query.dto.ts @@ -0,0 +1,75 @@ +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { Type } from 'class-transformer'; +import { IsBoolean, IsIn, IsInt, IsOptional, IsString, IsUUID, Max, Min } from 'class-validator'; + +export class LimitQueryDto { + @ApiPropertyOptional({ description: '返回条数上限', minimum: 1, maximum: 500 }) + @IsOptional() @Type(() => Number) @IsInt() @Min(1) @Max(500) limit?: number; +} + +export class ProfileNotificationQueryDto extends LimitQueryDto { + @ApiPropertyOptional({ description: '通知状态', enum: ['unread', 'read', 'dismissed', 'archived'] }) @IsOptional() @IsString() status?: string; + @ApiPropertyOptional({ description: '通知业务类型;type 是兼容别名' }) @IsOptional() @IsString() notificationType?: string; + @ApiPropertyOptional({ description: 'notificationType 的兼容别名' }) @IsOptional() @IsString() type?: string; +} + +export class BadgeQueryDto extends LimitQueryDto { + @ApiPropertyOptional({ description: '是否同时返回未解锁徽章', default: false }) @IsOptional() @Type(() => Boolean) @IsBoolean() includeLocked?: boolean; + @ApiPropertyOptional({ description: '徽章分类' }) @IsOptional() @IsString() category?: string; +} + +export class FeedbackQueryDto extends LimitQueryDto { + @ApiPropertyOptional({ description: '反馈处理状态' }) @IsOptional() @IsString() status?: string; +} + +export class PracticeSessionQueryDto { + @ApiPropertyOptional({ description: '练习会话 ID', format: 'uuid' }) @IsOptional() @IsUUID() practiceSessionId?: string; +} + +export class LearningWindowQueryDto { + @ApiPropertyOptional({ description: '统计时间窗口天数', example: 30 }) @IsOptional() @Type(() => Number) @IsInt() @Min(1) days?: number; +} + +export class LeaderboardQueryDto extends LimitQueryDto { + @ApiPropertyOptional({ description: '排行指标' }) @IsOptional() @IsString() metric?: string; + @ApiPropertyOptional({ description: '统计周期' }) @IsOptional() @IsString() period?: string; + @ApiPropertyOptional({ description: '页码', minimum: 1 }) @IsOptional() @Type(() => Number) @IsInt() @Min(1) page?: number; + @ApiPropertyOptional({ description: '地区 ID', format: 'uuid' }) @IsOptional() @IsUUID() regionId?: string; + @ApiPropertyOptional({ description: '班级 ID', format: 'uuid' }) @IsOptional() @IsUUID() classId?: string; +} + +export class WrongQuestionQueryDto extends LimitQueryDto { + @ApiPropertyOptional({ description: '传 all 返回全部错题,否则仅返回未解决错题', example: 'all' }) @IsOptional() @IsString() status?: string; +} + +export class WrongReviewPlanQueryDto extends LimitQueryDto { + @ApiPropertyOptional({ description: '科目 ID', format: 'uuid' }) @IsOptional() @IsUUID() subjectId?: string; + @ApiPropertyOptional({ description: '分类 ID', format: 'uuid' }) @IsOptional() @IsUUID() categoryId?: string; +} + +export class WordProgressQueryDto extends LimitQueryDto { + @ApiPropertyOptional({ description: '词汇单元 ID', format: 'uuid' }) @IsOptional() @IsUUID() unitId?: string; + @ApiPropertyOptional({ description: '学习状态筛选' }) @IsOptional() @IsString() status?: string; +} + +export class WordReviewPlanQueryDto { + @ApiPropertyOptional({ description: '词汇单元 ID', format: 'uuid' }) @IsOptional() @IsUUID() unitId?: string; + @ApiPropertyOptional({ description: '到期复习词数量', minimum: 1, maximum: 200, default: 30 }) @IsOptional() @Type(() => Number) @IsInt() @Min(1) @Max(200) reviewLimit?: number; + @ApiPropertyOptional({ description: '新词数量', minimum: 1, maximum: 100, default: 20 }) @IsOptional() @Type(() => Number) @IsInt() @Min(1) @Max(100) newLimit?: number; +} + +export class UnitQueryDto extends LimitQueryDto { + @ApiPropertyOptional({ description: '词汇单元 ID', format: 'uuid' }) @IsOptional() @IsUUID() unitId?: string; +} + +export class RecommendationListQueryDto extends LimitQueryDto { + @ApiPropertyOptional({ description: '地区 ID', format: 'uuid' }) @IsOptional() @IsUUID() regionId?: string; +} + +export class RecommendationDetailQueryDto { + @ApiPropertyOptional({ description: '推荐报告 ID', format: 'uuid' }) @IsOptional() @IsUUID() reportId?: string; +} + +export class RecommendationExportQueryDto extends RecommendationDetailQueryDto { + @ApiPropertyOptional({ description: '导出格式', enum: ['markdown', 'html'], default: 'markdown' }) @IsOptional() @IsIn(['markdown', 'html']) format?: string; +} diff --git a/apps/api/src/nest/request-context.factory.ts b/apps/api/src/nest/request-context.factory.ts new file mode 100644 index 00000000..aa09514f --- /dev/null +++ b/apps/api/src/nest/request-context.factory.ts @@ -0,0 +1,17 @@ +import { Injectable } from '@nestjs/common'; +import type { FastifyReply, FastifyRequest } from 'fastify'; +import type { RequestContext } from '../core/http.js'; + +@Injectable() +export class RequestContextFactory { + create(request: FastifyRequest, reply: FastifyReply): RequestContext { + const requestId = String(reply.getHeader('x-request-id') || request.headers['x-request-id'] || ''); + return { + req: request.raw, + res: reply.raw, + url: new URL(request.raw.url || '/', `http://${request.headers.host || 'localhost'}`), + requestId, + parsedBody: request.body, + }; + } +} diff --git a/apps/api/src/nest/request.dto.ts b/apps/api/src/nest/request.dto.ts new file mode 100644 index 00000000..cac42fca --- /dev/null +++ b/apps/api/src/nest/request.dto.ts @@ -0,0 +1,113 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { Type } from 'class-transformer'; +import { IsArray, IsBoolean, IsIn, IsInt, IsNumber, IsObject, IsOptional, IsString, IsUUID, Max, MaxLength, Min } from 'class-validator'; + +export class UpdateProfileDto { + @ApiPropertyOptional({ description: '学生姓名', example: '张三' }) @IsOptional() @IsString() name?: string; + @ApiPropertyOptional({ description: '预设头像标识,不允许直接传 avatarUrl', example: 'male' }) @IsOptional() @IsString() avatarPreset?: string; + @ApiPropertyOptional({ description: '所属地区 ID', format: 'uuid' }) @IsOptional() @IsUUID() regionId?: string; + @ApiPropertyOptional({ description: '已选择院校 ID', format: 'uuid' }) @IsOptional() @IsUUID() selectedSchoolId?: string; + @ApiPropertyOptional({ description: '已选择专业 ID', format: 'uuid' }) @IsOptional() @IsUUID() selectedMajorId?: string; + @ApiPropertyOptional({ description: '个人统计扩展数据', type: 'object', additionalProperties: true }) @IsOptional() @IsObject() stats?: Record; + @ApiPropertyOptional({ description: '学习进度扩展数据', type: 'object', additionalProperties: true }) @IsOptional() @IsObject() progress?: Record; + @ApiPropertyOptional({ description: '模块选择配置', type: 'object', additionalProperties: true }) @IsOptional() @IsObject() moduleSelections?: Record; + @ApiPropertyOptional({ description: '最近活动记录', type: 'array', items: { type: 'object' } }) @IsOptional() @IsArray() recentActivities?: unknown[]; +} + +export class ActivityTaskClaimDto { + @ApiPropertyOptional({ description: '活动任务 ID,与 code 至少提供一个', format: 'uuid' }) @IsOptional() @IsUUID() taskId?: string; + @ApiPropertyOptional({ description: '活动任务编码,与 taskId 至少提供一个' }) @IsOptional() @IsString() code?: string; + @ApiPropertyOptional({ description: '业务来源类型' }) @IsOptional() @IsString() sourceType?: string; + @ApiPropertyOptional({ description: '业务来源记录 ID', format: 'uuid' }) @IsOptional() @IsUUID() sourceId?: string; + @ApiPropertyOptional({ description: '幂等键;重复请求返回冲突或既有结果' }) @IsOptional() @IsString() idempotencyKey?: string; +} + +export class ExchangeRedeemDto { + @ApiPropertyOptional({ description: '兑换商品 ID,与 code 至少提供一个', format: 'uuid' }) @IsOptional() @IsUUID() itemId?: string; + @ApiPropertyOptional({ description: '兑换商品编码,与 itemId 至少提供一个' }) @IsOptional() @IsString() code?: string; + @ApiPropertyOptional({ description: '客户端幂等键' }) @IsOptional() @IsString() idempotencyKey?: string; +} + +export class NotificationStatusDto { + @ApiProperty({ description: '要更新的通知 ID,最多 100 个', type: [String], format: 'uuid' }) @IsArray() @IsUUID(undefined, { each: true }) notificationIds!: string[]; + @ApiPropertyOptional({ description: '目标状态', enum: ['read', 'dismissed', 'archived'], default: 'read' }) @IsOptional() @IsIn(['read', 'dismissed', 'archived']) status?: string; +} + +export class SubmitFeedbackDto { + @ApiPropertyOptional({ description: '相关题目 ID', format: 'uuid' }) @IsOptional() @IsUUID() questionId?: string; + @ApiPropertyOptional({ description: '反馈类型', example: 'question_error' }) @IsOptional() @IsString() type?: string; + @ApiPropertyOptional({ description: '反馈分类' }) @IsOptional() @IsString() category?: string; + @ApiPropertyOptional({ description: '反馈标题' }) @IsOptional() @IsString() title?: string; + @ApiProperty({ description: '问题详细描述', example: '题目答案与解析不一致' }) @IsString() description!: string; + @ApiPropertyOptional({ description: '优先级', enum: ['low', 'normal', 'high', 'urgent'], default: 'normal' }) @IsOptional() @IsIn(['low', 'normal', 'high', 'urgent']) priority?: string; + @ApiPropertyOptional({ description: '联系方式' }) @IsOptional() @IsString() contact?: string; + @ApiPropertyOptional({ description: '附件描述或资源引用数组', type: 'array', items: { type: 'object' } }) @IsOptional() @IsArray() attachments?: unknown[]; + @ApiPropertyOptional({ description: '扩展元数据', type: 'object', additionalProperties: true }) @IsOptional() @IsObject() metadata?: Record; +} + +export class CreatePracticeSessionDto { + @ApiPropertyOptional({ description: '练习模式', example: 'practice' }) @IsOptional() @IsString() mode?: string; + @ApiPropertyOptional({ description: '练习目标类型' }) @IsOptional() @IsString() targetType?: string; + @ApiPropertyOptional({ description: '练习目标 ID', format: 'uuid' }) @IsOptional() @IsUUID() targetId?: string; + @ApiPropertyOptional({ description: '组卷蓝图 ID', format: 'uuid' }) @IsOptional() @IsUUID() blueprintId?: string; + @ApiPropertyOptional({ description: '题集 ID', format: 'uuid' }) @IsOptional() @IsUUID() collectionId?: string; + @ApiPropertyOptional({ description: '内容条目 ID', format: 'uuid' }) @IsOptional() @IsUUID() entryId?: string; + @ApiPropertyOptional({ description: '内容节点 ID', format: 'uuid' }) @IsOptional() @IsUUID() contentNodeId?: string; + @ApiPropertyOptional({ description: '题目数量', minimum: 1, default: 100 }) @IsOptional() @Type(() => Number) @IsInt() @Min(1) questionLimit?: number; + @ApiPropertyOptional({ description: '限时分钟数', minimum: 1, maximum: 1440 }) @IsOptional() @Type(() => Number) @IsInt() @Min(1) @Max(1440) durationMinutes?: number; + @ApiPropertyOptional({ description: '试卷总分' }) @IsOptional() @Type(() => Number) @IsNumber() totalScore?: number; + @ApiPropertyOptional({ description: '组卷分区定义', type: 'array', items: { type: 'object' } }) @IsOptional() @IsArray() sections?: unknown[]; + @ApiPropertyOptional({ description: '组卷规则', type: 'object', additionalProperties: true }) @IsOptional() @IsObject() rules?: Record; + @ApiPropertyOptional({ description: '会话扩展元数据', type: 'object', additionalProperties: true }) @IsOptional() @IsObject() metadata?: Record; +} + +export class SubmitAnswerDto { + @ApiProperty({ description: '题目 ID', format: 'uuid' }) @IsUUID() questionId!: string; + @ApiPropertyOptional({ description: '所属练习会话 ID', format: 'uuid' }) @IsOptional() @IsUUID() practiceSessionId?: string; + @ApiPropertyOptional({ description: '选择题选项值数组', type: [String], example: ['A'] }) @IsOptional() @IsArray() @IsString({ each: true }) selectedOptions?: string[]; + @ApiPropertyOptional({ description: '主观题文本答案' }) @IsOptional() @IsString() answerText?: string; + @ApiPropertyOptional({ description: '主观题由用户自评是否正确' }) @IsOptional() @IsBoolean() selfJudgedCorrect?: boolean; + @ApiPropertyOptional({ description: '复合题子题答案', type: 'array', items: { type: 'object' } }) @IsOptional() @IsArray() subAnswers?: unknown[]; +} + +export class SubmitPracticeSessionDto { + @ApiProperty({ description: '要提交的练习会话 ID', format: 'uuid' }) @IsUUID() practiceSessionId!: string; +} + +export class QuestionActionDto { + @ApiProperty({ description: '题目 ID', format: 'uuid' }) @IsUUID() questionId!: string; + @ApiPropertyOptional({ description: '是否收藏;不传时默认为 true' }) @IsOptional() @IsBoolean() favorite?: boolean; +} + +export class WordReviewDto { + @ApiProperty({ description: '单词 ID', format: 'uuid' }) @IsUUID() wordId!: string; + @ApiPropertyOptional({ description: '复习结果', enum: ['known', 'unknown', 'again', 'hard', 'good', 'easy'] }) @IsOptional() @IsString() result?: string; + @ApiPropertyOptional({ description: 'result 的兼容别名' }) @IsOptional() @IsString() answerResult?: string; +} + +export class WordProgressDto { + @ApiProperty({ description: '单词 ID', format: 'uuid' }) @IsUUID() wordId!: string; + @ApiPropertyOptional({ description: '学习状态', enum: ['new', 'learning', 'reviewing', 'mastered'], default: 'learning' }) @IsOptional() @IsString() status?: string; + @ApiPropertyOptional({ description: '正确次数增量', minimum: 0 }) @IsOptional() @Type(() => Number) @IsInt() @Min(0) correctDelta?: number; + @ApiPropertyOptional({ description: '错误次数增量', minimum: 0 }) @IsOptional() @Type(() => Number) @IsInt() @Min(0) wrongDelta?: number; + @ApiPropertyOptional({ description: '下次复习时间', format: 'date-time' }) @IsOptional() @IsString() nextReviewDate?: string; +} + +export class FavoriteWordDto { + @ApiProperty({ description: '单词 ID', format: 'uuid' }) @IsUUID() wordId!: string; + @ApiPropertyOptional({ description: '是否收藏;不传时默认为 true' }) @IsOptional() @IsBoolean() favorite?: boolean; + @ApiPropertyOptional({ description: '收藏备注' }) @IsOptional() @IsString() @MaxLength(1000) note?: string; +} + +export class GenerateRecommendationDto { + @ApiPropertyOptional({ description: '地区 ID;未传时使用学生资料中的地区', format: 'uuid' }) @IsOptional() @IsUUID() regionId?: string; + @ApiPropertyOptional({ description: '预估成绩', minimum: 0, maximum: 1000 }) @IsOptional() @Type(() => Number) @IsNumber() @Min(0) @Max(1000) estimatedScore?: number; + @ApiPropertyOptional({ description: '考试科类或选科', maxLength: 80 }) @IsOptional() @IsString() @MaxLength(80) examTrack?: string; + @ApiPropertyOptional({ description: '意向城市', maxLength: 80 }) @IsOptional() @IsString() @MaxLength(80) preferredCity?: string; + @ApiPropertyOptional({ description: '目标院校 ID', format: 'uuid' }) @IsOptional() @IsUUID() targetSchoolId?: string; + @ApiPropertyOptional({ description: '目标专业 ID', format: 'uuid' }) @IsOptional() @IsUUID() targetMajorId?: string; + @ApiPropertyOptional({ description: '风险偏好', enum: ['safe', 'balanced', 'sprint'], default: 'balanced' }) @IsOptional() @IsIn(['safe', 'balanced', 'sprint']) riskPreference?: string; + @ApiPropertyOptional({ description: '其他限制条件', maxLength: 500 }) @IsOptional() @IsString() @MaxLength(500) constraints?: string; + @ApiPropertyOptional({ description: '补充说明', maxLength: 500 }) @IsOptional() @IsString() @MaxLength(500) notes?: string; + @ApiPropertyOptional({ description: '推荐数量', minimum: 1, maximum: 12, default: 5 }) @IsOptional() @Type(() => Number) @IsInt() @Min(1) @Max(12) recommendationLimit?: number; +} diff --git a/apps/api/src/nest/tenant.module.ts b/apps/api/src/nest/tenant.module.ts new file mode 100644 index 00000000..75c47e9d --- /dev/null +++ b/apps/api/src/nest/tenant.module.ts @@ -0,0 +1,40 @@ +import { Controller, Get, Inject, Injectable, Module, Query, Req, Res } from '@nestjs/common'; +import { ApiOperation, ApiTags } from '@nestjs/swagger'; +import type { FastifyReply, FastifyRequest } from 'fastify'; +import { resolveTenantRoute } from '../features/tenant/routes.js'; +import { DomainRouteService } from './domain-route.service.js'; +import { TenantResolveQueryDto } from './dto.js'; +import { RequestContextFactory } from './request-context.factory.js'; +import { ApiEnvelopeProperties } from './api-doc.decorators.js'; + +const TENANT_HANDLERS = Symbol('TENANT_HANDLERS'); +const tenantHandlers = { resolve: resolveTenantRoute }; + +@Injectable() +class TenantService extends DomainRouteService { + constructor(factory: RequestContextFactory, @Inject(TENANT_HANDLERS) injectedHandlers: typeof tenantHandlers) { + super(factory, injectedHandlers); + } +} + +@ApiTags('租户解析') +@Controller('/api/tenant') +class TenantController { + constructor(private readonly service: TenantService) {} + + @Get('resolve') + @ApiOperation({ summary: '解析当前租户', description: '根据访问域名、host 参数或 tenantCode 解析启用中的租户及公开品牌配置。' }) + @ApiEnvelopeProperties({ + tenant: { type: 'object', description: '租户基本信息', properties: { id: { type: 'string', format: 'uuid' }, slug: { type: 'string' }, name: { type: 'string' }, mode: { type: 'string' } } }, + branding: { type: 'object', description: '公开品牌与主题配置', additionalProperties: true }, + features: { type: 'object', description: '学生端功能开关', additionalProperties: true }, + adminFeatures: { type: 'object', description: '管理端功能开关', additionalProperties: true }, + publicConfig: { type: 'object', description: '允许公开给客户端的租户配置', additionalProperties: true }, + }) + resolve(@Query() _query: TenantResolveQueryDto, @Req() req: FastifyRequest, @Res({ passthrough: true }) res: FastifyReply) { + return this.service.execute('resolve', req, res); + } +} + +@Module({ controllers: [TenantController], providers: [TenantService, { provide: TENANT_HANDLERS, useValue: tenantHandlers }] }) +export class TenantModule {} diff --git a/apps/api/src/server.ts b/apps/api/src/server.ts index a56fee38..61e93660 100644 --- a/apps/api/src/server.ts +++ b/apps/api/src/server.ts @@ -1,148 +1,64 @@ -import http from 'node:http'; +import 'reflect-metadata'; +import { ValidationPipe } from '@nestjs/common'; +import { NestFactory } from '@nestjs/core'; +import { FastifyAdapter, type NestFastifyApplication } from '@nestjs/platform-fastify'; import { config } from './core/config.js'; -import { authorizeCorsRequest, CorsPolicy } from './core/cors.js'; -import { closePool } from './core/db.js'; -import { publicErrorBody, routeKey, sendJson, withResponseMeta } from './core/http.js'; -import { requestIdFrom } from './core/request-id.js'; -import { createRouter } from './core/router.js'; +import { ApiExceptionFilter } from './nest/api-exception.filter.js'; +import { ApiEnvelopeInterceptor } from './nest/api.interceptor.js'; +import { AppModule } from './nest/app.module.js'; +import { registerHttpHooks, writeLog } from './nest/http-hooks.js'; +import { registerLegacyRoutes } from './nest/legacy-bridge.js'; +import { registerOpenApi } from './nest/openapi.js'; +import { RequestContextFactory } from './nest/request-context.factory.js'; -const routes = createRouter(); +async function bootstrap() { + const adapter = new FastifyAdapter({ + bodyLimit: config.maxImportJsonBodyBytes, + requestTimeout: config.apiRequestTimeoutMs, + keepAliveTimeout: config.apiKeepAliveTimeoutMs, + maxRequestsPerSocket: config.apiMaxRequestsPerSocket, + }); + const app = await NestFactory.create(AppModule, adapter, { logger: false }); + app.useGlobalPipes(new ValidationPipe({ transform: true, whitelist: false, forbidUnknownValues: false })); + app.useGlobalInterceptors(new ApiEnvelopeInterceptor()); + app.useGlobalFilters(new ApiExceptionFilter()); + const instance = adapter.getInstance(); + registerHttpHooks(instance); + registerLegacyRoutes(instance, app.get(RequestContextFactory)); + if (!config.isProduction) registerOpenApi(app, instance); -function resolveHandler(method: string | undefined, url: URL) { - const exact = routes.get(routeKey(method, url.pathname)); - if (exact) return exact; - - const questionVideosMatch = url.pathname.match(/^\/api\/questions\/([^/]+)\/videos$/); - if (method === 'GET' && questionVideosMatch?.[1]) { - url.searchParams.set('questionId', decodeURIComponent(questionVideosMatch[1])); - return routes.get(routeKey(method, '/api/questions/videos')); - } - - return null; -} - -function writeLog(event: Record, error = false) { - const line = JSON.stringify({ timestamp: new Date().toISOString(), service: 'tiku-saas-api', ...event }); - if (error) console.error(line); - else console.log(line); -} - -let shuttingDown = false; - -const corsPolicy = new CorsPolicy({ - staticOrigins: config.corsOrigins, - tenantDomainsEnabled: config.corsTenantDomainsEnabled, - positiveCacheTtlMs: config.corsTenantDomainCacheTtlMs, - negativeCacheTtlMs: config.corsTenantDomainNegativeCacheTtlMs, - maxCacheEntries: config.corsTenantDomainCacheMaxEntries, - onLookupError(error, host) { - writeLog({ - event: 'cors_tenant_domain_lookup_failed', - host, - error: error instanceof Error ? error.message : 'unknown', - }, true); - }, -}); - -export const server = http.createServer(async (req, res) => { - const requestId = requestIdFrom(req); - const startedAt = process.hrtime.bigint(); - res.setHeader('x-request-id', requestId); - res.once('finish', () => { - const durationMs = Number(process.hrtime.bigint() - startedAt) / 1_000_000; - writeLog({ - event: 'http_request', - requestId, - method: req.method || 'GET', - path: (() => { - try { return new URL(req.url || '/', 'http://localhost').pathname; } catch { return '/'; } - })(), - status: res.statusCode, - durationMs: Number(durationMs.toFixed(2)), - }, res.statusCode >= 500); + instance.server.headersTimeout = config.apiHeadersTimeoutMs; + instance.server.requestTimeout = config.apiRequestTimeoutMs; + instance.server.keepAliveTimeout = config.apiKeepAliveTimeoutMs; + instance.server.maxRequestsPerSocket = config.apiMaxRequestsPerSocket; + instance.server.on('clientError', error => { + writeLog({ event: 'http_client_error', code: (error as NodeJS.ErrnoException).code || 'CLIENT_ERROR' }, true); }); - if (shuttingDown) { - res.setHeader('connection', 'close'); - sendJson(res, 503, withResponseMeta({ error: 'Service is shutting down', code: 'SERVICE_UNAVAILABLE', requestId }, requestId)); - return; - } - - const corsDecision = await authorizeCorsRequest(req, res, corsPolicy); - if (!corsDecision.allowed) { - sendJson(res, 403, withResponseMeta({ error: 'Request origin is not allowed', code: 'CORS_ORIGIN_DENIED', requestId }, requestId)); - return; - } - if (req.method === 'OPTIONS') { - res.statusCode = 204; - res.end(); - return; - } - - const url = new URL(req.url || '/', `http://${req.headers.host || 'localhost'}`); - const handler = resolveHandler(req.method, url); - - if (!handler) { - sendJson(res, 404, withResponseMeta({ error: 'Not found', code: 'NOT_FOUND', path: url.pathname, requestId }, requestId)); - return; - } - - try { - const result = await handler({ req, res, url, requestId }); - sendJson(res, 200, withResponseMeta(result, requestId)); - } catch (error) { - const { statusCode, body } = publicErrorBody(error); - sendJson(res, statusCode, withResponseMeta({ ...body, requestId }, requestId)); - } -}); - -server.headersTimeout = config.apiHeadersTimeoutMs; -server.requestTimeout = config.apiRequestTimeoutMs; -server.keepAliveTimeout = config.apiKeepAliveTimeoutMs; -server.maxRequestsPerSocket = config.apiMaxRequestsPerSocket; - -server.on('clientError', (error, socket) => { - writeLog({ event: 'http_client_error', code: (error as NodeJS.ErrnoException).code || 'CLIENT_ERROR' }, true); - if (socket.writable) socket.end('HTTP/1.1 400 Bad Request\r\nConnection: close\r\n\r\n'); -}); - -let shutdownPromise: Promise | null = null; - -export function shutdown(signal: string) { - if (shutdownPromise) return shutdownPromise; - shuttingDown = true; - shutdownPromise = new Promise(resolve => { + let closing = false; + const shutdown = async (signal: string) => { + if (closing) return; + closing = true; writeLog({ event: 'shutdown_started', signal }); - const forceTimer = setTimeout(() => { - writeLog({ event: 'shutdown_deadline_reached', signal }, true); - server.closeAllConnections(); - }, config.apiShutdownGracePeriodMs); - forceTimer.unref(); - server.close(() => { - clearTimeout(forceTimer); - closePool() - .catch(error => writeLog({ event: 'database_pool_close_failed', error: error instanceof Error ? error.message : 'unknown' }, true)) - .finally(() => { - writeLog({ event: 'shutdown_complete', signal }); - resolve(); - }); - }); - server.closeIdleConnections(); - }); - return shutdownPromise; -} + const timer = setTimeout(() => instance.server.closeAllConnections(), config.apiShutdownGracePeriodMs); + timer.unref(); + try { + await app.close(); + writeLog({ event: 'shutdown_complete', signal }); + } finally { + clearTimeout(timer); + } + }; + for (const signal of ['SIGTERM', 'SIGINT'] as const) { + process.once(signal, () => void shutdown(signal).catch(error => { + writeLog({ event: 'shutdown_failed', signal, error: error instanceof Error ? error.message : 'unknown' }, true); + process.exitCode = 1; + })); + } -server.listen(config.port, () => { + await app.listen(config.port, '127.0.0.1'); writeLog({ event: 'server_listening', host: '127.0.0.1', port: config.port }); -}); - -for (const signal of ['SIGTERM', 'SIGINT'] as const) { - process.once(signal, () => { - shutdown(signal) - .then(() => { process.exitCode = 0; }) - .catch(error => { - writeLog({ event: 'shutdown_failed', signal, error: error instanceof Error ? error.message : 'unknown' }, true); - process.exitCode = 1; - }); - }); + return app; } + +export const application = bootstrap(); diff --git a/apps/api/tsconfig.json b/apps/api/tsconfig.json index d8eb659e..f2d46d36 100644 --- a/apps/api/tsconfig.json +++ b/apps/api/tsconfig.json @@ -4,6 +4,8 @@ "module": "NodeNext", "moduleResolution": "NodeNext", "strict": true, + "experimentalDecorators": true, + "emitDecoratorMetadata": true, "esModuleInterop": true, "skipLibCheck": true, "outDir": "dist", diff --git a/apps/worker/package.json b/apps/worker/package.json index f64fd8fa..4bd4c692 100644 --- a/apps/worker/package.json +++ b/apps/worker/package.json @@ -25,6 +25,8 @@ "student-supervision:once": "tsx src/index.ts --once --job student-supervision" }, "dependencies": { + "@nestjs/common": "^11.1.28", + "@nestjs/core": "^11.1.28", "@resvg/resvg-js": "^2.6.2", "@supabase/storage-js": "^2.108.2", "ali-oss": "^6.23.0", @@ -32,7 +34,9 @@ "iconv-lite": "^0.6.3", "jszip": "^3.10.1", "pdfkit": "^0.19.1", - "pg": "^8.16.3" + "pg": "^8.16.3", + "reflect-metadata": "^0.2.2", + "rxjs": "^7.8.2" }, "devDependencies": { "@types/node": "^24.0.4", diff --git a/apps/worker/src/index.ts b/apps/worker/src/index.ts index 6ff7db8f..ba5f6d34 100644 --- a/apps/worker/src/index.ts +++ b/apps/worker/src/index.ts @@ -1,210 +1,13 @@ -import { closePool } from './db.js'; -import { config } from './config.js'; -import { processCrmBatch } from './jobs/crm.js'; -import { processCommerceBatch } from './jobs/commerce.js'; -import { processAssetBatch } from './jobs/assets.js'; -import { - parseWorkerCli, - type ContinuousWorkerJob, - type WorkerJob, -} from './cli.js'; - -const extraClosers = new Set<() => Promise>(); - -async function runOnce(job: WorkerJob, month?: string) { - if (job === 'crm') { - const result = await processCrmBatch(); - console.log(`[worker] crm batch processed=${result.processed} sent=${result.sent} failed=${result.failed} retrying=${result.retrying} discarded=${result.discarded}`); - return; - } - if (job === 'commerce') { - const result = await processCommerceBatch(); - console.log( - `[worker] commerce batch processed=${result.processed}` - + ` payments=${result.payments.processed} paid=${result.payments.paid} pending=${result.payments.pending} closed=${result.payments.closed} failed=${result.payments.failed} paymentErrors=${result.payments.errors}` - + ` refunds=${result.refunds.processed} succeeded=${result.refunds.succeeded} processing=${result.refunds.processing} refundFailed=${result.refunds.failed} refundErrors=${result.refunds.errors}`, - ); - return; - } - if (job === 'provider-bills') { - const { closePool: closeApiPool } = await import('../../api/src/core/db.js'); - const { processProviderBillBatch } = await import('./jobs/provider-bills.js'); - extraClosers.add(closeApiPool); - const result = await processProviderBillBatch(); - console.log( - `[worker] provider-bills batch processed=${result.processed}` - + ` completed=${result.completed} failed=${result.failed} skipped=${result.skipped}`, - ); - return; - } - if (job === 'platform-billing') { - const { processPlatformBillingBatch } = await import('./jobs/platform-billing.js'); - const result = await processPlatformBillingBatch(); - console.log( - `[worker] platform-billing batch processed=${result.processed}` - + ` created=${result.created} skipped=${result.skipped} failed=${result.failed}`, - ); - return; - } - if (job === 'platform-usage') { - const { processPlatformUsageBatch } = await import('./jobs/platform-usage.js'); - const result = await processPlatformUsageBatch({ month }); - console.log( - `[worker] platform-usage batch processed=${result.processed}` - + ` metrics=${result.metrics} created=${result.created}` - + ` updated=${result.updated} failed=${result.failed} skipped=${result.skipped}`, - ); - return; - } - if (job === 'platform-usage-overage') { - const { processPlatformUsageOverageBatch } = await import('./jobs/platform-usage-overage.js'); - const result = await processPlatformUsageOverageBatch({ month }); - console.log( - `[worker] platform-usage-overage batch processed=${result.processed}` - + ` created=${result.created} skipped=${result.skipped}` - + ` failed=${result.failed} totalCents=${result.totalCents}`, - ); - return; - } - if (job === 'platform-dunning') { - const { processPlatformDunningBatch } = await import('./jobs/platform-dunning.js'); - const result = await processPlatformDunningBatch(); - console.log( - `[worker] platform-dunning batch processed=${result.processed}` - + ` markedOverdue=${result.markedOverdue} reminderCreated=${result.reminderCreated}` - + ` skippedReminder=${result.skippedReminder}`, - ); - return; - } - if (job === 'platform-dunning-notifications') { - const { processPlatformDunningNotificationBatch } = await import('./jobs/platform-dunning-notifications.js'); - const result = await processPlatformDunningNotificationBatch(); - console.log( - `[worker] platform-dunning-notifications batch enqueued=${result.enqueued}` - + ` processed=${result.processed} sent=${result.sent} failed=${result.failed}` - + ` retrying=${result.retrying} discarded=${result.discarded}`, - ); - return; - } - if (job === 'platform-audit-alerts') { - const { processPlatformAuditAlertBatch } = await import('./jobs/platform-audit-alerts.js'); - const result = await processPlatformAuditAlertBatch(); - console.log( - `[worker] platform-audit-alerts batch processed=${result.processed}` - + ` created=${result.created} skipped=${result.skipped}`, - ); - return; - } - if (job === 'platform-audit-notifications') { - const { processPlatformAuditNotificationBatch } = await import('./jobs/platform-audit-notifications.js'); - const result = await processPlatformAuditNotificationBatch(); - console.log( - `[worker] platform-audit-notifications batch enqueued=${result.enqueued}` - + ` processed=${result.processed} sent=${result.sent} failed=${result.failed}` - + ` retrying=${result.retrying} discarded=${result.discarded}`, - ); - return; - } - if (job === 'assets') { - const result = await processAssetBatch(); - console.log( - `[worker] assets batch processed=${result.processed}` - + ` verified=${result.verified} failed=${result.failed} skipped=${result.skipped} errors=${result.errors}`, - ); - return; - } - if (job === 'imports') { - const { closeImportExecutorPool, processImportBatch } = await import('./jobs/imports.js'); - extraClosers.add(closeImportExecutorPool); - const result = await processImportBatch(); - console.log( - `[worker] imports batch processed=${result.processed}` - + ` completed=${result.completed} completedWithErrors=${result.completedWithErrors}` - + ` failed=${result.failed} retrying=${result.retrying}` - + ` leaseLost=${result.leaseLost} skipped=${result.skipped}`, - ); - return; - } - if (job === 'public-banks') { - const { closePublicBankSyncExecutorPool, processPublicBankSyncBatch } = await import('./jobs/public-banks.js'); - extraClosers.add(closePublicBankSyncExecutorPool); - const result = await processPublicBankSyncBatch(); - console.log( - `[worker] public-banks batch processed=${result.processed}` - + ` synced=${result.synced} conflicts=${result.conflicts}` - + ` failed=${result.failed} skipped=${result.skipped}`, - ); - return; - } - if (job === 'exports') { - const { closeExportExecutorPool, processExportBatch } = await import('./jobs/exports.js'); - extraClosers.add(closeExportExecutorPool); - const result = await processExportBatch(); - console.log( - `[worker] exports batch processed=${result.processed}` - + ` completed=${result.completed} failed=${result.failed}` - + ` retrying=${result.retrying} skipped=${result.skipped}`, - ); - return; - } - if (job === 'student-supervision') { - const { closePool: closeApiPool } = await import('../../api/src/core/db.js'); - const { processStudentSupervisionBatch } = await import('./jobs/student-supervision.js'); - extraClosers.add(closeApiPool); - const result = await processStudentSupervisionBatch(); - console.log( - `[worker] student-supervision batch processed=${result.processed}` - + ` generated=${result.generated} followups=${result.followups}` - + ` failed=${result.failed} skipped=${result.skipped}`, - ); - return; - } - throw new Error(`Unsupported worker job: ${job}`); -} - -function loopPollIntervalMs(job: ContinuousWorkerJob) { - if (job === 'crm') return config.crmPollIntervalMs; - if (job === 'commerce') return config.commercePollIntervalMs; - if (job === 'provider-bills') return config.providerBillPollIntervalMs; - if (job === 'platform-dunning-notifications') return config.platformDunningNotificationPollIntervalMs; - if (job === 'platform-audit-notifications') return config.platformAuditNotificationPollIntervalMs; - if (job === 'assets') return config.assetPollIntervalMs; - if (job === 'imports') return config.importPollIntervalMs; - if (job === 'public-banks') return config.publicBankSyncPollIntervalMs; - return config.exportPollIntervalMs; -} - -async function runLoop(job: ContinuousWorkerJob) { - const pollIntervalMs = loopPollIntervalMs(job); - console.log(`[worker] started job=${job} pollIntervalMs=${pollIntervalMs}`); - let stopped = false; - const stop = () => { - stopped = true; - }; - process.once('SIGINT', stop); - process.once('SIGTERM', stop); - - while (!stopped) { - try { - await runOnce(job); - } catch (error) { - console.error(`[worker] job=${job} failed`, error); - } - if (!stopped) await new Promise(resolve => setTimeout(resolve, pollIntervalMs)); - } -} +import 'reflect-metadata'; +import { NestFactory } from '@nestjs/core'; +import { parseWorkerCli } from './cli.js'; +import { WorkerModule, WorkerRunner } from './worker.module.js'; const cli = parseWorkerCli(process.argv.slice(2)); +const app = await NestFactory.createApplicationContext(WorkerModule, { logger: false }); try { - if (cli.loop) { - await runLoop(cli.job as ContinuousWorkerJob); - } else { - await runOnce(cli.job, cli.month); - } + await app.get(WorkerRunner).execute(cli); } finally { - for (const closeExtra of extraClosers) { - await closeExtra(); - } - await closePool(); + await app.close(); } diff --git a/apps/worker/src/worker.module.ts b/apps/worker/src/worker.module.ts new file mode 100644 index 00000000..e4fbb809 --- /dev/null +++ b/apps/worker/src/worker.module.ts @@ -0,0 +1,161 @@ +import { Injectable, Module, OnApplicationShutdown } from '@nestjs/common'; +import { config } from './config.js'; +import { closePool } from './db.js'; +import { type ContinuousWorkerJob, type WorkerCliOptions, type WorkerJob } from './cli.js'; +import { processAssetBatch } from './jobs/assets.js'; +import { processCommerceBatch } from './jobs/commerce.js'; +import { processCrmBatch } from './jobs/crm.js'; + +@Injectable() +export class WorkerCloserRegistry implements OnApplicationShutdown { + // 动态任务可能额外加载 API 或导入导出连接池,统一登记后由 Nest 生命周期关闭。 + private readonly closers = new Set<() => Promise>([closePool]); + + add(closer: () => Promise) { this.closers.add(closer); } + + async onApplicationShutdown() { + for (const closer of this.closers) await closer(); + } +} + +@Injectable() +export class WorkerJobRegistry { + constructor(private readonly closers: WorkerCloserRegistry) {} + + async run(job: WorkerJob, month?: string) { + if (job === 'crm') { + const result = await processCrmBatch(); + console.log(`[worker] crm batch processed=${result.processed} sent=${result.sent} failed=${result.failed} retrying=${result.retrying} discarded=${result.discarded}`); + return; + } + if (job === 'commerce') { + const result = await processCommerceBatch(); + console.log(`[worker] commerce batch processed=${result.processed} payments=${result.payments.processed} paid=${result.payments.paid} pending=${result.payments.pending} closed=${result.payments.closed} failed=${result.payments.failed} paymentErrors=${result.payments.errors} refunds=${result.refunds.processed} succeeded=${result.refunds.succeeded} processing=${result.refunds.processing} refundFailed=${result.refunds.failed} refundErrors=${result.refunds.errors}`); + return; + } + if (job === 'provider-bills') { + const { closePool: closeApiPool } = await import('../../api/src/core/db.js'); + const { processProviderBillBatch } = await import('./jobs/provider-bills.js'); + this.closers.add(closeApiPool); + const result = await processProviderBillBatch(); + console.log(`[worker] provider-bills batch processed=${result.processed} completed=${result.completed} failed=${result.failed} skipped=${result.skipped}`); + return; + } + if (job === 'platform-billing') { + const { processPlatformBillingBatch } = await import('./jobs/platform-billing.js'); + const result = await processPlatformBillingBatch(); + console.log(`[worker] platform-billing batch processed=${result.processed} created=${result.created} skipped=${result.skipped} failed=${result.failed}`); + return; + } + if (job === 'platform-usage') { + const { processPlatformUsageBatch } = await import('./jobs/platform-usage.js'); + const result = await processPlatformUsageBatch({ month }); + console.log(`[worker] platform-usage batch processed=${result.processed} metrics=${result.metrics} created=${result.created} updated=${result.updated} failed=${result.failed} skipped=${result.skipped}`); + return; + } + if (job === 'platform-usage-overage') { + const { processPlatformUsageOverageBatch } = await import('./jobs/platform-usage-overage.js'); + const result = await processPlatformUsageOverageBatch({ month }); + console.log(`[worker] platform-usage-overage batch processed=${result.processed} created=${result.created} skipped=${result.skipped} failed=${result.failed} totalCents=${result.totalCents}`); + return; + } + if (job === 'platform-dunning') { + const { processPlatformDunningBatch } = await import('./jobs/platform-dunning.js'); + const result = await processPlatformDunningBatch(); + console.log(`[worker] platform-dunning batch processed=${result.processed} markedOverdue=${result.markedOverdue} reminderCreated=${result.reminderCreated} skippedReminder=${result.skippedReminder}`); + return; + } + if (job === 'platform-dunning-notifications') { + const { processPlatformDunningNotificationBatch } = await import('./jobs/platform-dunning-notifications.js'); + const result = await processPlatformDunningNotificationBatch(); + console.log(`[worker] platform-dunning-notifications batch enqueued=${result.enqueued} processed=${result.processed} sent=${result.sent} failed=${result.failed} retrying=${result.retrying} discarded=${result.discarded}`); + return; + } + if (job === 'platform-audit-alerts') { + const { processPlatformAuditAlertBatch } = await import('./jobs/platform-audit-alerts.js'); + const result = await processPlatformAuditAlertBatch(); + console.log(`[worker] platform-audit-alerts batch processed=${result.processed} created=${result.created} skipped=${result.skipped}`); + return; + } + if (job === 'platform-audit-notifications') { + const { processPlatformAuditNotificationBatch } = await import('./jobs/platform-audit-notifications.js'); + const result = await processPlatformAuditNotificationBatch(); + console.log(`[worker] platform-audit-notifications batch enqueued=${result.enqueued} processed=${result.processed} sent=${result.sent} failed=${result.failed} retrying=${result.retrying} discarded=${result.discarded}`); + return; + } + if (job === 'assets') { + const result = await processAssetBatch(); + console.log(`[worker] assets batch processed=${result.processed} verified=${result.verified} failed=${result.failed} skipped=${result.skipped} errors=${result.errors}`); + return; + } + if (job === 'imports') { + const { closeImportExecutorPool, processImportBatch } = await import('./jobs/imports.js'); + this.closers.add(closeImportExecutorPool); + const result = await processImportBatch(); + console.log(`[worker] imports batch processed=${result.processed} completed=${result.completed} completedWithErrors=${result.completedWithErrors} failed=${result.failed} retrying=${result.retrying} leaseLost=${result.leaseLost} skipped=${result.skipped}`); + return; + } + if (job === 'public-banks') { + const { closePublicBankSyncExecutorPool, processPublicBankSyncBatch } = await import('./jobs/public-banks.js'); + this.closers.add(closePublicBankSyncExecutorPool); + const result = await processPublicBankSyncBatch(); + console.log(`[worker] public-banks batch processed=${result.processed} synced=${result.synced} conflicts=${result.conflicts} failed=${result.failed} skipped=${result.skipped}`); + return; + } + if (job === 'exports') { + const { closeExportExecutorPool, processExportBatch } = await import('./jobs/exports.js'); + this.closers.add(closeExportExecutorPool); + const result = await processExportBatch(); + console.log(`[worker] exports batch processed=${result.processed} completed=${result.completed} failed=${result.failed} retrying=${result.retrying} skipped=${result.skipped}`); + return; + } + if (job === 'student-supervision') { + const { closePool: closeApiPool } = await import('../../api/src/core/db.js'); + const { processStudentSupervisionBatch } = await import('./jobs/student-supervision.js'); + this.closers.add(closeApiPool); + const result = await processStudentSupervisionBatch(); + console.log(`[worker] student-supervision batch processed=${result.processed} generated=${result.generated} followups=${result.followups} failed=${result.failed} skipped=${result.skipped}`); + return; + } + throw new Error(`Unsupported worker job: ${job}`); + } +} + +@Injectable() +export class WorkerPollIntervals { + get(job: ContinuousWorkerJob) { + if (job === 'crm') return config.crmPollIntervalMs; + if (job === 'commerce') return config.commercePollIntervalMs; + if (job === 'provider-bills') return config.providerBillPollIntervalMs; + if (job === 'platform-dunning-notifications') return config.platformDunningNotificationPollIntervalMs; + if (job === 'platform-audit-notifications') return config.platformAuditNotificationPollIntervalMs; + if (job === 'assets') return config.assetPollIntervalMs; + if (job === 'imports') return config.importPollIntervalMs; + if (job === 'public-banks') return config.publicBankSyncPollIntervalMs; + return config.exportPollIntervalMs; + } +} + +@Injectable() +export class WorkerRunner { + constructor(private readonly jobs: WorkerJobRegistry, private readonly intervals: WorkerPollIntervals) {} + + async execute(cli: WorkerCliOptions) { + if (!cli.loop) return this.jobs.run(cli.job, cli.month); + const job = cli.job as ContinuousWorkerJob; + const pollIntervalMs = this.intervals.get(job); + console.log(`[worker] started job=${job} pollIntervalMs=${pollIntervalMs}`); + let stopped = false; + const stop = () => { stopped = true; }; + process.once('SIGINT', stop); + process.once('SIGTERM', stop); + while (!stopped) { + try { await this.jobs.run(job); } + catch (error) { console.error(`[worker] job=${job} failed`, error); } + if (!stopped) await new Promise(resolve => setTimeout(resolve, pollIntervalMs)); + } + } +} + +@Module({ providers: [WorkerCloserRegistry, WorkerJobRegistry, WorkerPollIntervals, WorkerRunner] }) +export class WorkerModule {} diff --git a/apps/worker/tsconfig.json b/apps/worker/tsconfig.json index d8eb659e..f2d46d36 100644 --- a/apps/worker/tsconfig.json +++ b/apps/worker/tsconfig.json @@ -4,6 +4,8 @@ "module": "NodeNext", "moduleResolution": "NodeNext", "strict": true, + "experimentalDecorators": true, + "emitDecoratorMetadata": true, "esModuleInterop": true, "skipLibCheck": true, "outDir": "dist", diff --git a/package-lock.json b/package-lock.json index 10ac2e3e..f9988159 100644 --- a/package-lock.json +++ b/package-lock.json @@ -23,11 +23,20 @@ "name": "@tiku-saas/api", "version": "0.1.0", "dependencies": { + "@nestjs/common": "^11.1.28", + "@nestjs/core": "^11.1.28", + "@nestjs/platform-fastify": "^11.1.28", + "@nestjs/swagger": "^11.4.6", + "@scalar/nestjs-api-reference": "^1.0.31", "@supabase/storage-js": "^2.108.2", "ali-oss": "^6.23.0", + "class-transformer": "^0.5.1", + "class-validator": "^0.14.4", "jose": "^6.2.3", "pg": "^8.16.3", - "read-excel-file": "^9.2.0" + "read-excel-file": "^9.2.0", + "reflect-metadata": "^0.2.2", + "rxjs": "^7.8.2" }, "devDependencies": { "@types/node": "^24.0.4", @@ -90,6 +99,8 @@ "name": "@tiku-saas/worker", "version": "0.1.0", "dependencies": { + "@nestjs/common": "^11.1.28", + "@nestjs/core": "^11.1.28", "@resvg/resvg-js": "^2.6.2", "@supabase/storage-js": "^2.108.2", "ali-oss": "^6.23.0", @@ -97,7 +108,9 @@ "iconv-lite": "^0.6.3", "jszip": "^3.10.1", "pdfkit": "^0.19.1", - "pg": "^8.16.3" + "pg": "^8.16.3", + "reflect-metadata": "^0.2.2", + "rxjs": "^7.8.2" }, "devDependencies": { "@types/node": "^24.0.4", @@ -129,7 +142,8 @@ "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.3.3.tgz", "integrity": "sha512-rE0Pygv0sEZ4vBWHlAgJLGDU7Pm8xoO6p3wsEceb7GYAjScrOHpEo8KK/eVkAcnSM+slAEtXjA2JpdjLp4fJQQ==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@asamuzakjp/css-color": { "version": "3.2.0", @@ -183,7 +197,6 @@ "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.7", @@ -1833,7 +1846,6 @@ "integrity": "sha512-C+PV1TFUPTmBQGoPBL8j2QmLpZ117YTCwxIZeJOM96GbYMFSc7/pOXU5lVykwnZxyTqQxRsvoRk6f2FktZgGHA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/helper-plugin-utils": "^7.29.7", "@babel/helper-validator-option": "^7.29.7", @@ -1875,7 +1887,6 @@ "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=6.9.0" } @@ -1941,6 +1952,16 @@ "node": ">=6.9.0" } }, + "node_modules/@borewit/text-codec": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/@borewit/text-codec/-/text-codec-0.2.2.tgz", + "integrity": "sha512-DDaRehssg1aNrH4+2hnj1B7vnUGEjU6OIlyRdkMd0aUdIUvKXrJfXsy8LVtXAy7DRvYVluWbMspsRhz2lcW0mQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + } + }, "node_modules/@csstools/color-helpers": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz", @@ -2029,7 +2050,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=18" }, @@ -2053,7 +2073,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=18" } @@ -2563,6 +2582,228 @@ "node": "^12.22.0 || ^14.17.0 || >=16.0.0" } }, + "node_modules/@fastify/ajv-compiler": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/@fastify/ajv-compiler/-/ajv-compiler-4.0.5.tgz", + "integrity": "sha512-KoWKW+MhvfTRWL4qrhUwAAZoaChluo0m0vbiJlGMt2GXvL4LVPQEjt8kSpHI3IBq5Rez8fg+XeH3cneztq+C7A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "ajv": "^8.12.0", + "ajv-formats": "^3.0.1", + "fast-uri": "^3.0.0" + } + }, + "node_modules/@fastify/ajv-compiler/node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/@fastify/ajv-compiler/node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/@fastify/ajv-compiler/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, + "node_modules/@fastify/cors": { + "version": "11.2.0", + "resolved": "https://registry.npmjs.org/@fastify/cors/-/cors-11.2.0.tgz", + "integrity": "sha512-LbLHBuSAdGdSFZYTLVA3+Ch2t+sA6nq3Ejc6XLAKiQ6ViS2qFnvicpj0htsx03FyYeLs04HfRNBsz/a8SvbcUw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "fastify-plugin": "^5.0.0", + "toad-cache": "^3.7.0" + } + }, + "node_modules/@fastify/cors/node_modules/fastify-plugin": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/fastify-plugin/-/fastify-plugin-5.1.0.tgz", + "integrity": "sha512-FAIDA8eovSt5qcDgcBvDuX/v0Cjz0ohGhENZ/wpc3y+oZCY2afZ9Baqql3g/lC+OHRnciQol4ww7tuthOb9idw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/@fastify/error": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@fastify/error/-/error-4.2.0.tgz", + "integrity": "sha512-RSo3sVDXfHskiBZKBPRgnQTtIqpi/7zhJOEmAxCiBcM7d0uwdGdxLlsCaLzGs8v8NnxIRlfG0N51p5yFaOentQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/@fastify/fast-json-stringify-compiler": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@fastify/fast-json-stringify-compiler/-/fast-json-stringify-compiler-5.1.0.tgz", + "integrity": "sha512-PxcYtKLbQ8Z+yApiqjK8FwxIwvEj38k2OiLc17u8dkJSlmfi2wHHPaSnaoqBPQqtvF8YVsDgDpP2snDCfFrpfw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "fast-json-stringify": "^7.0.0" + } + }, + "node_modules/@fastify/formbody": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@fastify/formbody/-/formbody-8.0.2.tgz", + "integrity": "sha512-84v5J2KrkXzjgBpYnaNRPqwgMsmY7ZDjuj0YVuMR3NXCJRCgKEZy/taSP1wUYGn0onfxJpLyRGDLa+NMaDJtnA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "fast-querystring": "^1.1.2", + "fastify-plugin": "^5.0.0" + } + }, + "node_modules/@fastify/formbody/node_modules/fastify-plugin": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/fastify-plugin/-/fastify-plugin-5.1.0.tgz", + "integrity": "sha512-FAIDA8eovSt5qcDgcBvDuX/v0Cjz0ohGhENZ/wpc3y+oZCY2afZ9Baqql3g/lC+OHRnciQol4ww7tuthOb9idw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/@fastify/forwarded": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@fastify/forwarded/-/forwarded-3.0.1.tgz", + "integrity": "sha512-JqDochHFqXs3C3Ml3gOY58zM7OqO9ENqPo0UqAjAjH8L01fRZqwX9iLeX34//kiJubF7r2ZQHtBRU36vONbLlw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/@fastify/merge-json-schemas": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/@fastify/merge-json-schemas/-/merge-json-schemas-0.2.1.tgz", + "integrity": "sha512-OA3KGBCy6KtIvLf8DINC5880o5iBlDX4SxzLQS8HorJAbqluzLRn80UXU0bxZn7UOFhFgpRJDasfwn9nG4FG4A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/@fastify/proxy-addr": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@fastify/proxy-addr/-/proxy-addr-5.1.0.tgz", + "integrity": "sha512-INS+6gh91cLUjB+PVHfu1UqcB76Sqtpyp7bnL+FYojhjygvOPA9ctiD/JDKsyD9Xgu4hUhCSJBPig/w7duNajw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@fastify/forwarded": "^3.0.0", + "ipaddr.js": "^2.1.0" + } + }, "node_modules/@hapi/hoek": { "version": "9.3.0", "resolved": "https://registry.npmjs.org/@hapi/hoek/-/hoek-9.3.0.tgz", @@ -2859,6 +3100,21 @@ "dev": true, "license": "MIT" }, + "node_modules/@lukeed/csprng": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@lukeed/csprng/-/csprng-1.1.0.tgz", + "integrity": "sha512-Z7C/xXCiGWsg0KuKsHTKJxbWhpI3Vs5GwLfOean7MGyVFGqdRgBbAjOCh6u4bbjPc/8MJ2pZmK/0DLdCbivLDA==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@microsoft/tsdoc": { + "version": "0.16.0", + "resolved": "https://registry.npmjs.org/@microsoft/tsdoc/-/tsdoc-0.16.0.tgz", + "integrity": "sha512-xgAyonlVVS+q7Vc7qLW0UrJU7rSFcETRWsqdXZtjzRU8dF+6CkozTK4V4y1LwOX7j8r/vHphjDeMeGI4tNGeGA==", + "license": "MIT" + }, "node_modules/@napi-rs/triples": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/@napi-rs/triples/-/triples-1.2.0.tgz", @@ -2866,6 +3122,235 @@ "dev": true, "license": "MIT" }, + "node_modules/@nestjs/common": { + "version": "11.1.28", + "resolved": "https://registry.npmjs.org/@nestjs/common/-/common-11.1.28.tgz", + "integrity": "sha512-bRImsxibie+AM7xjdwcrm/gr5YeacI65kSBNzTufa1Ib5iwziaY/lqMtRh9THq6pbV4e1HP9aI2ZxGUumnmaoQ==", + "license": "MIT", + "dependencies": { + "file-type": "21.3.4", + "iterare": "1.2.1", + "load-esm": "1.0.3", + "tslib": "2.8.1", + "uid": "2.0.2" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/nest" + }, + "peerDependencies": { + "class-transformer": ">=0.4.1", + "class-validator": ">=0.13.2", + "reflect-metadata": "^0.1.12 || ^0.2.0", + "rxjs": "^7.1.0" + }, + "peerDependenciesMeta": { + "class-transformer": { + "optional": true + }, + "class-validator": { + "optional": true + } + } + }, + "node_modules/@nestjs/common/node_modules/file-type": { + "version": "21.3.4", + "resolved": "https://registry.npmjs.org/file-type/-/file-type-21.3.4.tgz", + "integrity": "sha512-Ievi/yy8DS3ygGvT47PjSfdFoX+2isQueoYP1cntFW1JLYAuS4GD7NUPGg4zv2iZfV52uDyk5w5Z0TdpRS6Q1g==", + "license": "MIT", + "dependencies": { + "@tokenizer/inflate": "^0.4.1", + "strtok3": "^10.3.4", + "token-types": "^6.1.1", + "uint8array-extras": "^1.4.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sindresorhus/file-type?sponsor=1" + } + }, + "node_modules/@nestjs/core": { + "version": "11.1.28", + "resolved": "https://registry.npmjs.org/@nestjs/core/-/core-11.1.28.tgz", + "integrity": "sha512-06m63xIRj8+l8uOeh/8LnYupGubkyu4f+bPKIadaSui6vK9KpXgoz7HveT1yOVLcEt0M0oCOEW5EuEXZkEmBBQ==", + "license": "MIT", + "dependencies": { + "fast-safe-stringify": "2.1.1", + "iterare": "1.2.1", + "path-to-regexp": "8.4.2", + "tslib": "2.8.1", + "uid": "2.0.2" + }, + "engines": { + "node": ">= 20" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/nest" + }, + "peerDependencies": { + "@nestjs/common": "^11.0.0", + "@nestjs/microservices": "^11.0.0", + "@nestjs/platform-express": "^11.0.0", + "@nestjs/websockets": "^11.0.0", + "reflect-metadata": "^0.1.12 || ^0.2.0", + "rxjs": "^7.1.0" + }, + "peerDependenciesMeta": { + "@nestjs/microservices": { + "optional": true + }, + "@nestjs/platform-express": { + "optional": true + }, + "@nestjs/websockets": { + "optional": true + } + } + }, + "node_modules/@nestjs/core/node_modules/path-to-regexp": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", + "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@nestjs/mapped-types": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@nestjs/mapped-types/-/mapped-types-2.1.1.tgz", + "integrity": "sha512-SCCoMEJ6jdeI5h/N+KCVF1+pmg/hmEkNA5nHTS8Gvww7T/LCl4o1gFLinw2iQ60w7slFkszHcGLKGdazVI4F8A==", + "license": "MIT", + "peerDependencies": { + "@nestjs/common": "^10.0.0 || ^11.0.0", + "class-transformer": "^0.4.0 || ^0.5.0", + "class-validator": "^0.13.0 || ^0.14.0 || ^0.15.0", + "reflect-metadata": "^0.1.12 || ^0.2.0" + }, + "peerDependenciesMeta": { + "class-transformer": { + "optional": true + }, + "class-validator": { + "optional": true + } + } + }, + "node_modules/@nestjs/platform-fastify": { + "version": "11.1.28", + "resolved": "https://registry.npmjs.org/@nestjs/platform-fastify/-/platform-fastify-11.1.28.tgz", + "integrity": "sha512-utUfyxRzZsoFxz1GU3Z0OthHpijB605iqtxwFnk9yKowLrU1t1ZkxMUuad/csemImY6AsuaTpTjCecKbmfl+pQ==", + "license": "MIT", + "dependencies": { + "@fastify/cors": "11.2.0", + "@fastify/formbody": "8.0.2", + "fast-querystring": "1.1.2", + "fastify": "5.10.0", + "fastify-plugin": "6.0.0", + "find-my-way": "9.6.0", + "light-my-request": "6.6.0", + "path-to-regexp": "8.4.2", + "reusify": "1.1.0", + "tslib": "2.8.1" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/nest" + }, + "peerDependencies": { + "@fastify/static": "^8.0.0 || ^9.0.0", + "@fastify/view": "^10.0.0 || ^11.0.0 || ^12.0.0", + "@nestjs/common": "^11.0.0", + "@nestjs/core": "^11.0.0" + }, + "peerDependenciesMeta": { + "@fastify/static": { + "optional": true + }, + "@fastify/view": { + "optional": true + } + } + }, + "node_modules/@nestjs/platform-fastify/node_modules/path-to-regexp": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", + "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/@nestjs/swagger": { + "version": "11.4.6", + "resolved": "https://registry.npmjs.org/@nestjs/swagger/-/swagger-11.4.6.tgz", + "integrity": "sha512-Le136h2WC7HGsd70+WyK1qrm+Zq7kFxBLkYC1JgAVqNRCt8kNh7bMF7Qkn65D5j2t/aks0+VbWmUVlYIwPrs3A==", + "license": "MIT", + "dependencies": { + "@microsoft/tsdoc": "0.16.0", + "@nestjs/mapped-types": "2.1.1", + "js-yaml": "5.2.1", + "lodash": "4.18.1", + "path-to-regexp": "8.4.2", + "swagger-ui-dist": "5.32.8" + }, + "peerDependencies": { + "@fastify/static": "^8.0.0 || ^9.0.0 || ^10.0.0", + "@nestjs/common": "^11.0.1", + "@nestjs/core": "^11.0.1", + "class-transformer": "*", + "class-validator": "*", + "reflect-metadata": "^0.1.12 || ^0.2.0" + }, + "peerDependenciesMeta": { + "@fastify/static": { + "optional": true + }, + "class-transformer": { + "optional": true + }, + "class-validator": { + "optional": true + } + } + }, + "node_modules/@nestjs/swagger/node_modules/js-yaml": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-5.2.1.tgz", + "integrity": "sha512-zfLtNfQqxVqq3uaTqSkh4x4hZw3KHobGUA0fJUj4wawW8bsQLTVqpHdXSIzidh7o+4lEW36tANuAGdaFx6Zgnw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.mjs" + } + }, + "node_modules/@nestjs/swagger/node_modules/path-to-regexp": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", + "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/@noble/ciphers": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-1.3.0.tgz", @@ -3238,6 +3723,12 @@ "url": "https://opencollective.com/parcel" } }, + "node_modules/@pinojs/redact": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz", + "integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==", + "license": "MIT" + }, "node_modules/@pkgjs/parseargs": { "version": "0.11.0", "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", @@ -3500,6 +3991,94 @@ "dev": true, "license": "MIT" }, + "node_modules/@scalar/core": { + "version": "0.3.45", + "resolved": "https://registry.npmjs.org/@scalar/core/-/core-0.3.45.tgz", + "integrity": "sha512-f3jyzColUUcu3eYfjslgNjG2JABuFU8WR3P7a9GevMpisW3skWUqVVffyC1P4w4i9TVRk9FUjYEtGeP10Rw9lQ==", + "license": "MIT", + "dependencies": { + "@scalar/types": "0.6.10" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@scalar/helpers": { + "version": "0.2.18", + "resolved": "https://registry.npmjs.org/@scalar/helpers/-/helpers-0.2.18.tgz", + "integrity": "sha512-w1d4tpNEVZ293oB2BAgLrS0kVPUtG3eByNmOCJA5eK9vcT4D3cmsGtWjUaaqit0BQCsBFHK51rasGvSWnApYTw==", + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/@scalar/nestjs-api-reference": { + "version": "1.0.31", + "resolved": "https://registry.npmjs.org/@scalar/nestjs-api-reference/-/nestjs-api-reference-1.0.31.tgz", + "integrity": "sha512-pJEps+4/AQPgKQkrxquV/Rd06/y3Bz1sIiFxMH5jNEWPQ7fons+kzckkgNl8ohfGSz1au7+dmgH+l0DwAffCuw==", + "license": "MIT", + "dependencies": { + "@scalar/core": "0.3.45" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@scalar/types": { + "version": "0.6.10", + "resolved": "https://registry.npmjs.org/@scalar/types/-/types-0.6.10.tgz", + "integrity": "sha512-fZkelRwcEeAhsn4c0wjYXWrzSzLaEyfxTn/eazXJ4XfCIsgJTQyK0FD8mnOBZJ2vEIbtT2E1mBKnCbDxrJIlxA==", + "license": "MIT", + "dependencies": { + "@scalar/helpers": "0.2.18", + "nanoid": "^5.1.6", + "type-fest": "^5.3.1", + "zod": "^4.3.5" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@scalar/types/node_modules/nanoid": { + "version": "5.1.16", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.1.16.tgz", + "integrity": "sha512-kVrnsrJqMR8+oLJnGEmSWw9BivK5mt7H3FZatVRjrc5wGqFYuBxX1yG7+A7Gi5AefkX6t/oCkizcQgpu0cY1dQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.js" + }, + "engines": { + "node": "^18 || >=20" + } + }, + "node_modules/@scalar/types/node_modules/type-fest": { + "version": "5.8.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-5.8.0.tgz", + "integrity": "sha512-YGYEVz3Fm5iy/AybuA0oyNFq7H4CgQNfRp/qfe8nurE1kuCeNm3/vfm9X4Mtl+qLyaKJUh5xrFZwogr41SMjYA==", + "license": "(MIT OR CC0-1.0)", + "dependencies": { + "tagged-tag": "^1.0.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@scarf/scarf": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@scarf/scarf/-/scarf-1.4.0.tgz", + "integrity": "sha512-xxeapPiUXdZAE3che6f3xogoJPeZgig6omHEy1rIY5WVsB3H2BHNnZH+gHG6x91SCWyQCzWGsuL2Hh3ClO5/qQ==", + "hasInstallScript": true, + "license": "Apache-2.0" + }, "node_modules/@sideway/address": { "version": "4.1.5", "resolved": "https://registry.npmjs.org/@sideway/address/-/address-4.1.5.tgz", @@ -3777,7 +4356,6 @@ "dev": true, "hasInstallScript": true, "license": "Apache-2.0", - "peer": true, "dependencies": { "@swc/counter": "^0.1.1", "@swc/types": "^0.1.5" @@ -3992,7 +4570,6 @@ "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.23.tgz", "integrity": "sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw==", "license": "Apache-2.0", - "peer": true, "dependencies": { "tslib": "^2.8.0" } @@ -4258,7 +4835,6 @@ "integrity": "sha512-F8D5EbwS/d0Kkh4PWFHhXoM1l1Iy2ZxjsWmXEkUratiq+CktogjQDa87pY+lyPLFsArA8oAd01p7rsQmVSDfoA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/core": "^7.24.4", "@babel/generator": "^7.24.4", @@ -4681,7 +5257,6 @@ "integrity": "sha512-PztaDI5DfOCIYPn0/VeoZgtsKpE1Z/pXjrlB5w+9gZfM6bZZeS0j/GRdfHBz/BdmHMFYmQ+2HbZJ+sVj5DIUzA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@tarojs/shared": "4.2.0", "tslib": "^2.6.2" @@ -4696,7 +5271,6 @@ "integrity": "sha512-shUXAkxUNjrlgucU5yP4S1ZZnKFQmAvlnpDiignCF1dUvsDB+7Q8I+XTwI6JuK39HcCXxs3xIgYeIsBEHrIbZw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@tarojs/helper": "4.2.0", "@tarojs/runner-utils": "4.2.0", @@ -4718,7 +5292,6 @@ "integrity": "sha512-nNege+07WSMFr44oAa7B2dQprBGD35AV8AOgGTFIQ5bAlriE2nOUt8hoiEtYtRlWx2zhdP6iDyVM0aNo2ijskw==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">= 18" } @@ -4729,7 +5302,6 @@ "integrity": "sha512-GXdWlWRxhj0t4n8QMlL4DuyYQmq8yKnnhMQcwLLnLKgbcNfk2nQGZDGI7suT6Z9wkZvZDidnzzzjLJ5ogTQsDQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@tarojs/api": "4.2.0", "@tarojs/runtime": "4.2.0", @@ -4953,6 +5525,29 @@ "resolved": "apps/worker", "link": true }, + "node_modules/@tokenizer/inflate": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@tokenizer/inflate/-/inflate-0.4.1.tgz", + "integrity": "sha512-2mAv+8pkG6GIZiF1kNg1jAjh27IDxEPKwdGul3snfztFerfPGI1LjDezZp3i7BElXompqEtPmoPx6c2wgtWsOA==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "token-types": "^6.1.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + } + }, + "node_modules/@tokenizer/token": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@tokenizer/token/-/token-0.3.0.tgz", + "integrity": "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A==", + "license": "MIT" + }, "node_modules/@types/archy": { "version": "0.0.31", "resolved": "https://registry.npmjs.org/@types/archy/-/archy-0.0.31.tgz", @@ -5202,7 +5797,6 @@ "resolved": "https://registry.npmjs.org/@types/node/-/node-25.6.0.tgz", "integrity": "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==", "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~7.19.0" } @@ -5277,7 +5871,6 @@ "integrity": "sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/prop-types": "*", "csstype": "^3.2.2" @@ -5363,6 +5956,12 @@ "@types/node": "*" } }, + "node_modules/@types/validator": { + "version": "13.15.10", + "resolved": "https://registry.npmjs.org/@types/validator/-/validator-13.15.10.tgz", + "integrity": "sha512-T8L6i7wCuyoK8A/ZeLYt1+q0ty3Zb9+qbSSvrIVitzT3YjZqkTZ40IbRsPanlB4h1QB3JVL1SYCdR6ngtFYcuA==", + "license": "MIT" + }, "node_modules/@types/ws": { "version": "8.18.1", "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", @@ -5581,6 +6180,12 @@ "dev": true, "license": "MIT" }, + "node_modules/abstract-logging": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/abstract-logging/-/abstract-logging-2.0.1.tgz", + "integrity": "sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA==", + "license": "MIT" + }, "node_modules/accepts": { "version": "1.3.8", "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", @@ -5611,7 +6216,6 @@ "integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -5732,7 +6336,6 @@ "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -5954,7 +6557,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true, "license": "Python-2.0" }, "node_modules/array-flatten": { @@ -5971,6 +6573,15 @@ "dev": true, "license": "MIT" }, + "node_modules/atomic-sleep": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz", + "integrity": "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==", + "license": "MIT", + "engines": { + "node": ">=8.0.0" + } + }, "node_modules/autoprefixer": { "version": "10.5.2", "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.5.2.tgz", @@ -6024,6 +6635,26 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/avvio": { + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/avvio/-/avvio-9.3.0.tgz", + "integrity": "sha512-g2tQ7LE7oOSqDfwEm3M+ZCMTJc7KiZCdJ4UwyZJb5ckTKyYu50OYmvv0mCFXPuYXoM4zkSt8zM9XQ9KCvxA74A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@fastify/error": "^4.0.0", + "fastq": "^1.17.1" + } + }, "node_modules/axios": { "version": "1.18.1", "resolved": "https://registry.npmjs.org/axios/-/axios-1.18.1.tgz", @@ -6505,7 +7136,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.10.38", "caniuse-lite": "^1.0.30001799", @@ -6883,6 +7513,23 @@ "node": ">=8" } }, + "node_modules/class-transformer": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/class-transformer/-/class-transformer-0.5.1.tgz", + "integrity": "sha512-SQa1Ws6hUbfC98vKGxZH3KFY0Y1lm5Zm0SY8XX9zbK7FJCyVEac3ATW0RIpwzW+oOfmHE5PMPufDG9hCfoEOMw==", + "license": "MIT" + }, + "node_modules/class-validator": { + "version": "0.14.4", + "resolved": "https://registry.npmjs.org/class-validator/-/class-validator-0.14.4.tgz", + "integrity": "sha512-AwNusCCam51q703dW82x95tOqQp6oC9HNUl724KxJJOfnKscI8dOloXFgyez7LbTTKWuRBA37FScqVbJEoq8Yw==", + "license": "MIT", + "dependencies": { + "@types/validator": "^13.15.3", + "libphonenumber-js": "^1.11.1", + "validator": "^13.15.22" + } + }, "node_modules/classnames": { "version": "2.5.1", "resolved": "https://registry.npmjs.org/classnames/-/classnames-2.5.1.tgz", @@ -7235,6 +7882,7 @@ "integrity": "sha512-yCEafptTtb4bk7GLEQoM8KVJpxAfdBJYaXyzQEgQQQgYrZiDp8SJmGKlYza6CYjEDNstAdNdKA3UuoULlEbS6w==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "is-what": "^4.1.8" }, @@ -7282,7 +7930,6 @@ "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -7575,7 +8222,6 @@ "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -8239,6 +8885,15 @@ "node": ">= 0.8" } }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/destroy": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", @@ -9307,7 +9962,6 @@ "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.4.0", @@ -9670,6 +10324,12 @@ "node": ">=0.10.0" } }, + "node_modules/fast-decode-uri-component": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/fast-decode-uri-component/-/fast-decode-uri-component-1.0.1.tgz", + "integrity": "sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg==", + "license": "MIT" + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -9700,18 +10360,67 @@ "dev": true, "license": "MIT" }, - "node_modules/fast-levenshtein": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", - "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", - "dev": true, - "license": "MIT" + "node_modules/fast-json-stringify": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/fast-json-stringify/-/fast-json-stringify-7.0.1.tgz", + "integrity": "sha512-eRSayARSbbwlBjpP4vnTTIRD5QPcIrmihPxDeN1DtKnHPg66UuJLx+8hlK1kaFdjvzyQ/dzALoi4vwAQ+T+iZA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@fastify/merge-json-schemas": "^0.2.0", + "ajv": "^8.12.0", + "ajv-formats": "^3.0.1", + "fast-uri": "^4.0.0", + "json-schema-ref-resolver": "^3.0.0", + "rfdc": "^1.2.0" + } }, - "node_modules/fast-uri": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz", - "integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==", - "dev": true, + "node_modules/fast-json-stringify/node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/fast-json-stringify/node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/fast-json-stringify/node_modules/ajv/node_modules/fast-uri": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.3.tgz", + "integrity": "sha512-i70LwGWUduXqzicKXWshooq+sWL1K3WUU5rKZNG/0i3a1OSoX3HqhH5WbWwTmqWfor4urUakGPiRQcleRZTwOg==", "funding": [ { "type": "github", @@ -9724,11 +10433,131 @@ ], "license": "BSD-3-Clause" }, + "node_modules/fast-json-stringify/node_modules/fast-uri": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-4.1.0.tgz", + "integrity": "sha512-ZodJ2cRiLVWGi9IgPb3mbgSqM4CD3LexCHkuv0FfBXHJI1ADfucTD06m6clO2Cy5RZYsw/SiCVl/dyrFI/SYWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/fast-json-stringify/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-querystring": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/fast-querystring/-/fast-querystring-1.1.2.tgz", + "integrity": "sha512-g6KuKWmFXc0fID8WWH0jit4g0AGBoJhCkJMb1RmbsSEUNvQ+ZC8D6CUZ+GtF8nMzSPXnhiePyyqqipzNNEnHjg==", + "license": "MIT", + "dependencies": { + "fast-decode-uri-component": "^1.0.1" + } + }, + "node_modules/fast-safe-stringify": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz", + "integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==", + "license": "MIT" + }, + "node_modules/fast-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz", + "integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/fastify": { + "version": "5.10.0", + "resolved": "https://registry.npmjs.org/fastify/-/fastify-5.10.0.tgz", + "integrity": "sha512-A9L0ziuWGQHgEEVgF3davQ9vbD93IuX+lo2IsxapQmu5b/Y/ynn9m9K5JHt9dvyJXOFc5iN0Zk5GHEOqnzhWjg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@fastify/ajv-compiler": "^4.0.5", + "@fastify/error": "^4.0.0", + "@fastify/fast-json-stringify-compiler": "^5.0.0", + "@fastify/proxy-addr": "^5.0.0", + "abstract-logging": "^2.0.1", + "avvio": "^9.0.0", + "fast-json-stringify": "^7.0.0", + "find-my-way": "^9.6.0", + "light-my-request": "^6.0.0", + "pino": "^9.14.0 || ^10.1.0", + "process-warning": "^5.0.0", + "rfdc": "^1.3.1", + "secure-json-parse": "^4.0.0", + "semver": "^7.6.0", + "toad-cache": "^3.7.0" + } + }, + "node_modules/fastify-plugin": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/fastify-plugin/-/fastify-plugin-6.0.0.tgz", + "integrity": "sha512-fZOty7z3O7vOliF6d8bHE3wiEh1KcNnKEQensSgTk9C1DvN6nRLS++XVd86v33Hw/8u9Un8A1zDrQ8ujcQDHEg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/fastify/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/fastq": { "version": "1.20.1", "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", - "dev": true, "license": "ISC", "dependencies": { "reusify": "^1.0.4" @@ -9924,6 +10753,20 @@ "node": ">=6" } }, + "node_modules/find-my-way": { + "version": "9.6.0", + "resolved": "https://registry.npmjs.org/find-my-way/-/find-my-way-9.6.0.tgz", + "integrity": "sha512-Zf4Xve4RymLl7NgaavNebZ01joJ8MfVerOG43wy7SHLO+r+K0C6d/SE0BiR7AV5V1VOCFlOP7ecdo+I4qmiHrQ==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-querystring": "^1.0.0", + "safe-regex2": "^5.0.0" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/find-up": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", @@ -10838,7 +11681,6 @@ "integrity": "sha512-md+vXtdCAe60s1k6AU3dUyMJnDxUyQAwfwPKoLisvgUF1IXjtlLsk2se54+qfL9Mdm26bbwvjJybpNx48NKRLw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/html-minifier-terser": "^6.0.0", "html-minifier-terser": "^6.0.2", @@ -11111,7 +11953,6 @@ "version": "1.2.1", "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", - "dev": true, "funding": [ { "type": "github", @@ -11262,7 +12103,6 @@ "version": "2.4.0", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.4.0.tgz", "integrity": "sha512-9VGk3HGanVE6JoZXHiCpnGy5X0jYDnN4EA4lntFPj+1vIWlFhIylq2CrrCOJH9EAhc5CYhq18F2Av2tgoAPsYQ==", - "dev": true, "license": "MIT", "engines": { "node": ">= 10" @@ -11548,6 +12388,7 @@ "integrity": "sha512-ZhMwEosbFJkA0YhFnNDgTM4ZxDRsS6HqTo7qsZM08fehyRYIYa0yHu5R6mgo1n/8MgaPBXiPimPD77baVFYg+A==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12.13" }, @@ -11611,6 +12452,15 @@ "node": ">= 4" } }, + "node_modules/iterare": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/iterare/-/iterare-1.2.1.tgz", + "integrity": "sha512-RKYVTCjAnRthyJes037NX/IiqeidgN1xc3j1RjFfECFp28A1GVwK9nA+i0rJPaHqSZwygLzRnFlzUuHFoWWy+Q==", + "license": "ISC", + "engines": { + "node": ">=6" + } + }, "node_modules/j-component": { "version": "1.4.10", "resolved": "https://registry.npmjs.org/j-component/-/j-component-1.4.10.tgz", @@ -11918,6 +12768,25 @@ "dev": true, "license": "MIT" }, + "node_modules/json-schema-ref-resolver": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/json-schema-ref-resolver/-/json-schema-ref-resolver-3.0.0.tgz", + "integrity": "sha512-hOrZIVL5jyYFjzk7+y7n5JDzGlU8rfWDuYyHwGa2WA8/pcmMHezp2xsVwxrebD/Q9t8Nc5DboieySDpCp4WG4A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "dequal": "^2.0.3" + } + }, "node_modules/json-schema-traverse": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", @@ -12071,6 +12940,7 @@ "integrity": "sha512-o3UxHBPPVY1HtCXx15/z1NlknQiWyafRNbtLEv+6xFaDRI2g2xPKIH43do9dSwt8bGLTsjNSaifa48N3d6odsQ==", "dev": true, "license": "Apache-2.0", + "peer": true, "dependencies": { "copy-anything": "^3.0.5", "parse-node-version": "^1.0.1" @@ -12125,6 +12995,7 @@ "dev": true, "license": "MIT", "optional": true, + "peer": true, "engines": { "node": ">=18" }, @@ -12139,6 +13010,7 @@ "dev": true, "license": "MIT", "optional": true, + "peer": true, "bin": { "mime": "cli.js" }, @@ -12160,6 +13032,12 @@ "node": ">= 0.8.0" } }, + "node_modules/libphonenumber-js": { + "version": "1.13.8", + "resolved": "https://registry.npmjs.org/libphonenumber-js/-/libphonenumber-js-1.13.8.tgz", + "integrity": "sha512-80xal1m93rADejw2pMp2MSzFhHCPLEspjHxnH2UtqI+DgAmElsbmLMiqk9niwH9NWAfjsRtaJI+qBrOEmRx9nQ==", + "license": "MIT" + }, "node_modules/lie": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz", @@ -12169,6 +13047,56 @@ "immediate": "~3.0.5" } }, + "node_modules/light-my-request": { + "version": "6.6.0", + "resolved": "https://registry.npmjs.org/light-my-request/-/light-my-request-6.6.0.tgz", + "integrity": "sha512-CHYbu8RtboSIoVsHZ6Ye4cj4Aw/yg2oAFimlF7mNvfDV192LR7nDiKtSIfCuLT7KokPSTn/9kfVLm5OGN0A28A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause", + "dependencies": { + "cookie": "^1.0.1", + "process-warning": "^4.0.0", + "set-cookie-parser": "^2.6.0" + } + }, + "node_modules/light-my-request/node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/light-my-request/node_modules/process-warning": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-4.0.1.tgz", + "integrity": "sha512-3c2LzQ3rY9d0hc1emcsHhfT9Jwz0cChib/QN89oME2R451w5fy3f0afAhERFZAwrbDU43wk12d0ORBpDVME50Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, "node_modules/lightningcss": { "version": "1.32.0", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", @@ -12469,6 +13397,25 @@ "dev": true, "license": "MIT" }, + "node_modules/load-esm": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/load-esm/-/load-esm-1.0.3.tgz", + "integrity": "sha512-v5xlu8eHD1+6r8EHTg6hfmO97LN8ugKtiXcy5e6oN72iD2r6u0RPfLl6fxM+7Wnh2ZRq15o0russMst44WauPA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + }, + { + "type": "buymeacoffee", + "url": "https://buymeacoffee.com/borewit" + } + ], + "license": "MIT", + "engines": { + "node": ">=13.2.0" + } + }, "node_modules/loader-runner": { "version": "4.3.2", "resolved": "https://registry.npmjs.org/loader-runner/-/loader-runner-4.3.2.tgz", @@ -12868,7 +13815,6 @@ "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -13188,6 +14134,7 @@ "dev": true, "license": "MIT", "optional": true, + "peer": true, "dependencies": { "iconv-lite": "^0.6.3", "sax": "^1.2.4" @@ -13436,6 +14383,15 @@ "dev": true, "license": "MIT" }, + "node_modules/on-exit-leak-free": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz", + "integrity": "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/on-finished": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", @@ -13708,7 +14664,8 @@ "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", "dev": true, - "license": "BlueOak-1.0.0" + "license": "BlueOak-1.0.0", + "peer": true }, "node_modules/package-json/node_modules/@sindresorhus/is": { "version": "0.14.0", @@ -13900,6 +14857,7 @@ "integrity": "sha512-3YHlOa/JgH6Mnpr05jP9eDG254US9ek25LyIxZlDItp2iJtwyaXQb57lBYLdT3MowkUFYEV2XXNAYIPlESvJlA==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">= 0.10" } @@ -14079,7 +15037,6 @@ "resolved": "https://registry.npmjs.org/pg/-/pg-8.22.0.tgz", "integrity": "sha512-8wih1vVIBMxoUM2oB4soJsD9tDnDpLv4OXBJ+EJzFsvycD+lfyIreC2gGHq78f8jbLLt+bvlPTFdFZfJkOuzAA==", "license": "MIT", - "peer": true, "dependencies": { "pg-connection-string": "^2.14.0", "pg-pool": "^3.14.0", @@ -14217,6 +15174,43 @@ "node": ">=0.10.0" } }, + "node_modules/pino": { + "version": "10.3.1", + "resolved": "https://registry.npmjs.org/pino/-/pino-10.3.1.tgz", + "integrity": "sha512-r34yH/GlQpKZbU1BvFFqOjhISRo1MNx1tWYsYvmj6KIRHSPMT2+yHOEb1SG6NMvRoHRF0a07kCOox/9yakl1vg==", + "license": "MIT", + "dependencies": { + "@pinojs/redact": "^0.4.0", + "atomic-sleep": "^1.0.0", + "on-exit-leak-free": "^2.1.0", + "pino-abstract-transport": "^3.0.0", + "pino-std-serializers": "^7.0.0", + "process-warning": "^5.0.0", + "quick-format-unescaped": "^4.0.3", + "real-require": "^0.2.0", + "safe-stable-stringify": "^2.3.1", + "sonic-boom": "^4.0.1", + "thread-stream": "^4.0.0" + }, + "bin": { + "pino": "bin.js" + } + }, + "node_modules/pino-abstract-transport": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-3.0.0.tgz", + "integrity": "sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg==", + "license": "MIT", + "dependencies": { + "split2": "^4.0.0" + } + }, + "node_modules/pino-std-serializers": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/pino-std-serializers/-/pino-std-serializers-7.1.0.tgz", + "integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==", + "license": "MIT" + }, "node_modules/pirates": { "version": "4.0.7", "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", @@ -15197,6 +16191,22 @@ "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", "license": "MIT" }, + "node_modules/process-warning": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.0.0.tgz", + "integrity": "sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, "node_modules/promise-polyfill": { "version": "7.1.2", "resolved": "https://registry.npmjs.org/promise-polyfill/-/promise-polyfill-7.1.2.tgz", @@ -15354,6 +16364,12 @@ ], "license": "MIT" }, + "node_modules/quick-format-unescaped": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz", + "integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==", + "license": "MIT" + }, "node_modules/randombytes": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", @@ -15435,7 +16451,6 @@ "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "loose-envify": "^1.1.0" }, @@ -15487,7 +16502,6 @@ "integrity": "sha512-jCvmsr+1IUSMUyzOkRcvnVbX3ZYC6g9TDrDbFuFmRDq7PD4yaGbLKNQL6k2jnArV8hjYxh7hVhAZB6s9HDGpZA==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -15573,6 +16587,21 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/real-require": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/real-require/-/real-require-0.2.0.tgz", + "integrity": "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==", + "license": "MIT", + "engines": { + "node": ">= 12.13.0" + } + }, + "node_modules/reflect-metadata": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz", + "integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==", + "license": "Apache-2.0" + }, "node_modules/regenerate": { "version": "1.4.2", "resolved": "https://registry.npmjs.org/regenerate/-/regenerate-1.4.2.tgz", @@ -15709,7 +16738,6 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -15840,6 +16868,15 @@ "integrity": "sha512-gSfoiOEA0VPE6Tukkrr7I0RBdE0s7H1eFCDBk05l1KIQT1UIKNc5JZy6jdyW6eYH3aR3g5b3PuL77rq0hvwtAw==", "license": "MIT" }, + "node_modules/ret": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/ret/-/ret-0.5.0.tgz", + "integrity": "sha512-I1XxrZSQ+oErkRR4jYbAyEEu2I0avBvvMM5JN+6EBprOGRCs63ENqZ3vjavq8fBw2+62G5LF5XelKwuJpcvcxw==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, "node_modules/retry": { "version": "0.13.1", "resolved": "https://registry.npmjs.org/retry/-/retry-0.13.1.tgz", @@ -15854,20 +16891,24 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", - "dev": true, "license": "MIT", "engines": { "iojs": ">=1.0.0", "node": ">=0.10.0" } }, + "node_modules/rfdc": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz", + "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==", + "license": "MIT" + }, "node_modules/rollup": { "version": "3.30.0", "resolved": "https://registry.npmjs.org/rollup/-/rollup-3.30.0.tgz", "integrity": "sha512-kQvGasUgN+AlWGliFn2POSajRQEsULVYFGTvOZmK06d7vCD+YhZztt70kGk3qaeAXeWYL5eO7zx+rAubBc55eA==", "dev": true, "license": "MIT", - "peer": true, "bin": { "rollup": "dist/bin/rollup" }, @@ -15931,7 +16972,6 @@ "version": "7.8.2", "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", - "dev": true, "license": "Apache-2.0", "dependencies": { "tslib": "^2.1.0" @@ -15958,6 +16998,37 @@ ], "license": "MIT" }, + "node_modules/safe-regex2": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/safe-regex2/-/safe-regex2-5.1.1.tgz", + "integrity": "sha512-mOSBvHGDZMuIEZMdOz/aCEYDCv0E7nfcNsIhUF+/P+xC7Hyf3FkvymqgPbg9D1EdSGu+uKbJgy09K/RKKc7kJA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "ret": "~0.5.0" + }, + "bin": { + "safe-regex2": "bin/safe-regex2.js" + } + }, + "node_modules/safe-stable-stringify": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz", + "integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, "node_modules/safer-buffer": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", @@ -16199,6 +17270,22 @@ "get-ready": "~1.0.0" } }, + "node_modules/secure-json-parse": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/secure-json-parse/-/secure-json-parse-4.1.0.tgz", + "integrity": "sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, "node_modules/seek-bzip": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/seek-bzip/-/seek-bzip-1.0.6.tgz", @@ -16348,6 +17435,7 @@ "integrity": "sha512-S0xQPhUTefAhNvNWFg0c1J8qJArHt5KdtJ/cFAofo06KD1MVSeFWyl4iiu+ApDIuw0WhjpOfCdgConOfAnLgkw==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=10" }, @@ -16438,6 +17526,12 @@ "node": ">= 0.8.0" } }, + "node_modules/set-cookie-parser": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", + "license": "MIT" + }, "node_modules/set-function-length": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", @@ -16646,6 +17740,15 @@ "seroval-plugins": "~1.5.0" } }, + "node_modules/sonic-boom": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.1.tgz", + "integrity": "sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==", + "license": "MIT", + "dependencies": { + "atomic-sleep": "^1.0.0" + } + }, "node_modules/sort-keys": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/sort-keys/-/sort-keys-2.0.0.tgz", @@ -16968,6 +18071,22 @@ "node": ">=0.8.0" } }, + "node_modules/strtok3": { + "version": "10.3.5", + "resolved": "https://registry.npmjs.org/strtok3/-/strtok3-10.3.5.tgz", + "integrity": "sha512-ki4hZQfh5rX0QDLLkOCj+h+CVNkqmp/CMf8v8kZpkNVK6jGQooMytqzLZYUVYIZcFZ6yDB70EfD8POcFXiF5oA==", + "license": "MIT", + "dependencies": { + "@tokenizer/token": "^0.3.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + } + }, "node_modules/style-loader": { "version": "3.3.4", "resolved": "https://registry.npmjs.org/style-loader/-/style-loader-3.3.4.tgz", @@ -17008,6 +18127,7 @@ "integrity": "sha512-ZIdT8eUv8tegmqy1tTIdJv9We2DumkNZFdCF5mz/Kpq3OcTaxSuCAYZge6HKK2CmNC02G1eJig2RV7XTw5hQrA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@adobe/css-tools": "~4.3.3", "debug": "^4.3.2", @@ -17062,6 +18182,7 @@ "integrity": "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "balanced-match": "^1.0.0" } @@ -17073,6 +18194,7 @@ "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", "dev": true, "license": "ISC", + "peer": true, "dependencies": { "foreground-child": "^3.1.0", "jackspeak": "^3.1.2", @@ -17094,6 +18216,7 @@ "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", "dev": true, "license": "BlueOak-1.0.0", + "peer": true, "dependencies": { "@isaacs/cliui": "^8.0.2" }, @@ -17110,6 +18233,7 @@ "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", "dev": true, "license": "ISC", + "peer": true, "dependencies": { "brace-expansion": "^2.0.2" }, @@ -17126,6 +18250,7 @@ "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", "dev": true, "license": "BlueOak-1.0.0", + "peer": true, "engines": { "node": ">=16 || 14 >=14.17" } @@ -17136,6 +18261,7 @@ "integrity": "sha512-1n3r/tGXO6b6VXMdFT54SHzT9ytu9yr7TaELowdYpMqY/Ao7EnlQGmAQ1+RatX7Tkkdm6hONI2owqNx2aZj5Sw==", "dev": true, "license": "BlueOak-1.0.0", + "peer": true, "engines": { "node": ">=11.0.0" } @@ -17146,6 +18272,7 @@ "integrity": "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==", "dev": true, "license": "BSD-3-Clause", + "peer": true, "engines": { "node": ">= 12" } @@ -17382,6 +18509,15 @@ "dev": true, "license": "CC0-1.0" }, + "node_modules/swagger-ui-dist": { + "version": "5.32.8", + "resolved": "https://registry.npmjs.org/swagger-ui-dist/-/swagger-ui-dist-5.32.8.tgz", + "integrity": "sha512-dgMdWXIgnI4zX4OPhKEdWnlDODbgm8W3AX0Ivn/BBqcUh6xZsBxhZMnvk6DJyRz1BTrj8dPxtarmEGgkz30oyA==", + "license": "Apache-2.0", + "dependencies": { + "@scarf/scarf": "=1.4.0" + } + }, "node_modules/swiper": { "version": "12.1.2", "resolved": "https://registry.npmjs.org/swiper/-/swiper-12.1.2.tgz", @@ -17409,6 +18545,18 @@ "dev": true, "license": "MIT" }, + "node_modules/tagged-tag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/tagged-tag/-/tagged-tag-1.0.0.tgz", + "integrity": "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==", + "license": "MIT", + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/tapable": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz", @@ -17509,7 +18657,6 @@ "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -17620,6 +18767,24 @@ "node": ">=0.8" } }, + "node_modules/thread-stream": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-4.2.0.tgz", + "integrity": "sha512-e2zZ96wSChazBsbENf/Pcm/4swHt2cEKQ92rhUjkL9GCKiTDJIaTBenjE/m9DXi0QBmTMDkFDdOomUy20A1tDQ==", + "license": "MIT", + "dependencies": { + "real-require": "^1.0.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/thread-stream/node_modules/real-require": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/real-require/-/real-require-1.0.0.tgz", + "integrity": "sha512-P4nbQYQfePJxRSmY+v/KINxVucm4NF3p3s7pJveMTtom52FR4YGltUQLB8idDXwDDWW+eYrWDFbuzUnjoWHF7g==", + "license": "MIT" + }, "node_modules/through": { "version": "2.3.8", "resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz", @@ -17707,6 +18872,15 @@ "node": ">=8.0" } }, + "node_modules/toad-cache": { + "version": "3.7.4", + "resolved": "https://registry.npmjs.org/toad-cache/-/toad-cache-3.7.4.tgz", + "integrity": "sha512-m1TdR/rvT7kgGJZhspNtXdsdYk0fddFpJJFlG5s+UkPFo6lkLoZ3YLOaovPYjq1R75NP5JfeTlSHaOsE09peCg==", + "license": "MIT", + "engines": { + "node": ">=20" + } + }, "node_modules/toidentifier": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", @@ -17717,6 +18891,24 @@ "node": ">=0.6" } }, + "node_modules/token-types": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/token-types/-/token-types-6.1.2.tgz", + "integrity": "sha512-dRXchy+C0IgK8WPC6xvCHFRIWYUbqqdEIKPaKo/AcTUNzwLTK6AH7RjdLWsEZcAN/TBdtfUw3PYEgPr5VPr6ww==", + "license": "MIT", + "dependencies": { + "@borewit/text-codec": "^0.2.1", + "@tokenizer/token": "^0.3.0", + "ieee754": "^1.2.1" + }, + "engines": { + "node": ">=14.16" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + } + }, "node_modules/toposort": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/toposort/-/toposort-2.0.2.tgz", @@ -18339,6 +19531,30 @@ "node": ">=0.8.0" } }, + "node_modules/uid": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/uid/-/uid-2.0.2.tgz", + "integrity": "sha512-u3xV3X7uzvi5b1MncmZo3i2Aw222Zk1keqLA1YkHldREkAhAqi65wuPfe7lHx8H/Wzy+8CE7S7uS3jekIM5s8g==", + "license": "MIT", + "dependencies": { + "@lukeed/csprng": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/uint8array-extras": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/uint8array-extras/-/uint8array-extras-1.5.0.tgz", + "integrity": "sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/unbzip2-stream": { "version": "1.4.3", "resolved": "https://registry.npmjs.org/unbzip2-stream/-/unbzip2-stream-1.4.3.tgz", @@ -18697,6 +19913,15 @@ "node": "^14.17.0 || ^16.13.0 || >=18.0.0" } }, + "node_modules/validator": { + "version": "13.15.35", + "resolved": "https://registry.npmjs.org/validator/-/validator-13.15.35.tgz", + "integrity": "sha512-TQ5pAGhd5whStmqWvYF4OjQROlmv9SMFVt37qoCBdqRffuuklWYQlCNnEs2ZaIBD1kZRNnikiZOS1eqgkar0iw==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, "node_modules/vary": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", @@ -18786,7 +20011,6 @@ "integrity": "sha512-rzVwlLeBWHJbmgTC/8TvAcu5vpJNII+MelQpylD4jNERPwpBJOE2lEcko1zJX3QJeLjTTAnQxn/OJ8bjDzVQaw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/eslint-scope": "^3.7.3", "@types/estree": "^1.0.5", @@ -18836,7 +20060,6 @@ "deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.", "dev": true, "license": "MPL-2.0", - "peer": true, "dependencies": { "deepmerge": "^1.5.2", "javascript-stringify": "^2.0.1" @@ -18875,7 +20098,6 @@ "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -18933,7 +20155,6 @@ "integrity": "sha512-0XavAZbNJ5sDrCbkpWL8mia0o5WPOd2YGtxrEiZkBK9FjLppIUK2TgxK6qGD2P3hUXTJNNPVibrerKcx5WkR1g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/bonjour": "^3.5.9", "@types/connect-history-api-fallback": "^1.3.5", @@ -18994,7 +20215,6 @@ "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -19570,6 +20790,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/zod": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, "packages/config": { "name": "@tiku-saas/config", "version": "0.1.0" diff --git a/package.json b/package.json index 97d029d9..0c08a798 100644 --- a/package.json +++ b/package.json @@ -80,6 +80,9 @@ "test:deploy:contract": "node scripts/deploy-contract-test.js && node --import tsx scripts/worker-scheduling-contract-test.js", "test:worker:scheduling": "node --import tsx scripts/worker-scheduling-contract-test.js", "test:api:operations": "npm run build:api && node scripts/api-server-operations-contract-test.js", + "test:nest:migration": "node scripts/nest-migration-contract-test.js", + "test:nest:runtime": "npm run build:api && node scripts/nest-api-runtime-test.js", + "test:openapi:docs": "node scripts/openapi-documentation-contract-test.js", "test:api:cors": "node --import tsx scripts/api-cors-policy-test.js && npm run test:api:operations", "test:auth:foundation": "node --import tsx scripts/auth-context-platform-admin-test.js && node --import tsx scripts/tenant-permission-resolution-test.js && node --import tsx scripts/tenant-resolve-contract-test.js && node scripts/bootstrap-platform-admin-test.js", "test:data-api:security": "node scripts/data-api-security-contract-test.js", diff --git a/scripts/nest-api-runtime-test.js b/scripts/nest-api-runtime-test.js new file mode 100644 index 00000000..bcc92c27 --- /dev/null +++ b/scripts/nest-api-runtime-test.js @@ -0,0 +1,89 @@ +import assert from 'node:assert/strict'; +import net from 'node:net'; +import { spawn } from 'node:child_process'; + +function freePort() { + return new Promise((resolve, reject) => { + const server = net.createServer(); + server.once('error', reject); + server.listen(0, '127.0.0.1', () => { + const address = server.address(); + const port = typeof address === 'object' && address ? address.port : 0; + server.close(error => error ? reject(error) : resolve(port)); + }); + }); +} + +async function startApi(nodeEnv) { + const port = await freePort(); + const child = spawn(process.execPath, ['apps/api/dist/apps/api/src/server.js'], { + cwd: process.cwd(), + env: { + ...process.env, + NODE_ENV: nodeEnv, + PORT: String(port), + CORS_ORIGIN: '*', + CORS_TENANT_DOMAINS_ENABLED: 'false', + AUTH_CODE_PEPPER: 'runtime-test-auth-code-pepper-0123456789', + AUTH_SESSION_SECRET: 'runtime-test-auth-session-secret-0123456789', + AUTH_JWT_SECRET: 'runtime-test-auth-jwt-secret-0123456789', + PLATFORM_ADMIN_API_KEY: 'runtime-test-platform-key-0123456789', + }, + stdio: ['ignore', 'pipe', 'pipe'], + }); + let output = ''; + child.stdout.on('data', chunk => { output += chunk.toString(); }); + child.stderr.on('data', chunk => { output += chunk.toString(); }); + const startedAt = Date.now(); + while (!output.includes('"event":"server_listening"')) { + if (child.exitCode !== null) throw new Error(`API exited during startup: ${output}`); + if (Date.now() - startedAt > 10_000) throw new Error(`API startup timed out: ${output}`); + await new Promise(resolve => setTimeout(resolve, 25)); + } + return { child, port, output: () => output }; +} + +async function stopApi(api) { + api.child.kill('SIGTERM'); + await new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error(`API shutdown timed out: ${api.output()}`)), 8_000); + api.child.once('exit', code => { + clearTimeout(timer); + code === 0 ? resolve() : reject(new Error(`API shutdown failed code=${code}: ${api.output()}`)); + }); + }); +} + +const development = await startApi('development'); +try { + const docs = await fetch(`http://127.0.0.1:${development.port}/docs`); + assert.equal(docs.status, 200); + assert.match(await docs.text(), /scalar/i); + + const openapiResponse = await fetch(`http://127.0.0.1:${development.port}/openapi.json`); + assert.equal(openapiResponse.status, 200); + const openapi = await openapiResponse.json(); + assert.ok(openapi.paths['/api/auth/sms/send']?.post, 'native auth route must be documented'); + assert.equal(openapi.paths['/api/catalog/regions']?.get?.['x-migration-status'], 'legacy'); + + const invalidNative = await fetch(`http://127.0.0.1:${development.port}/api/auth/sms/send`, { + method: 'POST', + headers: { 'content-type': 'application/json', 'x-tenant-id': 'not-a-uuid' }, + body: JSON.stringify({ phone: 123 }), + }); + assert.equal(invalidNative.status, 400); + assert.equal((await invalidNative.json()).code, 'VALIDATION_ERROR'); + + const legacyPost = await fetch(`http://127.0.0.1:${development.port}/api/commerce/orders`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({}), + }); + assert.notEqual(legacyPost.status, 404, 'legacy POST route must remain registered'); + const legacyBody = await legacyPost.json(); + assert.ok(legacyBody.meta?.requestId, 'legacy response must include requestId metadata'); +} finally { + await stopApi(development); +} + +console.log('[PASS] NestJS API docs, DTO validation and legacy bridge runtime'); diff --git a/scripts/nest-migration-contract-test.js b/scripts/nest-migration-contract-test.js new file mode 100644 index 00000000..02467209 --- /dev/null +++ b/scripts/nest-migration-contract-test.js @@ -0,0 +1,28 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; + +const read = file => fs.readFileSync(file, 'utf8').replace(/\r\n/g, '\n'); +const legacyBridge = read('apps/api/src/nest/legacy-bridge.ts'); +const server = read('apps/api/src/server.ts'); +const worker = read('apps/worker/src/index.ts'); +const workerModule = read('apps/worker/src/worker.module.ts'); + +const nativeKeys = [...legacyBridge.matchAll(/'(GET|POST|PUT|PATCH|DELETE) ([^']+)'/g)] + .map(match => `${match[1]} ${match[2]}`); +assert.equal(new Set(nativeKeys).size, 50, 'the first NestJS batch must own exactly 50 unique routes'); +assert.equal(nativeKeys.length, 50, 'native route declarations must not contain duplicates'); +assert.match(legacyBridge, /definitions\.filter\(\(\[method, path\]\) => !NATIVE_ROUTE_KEYS\.has/); +assert.match(legacyBridge, /\/api\/questions\/:questionId\/videos/); + +assert.match(server, /NestFactory\.create<.*NestFastifyApplication>/); +assert.match(server, /registerLegacyRoutes/); +assert.match(server, /if \(!config\.isProduction\) registerOpenApi/); +assert.match(server, /ApiEnvelopeInterceptor/); +assert.match(server, /ApiExceptionFilter/); + +assert.match(worker, /NestFactory\.createApplicationContext\(WorkerModule/); +assert.match(worker, /app\.get\(WorkerRunner\)/); +assert.match(workerModule, /constructor\(private readonly jobs: WorkerJobRegistry, private readonly intervals: WorkerPollIntervals\)/); +assert.match(workerModule, /constructor\(private readonly closers: WorkerCloserRegistry\)/); + +console.log('[PASS] NestJS first-batch routes, legacy bridge, docs guard and worker DI contract'); diff --git a/scripts/openapi-documentation-contract-test.js b/scripts/openapi-documentation-contract-test.js new file mode 100644 index 00000000..e020b74a --- /dev/null +++ b/scripts/openapi-documentation-contract-test.js @@ -0,0 +1,39 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; + +const document = JSON.parse(fs.readFileSync(process.argv[2] || '/tmp/tiku-openapi.json', 'utf8')); +const schemas = document.components?.schemas || {}; +const operations = []; + +for (const [path, pathItem] of Object.entries(document.paths || {})) { + for (const [method, operation] of Object.entries(pathItem)) { + if (!['get', 'post', 'put', 'patch', 'delete'].includes(method)) continue; + if (operation['x-migration-status'] === 'legacy') continue; + operations.push({ path, method, operation }); + } +} + +assert.equal(operations.length, 50, '首批原生 Nest 接口数量应为 50'); +for (const { path, method, operation } of operations) { + assert.match(operation.summary || '', /[\u4e00-\u9fff]/, `${method.toUpperCase()} ${path} 缺少中文摘要`); + assert.ok(operation.responses?.['200']?.content?.['application/json']?.schema, `${method.toUpperCase()} ${path} 缺少 200 响应 schema`); +} + +const bodyless = new Set([ + 'POST /api/auth/logout', + 'POST /api/profile/check-in', +]); +for (const { path, method, operation } of operations.filter(item => ['post', 'put', 'patch'].includes(item.method))) { + const key = `${method.toUpperCase()} ${path}`; + if (bodyless.has(key)) continue; + const bodySchema = operation.requestBody?.content?.['application/json']?.schema; + assert.ok(bodySchema, `${key} 缺少 requestBody schema`); + const ref = bodySchema.$ref?.split('/').pop(); + const resolved = ref ? schemas[ref] : bodySchema; + assert.ok(resolved?.properties && Object.keys(resolved.properties).length > 0, `${key} 的 DTO 没有可见字段`); + for (const [field, property] of Object.entries(resolved.properties)) { + assert.ok(property.description, `${key} 的字段 ${field} 缺少中文说明`); + } +} + +console.log('[PASS] 50 个 Nest 接口均有中文摘要、请求 DTO 字段和响应 schema'); diff --git a/scripts/taro-api-compatibility-contract-test.js b/scripts/taro-api-compatibility-contract-test.js index 2608d03c..a8f644e4 100644 --- a/scripts/taro-api-compatibility-contract-test.js +++ b/scripts/taro-api-compatibility-contract-test.js @@ -5,6 +5,8 @@ const read = file => fs.readFileSync(file, 'utf8').replace(/\r\n/g, '\n'); const httpSource = read('apps/api/src/core/http.ts'); const serverSource = read('apps/api/src/server.ts'); +const envelopeInterceptorSource = read('apps/api/src/nest/api.interceptor.ts'); +const exceptionFilterSource = read('apps/api/src/nest/api-exception.filter.ts'); const apiSource = read('apps/taro/src/services/api.ts'); const typesSource = read('apps/taro/src/types.ts'); const tenantLocatorSource = read('apps/api/src/features/tenant/locator.ts'); @@ -13,8 +15,10 @@ const studentRouteSource = read('apps/api/src/features/tenant-admin/classes.ts') const studentCursorSource = read('apps/api/src/features/tenant-admin/student-cursor.ts'); assert.match(httpSource, /meta:\s*\{ \.\.\.existingMeta, requestId \}/, 'API responses must expose requestId without discarding endpoint metadata'); -assert.match(serverSource, /sendJson\(res, 200, withResponseMeta\(result, requestId\)\)/, 'Successful API responses must carry requestId metadata'); -assert.match(serverSource, /withResponseMeta\(\{ \.\.\.body, requestId \}, requestId\)/, 'Error API responses must carry the same requestId in the legacy field and metadata envelope'); +assert.match(serverSource, /useGlobalInterceptors\(new ApiEnvelopeInterceptor\(\)\)/, 'Successful responses must use the global API envelope interceptor'); +assert.match(envelopeInterceptorSource, /withResponseMeta\(body, String\(reply\.getHeader\('x-request-id'\)/, 'Successful API responses must carry requestId metadata'); +assert.match(serverSource, /useGlobalFilters\(new ApiExceptionFilter\(\)\)/, 'Errors must use the global API exception filter'); +assert.match(exceptionFilterSource, /withResponseMeta\(\{ error: message, code, requestId \}, requestId\)/, 'Error API responses must carry the same requestId in the legacy field and metadata envelope'); assert.match(apiSource, /responseHeaderValue\(response\.header, 'x-request-id'\)/, 'The Taro API client must fall back to the response header requestId'); assert.match(apiSource, /this\.requestId = payload\.requestId/, 'ApiError must retain requestId for support and observability'); assert.doesNotMatch(typesSource, /\[key:\s*string\]:\s*unknown/, 'The shared API envelope must not silently accept arbitrary response fields');