diff --git a/.env.example b/.env.example index 6f3f9f70..8677dc14 100644 --- a/.env.example +++ b/.env.example @@ -8,6 +8,7 @@ CORS_METHODS=HEAD,GET,POST,PUT,PATCH,DELETE CORS_ALLOWED_HEADERS=Content-Type,Authorization,X-Requested-With STORAGE_PATH=./uploads +EVIDENCIA_MAX_FILE_SIZE_MB=200 RECAPTCHA_SECRET_KEY=6LcYYYYYYYYYYYYYY diff --git a/.gitignore b/.gitignore index 265da821..31ac6bd9 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,10 @@ dist/ coverage/ node_modules/ +# Yarn Berry local state — este projeto usa Yarn Classic (yarn lockfile v1) +.yarnrc.yml +.yarn/ + uploads/ mysql/data/ postgresql/ diff --git a/.vscode/settings.json b/.vscode/settings.json index a547491a..eb55bbcb 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -18,5 +18,6 @@ "liveServer.settings.port": 5501, "[javascript]": { "editor.tabSize": 4 - } + }, + "js/ts.tsdk.path": "./node_modules/typescript/lib" } diff --git a/compose.integration.yml b/compose.integration.yml index ebffc49e..732e8c22 100644 --- a/compose.integration.yml +++ b/compose.integration.yml @@ -1,3 +1,5 @@ +name: hcf-api-test + services: postgres: image: postgis/postgis:18-3.6 diff --git a/package.json b/package.json index d893439c..87d7d179 100644 --- a/package.json +++ b/package.json @@ -72,6 +72,7 @@ "@types/express": "5.0.5", "@types/jsonwebtoken": "9.0.10", "@types/morgan": "1.9.10", + "@types/multer": "^2.2.0", "@types/pg": "^8.16.0", "@types/react": "19.2.2", "@types/react-dom": "19.2.2", diff --git a/src/application/create-app.ts b/src/application/create-app.ts index 9f45731c..96dd93ac 100644 --- a/src/application/create-app.ts +++ b/src/application/create-app.ts @@ -13,7 +13,11 @@ import { assets, upload } from '../config/directory' import legacyErrors from '../middlewares/erros-middleware' import { generatePreview, reportPreview } from '../reports/controller' import { routes as createEstadoRoutes } from './estado' +import { routes as createEventoRoutes } from './evento' +import { routes as createEvidenciaRoutes } from './evidencia' +import { routes as createExpedicoesRoutes } from './expedicao' import { routes as createFaseSucessionalRoutes } from './fase-sucessional' +import { routes as createLembreteRoutes } from './lembrete' import { routes as createPaisRoutes } from './pais' import { assertCookieSafeOrigins } from './parseCorsOrigins' import { routes as createRelevoRoutes } from './relevo' @@ -62,10 +66,14 @@ export function createApp({ const routes: Route[] = [ ...createPaisRoutes(knex), ...createEstadoRoutes(knex), + ...createEventoRoutes(knex), ...createSoloRoutes(knex), ...createRelevoRoutes(knex), ...createFaseSucessionalRoutes(knex), ...createVegetacaoRoutes(knex), + ...createEvidenciaRoutes(knex), + ...createExpedicoesRoutes(knex), + ...createLembreteRoutes(knex), ...createUsuarioSessaoRoutes(knex) ] const origins = assertCookieSafeOrigins(cors.origins) @@ -122,7 +130,7 @@ export function createApp({ for (const route of routes) { const sanitizedPath = `/api/${route.path}`.replaceAll(/\/{2,}/g, '/').replaceAll(/\/$/g, '') - application.endpoint(route.method, sanitizedPath, ...route.handlers) + application.endpoint(route.method, sanitizedPath, route.handlers, route.middlewares) } if (legacyRouter) { diff --git a/src/application/evento/AtualizarEventoController.ts b/src/application/evento/AtualizarEventoController.ts new file mode 100644 index 00000000..edf6838d --- /dev/null +++ b/src/application/evento/AtualizarEventoController.ts @@ -0,0 +1,153 @@ +import { AtualizarEventoUseCase, Input as AtualizarEventoInput } from '@/domain/evento/AtualizarEventoUseCase' +import { EVENTO_TIPOS, EventoTipo } from '@/domain/evento/Evento' +import { CheckViolationError } from '@/infrastructure/error/CheckViolationError' +import { ForeignKeyViolationError } from '@/infrastructure/error/ForeignKeyViolationError' +import { InfrastructureError } from '@/infrastructure/error/InfrastructureError' +import { + HttpRequest, HttpResponse, StatusCode +} from '@/library/http/common' +import { BadRequestError } from '@/library/http/error/BadRequestError' +import { HttpError } from '@/library/http/error/HttpError' +import { InternalServerError } from '@/library/http/error/InternalServerError' +import { NotFoundError } from '@/library/http/error/NotFoundError' +import { UnprocessableEntityError } from '@/library/http/error/UnprocessableEntityError' +import { NextHandler, RequestHandler } from '@/library/http/Server' + +import { parseColeta } from './coleta-parsing' + +interface Dependencies { + atualizarEventoUseCase: AtualizarEventoUseCase +} + +interface Body { + tipo?: unknown + capturado_em?: unknown + latitude?: unknown + longitude?: unknown + altitude?: unknown + observacoes?: unknown + coleta?: unknown +} + +export class AtualizarEventoController implements RequestHandler { + private readonly atualizarEventoUseCase: AtualizarEventoUseCase + + constructor(dependencies: Dependencies) { + this.atualizarEventoUseCase = dependencies.atualizarEventoUseCase + } + + async handle(request: HttpRequest, _next: NextHandler): Promise { + const { eventoId: rawEventoId } = request.params as { eventoId?: string } + const eventoId = parseId(rawEventoId, 'eventoId') + if (eventoId instanceof Error) return new BadRequestError({ message: eventoId.message }) + + const body = (request.body ?? {}) as Body + + // Substituir por request.usuario.id assim que a + // autenticação for integrada. + const input: AtualizarEventoInput = { id: eventoId, updated_by: null } + + if (body.tipo !== undefined) { + const tipo = parseTipo(body.tipo) + if (tipo instanceof Error) return new BadRequestError({ message: tipo.message }) + input.tipo = tipo + } + + if (body.capturado_em !== undefined) { + const capturadoEm = parseDate(body.capturado_em, 'capturado_em') + if (capturadoEm instanceof Error) return new BadRequestError({ message: capturadoEm.message }) + input.capturado_em = capturadoEm + } + + if (body.latitude !== undefined) { + const latitude = parseOptionalNumber(body.latitude, 'latitude') + if (latitude instanceof Error) return new BadRequestError({ message: latitude.message }) + input.latitude = latitude + } + + if (body.longitude !== undefined) { + const longitude = parseOptionalNumber(body.longitude, 'longitude') + if (longitude instanceof Error) return new BadRequestError({ message: longitude.message }) + input.longitude = longitude + } + + if (body.altitude !== undefined) { + const altitude = parseOptionalNumber(body.altitude, 'altitude') + if (altitude instanceof Error) return new BadRequestError({ message: altitude.message }) + input.altitude = altitude + } + + if (body.observacoes !== undefined) { + const observacoes = parseOptionalString(body.observacoes, 'observacoes') + if (observacoes instanceof Error) return new BadRequestError({ message: observacoes.message }) + input.observacoes = observacoes + } + + if (body.coleta !== undefined) { + const coleta = body.coleta === null ? null : parseColeta(body.coleta) + if (coleta instanceof Error) return new BadRequestError({ message: coleta.message }) + input.coleta = coleta + } + + const result = await this.atualizarEventoUseCase.execute(input) + + if (result.left()) { + const error = result.value + if (error instanceof ForeignKeyViolationError) return new NotFoundError({ message: error.message }) + if (error instanceof CheckViolationError) return new UnprocessableEntityError({ message: error.message }) + if (!(error instanceof InfrastructureError)) return new BadRequestError({ message: error.message }) + return new InternalServerError({ message: error.message }) + } + + if (!result.value) { + return new NotFoundError({ message: 'Evento não encontrado' }) + } + + return { body: result.value, statusCode: StatusCode.Ok } + } +} + +function parseId(raw: unknown, field: string): number | Error { + if (typeof raw !== 'string' || !/^\d+$/.test(raw)) { + return new Error(`${field} inválido`) + } + return Number(raw) +} + +function parseTipo(raw: unknown): EventoTipo | Error { + if (typeof raw !== 'string' || !EVENTO_TIPOS.includes(raw as EventoTipo)) { + return new Error(`tipo inválido. Use um de: ${EVENTO_TIPOS.join(', ')}`) + } + return raw as EventoTipo +} + +function parseDate(raw: unknown, field: string): Date | Error { + if (typeof raw !== 'string') { + return new Error(`${field} inválido. Use uma data no formato ISO 8601`) + } + const date = new Date(raw) + if (Number.isNaN(date.getTime())) { + return new Error(`${field} inválido. Use uma data no formato ISO 8601`) + } + return date +} + +function parseOptionalNumber(raw: unknown, field: string): number | null | Error { + if (raw === undefined || raw === null) { + return null + } + if (typeof raw !== 'number' || Number.isNaN(raw)) { + return new Error(`${field} inválido`) + } + return raw +} + +function parseOptionalString(raw: unknown, field: string): string | null | Error { + if (raw === undefined || raw === null) { + return null + } + if (typeof raw !== 'string') { + return new Error(`${field} inválido`) + } + return raw +} diff --git a/src/application/evento/BuscarEventoController.ts b/src/application/evento/BuscarEventoController.ts new file mode 100644 index 00000000..5fdc83d4 --- /dev/null +++ b/src/application/evento/BuscarEventoController.ts @@ -0,0 +1,51 @@ +import { BuscarEventoPorIdUseCase } from '@/domain/evento/BuscarEventoPorIdUseCase' +import { + HttpRequest, HttpResponse, StatusCode +} from '@/library/http/common' +import { BadRequestError } from '@/library/http/error/BadRequestError' +import { HttpError } from '@/library/http/error/HttpError' +import { InternalServerError } from '@/library/http/error/InternalServerError' +import { NotFoundError } from '@/library/http/error/NotFoundError' +import { NextHandler, RequestHandler } from '@/library/http/Server' + +interface Dependencies { + buscarEventoPorIdUseCase: BuscarEventoPorIdUseCase +} + +export class BuscarEventoController implements RequestHandler { + private readonly buscarEventoPorIdUseCase: BuscarEventoPorIdUseCase + + constructor(dependencies: Dependencies) { + this.buscarEventoPorIdUseCase = dependencies.buscarEventoPorIdUseCase + } + + async handle(request: HttpRequest, _next: NextHandler): Promise { + const { eventoId } = request.params as { eventoId?: string } + + if ( + eventoId === undefined + || eventoId === null + || eventoId === '' + || !/^\d+$/.test(eventoId) + ) { + return new BadRequestError({ message: 'eventoId inválido' }) + } + + const result = await this.buscarEventoPorIdUseCase.execute({ + id: Number(eventoId) + }) + + if (result.left()) { + return new InternalServerError({ message: result.value.message }) + } + + if (!result.value) { + return new NotFoundError({ message: 'Evento não encontrado' }) + } + + return { + statusCode: StatusCode.Ok, + body: result.value + } + } +} diff --git a/src/application/evento/CriarEventoController.ts b/src/application/evento/CriarEventoController.ts new file mode 100644 index 00000000..6fb0276c --- /dev/null +++ b/src/application/evento/CriarEventoController.ts @@ -0,0 +1,138 @@ +import { CriarEventoUseCase } from '@/domain/evento/CriarEventoUseCase' +import { EVENTO_TIPOS, EventoTipo } from '@/domain/evento/Evento' +import { CheckViolationError } from '@/infrastructure/error/CheckViolationError' +import { ForeignKeyViolationError } from '@/infrastructure/error/ForeignKeyViolationError' +import { InfrastructureError } from '@/infrastructure/error/InfrastructureError' +import { + HttpRequest, HttpResponse, StatusCode +} from '@/library/http/common' +import { BadRequestError } from '@/library/http/error/BadRequestError' +import { HttpError } from '@/library/http/error/HttpError' +import { InternalServerError } from '@/library/http/error/InternalServerError' +import { NotFoundError } from '@/library/http/error/NotFoundError' +import { UnprocessableEntityError } from '@/library/http/error/UnprocessableEntityError' +import { NextHandler, RequestHandler } from '@/library/http/Server' + +import { parseColeta } from './coleta-parsing' + +interface Dependencies { + criarEventoUseCase: CriarEventoUseCase +} + +interface Body { + tipo?: unknown + capturado_em?: unknown + latitude?: unknown + longitude?: unknown + altitude?: unknown + observacoes?: unknown + coleta?: unknown +} + +export class CriarEventoController implements RequestHandler { + private readonly criarEventoUseCase: CriarEventoUseCase + + constructor(dependencies: Dependencies) { + this.criarEventoUseCase = dependencies.criarEventoUseCase + } + + async handle(request: HttpRequest, _next: NextHandler): Promise { + const { expedicaoId: rawExpedicaoId } = request.params as { expedicaoId?: string } + const expedicaoId = parseId(rawExpedicaoId, 'expedicaoId') + if (expedicaoId instanceof Error) return new BadRequestError({ message: expedicaoId.message }) + + const body = (request.body ?? {}) as Body + + const tipo = parseTipo(body.tipo) + if (tipo instanceof Error) return new BadRequestError({ message: tipo.message }) + + const capturadoEm = parseDate(body.capturado_em, 'capturado_em') + if (capturadoEm instanceof Error) return new BadRequestError({ message: capturadoEm.message }) + + const latitude = parseOptionalNumber(body.latitude, 'latitude') + if (latitude instanceof Error) return new BadRequestError({ message: latitude.message }) + + const longitude = parseOptionalNumber(body.longitude, 'longitude') + if (longitude instanceof Error) return new BadRequestError({ message: longitude.message }) + + const altitude = parseOptionalNumber(body.altitude, 'altitude') + if (altitude instanceof Error) return new BadRequestError({ message: altitude.message }) + + const observacoes = parseOptionalString(body.observacoes, 'observacoes') + if (observacoes instanceof Error) return new BadRequestError({ message: observacoes.message }) + + const coleta = body.coleta === undefined ? null : parseColeta(body.coleta) + if (coleta instanceof Error) return new BadRequestError({ message: coleta.message }) + + // Substituir por request.usuario.id assim que a + // autenticação for integrada. + const usuarioId = null + + const result = await this.criarEventoUseCase.execute({ + altitude, + capturado_em: capturadoEm, + coleta, + created_by: usuarioId, + expedicao_id: expedicaoId, + latitude, + longitude, + observacoes, + tipo + }) + + if (result.left()) { + const error = result.value + if (error instanceof ForeignKeyViolationError) return new NotFoundError({ message: error.message }) + if (error instanceof CheckViolationError) return new UnprocessableEntityError({ message: error.message }) + if (!(error instanceof InfrastructureError)) return new BadRequestError({ message: error.message }) + return new InternalServerError({ message: error.message }) + } + + return { body: result.value, statusCode: StatusCode.Created } + } +} + +function parseId(raw: unknown, field: string): number | Error { + if (typeof raw !== 'string' || !/^\d+$/.test(raw)) { + return new Error(`${field} inválido`) + } + return Number(raw) +} + +function parseTipo(raw: unknown): EventoTipo | Error { + if (typeof raw !== 'string' || !EVENTO_TIPOS.includes(raw as EventoTipo)) { + return new Error(`tipo inválido. Use um de: ${EVENTO_TIPOS.join(', ')}`) + } + return raw as EventoTipo +} + +function parseDate(raw: unknown, field: string): Date | Error { + if (typeof raw !== 'string') { + return new Error(`${field} inválido. Use uma data no formato ISO 8601`) + } + const date = new Date(raw) + if (Number.isNaN(date.getTime())) { + return new Error(`${field} inválido. Use uma data no formato ISO 8601`) + } + return date +} + +function parseOptionalNumber(raw: unknown, field: string): number | null | Error { + if (raw === undefined || raw === null) { + return null + } + if (typeof raw !== 'number' || Number.isNaN(raw)) { + return new Error(`${field} inválido`) + } + return raw +} + +function parseOptionalString(raw: unknown, field: string): string | null | Error { + if (raw === undefined || raw === null) { + return null + } + if (typeof raw !== 'string') { + return new Error(`${field} inválido`) + } + return raw +} diff --git a/src/application/evento/ListaEventosController.ts b/src/application/evento/ListaEventosController.ts new file mode 100644 index 00000000..af1f8535 --- /dev/null +++ b/src/application/evento/ListaEventosController.ts @@ -0,0 +1,72 @@ +import { EventoTipo } from '@/domain/evento/Evento' +import { ListaEventosUseCase } from '@/domain/evento/ListaEventosUseCase' +import { + HttpRequest, HttpResponse, StatusCode +} from '@/library/http/common' +import { BadRequestError } from '@/library/http/error/BadRequestError' +import { HttpError } from '@/library/http/error/HttpError' +import { InternalServerError } from '@/library/http/error/InternalServerError' +import { NextHandler, RequestHandler } from '@/library/http/Server' + +interface Dependencies { + listaEventosUseCase: ListaEventosUseCase +} + +export class ListaEventosController implements RequestHandler { + private readonly listaEventosUseCase: ListaEventosUseCase + + constructor(dependencies: Dependencies) { + this.listaEventosUseCase = dependencies.listaEventosUseCase + } + + async handle(request: HttpRequest, _next: NextHandler): Promise { + const { + expedicaoId, + tipo, + capturado_de, + capturado_ate, + limite, + pagina + } = request.params as { + expedicaoId?: string + tipo?: string + capturado_de?: string + capturado_ate?: string + limite?: string + pagina?: string + } + + if ( + expedicaoId === undefined + || expedicaoId === null + || expedicaoId === '' + || !/^\d+$/.test(expedicaoId) + ) { + return new BadRequestError({ message: 'expedicaoId inválido' }) + } + + const filters = { + expedicao_id: Number(expedicaoId), + ...(limite && !Number.isNaN(Number(limite)) + ? { limite: Number(limite) } + : {}), + ...(pagina && !Number.isNaN(Number(pagina)) + ? { pagina: Number(pagina) } + : {}), + ...(tipo ? { tipo: tipo as EventoTipo } : {}), + ...(capturado_de ? { capturado_de: new Date(capturado_de) } : {}), + ...(capturado_ate ? { capturado_ate: new Date(capturado_ate) } : {}) + } + + const result = await this.listaEventosUseCase.execute(filters) + + if (result.left()) { + return new InternalServerError({ message: result.value.message }) + } + + return { + statusCode: StatusCode.Ok, + body: result.value + } + } +} diff --git a/src/application/evento/RemoverEventoController.ts b/src/application/evento/RemoverEventoController.ts new file mode 100644 index 00000000..f2bb5a70 --- /dev/null +++ b/src/application/evento/RemoverEventoController.ts @@ -0,0 +1,51 @@ +import { RemoverEventoUseCase } from '@/domain/evento/RemoverEventoUseCase' +import { CheckViolationError } from '@/infrastructure/error/CheckViolationError' +import { ForeignKeyViolationError } from '@/infrastructure/error/ForeignKeyViolationError' +import { + HttpRequest, HttpResponse, StatusCode +} from '@/library/http/common' +import { BadRequestError } from '@/library/http/error/BadRequestError' +import { HttpError } from '@/library/http/error/HttpError' +import { InternalServerError } from '@/library/http/error/InternalServerError' +import { NotFoundError } from '@/library/http/error/NotFoundError' +import { UnprocessableEntityError } from '@/library/http/error/UnprocessableEntityError' +import { NextHandler, RequestHandler } from '@/library/http/Server' + +interface Dependencies { + removerEventoUseCase: RemoverEventoUseCase +} + +export class RemoverEventoController implements RequestHandler { + private readonly removerEventoUseCase: RemoverEventoUseCase + + constructor(dependencies: Dependencies) { + this.removerEventoUseCase = dependencies.removerEventoUseCase + } + + async handle(request: HttpRequest, _next: NextHandler): Promise { + const { eventoId: rawEventoId } = request.params as { eventoId?: string } + const eventoId = parseId(rawEventoId, 'eventoId') + if (eventoId instanceof Error) return new BadRequestError({ message: eventoId.message }) + + const result = await this.removerEventoUseCase.execute({ id: eventoId }) + + if (result.left()) { + const error = result.value + if (error instanceof ForeignKeyViolationError) return new NotFoundError({ message: error.message }) + if (error instanceof CheckViolationError) return new UnprocessableEntityError({ message: error.message }) + return new InternalServerError({ message: error.message }) + } + if (!result.value) { + return new NotFoundError({ message: 'Evento não encontrado' }) + } + + return { statusCode: StatusCode.NoContent } + } +} + +function parseId(raw: unknown, field: string): number | Error { + if (typeof raw !== 'string' || !/^\d+$/.test(raw)) { + return new Error(`${field} inválido`) + } + return Number(raw) +} diff --git a/src/application/evento/coleta-parsing.ts b/src/application/evento/coleta-parsing.ts new file mode 100644 index 00000000..92c09ca0 --- /dev/null +++ b/src/application/evento/coleta-parsing.ts @@ -0,0 +1,25 @@ +import { COLETA_FIELDS, ColetaAttributes } from '@/domain/evento/Evento' + +function isPlainObject(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +export function parseColeta(raw: unknown): ColetaAttributes | Error { + if (!isPlainObject(raw)) { + return new Error('coleta inválido. Envie um objeto com os campos da ficha') + } + + const result = {} as Record + for (const campo of COLETA_FIELDS) { + const value = raw[campo] + if (value === undefined) { + result[campo] = null + continue + } + if (value !== null && typeof value !== 'string') { + return new Error(`coleta.${campo} inválido`) + } + result[campo] = value + } + return result as unknown as ColetaAttributes +} diff --git a/src/application/evento/index.ts b/src/application/evento/index.ts new file mode 100644 index 00000000..ddb47fd8 --- /dev/null +++ b/src/application/evento/index.ts @@ -0,0 +1,72 @@ +import { type Knex } from 'knex' + +import { AtualizarEventoUseCase } from '@/domain/evento/AtualizarEventoUseCase' +import { BuscarEventoPorIdUseCase } from '@/domain/evento/BuscarEventoPorIdUseCase' +import { CriarEventoUseCase } from '@/domain/evento/CriarEventoUseCase' +import { ListaEventosUseCase } from '@/domain/evento/ListaEventosUseCase' +import { RemoverEventoUseCase } from '@/domain/evento/RemoverEventoUseCase' +import { EventoCollectionKnexAdapter } from '@/infrastructure/EventoCollectionKnexAdapter' +import { EvidenciaCollectionKnexAdapter } from '@/infrastructure/EvidenciaCollectionKnexAdapter' +import { Method } from '@/library/http/common' +import { Route } from '@/library/http/Router' + +import { AtualizarEventoController } from './AtualizarEventoController' +import { BuscarEventoController } from './BuscarEventoController' +import { CriarEventoController } from './CriarEventoController' +import { ListaEventosController } from './ListaEventosController' +import { RemoverEventoController } from './RemoverEventoController' + +export function routes(knex: Knex): Route[] { + const eventoCollection = new EventoCollectionKnexAdapter({ knex }) + const evidenciaCollection = new EvidenciaCollectionKnexAdapter({ knex }) + + // TODO: adicionar autenticação quando a camada HTTP de autenticação estiver disponível. + + return [ + { + handlers: [ + new ListaEventosController({ + listaEventosUseCase: new ListaEventosUseCase({ eventoCollection }) + }) + ], + method: Method.Get, + path: '/v2/expedicoes/:expedicaoId/eventos' + }, + { + handlers: [ + new CriarEventoController({ + criarEventoUseCase: new CriarEventoUseCase({ eventoCollection }) + }) + ], + method: Method.Post, + path: '/v2/expedicoes/:expedicaoId/eventos' + }, + { + handlers: [ + new BuscarEventoController({ + buscarEventoPorIdUseCase: new BuscarEventoPorIdUseCase({ eventoCollection }) + }) + ], + method: Method.Get, + path: '/v2/eventos/:eventoId' + }, + { + handlers: [ + new AtualizarEventoController({ + atualizarEventoUseCase: new AtualizarEventoUseCase({ eventoCollection }) + }) + ], + method: Method.Put, + path: '/v2/eventos/:eventoId' + }, + { + handlers: [ + new RemoverEventoController({ + removerEventoUseCase: new RemoverEventoUseCase({ eventoCollection, evidenciaCollection }) + }) + ], + method: Method.Delete, + path: '/v2/eventos/:eventoId' + } + ] +} diff --git a/src/application/evidencia/CriarEvidenciaController.ts b/src/application/evidencia/CriarEvidenciaController.ts new file mode 100644 index 00000000..20a1d262 --- /dev/null +++ b/src/application/evidencia/CriarEvidenciaController.ts @@ -0,0 +1,112 @@ +import { unlink } from 'node:fs/promises' + +import { CriarEvidenciaUseCase } from '@/domain/evidencia/CriarEvidenciaUseCase' +import { ForeignKeyViolationError } from '@/infrastructure/error/ForeignKeyViolationError' +import { InfrastructureError } from '@/infrastructure/error/InfrastructureError' +import { + HttpRequest, HttpResponse, StatusCode +} from '@/library/http/common' +import { BadRequestError } from '@/library/http/error/BadRequestError' +import { HttpError } from '@/library/http/error/HttpError' +import { InternalServerError } from '@/library/http/error/InternalServerError' +import { NotFoundError } from '@/library/http/error/NotFoundError' +import { NextHandler, RequestHandler } from '@/library/http/Server' + +interface Dependencies { + criarEvidenciaUseCase: CriarEvidenciaUseCase +} + +interface Body { + nome?: unknown + capturado_em?: unknown +} + +interface UploadedFile { + filename: string + mimetype: string + size: number + path: string +} + +export class CriarEvidenciaController implements RequestHandler { + private readonly criarEvidenciaUseCase: CriarEvidenciaUseCase + + constructor(dependencies: Dependencies) { + this.criarEvidenciaUseCase = dependencies.criarEvidenciaUseCase + } + + async handle(request: HttpRequest, _next: NextHandler): Promise { + const { eventoId: rawEventoId, file: rawFile } = request.params as { eventoId?: string; file?: unknown } + const eventoId = parseId(rawEventoId, 'eventoId') + if (eventoId instanceof Error) return new BadRequestError({ message: eventoId.message }) + + const file = rawFile as UploadedFile | undefined + if (!file) return new BadRequestError({ message: 'Arquivo (campo "arquivo") é obrigatório' }) + + const body = (request.body ?? {}) as Body + + const nome = parseString(body.nome, 'nome') + if (nome instanceof Error) return await cleanupAndReturn(file.path, nome) + + const capturadoEm = parseDate(body.capturado_em, 'capturado_em') + if (capturadoEm instanceof Error) return await cleanupAndReturn(file.path, capturadoEm) + + // Substituir por request.usuario.id assim que a + // autenticação for integrada. + const usuarioId = null + + const result = await this.criarEvidenciaUseCase.execute({ + evento_id: eventoId, + nome, + arquivo: file.filename, + mime_type: file.mimetype, + tamanho: file.size, + capturado_em: capturadoEm, + created_by: usuarioId + }) + + if (result.left()) { + await unlink(file.path).catch(() => {}) + + const error = result.value + if (error instanceof ForeignKeyViolationError) return new NotFoundError({ message: error.message }) + if (!(error instanceof InfrastructureError)) return new BadRequestError({ message: error.message }) + return new InternalServerError({ message: error.message }) + } + + return { + body: { ...result.value, url: `/uploads/evidencias/${result.value.arquivo}` }, + statusCode: StatusCode.Created + } + } +} + +async function cleanupAndReturn(filePath: string, error: Error): Promise { + await unlink(filePath).catch(() => {}) + return new BadRequestError({ message: error.message }) +} + +function parseId(raw: unknown, field: string): number | Error { + if (typeof raw !== 'string' || !/^\d+$/.test(raw)) { + return new Error(`${field} inválido`) + } + return Number(raw) +} + +function parseString(raw: unknown, field: string): string | Error { + if (typeof raw !== 'string' || !raw.trim()) { + return new Error(`${field} é obrigatório`) + } + return raw +} + +function parseDate(raw: unknown, field: string): Date | Error { + if (typeof raw !== 'string') { + return new Error(`${field} inválido. Use uma data no formato ISO 8601`) + } + const date = new Date(raw) + if (Number.isNaN(date.getTime())) { + return new Error(`${field} inválido. Use uma data no formato ISO 8601`) + } + return date +} diff --git a/src/application/evidencia/ListarEvidenciasController.ts b/src/application/evidencia/ListarEvidenciasController.ts new file mode 100644 index 00000000..53ddecbd --- /dev/null +++ b/src/application/evidencia/ListarEvidenciasController.ts @@ -0,0 +1,43 @@ +import { EvidenciaCollection } from '@/domain/evidencia/EvidenciaCollection' +import { + HttpRequest, HttpResponse, StatusCode +} from '@/library/http/common' +import { BadRequestError } from '@/library/http/error/BadRequestError' +import { HttpError } from '@/library/http/error/HttpError' +import { InternalServerError } from '@/library/http/error/InternalServerError' +import { NextHandler, RequestHandler } from '@/library/http/Server' + +interface Dependencies { + evidenciaCollection: EvidenciaCollection +} + +export class ListarEvidenciasController implements RequestHandler { + private readonly evidenciaCollection: EvidenciaCollection + + constructor(dependencies: Dependencies) { + this.evidenciaCollection = dependencies.evidenciaCollection + } + + async handle(request: HttpRequest, _next: NextHandler): Promise { + const { eventoId: rawEventoId } = request.params as { eventoId?: string } + const eventoId = parseId(rawEventoId, 'eventoId') + if (eventoId instanceof Error) return new BadRequestError({ message: eventoId.message }) + + const result = await this.evidenciaCollection.findAll({ evento_id: eventoId }) + if (result.left()) return new InternalServerError({ message: result.value.message }) + + const body = result.value.map(evidencia => ({ + ...evidencia, + url: `/uploads/evidencias/${evidencia.arquivo}` + })) + + return { body, statusCode: StatusCode.Ok } + } +} + +function parseId(raw: unknown, field: string): number | Error { + if (typeof raw !== 'string' || !/^\d+$/.test(raw)) { + return new Error(`${field} inválido`) + } + return Number(raw) +} diff --git a/src/application/evidencia/RemoverEvidenciaController.ts b/src/application/evidencia/RemoverEvidenciaController.ts new file mode 100644 index 00000000..cfa2c1c5 --- /dev/null +++ b/src/application/evidencia/RemoverEvidenciaController.ts @@ -0,0 +1,46 @@ +import { RemoverEvidenciaUseCase } from '@/domain/evidencia/RemoverEvidenciaUseCase' +import { + HttpRequest, HttpResponse, StatusCode +} from '@/library/http/common' +import { BadRequestError } from '@/library/http/error/BadRequestError' +import { HttpError } from '@/library/http/error/HttpError' +import { InternalServerError } from '@/library/http/error/InternalServerError' +import { NotFoundError } from '@/library/http/error/NotFoundError' +import { NextHandler, RequestHandler } from '@/library/http/Server' + +interface Dependencies { + removerEvidenciaUseCase: RemoverEvidenciaUseCase +} + +export class RemoverEvidenciaController implements RequestHandler { + private readonly removerEvidenciaUseCase: RemoverEvidenciaUseCase + + constructor(dependencies: Dependencies) { + this.removerEvidenciaUseCase = dependencies.removerEvidenciaUseCase + } + + async handle(request: HttpRequest, _next: NextHandler): Promise { + const { evidenciaId: rawEvidenciaId } = request.params as { evidenciaId?: string } + const evidenciaId = parseId(rawEvidenciaId, 'evidenciaId') + if (evidenciaId instanceof Error) return new BadRequestError({ message: evidenciaId.message }) + + const result = await this.removerEvidenciaUseCase.execute({ id: evidenciaId }) + + if (result.left()) { + return new InternalServerError({ message: result.value.message }) + } + + if (!result.value) { + return new NotFoundError({ message: 'Evidência não encontrada' }) + } + + return { statusCode: StatusCode.NoContent } + } +} + +function parseId(raw: unknown, field: string): number | Error { + if (typeof raw !== 'string' || !/^\d+$/.test(raw)) { + return new Error(`${field} inválido`) + } + return Number(raw) +} diff --git a/src/application/evidencia/ValidarEventoExisteMiddleware.ts b/src/application/evidencia/ValidarEventoExisteMiddleware.ts new file mode 100644 index 00000000..e0f892b1 --- /dev/null +++ b/src/application/evidencia/ValidarEventoExisteMiddleware.ts @@ -0,0 +1,52 @@ +import express from 'express' + +import { EventoCollection } from '@/domain/evento/EventoCollection' + +declare global { + // eslint-disable-next-line @typescript-eslint/no-namespace -- é o jeito canônico de estender o Request do Express + namespace Express { + interface Request { + evidenciaEvento?: { expedicaoId: number } + } + } +} + +export function validarEventoExisteMiddleware(eventoCollection: EventoCollection): express.RequestHandler { + return (request, response, next) => { + const eventoId = Number(request.params.eventoId) + + if (!Number.isInteger(eventoId) || eventoId <= 0) { + response.status(400).json({ + error: { + statusCode: 400, name: 'BadRequestError', message: 'eventoId inválido' + } + }) + return + } + + eventoCollection.findById(eventoId) + .then(result => { + if (result.left()) { + response.status(500).json({ + error: { + statusCode: 500, name: 'InternalServerError', message: result.value.message + } + }) + return + } + + if (!result.value) { + response.status(404).json({ + error: { + statusCode: 404, name: 'NotFoundError', message: 'Evento não encontrado' + } + }) + return + } + + request.evidenciaEvento = { expedicaoId: result.value.expedicao_id } + next() + }) + .catch(next) + } +} diff --git a/src/application/evidencia/index.ts b/src/application/evidencia/index.ts new file mode 100644 index 00000000..04fe6085 --- /dev/null +++ b/src/application/evidencia/index.ts @@ -0,0 +1,46 @@ +import { type Knex } from 'knex' + +import { CriarEvidenciaUseCase } from '@/domain/evidencia/CriarEvidenciaUseCase' +import { RemoverEvidenciaUseCase } from '@/domain/evidencia/RemoverEvidenciaUseCase' +import { EventoCollectionKnexAdapter } from '@/infrastructure/EventoCollectionKnexAdapter' +import { EvidenciaCollectionKnexAdapter } from '@/infrastructure/EvidenciaCollectionKnexAdapter' +import { evidenciaUpload } from '@/infrastructure/EvidenciaUploadMiddleware' +import { Method } from '@/library/http/common' +import { Route } from '@/library/http/Router' + +import { CriarEvidenciaController } from './CriarEvidenciaController' +import { ListarEvidenciasController } from './ListarEvidenciasController' +import { RemoverEvidenciaController } from './RemoverEvidenciaController' +import { validarEventoExisteMiddleware } from './ValidarEventoExisteMiddleware' + +export function routes(knex: Knex): Route[] { + const evidenciaCollection = new EvidenciaCollectionKnexAdapter({ knex }) + const eventoCollection = new EventoCollectionKnexAdapter({ knex }) + + return [ + { + handlers: [ + new CriarEvidenciaController({ + criarEvidenciaUseCase: new CriarEvidenciaUseCase({ evidenciaCollection }) + }) + ], + method: Method.Post, + middlewares: [validarEventoExisteMiddleware(eventoCollection), evidenciaUpload], + path: '/v2/eventos/:eventoId/evidencias' + }, + { + handlers: [new ListarEvidenciasController({ evidenciaCollection })], + method: Method.Get, + path: '/v2/eventos/:eventoId/evidencias' + }, + { + handlers: [ + new RemoverEvidenciaController({ + removerEvidenciaUseCase: new RemoverEvidenciaUseCase({ evidenciaCollection }) + }) + ], + method: Method.Delete, + path: '/v2/evidencias/:evidenciaId' + } + ] +} diff --git a/src/application/expedicao/AdicionaParticipanteController.ts b/src/application/expedicao/AdicionaParticipanteController.ts new file mode 100644 index 00000000..630225e2 --- /dev/null +++ b/src/application/expedicao/AdicionaParticipanteController.ts @@ -0,0 +1,67 @@ +import { AdicionaParticipanteUseCase } from '@/domain/expedicao/AdcionaParticipanteUseCase' +import { + HttpRequest, HttpResponse, StatusCode +} from '@/library/http/common' +import { BadRequestError } from '@/library/http/error/BadRequestError' +import { ConflictError } from '@/library/http/error/ConflictError' +import { HttpError } from '@/library/http/error/HttpError' +import { InternalServerError } from '@/library/http/error/InternalServerError' +import { NextHandler, RequestHandler } from '@/library/http/Server' + +interface Dependencies { + adicionaParticipanteUseCase: AdicionaParticipanteUseCase +} + +interface CustomHttpRequest extends HttpRequest { + params: Record + body: unknown + usuario?: { + id: number + tipo_usuario_id: number + } +} + +export class AdicionaParticipanteController implements RequestHandler { + private readonly adicionaParticipanteUseCase: AdicionaParticipanteUseCase + + constructor(dependencies: Dependencies) { + this.adicionaParticipanteUseCase = dependencies.adicionaParticipanteUseCase + } + + async handle(request: CustomHttpRequest, _next: NextHandler): Promise { + try { + // TODO: AUTENTICAÇÃO TEMPORARIAMENTE DESABILITADA + + const { expedicaoId } = request.params + const { usuarioId } = request.body as { usuarioId?: number } + if (expedicaoId === undefined || expedicaoId === null || expedicaoId === '' || !/^\d+$/.test(expedicaoId)) { + return new BadRequestError({ message: 'expedicaoId inválido' }) + } + + if (usuarioId === undefined || typeof usuarioId !== 'number' || usuarioId <= 0) { + return new BadRequestError({ message: 'usuarioId inválido' }) + } + + const result = await this.adicionaParticipanteUseCase.execute(Number(expedicaoId), usuarioId) + + if (result.left()) { + const error = result.value + if (error.name === 'DuplicateParticipantError') { + return new ConflictError({ message: error.message }) + } + if (error.name === 'CollectionError') { + return new InternalServerError({ message: 'Falha interna ao adicionar participante' }) + } + return new BadRequestError({ message: error.message }) + } + + return { + statusCode: StatusCode.Created, + body: { message: 'Participante adicionado com sucesso.' } + } + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Erro inesperado ao adicionar participante' + return new InternalServerError({ message: errorMessage }) + } + } +} diff --git a/src/application/expedicao/AtualizaExpedicaoController.ts b/src/application/expedicao/AtualizaExpedicaoController.ts new file mode 100644 index 00000000..11a6962b --- /dev/null +++ b/src/application/expedicao/AtualizaExpedicaoController.ts @@ -0,0 +1,109 @@ +import { AtualizaExpedicaoUseCase } from '@/domain/expedicao/AtualizaExpedicaoUseCase' +import { + HttpRequest, HttpResponse, StatusCode +} from '@/library/http/common' +import { BadRequestError } from '@/library/http/error/BadRequestError' +import { HttpError } from '@/library/http/error/HttpError' +import { InternalServerError } from '@/library/http/error/InternalServerError' +import { NotFoundError } from '@/library/http/error/NotFoundError' +import { NextHandler, RequestHandler } from '@/library/http/Server' + +interface Dependencies { + atualizaExpedicaoUseCase: AtualizaExpedicaoUseCase +} + +interface CustomHttpRequest extends HttpRequest { + usuario?: { + id: number + tipo_usuario_id: number + } +} + +export class AtualizaExpedicaoController implements RequestHandler { + private readonly atualizaExpedicaoUseCase: AtualizaExpedicaoUseCase + + constructor(dependencies: Dependencies) { + this.atualizaExpedicaoUseCase = dependencies.atualizaExpedicaoUseCase + } + + async handle(request: CustomHttpRequest, _next: NextHandler): Promise { + try { + const { expedicaoId } = request.params + const id = Number(expedicaoId) + + if (!expedicaoId || Number.isNaN(id)) { + return new BadRequestError({ message: 'O ID da expedição é inválido.' }) + } + + const { + descricao, data_inicio, data_fim, cidade_id + } = request.body as { + descricao: string | null + data_inicio: string + data_fim: string + cidade_id: number + } + + const isValidDate = (dateString: string) => { + if (!/^\d{4}-\d{2}-\d{2}$/.test(dateString)) return false + + const [ + year, + month, + day + ] = dateString.split('-').map(Number) + const date = new Date(year, month - 1, day) + + return date.getFullYear() === year && date.getMonth() === month - 1 && date.getDate() === day + } + + if (typeof data_inicio !== 'string' || !isValidDate(data_inicio)) { + return new BadRequestError({ message: 'A data_inicio é obrigatória e deve ser uma data válida no calendário (formato YYYY-MM-DD).' }) + } + if (typeof data_fim !== 'string' || !isValidDate(data_fim)) { + return new BadRequestError({ message: 'A data_fim é obrigatória e deve ser uma data válida no calendário (formato YYYY-MM-DD).' }) + } + if (!Number.isInteger(cidade_id) || cidade_id <= 0) { + return new BadRequestError({ message: 'O cidade_id é obrigatório e deve ser um número inteiro válido.' }) + } + if (descricao !== undefined && descricao !== null && typeof descricao !== 'string') { + return new BadRequestError({ message: 'A descricao, se informada, deve ser um texto.' }) + } + + const updated_by = 10 // MOCK TEMPORÁRIO, trocar por request.usuario.id + + const result = await this.atualizaExpedicaoUseCase.execute(id, { + descricao: descricao ?? null, + data_inicio, + data_fim, + cidade_id, + updated_by + }) + + if (result.left()) { + const error = result.value + + // Não encontrado (404) + if (error.message === 'Expedição não encontrada') { + return new NotFoundError({ message: error.message }) + } + + // Erro de Infraestrutura / Banco de Dados (500) + if (error.name === 'CollectionError' || error.message.includes('Failed to update')) { + return new InternalServerError({ message: 'Falha interna ao atualizar expedição' }) + } + + // Erro de validação da Entidade / Regra de Negócio (400) + return new BadRequestError({ message: error.message }) + } + + return { + statusCode: StatusCode.Ok, + body: result.value + } + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Erro inesperado ao atualizar expedição' + return new InternalServerError({ message: errorMessage }) + } + } +} diff --git a/src/application/expedicao/BuscaExpedicaoController.ts b/src/application/expedicao/BuscaExpedicaoController.ts new file mode 100644 index 00000000..3d62dd55 --- /dev/null +++ b/src/application/expedicao/BuscaExpedicaoController.ts @@ -0,0 +1,59 @@ +import { BuscaExpedicaoUseCase } from '@/domain/expedicao/BuscaExpedicaoUseCase' +import { + HttpRequest, HttpResponse, StatusCode +} from '@/library/http/common' +import { BadRequestError } from '@/library/http/error/BadRequestError' +import { HttpError } from '@/library/http/error/HttpError' +import { InternalServerError } from '@/library/http/error/InternalServerError' +import { NotFoundError } from '@/library/http/error/NotFoundError' +import { NextHandler, RequestHandler } from '@/library/http/Server' + +interface Dependencies { + buscaExpedicaoUseCase: BuscaExpedicaoUseCase +} + +interface CustomHttpRequest extends HttpRequest { + params: Record +} + +export class BuscaExpedicaoController implements RequestHandler { + private readonly buscaExpedicaoUseCase: BuscaExpedicaoUseCase + + constructor(dependencies: Dependencies) { + this.buscaExpedicaoUseCase = dependencies.buscaExpedicaoUseCase + } + + // Busca expedição por ID + // GET /v2/expedicoes/:expedicaoId + async handle(request: CustomHttpRequest, _next: NextHandler): Promise { + try { + const { expedicaoId } = request.params + + // validação id + if (expedicaoId === undefined || expedicaoId === null || expedicaoId === '' || !/^\d+$/.test(expedicaoId)) { + return new BadRequestError({ message: 'expedicaoId inválido' }) + } + + const result = await this.buscaExpedicaoUseCase.execute({ id: Number(expedicaoId) }) + + // Falha na infra ou exceção de regra de negócio + if (result.left()) { + return new InternalServerError({ message: result.value.message }) + } + + // sucesso na querry, mas a expedição não encontrada + if (!result.value) { + return new NotFoundError({ message: 'Expedição não encontrada' }) + } + + // sucesso + return { + statusCode: StatusCode.Ok, + body: result.value + } + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Erro inesperado ao buscar expedição por ID' + return new InternalServerError({ message: errorMessage }) + } + } +} diff --git a/src/application/expedicao/CadastraExpedicaoController.ts b/src/application/expedicao/CadastraExpedicaoController.ts new file mode 100644 index 00000000..a557e64d --- /dev/null +++ b/src/application/expedicao/CadastraExpedicaoController.ts @@ -0,0 +1,101 @@ +import { CadastraExpedicaoUseCase } from '@/domain/expedicao/CadastraExpedicaoUseCase' +import { + HttpRequest, HttpResponse, StatusCode +} from '@/library/http/common' +import { BadRequestError } from '@/library/http/error/BadRequestError' +import { HttpError } from '@/library/http/error/HttpError' +import { InternalServerError } from '@/library/http/error/InternalServerError' +import { NextHandler, RequestHandler } from '@/library/http/Server' +// import { TIPOS_USUARIOS } from '@/middlewares/tokens-middleware' +// import { UnauthorizedError } from '@/library/http/error/UnauthorizedError' + +interface Dependencies { + cadastraExpedicaoUseCase: CadastraExpedicaoUseCase +} + +interface CustomHttpRequest extends HttpRequest { + usuario?: { + id: number + tipo_usuario_id: number + } +} + +export class CadastraExpedicaoController implements RequestHandler { + private readonly cadastraExpedicaoUseCase: CadastraExpedicaoUseCase + + constructor(dependencies: Dependencies) { + this.cadastraExpedicaoUseCase = dependencies.cadastraExpedicaoUseCase + } + + async handle(request: CustomHttpRequest, _next: NextHandler): Promise { + try { + // TODO: AUTENTICAÇÃO TEMPORARIAMENTE DESABILITADA + // A infraestrutura de request HTTP autenticada está sendo desenvolvida. + // Assim que integrada, descomentar a validação abaixo e remover o mock do 'created_by'. + + // if (!request.usuario || ![TIPOS_USUARIOS.CURADOR, TIPOS_USUARIOS.OPERADOR].includes(request.usuario.tipo_usuario_id)) { + // return new UnauthorizedError({ message: 'Não tem permissão para realizar esta ação' }) + // } + + const { + descricao, data_inicio, data_fim, cidade_id, participantes, rotas + } = request.body as { + descricao?: string | null + data_inicio: string + data_fim: string + cidade_id: number + participantes?: number[] + rotas?: number[] + } + + // VALIDAÇÃO MANUAL DE DADOS + if (!data_inicio || Number.isNaN(Date.parse(data_inicio))) { + return new BadRequestError({ message: 'A data_inicio é obrigatória e deve ser uma data válida (ex: YYYY-MM-DD).' }) + } + + if (!data_fim || Number.isNaN(Date.parse(data_fim))) { + return new BadRequestError({ message: 'A data_fim é obrigatória e deve ser uma data válida (ex: YYYY-MM-DD).' }) + } + + if (!cidade_id || typeof cidade_id !== 'number' || cidade_id <= 0) { + return new BadRequestError({ message: 'O cidade_id é obrigatório e deve ser um número válido.' }) + } + + if (descricao !== undefined && descricao !== null && typeof descricao !== 'string') { + return new BadRequestError({ message: 'A descricao, se informada, deve ser um texto.' }) + } + + // MOCK TEMPORÁRIO: Substituir pelo request.usuario.id quando a autenticação estiver pronta + const created_by = 9 // Id válido de usuário para teste + + // EXECUÇÃO DO CASO DE USO + const result = await this.cadastraExpedicaoUseCase.execute({ + descricao: descricao ?? null, + data_inicio, + data_fim, + cidade_id, + created_by, + participantes: participantes ?? [], + rotas: rotas ?? [] + }) + + if (result.left()) { + // Se for um erro que sabemos ser de banco/infraestrutura devolve 500 + if (result.value.name === 'CollectionError' || result.value.message.includes('Failed to create')) { + return new InternalServerError({ message: 'Falha interna ao cadastrar expedição' }) + } + + // Se for um erro de validação de domínio (ex: data_fim antes de data_inicio) devolve 400 + return new BadRequestError({ message: result.value.message }) + } + + return { + statusCode: StatusCode.Created, + body: result.value + } + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Erro inesperado ao cadastrar expedição' + return new InternalServerError({ message: errorMessage }) + } + } +} diff --git a/src/application/expedicao/DeletaExpedicaoController.ts b/src/application/expedicao/DeletaExpedicaoController.ts new file mode 100644 index 00000000..be275e4a --- /dev/null +++ b/src/application/expedicao/DeletaExpedicaoController.ts @@ -0,0 +1,52 @@ +import { DeletaExpedicaoUseCase } from '@/domain/expedicao/DeletaExpedicaoUseCase' +import { + HttpRequest, HttpResponse, StatusCode +} from '@/library/http/common' +import { BadRequestError } from '@/library/http/error/BadRequestError' +import { HttpError } from '@/library/http/error/HttpError' +import { InternalServerError } from '@/library/http/error/InternalServerError' +import { NotFoundError } from '@/library/http/error/NotFoundError' +import { NextHandler, RequestHandler } from '@/library/http/Server' + +interface Dependencies { + deletaExpedicaoUseCase: DeletaExpedicaoUseCase +} + +interface CustomHttpRequest extends HttpRequest { + params: Record +} + +export class DeletaExpedicaoController implements RequestHandler { + private readonly deletaExpedicaoUseCase: DeletaExpedicaoUseCase + + constructor(dependencies: Dependencies) { + this.deletaExpedicaoUseCase = dependencies.deletaExpedicaoUseCase + } + + async handle(request: CustomHttpRequest, _next: NextHandler): Promise { + try { + const { expedicaoId } = request.params + + if (expedicaoId === undefined || expedicaoId === null || expedicaoId === '' || !/^\d+$/.test(expedicaoId)) { + return new BadRequestError({ message: 'O ID da expedição é inválido.' }) + } + + const result = await this.deletaExpedicaoUseCase.execute({ id: Number(expedicaoId) }) + + if (result.left()) { + // Distingue não encontrado (404) de erro no banco (500) + if (result.value.message === 'Expedição não encontrada') { + return new NotFoundError({ message: result.value.message }) + } + return new InternalServerError({ message: result.value.message }) + } + + return { + statusCode: StatusCode.NoContent + } + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Erro inesperado ao deletar a expedição.' + return new InternalServerError({ message: errorMessage }) + } + } +} diff --git a/src/application/expedicao/ListaExpedicoesController.ts b/src/application/expedicao/ListaExpedicoesController.ts new file mode 100644 index 00000000..4c3ca3fc --- /dev/null +++ b/src/application/expedicao/ListaExpedicoesController.ts @@ -0,0 +1,119 @@ +import { ExpedicaoFilters } from '@/domain/expedicao/ExpedicaoCollection' +import { ListaExpedicoesUseCase } from '@/domain/expedicao/ListaExpedicoesUseCase' +import { + HttpRequest, HttpResponse, StatusCode +} from '@/library/http/common' +import { BadRequestError } from '@/library/http/error/BadRequestError' +import { HttpError } from '@/library/http/error/HttpError' +import { InternalServerError } from '@/library/http/error/InternalServerError' +import { NextHandler, RequestHandler } from '@/library/http/Server' + +interface Dependencies { + listaExpedicoesUseCase: ListaExpedicoesUseCase +} + +// interface do formato da nossa query +export interface CustomHttpRequest extends HttpRequest { + params: Record +} + +export class ListaExpedicoesController implements RequestHandler { + private readonly listaExpedicoesUseCase: ListaExpedicoesUseCase + + constructor(dependencies: Dependencies) { + this.listaExpedicoesUseCase = dependencies.listaExpedicoesUseCase + } + + // Listar todas (com ordenação padrão DESC): + // GET /api/v2/expedicoes + + // Filtrar por uma cidade específica: + // GET /api/v2/expedicoes?cidade_id=1100049 + + // Filtrar por intervalo de datas: + // GET /api/v2/expedicoes?data_inicio_de=2026-01-01&data_fim_ate=2026-12-31 + + // Filtrar próximas expedições (ainda não terminaram, com a que começa primeiro antes): + // GET /api/v2/expedicoes?data_fim_de=2026-10-04&order=data_inicio:asc + + // Filtrar expedições realizadas (já terminaram, com a mais recente antes): + // GET /api/v2/expedicoes?data_fim_ate=2026-10-03&order=data_fim:desc + + // Filtrar com ordenação segura: + // GET /api/v2/expedicoes?order=data_inicio:desc + + async handle(request: CustomHttpRequest, _next: NextHandler): Promise { + try { + const { + cidade_id, + usuario_id, + data_inicio_de, + data_inicio_ate, + data_fim_de, + data_fim_ate, + order, + limite, + pagina + } = request.params + + const filters: ExpedicaoFilters = {} + + if (cidade_id && !Number.isNaN(Number(cidade_id))) { + filters.cidade_id = Number(cidade_id) + } + + if (usuario_id && !Number.isNaN(Number(usuario_id))) { + filters.usuario_id = Number(usuario_id) + } + + if (data_inicio_de) filters.data_inicio_de = data_inicio_de + if (data_inicio_ate) filters.data_inicio_ate = data_inicio_ate + if (data_fim_de) filters.data_fim_de = data_fim_de + if (data_fim_ate) filters.data_fim_ate = data_fim_ate + + // Validação da ordenação + if (order) { + const [column, direction] = order.split(':') + const colunasValidas = [ + 'id', + 'data_inicio', + 'data_fim' + ] + const direcoesValidas = ['asc', 'desc'] + + if (!colunasValidas.includes(column) || !direcoesValidas.includes(direction)) { + return new BadRequestError({ + message: 'order inválido. Use o formato "id:asc", "id:desc", "data_inicio:asc" ou "data_fim:desc"' + }) + } + + filters.order = { + column: column as 'id' | 'data_inicio' | 'data_fim', + direction: direction as 'asc' | 'desc' + } + } + + if (limite && !Number.isNaN(Number(limite))) { + filters.limite = Number(limite) + } + if (pagina && !Number.isNaN(Number(pagina))) { + filters.pagina = Number(pagina) + } + + const result = await this.listaExpedicoesUseCase.execute(filters) + + // erro de infra + if (result.left()) { + return new InternalServerError({ message: result.value.message }) + } + + return { + statusCode: StatusCode.Ok, + body: result.value + } + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Erro inesperado ao listar expedições' + return new InternalServerError({ message: errorMessage }) + } + } +} diff --git a/src/application/expedicao/RemoveParticipanteController.ts b/src/application/expedicao/RemoveParticipanteController.ts new file mode 100644 index 00000000..726da6a4 --- /dev/null +++ b/src/application/expedicao/RemoveParticipanteController.ts @@ -0,0 +1,58 @@ +import { RemoveParticipanteUseCase } from '@/domain/expedicao/RemoveParticipanteUseCase' +import { + HttpRequest, HttpResponse, StatusCode +} from '@/library/http/common' +import { BadRequestError } from '@/library/http/error/BadRequestError' +import { HttpError } from '@/library/http/error/HttpError' +import { InternalServerError } from '@/library/http/error/InternalServerError' +import { NextHandler, RequestHandler } from '@/library/http/Server' + +interface Dependencies { + removeParticipanteUseCase: RemoveParticipanteUseCase +} + +interface CustomHttpRequest extends HttpRequest { + usuario?: { + id: number + tipo_usuario_id: number + } +} + +export class RemoveParticipanteController implements RequestHandler { + private readonly removeParticipanteUseCase: RemoveParticipanteUseCase + + constructor(dependencies: Dependencies) { + this.removeParticipanteUseCase = dependencies.removeParticipanteUseCase + } + + async handle(request: CustomHttpRequest, _next: NextHandler): Promise { + try { + const { expedicaoId, usuarioId } = request.params + + if (expedicaoId === undefined || expedicaoId === null || expedicaoId === '' || typeof expedicaoId !== 'string' || !/^\d+$/.test(expedicaoId)) { + return new BadRequestError({ message: 'expedicaoId inválido' }) + } + + if (usuarioId === undefined || usuarioId === null || usuarioId === '' || typeof usuarioId !== 'string' || !/^\d+$/.test(usuarioId)) { + return new BadRequestError({ message: 'usuarioId inválido' }) + } + + const result = await this.removeParticipanteUseCase.execute(Number(expedicaoId), Number(usuarioId)) + + if (result.left()) { + if (result.value.name === 'CollectionError' || result.value.message.includes('Falha')) { + return new InternalServerError({ message: 'Falha interna ao remover participante' }) + } + return new BadRequestError({ message: result.value.message }) + } + + return { + statusCode: StatusCode.NoContent, + body: undefined + } + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Erro inesperado ao remover participante' + return new InternalServerError({ message: errorMessage }) + } + } +} diff --git a/src/application/expedicao/SubstituiRotaController.ts b/src/application/expedicao/SubstituiRotaController.ts new file mode 100644 index 00000000..f4525c7c --- /dev/null +++ b/src/application/expedicao/SubstituiRotaController.ts @@ -0,0 +1,60 @@ +import { SubstituiRotasUseCase } from '@/domain/expedicao/SubstituiRotaUseCase' +import { + HttpRequest, HttpResponse, StatusCode +} from '@/library/http/common' +import { BadRequestError } from '@/library/http/error/BadRequestError' +import { HttpError } from '@/library/http/error/HttpError' +import { InternalServerError } from '@/library/http/error/InternalServerError' +import { NextHandler, RequestHandler } from '@/library/http/Server' + +interface Dependencies { + SubstituiRotasUseCase: SubstituiRotasUseCase +} + +interface CustomHttpRequest extends HttpRequest { + usuario?: { + id: number + tipo_usuario_id: number + } +} + +export class SubstituiRotasController implements RequestHandler { + private readonly SubstituiRotasUseCase: SubstituiRotasUseCase + + constructor(dependencies: Dependencies) { + this.SubstituiRotasUseCase = dependencies.SubstituiRotasUseCase + } + + async handle(request: CustomHttpRequest, _next: NextHandler): Promise { + try { + const { expedicaoId } = request.params + const { rotas } = request.body as { rotas?: number[] } + + if (expedicaoId === undefined || expedicaoId === null || expedicaoId === '' || typeof expedicaoId !== 'string' || !/^\d+$/.test(expedicaoId)) { + return new BadRequestError({ message: 'expedicaoId inválido' }) + } + + if (!rotas || !Array.isArray(rotas) || rotas.some(id => typeof id !== 'number' || id <= 0)) { + return new BadRequestError({ message: 'rotas inválidas' }) + } + + const result = await this.SubstituiRotasUseCase.execute(Number(expedicaoId), rotas) + + if (result.left()) { + const error = result.value + if (error.name === 'CollectionError') { + return new InternalServerError({ message: 'Falha interna ao substituir rotas' }) + } + return new BadRequestError({ message: error.message }) + } + + return { + statusCode: StatusCode.NoContent, + body: undefined + } + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Erro inesperado ao substituir rotas' + return new InternalServerError({ message: errorMessage }) + } + } +} diff --git a/src/application/expedicao/index.ts b/src/application/expedicao/index.ts new file mode 100644 index 00000000..f38d475c --- /dev/null +++ b/src/application/expedicao/index.ts @@ -0,0 +1,101 @@ +import { type Knex } from 'knex' + +import { AdicionaParticipanteUseCase } from '@/domain/expedicao/AdcionaParticipanteUseCase' +import { AtualizaExpedicaoUseCase } from '@/domain/expedicao/AtualizaExpedicaoUseCase' +import { BuscaExpedicaoUseCase } from '@/domain/expedicao/BuscaExpedicaoUseCase' +import { CadastraExpedicaoUseCase } from '@/domain/expedicao/CadastraExpedicaoUseCase' +import { DeletaExpedicaoUseCase } from '@/domain/expedicao/DeletaExpedicaoUseCase' +import { ListaExpedicoesUseCase } from '@/domain/expedicao/ListaExpedicoesUseCase' +import { RemoveParticipanteUseCase } from '@/domain/expedicao/RemoveParticipanteUseCase' +import { SubstituiRotasUseCase } from '@/domain/expedicao/SubstituiRotaUseCase' +import { createExpedicaoCollection } from '@/factory/ExpedicaoCollectionFactory' +import { Method } from '@/library/http/common' +import { Route } from '@/library/http/Router' + +import { AdicionaParticipanteController } from './AdicionaParticipanteController' +import { AtualizaExpedicaoController } from './AtualizaExpedicaoController' +import { BuscaExpedicaoController } from './BuscaExpedicaoController' +import { CadastraExpedicaoController } from './CadastraExpedicaoController' +import { DeletaExpedicaoController } from './DeletaExpedicaoController' +import { ListaExpedicoesController } from './ListaExpedicoesController' +import { RemoveParticipanteController } from './RemoveParticipanteController' +import { SubstituiRotasController } from './SubstituiRotaController' + +export function routes(knex: Knex): Route[] { + const expedicaoCollection = createExpedicaoCollection() + + return [ + { + method: Method.Post, + path: '/v2/expedicoes', + handlers: [ + new CadastraExpedicaoController({ + cadastraExpedicaoUseCase: new CadastraExpedicaoUseCase({ expedicaoCollection }) + }) + ] + }, + { + method: Method.Get, + path: '/v2/expedicoes', + handlers: [ + new ListaExpedicoesController({ + listaExpedicoesUseCase: new ListaExpedicoesUseCase({ expedicaoCollection }) + }) + ] + }, + { + method: Method.Get, + path: '/v2/expedicoes/:expedicaoId', + handlers: [ + new BuscaExpedicaoController({ + buscaExpedicaoUseCase: new BuscaExpedicaoUseCase({ expedicaoCollection }) + }) + ] + }, + { + method: Method.Delete, + path: '/v2/expedicoes/:expedicaoId', + handlers: [ + new DeletaExpedicaoController({ + deletaExpedicaoUseCase: new DeletaExpedicaoUseCase({ expedicaoCollection }) + }) + ] + }, + { + method: Method.Put, + path: '/v2/expedicoes/:expedicaoId', + handlers: [ + new AtualizaExpedicaoController({ + atualizaExpedicaoUseCase: new AtualizaExpedicaoUseCase({ expedicaoCollection }) + }) + ] + }, + { + method: Method.Post, + path: '/v2/expedicoes/:expedicaoId/participantes', + handlers: [ + new AdicionaParticipanteController({ + adicionaParticipanteUseCase: new AdicionaParticipanteUseCase({ expedicaoCollection }) + }) + ] + }, + { + method: Method.Delete, + path: '/v2/expedicoes/:expedicaoId/participantes/:usuarioId', + handlers: [ + new RemoveParticipanteController({ + removeParticipanteUseCase: new RemoveParticipanteUseCase({ expedicaoCollection }) + }) + ] + }, + { + method: Method.Put, + path: '/v2/expedicoes/:expedicaoId/rotas', + handlers: [ + new SubstituiRotasController({ + SubstituiRotasUseCase: new SubstituiRotasUseCase({ expedicaoCollection }) + }) + ] + } + ] +} diff --git a/src/application/lembrete/AtualizaLembreteController.ts b/src/application/lembrete/AtualizaLembreteController.ts new file mode 100644 index 00000000..34620531 --- /dev/null +++ b/src/application/lembrete/AtualizaLembreteController.ts @@ -0,0 +1,69 @@ +import { AtualizaLembreteUseCase, Input as AtualizaLembreteInput } from '@/domain/lembrete/AtualizaLembreteUseCase' +import { InfrastructureError } from '@/infrastructure/error/InfrastructureError' +import { + HttpRequest, HttpResponse, StatusCode +} from '@/library/http/common' +import { BadRequestError } from '@/library/http/error/BadRequestError' +import { HttpError } from '@/library/http/error/HttpError' +import { InternalServerError } from '@/library/http/error/InternalServerError' +import { NotFoundError } from '@/library/http/error/NotFoundError' +import { NextHandler, RequestHandler } from '@/library/http/Server' + +import { + parseFicha, parseId, parseRequiredString +} from './lembrete-parsing' + +interface Dependencies { + atualizaLembreteUseCase: AtualizaLembreteUseCase +} + +export class AtualizaLembreteController implements RequestHandler { + private readonly atualizaLembreteUseCase: AtualizaLembreteUseCase + + constructor(dependencies: Dependencies) { + this.atualizaLembreteUseCase = dependencies.atualizaLembreteUseCase + } + + async handle(request: HttpRequest, _next: NextHandler): Promise { + const { lembreteId: rawLembreteId } = request.params as { lembreteId?: string } + const lembreteId = parseId(rawLembreteId, 'lembreteId') + if (lembreteId instanceof Error) return new BadRequestError({ message: lembreteId.message }) + + const body = (request.body ?? {}) as Record + + const ficha = parseFicha(body, { preencherAusentes: false }) + if (ficha instanceof Error) return new BadRequestError({ message: ficha.message }) + + // Substituir por request.usuario.id assim que a + // autenticação for integrada. + const input: AtualizaLembreteInput = { + ...ficha, id: lembreteId, updated_by: null + } + + if (body.data_coleta !== undefined) { + const dataColeta = parseRequiredString(body.data_coleta, 'data_coleta') + if (dataColeta instanceof Error) return new BadRequestError({ message: dataColeta.message }) + input.data_coleta = dataColeta + } + + if (body.local_coleta !== undefined) { + const localColeta = parseRequiredString(body.local_coleta, 'local_coleta') + if (localColeta instanceof Error) return new BadRequestError({ message: localColeta.message }) + input.local_coleta = localColeta + } + + const result = await this.atualizaLembreteUseCase.execute(input) + + if (result.left()) { + const error = result.value + if (!(error instanceof InfrastructureError)) return new BadRequestError({ message: error.message }) + return new InternalServerError({ message: error.message }) + } + + if (!result.value) { + return new NotFoundError({ message: 'Lembrete não encontrado' }) + } + + return { body: result.value, statusCode: StatusCode.Ok } + } +} diff --git a/src/application/lembrete/BuscaLembreteController.ts b/src/application/lembrete/BuscaLembreteController.ts new file mode 100644 index 00000000..aa2b3cd9 --- /dev/null +++ b/src/application/lembrete/BuscaLembreteController.ts @@ -0,0 +1,41 @@ +import { BuscaLembreteUseCase } from '@/domain/lembrete/BuscaLembreteUseCase' +import { + HttpRequest, HttpResponse, StatusCode +} from '@/library/http/common' +import { BadRequestError } from '@/library/http/error/BadRequestError' +import { HttpError } from '@/library/http/error/HttpError' +import { InternalServerError } from '@/library/http/error/InternalServerError' +import { NotFoundError } from '@/library/http/error/NotFoundError' +import { NextHandler, RequestHandler } from '@/library/http/Server' + +import { parseId } from './lembrete-parsing' + +interface Dependencies { + buscaLembreteUseCase: BuscaLembreteUseCase +} + +export class BuscaLembreteController implements RequestHandler { + private readonly buscaLembreteUseCase: BuscaLembreteUseCase + + constructor(dependencies: Dependencies) { + this.buscaLembreteUseCase = dependencies.buscaLembreteUseCase + } + + async handle(request: HttpRequest, _next: NextHandler): Promise { + const { lembreteId: rawLembreteId } = request.params as { lembreteId?: string } + const lembreteId = parseId(rawLembreteId, 'lembreteId') + if (lembreteId instanceof Error) return new BadRequestError({ message: lembreteId.message }) + + const result = await this.buscaLembreteUseCase.execute({ id: lembreteId }) + + if (result.left()) { + return new InternalServerError({ message: result.value.message }) + } + + if (!result.value) { + return new NotFoundError({ message: 'Lembrete não encontrado' }) + } + + return { body: result.value, statusCode: StatusCode.Ok } + } +} diff --git a/src/application/lembrete/CriaLembreteController.ts b/src/application/lembrete/CriaLembreteController.ts new file mode 100644 index 00000000..0252be9c --- /dev/null +++ b/src/application/lembrete/CriaLembreteController.ts @@ -0,0 +1,56 @@ +import { CriaLembreteUseCase } from '@/domain/lembrete/CriaLembreteUseCase' +import { FichaAttributes } from '@/domain/lembrete/Lembrete' +import { InfrastructureError } from '@/infrastructure/error/InfrastructureError' +import { + HttpRequest, HttpResponse, StatusCode +} from '@/library/http/common' +import { BadRequestError } from '@/library/http/error/BadRequestError' +import { HttpError } from '@/library/http/error/HttpError' +import { InternalServerError } from '@/library/http/error/InternalServerError' +import { NextHandler, RequestHandler } from '@/library/http/Server' + +import { parseFicha, parseRequiredString } from './lembrete-parsing' + +interface Dependencies { + criaLembreteUseCase: CriaLembreteUseCase +} + +export class CriaLembreteController implements RequestHandler { + private readonly criaLembreteUseCase: CriaLembreteUseCase + + constructor(dependencies: Dependencies) { + this.criaLembreteUseCase = dependencies.criaLembreteUseCase + } + + async handle(request: HttpRequest, _next: NextHandler): Promise { + const body = (request.body ?? {}) as Record + + const dataColeta = parseRequiredString(body.data_coleta, 'data_coleta') + if (dataColeta instanceof Error) return new BadRequestError({ message: dataColeta.message }) + + const localColeta = parseRequiredString(body.local_coleta, 'local_coleta') + if (localColeta instanceof Error) return new BadRequestError({ message: localColeta.message }) + + const ficha = parseFicha(body, { preencherAusentes: true }) + if (ficha instanceof Error) return new BadRequestError({ message: ficha.message }) + + // Substituir por request.usuario.id assim que a + // autenticação for integrada. + const usuarioId = null + + const result = await this.criaLembreteUseCase.execute({ + ...ficha as FichaAttributes, + created_by: usuarioId, + data_coleta: dataColeta, + local_coleta: localColeta + }) + + if (result.left()) { + const error = result.value + if (!(error instanceof InfrastructureError)) return new BadRequestError({ message: error.message }) + return new InternalServerError({ message: error.message }) + } + + return { body: result.value, statusCode: StatusCode.Created } + } +} diff --git a/src/application/lembrete/ListaLembretesController.ts b/src/application/lembrete/ListaLembretesController.ts new file mode 100644 index 00000000..2866d43f --- /dev/null +++ b/src/application/lembrete/ListaLembretesController.ts @@ -0,0 +1,83 @@ +import { isDataValida } from '@/domain/lembrete/Lembrete' +import { LembreteFilters, LembreteOrder } from '@/domain/lembrete/LembreteCollection' +import { ListaLembretesUseCase } from '@/domain/lembrete/ListaLembretesUseCase' +import { + HttpRequest, HttpResponse, StatusCode +} from '@/library/http/common' +import { BadRequestError } from '@/library/http/error/BadRequestError' +import { HttpError } from '@/library/http/error/HttpError' +import { InternalServerError } from '@/library/http/error/InternalServerError' +import { NextHandler, RequestHandler } from '@/library/http/Server' + +interface Dependencies { + listaLembretesUseCase: ListaLembretesUseCase +} + +const COLUNAS_ORDENAVEIS: ReadonlyArray = ['id', 'data_coleta'] +const DIRECOES: ReadonlyArray = ['asc', 'desc'] + +export class ListaLembretesController implements RequestHandler { + private readonly listaLembretesUseCase: ListaLembretesUseCase + + constructor(dependencies: Dependencies) { + this.listaLembretesUseCase = dependencies.listaLembretesUseCase + } + + // Próximos lembretes (a partir de hoje, o mais próximo primeiro): + // GET /api/v2/lembretes?data_coleta_de=2026-10-05 + + // Lembretes de um intervalo: + // GET /api/v2/lembretes?data_coleta_de=2026-11-01&data_coleta_ate=2026-11-30 + + async handle(request: HttpRequest, _next: NextHandler): Promise { + const { + data_coleta_de, + data_coleta_ate, + order, + limite, + pagina + } = request.params as Record + + const filters: LembreteFilters = {} + + if (data_coleta_de) { + if (!isDataValida(data_coleta_de)) { + return new BadRequestError({ message: 'data_coleta_de inválido. Use o formato YYYY-MM-DD' }) + } + filters.data_coleta_de = data_coleta_de + } + + if (data_coleta_ate) { + if (!isDataValida(data_coleta_ate)) { + return new BadRequestError({ message: 'data_coleta_ate inválido. Use o formato YYYY-MM-DD' }) + } + filters.data_coleta_ate = data_coleta_ate + } + + if (order) { + const [column, direction] = order.split(':') as [LembreteOrder['column'], LembreteOrder['direction']] + if (!COLUNAS_ORDENAVEIS.includes(column) || !DIRECOES.includes(direction)) { + return new BadRequestError({ + message: 'order inválido. Use o formato "data_coleta:asc", "data_coleta:desc", "id:asc" ou "id:desc"' + }) + } + filters.order = { column, direction } + } + + if (limite && !Number.isNaN(Number(limite))) { + filters.limite = Number(limite) + } + + if (pagina && !Number.isNaN(Number(pagina))) { + filters.pagina = Number(pagina) + } + + const result = await this.listaLembretesUseCase.execute(filters) + + if (result.left()) { + return new InternalServerError({ message: result.value.message }) + } + + return { body: result.value, statusCode: StatusCode.Ok } + } +} diff --git a/src/application/lembrete/RemoveLembreteController.ts b/src/application/lembrete/RemoveLembreteController.ts new file mode 100644 index 00000000..ec3a828a --- /dev/null +++ b/src/application/lembrete/RemoveLembreteController.ts @@ -0,0 +1,41 @@ +import { RemoveLembreteUseCase } from '@/domain/lembrete/RemoveLembreteUseCase' +import { + HttpRequest, HttpResponse, StatusCode +} from '@/library/http/common' +import { BadRequestError } from '@/library/http/error/BadRequestError' +import { HttpError } from '@/library/http/error/HttpError' +import { InternalServerError } from '@/library/http/error/InternalServerError' +import { NotFoundError } from '@/library/http/error/NotFoundError' +import { NextHandler, RequestHandler } from '@/library/http/Server' + +import { parseId } from './lembrete-parsing' + +interface Dependencies { + removeLembreteUseCase: RemoveLembreteUseCase +} + +export class RemoveLembreteController implements RequestHandler { + private readonly removeLembreteUseCase: RemoveLembreteUseCase + + constructor(dependencies: Dependencies) { + this.removeLembreteUseCase = dependencies.removeLembreteUseCase + } + + async handle(request: HttpRequest, _next: NextHandler): Promise { + const { lembreteId: rawLembreteId } = request.params as { lembreteId?: string } + const lembreteId = parseId(rawLembreteId, 'lembreteId') + if (lembreteId instanceof Error) return new BadRequestError({ message: lembreteId.message }) + + const result = await this.removeLembreteUseCase.execute({ id: lembreteId }) + + if (result.left()) { + return new InternalServerError({ message: result.value.message }) + } + + if (!result.value) { + return new NotFoundError({ message: 'Lembrete não encontrado' }) + } + + return { statusCode: StatusCode.NoContent } + } +} diff --git a/src/application/lembrete/index.ts b/src/application/lembrete/index.ts new file mode 100644 index 00000000..99432b36 --- /dev/null +++ b/src/application/lembrete/index.ts @@ -0,0 +1,70 @@ +import { type Knex } from 'knex' + +import { AtualizaLembreteUseCase } from '@/domain/lembrete/AtualizaLembreteUseCase' +import { BuscaLembreteUseCase } from '@/domain/lembrete/BuscaLembreteUseCase' +import { CriaLembreteUseCase } from '@/domain/lembrete/CriaLembreteUseCase' +import { ListaLembretesUseCase } from '@/domain/lembrete/ListaLembretesUseCase' +import { RemoveLembreteUseCase } from '@/domain/lembrete/RemoveLembreteUseCase' +import { LembreteCollectionKnexAdapter } from '@/infrastructure/LembreteCollectionKnexAdapter' +import { Method } from '@/library/http/common' +import { Route } from '@/library/http/Router' + +import { AtualizaLembreteController } from './AtualizaLembreteController' +import { BuscaLembreteController } from './BuscaLembreteController' +import { CriaLembreteController } from './CriaLembreteController' +import { ListaLembretesController } from './ListaLembretesController' +import { RemoveLembreteController } from './RemoveLembreteController' + +export function routes(knex: Knex): Route[] { + const lembreteCollection = new LembreteCollectionKnexAdapter({ knex }) + + // TODO: adicionar autenticação quando a camada HTTP de autenticação estiver disponível. + + return [ + { + handlers: [ + new ListaLembretesController({ + listaLembretesUseCase: new ListaLembretesUseCase({ lembreteCollection }) + }) + ], + method: Method.Get, + path: '/v2/lembretes' + }, + { + handlers: [ + new CriaLembreteController({ + criaLembreteUseCase: new CriaLembreteUseCase({ lembreteCollection }) + }) + ], + method: Method.Post, + path: '/v2/lembretes' + }, + { + handlers: [ + new BuscaLembreteController({ + buscaLembreteUseCase: new BuscaLembreteUseCase({ lembreteCollection }) + }) + ], + method: Method.Get, + path: '/v2/lembretes/:lembreteId' + }, + { + handlers: [ + new AtualizaLembreteController({ + atualizaLembreteUseCase: new AtualizaLembreteUseCase({ lembreteCollection }) + }) + ], + method: Method.Put, + path: '/v2/lembretes/:lembreteId' + }, + { + handlers: [ + new RemoveLembreteController({ + removeLembreteUseCase: new RemoveLembreteUseCase({ lembreteCollection }) + }) + ], + method: Method.Delete, + path: '/v2/lembretes/:lembreteId' + } + ] +} diff --git a/src/application/lembrete/lembrete-parsing.ts b/src/application/lembrete/lembrete-parsing.ts new file mode 100644 index 00000000..4d1643a0 --- /dev/null +++ b/src/application/lembrete/lembrete-parsing.ts @@ -0,0 +1,38 @@ +import { FICHA_FIELDS, FichaAttributes } from '@/domain/lembrete/Lembrete' + +export function parseId(raw: unknown, field: string): number | Error { + if (typeof raw !== 'string' || !/^\d+$/.test(raw)) { + return new Error(`${field} inválido`) + } + return Number(raw) +} + +export function parseRequiredString(raw: unknown, field: string): string | Error { + if (typeof raw !== 'string') { + return new Error(`${field} inválido`) + } + return raw +} + +/** + * Lê só os campos da ficha que vieram no corpo. Com `preencherAusentes`, os + * que faltam viram null (criação); sem, ficam de fora (atualização parcial). + */ +export function parseFicha( + body: Record, + { preencherAusentes }: { preencherAusentes: boolean } +): Partial | Error { + const result: Partial> = {} + for (const campo of FICHA_FIELDS) { + const value = body[campo] + if (value === undefined) { + if (preencherAusentes) result[campo] = null + continue + } + if (value !== null && typeof value !== 'string') { + return new Error(`${campo} inválido`) + } + result[campo] = value + } + return result +} diff --git a/src/config/evidencia.ts b/src/config/evidencia.ts new file mode 100644 index 00000000..f9d38ace --- /dev/null +++ b/src/config/evidencia.ts @@ -0,0 +1,22 @@ +import { resolve } from 'node:path' + +import { upload } from './directory' + +export const EVIDENCIA_DIR = resolve(upload, 'evidencias') + +const DEFAULT_MAX_FILE_SIZE_MB = 200 +const maxFileSizeMb = Number(process.env.EVIDENCIA_MAX_FILE_SIZE_MB) || DEFAULT_MAX_FILE_SIZE_MB +export const EVIDENCIA_MAX_FILE_SIZE_BYTES = maxFileSizeMb * 1024 * 1024 + +export const MIME_TYPE_EXTENSAO: Record = { + 'image/jpeg': '.jpg', + 'image/png': '.png', + 'image/webp': '.webp', + 'video/mp4': '.mp4', + 'video/quicktime': '.mov', + 'audio/mpeg': '.mp3', + 'audio/mp4': '.m4a', + 'audio/wav': '.wav' +} + +export const MIME_TYPES_ACEITOS = Object.keys(MIME_TYPE_EXTENSAO) diff --git a/src/database/migration/20260914100000_cria_tabela_expedicoes.ts b/src/database/migration/20260914100000_cria_tabela_expedicoes.ts new file mode 100644 index 00000000..3a789a3a --- /dev/null +++ b/src/database/migration/20260914100000_cria_tabela_expedicoes.ts @@ -0,0 +1,44 @@ +import { Knex } from 'knex' + +export async function run(knex: Knex): Promise { + const hasTable = await knex.schema.hasTable('expedicoes') + if (hasTable) { + return + } + + await knex.transaction(async trx => { + await trx.schema.createTable('expedicoes', table => { + table.increments('id').primary() + + table.text('descricao').nullable() + table.date('data_inicio').notNullable() + table.date('data_fim').notNullable() + + table.bigInteger('cidade_id').notNullable() + table.foreign('cidade_id') + .references('id') + .inTable('cidades') + .onDelete('NO ACTION') + .onUpdate('CASCADE') + + table.timestamp('created_at').notNullable().defaultTo(trx.fn.now()) + table.timestamp('updated_at').notNullable().defaultTo(trx.fn.now()) + + table.bigInteger('created_by').nullable() + table.foreign('created_by') + .references('id') + .inTable('usuarios') + .onDelete('NO ACTION') + .onUpdate('CASCADE') + + table.bigInteger('updated_by').nullable() + table.foreign('updated_by') + .references('id') + .inTable('usuarios') + .onDelete('NO ACTION') + .onUpdate('CASCADE') + + table.index('cidade_id', 'expedicoes_cidade_id_index') + }) + }) +} diff --git a/src/database/migration/20260914100001_cria_tabela_expedicoes_participantes.ts b/src/database/migration/20260914100001_cria_tabela_expedicoes_participantes.ts new file mode 100644 index 00000000..5eebd384 --- /dev/null +++ b/src/database/migration/20260914100001_cria_tabela_expedicoes_participantes.ts @@ -0,0 +1,47 @@ +import { Knex } from 'knex' + +export async function run(knex: Knex): Promise { + const hasTable = await knex.schema.hasTable('expedicoes_participantes') + if (hasTable) { + return + } + + await knex.transaction(async trx => { + await trx.schema.createTable('expedicoes_participantes', table => { + table.increments('id').primary() + + table.integer('expedicao_id').notNullable() + table.foreign('expedicao_id') + .references('id') + .inTable('expedicoes') + .onDelete('CASCADE') + .onUpdate('CASCADE') + + table.bigInteger('usuario_id').notNullable() + table.foreign('usuario_id') + .references('id') + .inTable('usuarios') + .onDelete('NO ACTION') + .onUpdate('CASCADE') + + table.timestamp('created_at').notNullable().defaultTo(trx.fn.now()) + table.timestamp('updated_at').notNullable().defaultTo(trx.fn.now()) + + table.bigInteger('created_by').nullable() + table.foreign('created_by') + .references('id') + .inTable('usuarios') + .onDelete('NO ACTION') + .onUpdate('CASCADE') + + table.bigInteger('updated_by').nullable() + table.foreign('updated_by') + .references('id') + .inTable('usuarios') + .onDelete('NO ACTION') + .onUpdate('CASCADE') + + table.unique(['expedicao_id', 'usuario_id'], { indexName: 'expedicoes_participantes_expedicao_id_usuario_id_unique' }) + }) + }) +} diff --git a/src/database/migration/20260914100002_cria_tabela_expedicoes_rotas.ts b/src/database/migration/20260914100002_cria_tabela_expedicoes_rotas.ts new file mode 100644 index 00000000..ba2844da --- /dev/null +++ b/src/database/migration/20260914100002_cria_tabela_expedicoes_rotas.ts @@ -0,0 +1,50 @@ +import { Knex } from 'knex' + +export async function run(knex: Knex): Promise { + const hasTable = await knex.schema.hasTable('expedicoes_rotas') + if (hasTable) { + return + } + + await knex.transaction(async trx => { + await trx.schema.createTable('expedicoes_rotas', table => { + table.increments('id').primary() + + table.integer('expedicao_id').notNullable() + table.foreign('expedicao_id') + .references('id') + .inTable('expedicoes') + .onDelete('CASCADE') + .onUpdate('CASCADE') + + table.bigInteger('cidade_id').notNullable() + table.foreign('cidade_id') + .references('id') + .inTable('cidades') + .onDelete('NO ACTION') + .onUpdate('CASCADE') + + table.specificType('ordem', 'smallint').notNullable() + + table.timestamp('created_at').notNullable().defaultTo(trx.fn.now()) + table.timestamp('updated_at').notNullable().defaultTo(trx.fn.now()) + + table.bigInteger('created_by').nullable() + table.foreign('created_by') + .references('id') + .inTable('usuarios') + .onDelete('NO ACTION') + .onUpdate('CASCADE') + + table.bigInteger('updated_by').nullable() + table.foreign('updated_by') + .references('id') + .inTable('usuarios') + .onDelete('NO ACTION') + .onUpdate('CASCADE') + + table.unique(['expedicao_id', 'ordem'], { indexName: 'expedicoes_rotas_expedicao_id_ordem_unique' }) + table.index('cidade_id', 'expedicoes_rotas_cidade_id_index') + }) + }) +} diff --git a/src/database/migration/20260915100000_cria_tabela_eventos.ts b/src/database/migration/20260915100000_cria_tabela_eventos.ts new file mode 100644 index 00000000..446e928e --- /dev/null +++ b/src/database/migration/20260915100000_cria_tabela_eventos.ts @@ -0,0 +1,56 @@ +import { Knex } from 'knex' + +export async function run(knex: Knex): Promise { + const hasTable = await knex.schema.hasTable('eventos') + if (hasTable) { + return + } + + await knex.transaction(async trx => { + await trx.schema.createTable('eventos', table => { + table.increments('id').primary() + + table.integer('expedicao_id').notNullable() + table.foreign('expedicao_id') + .references('id') + .inTable('expedicoes') + .onDelete('CASCADE') + .onUpdate('CASCADE') + + table.text('tipo').notNullable() + table.timestamp('capturado_em').notNullable() + + table.double('latitude').nullable() + table.double('longitude').nullable() + table.double('altitude').nullable() + + table.text('observacoes').nullable() + + table.timestamp('created_at').notNullable().defaultTo(trx.fn.now()) + table.timestamp('updated_at').notNullable().defaultTo(trx.fn.now()) + + table.bigInteger('created_by').nullable() + table.foreign('created_by') + .references('id') + .inTable('usuarios') + .onDelete('NO ACTION') + .onUpdate('CASCADE') + + table.bigInteger('updated_by').nullable() + table.foreign('updated_by') + .references('id') + .inTable('usuarios') + .onDelete('NO ACTION') + .onUpdate('CASCADE') + + table.check('?? in (?, ?)', [ + 'tipo', + 'DIARIO', + 'COLETA' + ], 'eventos_tipo_check') + + table.index('expedicao_id', 'eventos_expedicao_id_index') + table.index('capturado_em', 'eventos_capturado_em_index') + }) + }) +} diff --git a/src/database/migration/20260915100001_cria_tabela_eventos_coletas.ts b/src/database/migration/20260915100001_cria_tabela_eventos_coletas.ts new file mode 100644 index 00000000..1b49ea90 --- /dev/null +++ b/src/database/migration/20260915100001_cria_tabela_eventos_coletas.ts @@ -0,0 +1,52 @@ +import { Knex } from 'knex' + +/** + * Ficha botânica de campo. Existe apenas para eventos com tipo = 'COLETA'; a + * garantia de que toda coleta tem a sua é da transação do adapter, já que uma + * constraint entre tabelas não é expressável aqui. + * + * Os campos são strings planas, não FKs para familias/cidades/vegetacoes/solos/ + * relevos: a captura é offline e a determinação é provisória. A normalização + * acontece na promoção a tombo. + */ +const CAMPOS_DA_FICHA = [ + 'familia', + 'nome_popular', + 'nome_cientifico', + 'municipio', + 'estado', + 'referencia_local', + 'tipo_vegetacao', + 'solo', + 'relevo', + 'substrato', + 'tronco_com_casca', + 'associacoes', + 'folhas', + 'habito', + 'frutos', + 'flores', + 'luminosidade' +] + +export async function run(knex: Knex): Promise { + const hasTable = await knex.schema.hasTable('eventos_coletas') + if (hasTable) { + return + } + + await knex.transaction(async trx => { + await trx.schema.createTable('eventos_coletas', table => { + table.integer('evento_id').primary() + table.foreign('evento_id') + .references('id') + .inTable('eventos') + .onDelete('CASCADE') + .onUpdate('CASCADE') + + CAMPOS_DA_FICHA.forEach(campo => { + table.text(campo).nullable() + }) + }) + }) +} diff --git a/src/database/migration/20260915100002_cria_tabela_evidencias.ts b/src/database/migration/20260915100002_cria_tabela_evidencias.ts new file mode 100644 index 00000000..5b871010 --- /dev/null +++ b/src/database/migration/20260915100002_cria_tabela_evidencias.ts @@ -0,0 +1,43 @@ +import { Knex } from 'knex' + +export async function run(knex: Knex): Promise { + const hasTable = await knex.schema.hasTable('evidencias') + if (hasTable) { + return + } + + await knex.transaction(async trx => { + await trx.schema.createTable('evidencias', table => { + table.increments('id').primary() + + table.integer('evento_id').notNullable() + table.foreign('evento_id') + .references('id') + .inTable('eventos') + .onDelete('CASCADE') + .onUpdate('CASCADE') + + table.string('nome', 255).notNullable() + table.timestamp('capturado_em').notNullable() + + table.timestamp('created_at').notNullable().defaultTo(trx.fn.now()) + table.timestamp('updated_at').notNullable().defaultTo(trx.fn.now()) + + table.bigInteger('created_by').nullable() + table.foreign('created_by') + .references('id') + .inTable('usuarios') + .onDelete('NO ACTION') + .onUpdate('CASCADE') + + table.bigInteger('updated_by').nullable() + table.foreign('updated_by') + .references('id') + .inTable('usuarios') + .onDelete('NO ACTION') + .onUpdate('CASCADE') + + table.index('evento_id', 'evidencias_evento_id_index') + }) + }) +} diff --git a/src/database/migration/20260921100000_adiciona_colunas_arquivo_evidencias.ts b/src/database/migration/20260921100000_adiciona_colunas_arquivo_evidencias.ts new file mode 100644 index 00000000..af261e7f --- /dev/null +++ b/src/database/migration/20260921100000_adiciona_colunas_arquivo_evidencias.ts @@ -0,0 +1,16 @@ +import { Knex } from 'knex' + +export async function run(knex: Knex): Promise { + const hasColumn = await knex.schema.hasColumn('evidencias', 'arquivo') + if (hasColumn) { + return + } + + await knex.transaction(async trx => { + await trx.schema.alterTable('evidencias', table => { + table.string('arquivo', 255).notNullable() + table.string('mime_type', 100).notNullable() + table.bigInteger('tamanho').notNullable() + }) + }) +} diff --git a/src/database/migration/20261005120000_cria_tabela_lembretes.ts b/src/database/migration/20261005120000_cria_tabela_lembretes.ts new file mode 100644 index 00000000..45f7b29f --- /dev/null +++ b/src/database/migration/20261005120000_cria_tabela_lembretes.ts @@ -0,0 +1,68 @@ +import { Knex } from 'knex' + +/** + * Lembrete de coleta: a mesma ficha botânica de `eventos_coletas`, mas sem + * evento por trás. Guarda uma data e um local para voltar a coletar numa + * expedição futura. + * + * Como na ficha de coleta, os campos são strings planas, não FKs: a captura + * é offline e a determinação é provisória. + */ +const CAMPOS_DA_FICHA = [ + 'familia', + 'nome_popular', + 'nome_cientifico', + 'municipio', + 'estado', + 'referencia_local', + 'tipo_vegetacao', + 'solo', + 'relevo', + 'substrato', + 'tronco_com_casca', + 'associacoes', + 'folhas', + 'habito', + 'frutos', + 'flores', + 'luminosidade' +] + +export async function run(knex: Knex): Promise { + const hasTable = await knex.schema.hasTable('lembretes') + if (hasTable) { + return + } + + await knex.transaction(async trx => { + await trx.schema.createTable('lembretes', table => { + table.increments('id').primary() + + table.date('data_coleta').notNullable() + table.text('local_coleta').notNullable() + + CAMPOS_DA_FICHA.forEach(campo => { + table.text(campo).nullable() + }) + + table.timestamp('created_at').notNullable().defaultTo(trx.fn.now()) + table.timestamp('updated_at').notNullable().defaultTo(trx.fn.now()) + + table.bigInteger('created_by').nullable() + table.foreign('created_by') + .references('id') + .inTable('usuarios') + .onDelete('NO ACTION') + .onUpdate('CASCADE') + + table.bigInteger('updated_by').nullable() + table.foreign('updated_by') + .references('id') + .inTable('usuarios') + .onDelete('NO ACTION') + .onUpdate('CASCADE') + + table.index('data_coleta', 'lembretes_data_coleta_index') + }) + }) +} diff --git a/src/domain/evento/AtualizarEventoUseCase.ts b/src/domain/evento/AtualizarEventoUseCase.ts new file mode 100644 index 00000000..8bb94495 --- /dev/null +++ b/src/domain/evento/AtualizarEventoUseCase.ts @@ -0,0 +1,57 @@ +import { Either } from '@/library/either/Either' + +import { AtualizarEventoInput, AtualizarEventoValidator } from './AtualizarEventoValidator' +import { Attributes } from './Evento' +import { AtualizarEventoAttributes, EventoCollection } from './EventoCollection' + +interface Dependencies { + eventoCollection: EventoCollection + atualizarEventoValidator?: AtualizarEventoValidator +} + +export type Input = AtualizarEventoInput & { id: number } + +/** + * O PUT do agregado evento+ficha. A regra de coerência própria do update + * (quando `tipo` da ficha muda), agora está em AtualizarEventoValidator. + */ +export class AtualizarEventoUseCase { + private readonly eventoCollection: EventoCollection + private readonly atualizarEventoValidator: AtualizarEventoValidator + + constructor(dependencies: Dependencies) { + this.eventoCollection = dependencies.eventoCollection + this.atualizarEventoValidator = dependencies.atualizarEventoValidator ?? new AtualizarEventoValidator() + } + + async execute(input: Input): Promise> { + const existente = await this.eventoCollection.findById(input.id) + if (existente.left()) { + return Either.left(existente.value) + } + + if (!existente.value) { + return Either.right(null) + } + + const validado = this.atualizarEventoValidator.validar(existente.value, input) + if (validado.left()) { + return Either.left(validado.value) + } + + const mesclado = validado.value + + const patch: AtualizarEventoAttributes = { + altitude: mesclado.altitude, + capturado_em: mesclado.capturado_em, + coleta: mesclado.coleta, + latitude: mesclado.latitude, + longitude: mesclado.longitude, + observacoes: mesclado.observacoes, + tipo: mesclado.tipo, + updated_by: mesclado.updated_by + } + + return this.eventoCollection.update(input.id, patch) + } +} diff --git a/src/domain/evento/AtualizarEventoValidator.ts b/src/domain/evento/AtualizarEventoValidator.ts new file mode 100644 index 00000000..d90a66b8 --- /dev/null +++ b/src/domain/evento/AtualizarEventoValidator.ts @@ -0,0 +1,54 @@ +import { Either } from '@/library/either/Either' + +import { + Attributes, ColetaAttributes, Evento, EventoTipo +} from './Evento' + +export interface AtualizarEventoInput { + tipo?: EventoTipo + capturado_em?: Date + latitude?: number | null + longitude?: number | null + altitude?: number | null + observacoes?: string | null + coleta?: ColetaAttributes | null + updated_by: number | null +} + +/** + * Regra de coerência própria do update, `tipo` e `coleta` não são independentes: + * - vira DIARIO -> a ficha é descartada, não importa o que o cliente mandou + * - vira COLETA sem `coleta` no corpo -> mantém a ficha atual + * - vira COLETA com `coleta` no corpo -> substitui a ficha + * + * Reaproveita de Evento.create() para checagem final de coerência (tipo/ficha, + * coordenadas, capturado_em) sobre o estado já mesclado, afim não duplicar + * essa regra em dois lugares. + */ + +export class AtualizarEventoValidator { + validar(atual: Attributes, input: AtualizarEventoInput): Either { + const mesclado = this.mesclar(atual, input) + return Evento.create(mesclado) + } + + private mesclar(atual: Attributes, input: AtualizarEventoInput): Attributes { + const tipo = input.tipo ?? atual.tipo + + const coleta = tipo === 'DIARIO' + ? null + : (input.coleta !== undefined ? input.coleta : atual.coleta) + + return { + ...atual, + altitude: input.altitude !== undefined ? input.altitude : atual.altitude, + capturado_em: input.capturado_em ?? atual.capturado_em, + coleta, + latitude: input.latitude !== undefined ? input.latitude : atual.latitude, + longitude: input.longitude !== undefined ? input.longitude : atual.longitude, + observacoes: input.observacoes !== undefined ? input.observacoes : atual.observacoes, + tipo, + updated_by: input.updated_by + } + } +} diff --git a/src/domain/evento/BuscarEventoPorIdUseCase.ts b/src/domain/evento/BuscarEventoPorIdUseCase.ts new file mode 100644 index 00000000..76e08343 --- /dev/null +++ b/src/domain/evento/BuscarEventoPorIdUseCase.ts @@ -0,0 +1,20 @@ +import { Either } from '@/library/either/Either' + +import { Attributes } from './Evento' +import { EventoCollection } from './EventoCollection' + +interface Dependencies { + eventoCollection: EventoCollection +} + +export class BuscarEventoPorIdUseCase { + private readonly eventoCollection: EventoCollection + + constructor(dependencies: Dependencies) { + this.eventoCollection = dependencies.eventoCollection + } + + execute({ id }: { id: number }): Promise> { + return this.eventoCollection.findById(id) + } +} diff --git a/src/domain/evento/CriarEventoUseCase.ts b/src/domain/evento/CriarEventoUseCase.ts new file mode 100644 index 00000000..3c868feb --- /dev/null +++ b/src/domain/evento/CriarEventoUseCase.ts @@ -0,0 +1,36 @@ +import { Either } from '@/library/either/Either' + +import { + Attributes, CreateAttributes, Evento +} from './Evento' +import { EventoCollection } from './EventoCollection' + +interface Dependencies { + eventoCollection: EventoCollection +} + +export class CriarEventoUseCase { + private readonly eventoCollection: EventoCollection + + constructor(dependencies: Dependencies) { + this.eventoCollection = dependencies.eventoCollection + } + + async execute(input: CreateAttributes): Promise> { + // Evento.create() exige um Attributes completo, usado para validar + // a coerência de negócio (tipo/ficha, coordenadas,capturado_em). + const validated = Evento.create({ + ...input, + created_at: new Date(), + id: 0, + updated_at: new Date(), + updated_by: input.created_by + }) + + if (validated.left()) { + return Either.left(validated.value) + } + + return this.eventoCollection.create(input) + } +} diff --git a/src/domain/evento/Evento.ts b/src/domain/evento/Evento.ts new file mode 100644 index 00000000..1ff2eedd --- /dev/null +++ b/src/domain/evento/Evento.ts @@ -0,0 +1,130 @@ +import { Either } from '@/library/either/Either' + +export const EVENTO_TIPOS = ['DIARIO', 'COLETA'] as const + +export type EventoTipo = typeof EVENTO_TIPOS[number] + +export interface ColetaAttributes { + familia: string | null + nome_popular: string | null + nome_cientifico: string | null + municipio: string | null + estado: string | null + referencia_local: string | null + tipo_vegetacao: string | null + solo: string | null + relevo: string | null + substrato: string | null + tronco_com_casca: string | null + associacoes: string | null + folhas: string | null + habito: string | null + frutos: string | null + flores: string | null + luminosidade: string | null +} + +/** + * Lista de campos da ficha de coleta. + */ +export const COLETA_FIELDS = [ + 'familia', + 'nome_popular', + 'nome_cientifico', + 'municipio', + 'estado', + 'referencia_local', + 'tipo_vegetacao', + 'solo', + 'relevo', + 'substrato', + 'tronco_com_casca', + 'associacoes', + 'folhas', + 'habito', + 'frutos', + 'flores', + 'luminosidade' +] as const satisfies ReadonlyArray + +export interface Attributes { + id: number + expedicao_id: number + tipo: EventoTipo + capturado_em: Date + latitude: number | null + longitude: number | null + altitude: number | null + observacoes: string | null + coleta: ColetaAttributes | null + created_at: Date + updated_at: Date + created_by: number | null + updated_by: number | null +} + +export type CreateAttributes = Omit + +export class Evento { + readonly id: number + readonly expedicao_id: number + readonly tipo: EventoTipo + readonly capturado_em: Date + readonly latitude: number | null + readonly longitude: number | null + readonly altitude: number | null + readonly observacoes: string | null + readonly coleta: ColetaAttributes | null + readonly created_at: Date + readonly updated_at: Date + readonly created_by: number | null + readonly updated_by: number | null + + private constructor(attributes: Attributes) { + this.id = attributes.id + this.expedicao_id = attributes.expedicao_id + this.tipo = attributes.tipo + this.capturado_em = attributes.capturado_em + this.latitude = attributes.latitude + this.longitude = attributes.longitude + this.altitude = attributes.altitude + this.observacoes = attributes.observacoes + this.coleta = attributes.coleta + this.created_at = attributes.created_at + this.updated_at = attributes.updated_at + this.created_by = attributes.created_by + this.updated_by = attributes.updated_by + } + + static create(attributes: Attributes): Either { + if (!Number.isInteger(attributes.expedicao_id) || attributes.expedicao_id <= 0) { + return Either.left(new Error('Expedição do evento é obrigatória')) + } + + if (!EVENTO_TIPOS.includes(attributes.tipo)) { + return Either.left(new Error(`Tipo do evento deve ser um de: ${EVENTO_TIPOS.join(', ')}`)) + } + + if (Number.isNaN(attributes.capturado_em.getTime())) { + return Either.left(new Error('Instante de captura do evento é inválido')) + } + + if (attributes.tipo === 'COLETA' && !attributes.coleta) { + return Either.left(new Error('Evento de coleta exige a ficha de coleta')) + } + + if (attributes.tipo === 'DIARIO' && attributes.coleta) { + return Either.left(new Error('Evento de diário não pode ter ficha de coleta')) + } + + if (attributes.latitude !== null && (attributes.latitude < -90 || attributes.latitude > 90)) { + return Either.left(new Error('Latitude do evento deve estar entre -90 e 90')) + } + + if (attributes.longitude !== null && (attributes.longitude < -180 || attributes.longitude > 180)) { + return Either.left(new Error('Longitude do evento deve estar entre -180 e 180')) + } + + return Either.right(new Evento(attributes)) + } +} diff --git a/src/domain/evento/EventoCollection.ts b/src/domain/evento/EventoCollection.ts new file mode 100644 index 00000000..ccd5b4e1 --- /dev/null +++ b/src/domain/evento/EventoCollection.ts @@ -0,0 +1,46 @@ +import { Either } from '@/library/either/Either' + +import { + Attributes, ColetaAttributes, CreateAttributes, EventoTipo +} from './Evento' + +export interface EventoOrder { + column: 'id' | 'capturado_em' + direction: 'asc' | 'desc' +} + +export interface EventoFilters { + expedicao_id?: number + tipo?: EventoTipo + capturado_de?: Date + capturado_ate?: Date + order?: EventoOrder + limite?: number + pagina?: number +} + +export interface Paginated { + itens: T[] + total: number + limite: number + pagina: number +} + +export interface AtualizarEventoAttributes { + tipo: EventoTipo + capturado_em: Date + latitude: number | null + longitude: number | null + altitude: number | null + observacoes: string | null + coleta: ColetaAttributes | null + updated_by: number | null +} + +export interface EventoCollection { + findAll(filters: EventoFilters): Promise>> + findById(id: number): Promise> + create(attributes: CreateAttributes): Promise> + update(id: number, attributes: AtualizarEventoAttributes): Promise> + delete(id: number): Promise> +} diff --git a/src/domain/evento/ListaEventosUseCase.ts b/src/domain/evento/ListaEventosUseCase.ts new file mode 100644 index 00000000..ac2b3909 --- /dev/null +++ b/src/domain/evento/ListaEventosUseCase.ts @@ -0,0 +1,22 @@ +import { Either } from '@/library/either/Either' + +import { Attributes } from './Evento' +import { + EventoCollection, EventoFilters, Paginated +} from './EventoCollection' + +interface Dependencies { + eventoCollection: EventoCollection +} + +export class ListaEventosUseCase { + private readonly eventoCollection: EventoCollection + + constructor(dependencies: Dependencies) { + this.eventoCollection = dependencies.eventoCollection + } + + execute(filters: EventoFilters): Promise>> { + return this.eventoCollection.findAll(filters) + } +} diff --git a/src/domain/evento/RemoverEventoUseCase.ts b/src/domain/evento/RemoverEventoUseCase.ts new file mode 100644 index 00000000..6e0fbdcc --- /dev/null +++ b/src/domain/evento/RemoverEventoUseCase.ts @@ -0,0 +1,47 @@ +import { unlink } from 'node:fs/promises' +import path from 'node:path' + +import { EVIDENCIA_DIR } from '@/config/evidencia' +import { EvidenciaCollection } from '@/domain/evidencia/EvidenciaCollection' +import { Either } from '@/library/either/Either' + +import { EventoCollection } from './EventoCollection' + +interface Dependencies { + eventoCollection: EventoCollection + evidenciaCollection: EvidenciaCollection +} + +export class RemoverEventoUseCase { + private readonly eventoCollection: EventoCollection + private readonly evidenciaCollection: EvidenciaCollection + + constructor(dependencies: Dependencies) { + this.eventoCollection = dependencies.eventoCollection + this.evidenciaCollection = dependencies.evidenciaCollection + } + + async execute({ id }: { id: number }): Promise> { + const evidencias = await this.evidenciaCollection.findAll({ evento_id: id }) + if (evidencias.left()) return Either.left(evidencias.value) + + const deleted = await this.eventoCollection.delete(id) + if (deleted.left()) return Either.left(deleted.value) + + if (deleted.value) { + await Promise.all( + evidencias.value.map(evidencia => + unlink(path.join(EVIDENCIA_DIR, evidencia.arquivo)).catch((error: unknown) => { + // eslint-disable-next-line no-console -- evento já apagado do banco; sem log aqui, o arquivo órfão nunca seria detectável depois + console.error( + `Falha ao remover arquivo órfão de evidência (evento_id=${id}, arquivo=${evidencia.arquivo})`, + error + ) + }) + ) + ) + } + + return deleted + } +} diff --git a/src/domain/evidencia/CriarEvidenciaUseCase.ts b/src/domain/evidencia/CriarEvidenciaUseCase.ts new file mode 100644 index 00000000..fdd6c48e --- /dev/null +++ b/src/domain/evidencia/CriarEvidenciaUseCase.ts @@ -0,0 +1,34 @@ +import { Either } from '@/library/either/Either' + +import { + Attributes, CreateAttributes, Evidencia +} from './Evidencia' +import { EvidenciaCollection } from './EvidenciaCollection' + +interface Dependencies { + evidenciaCollection: EvidenciaCollection +} + +export class CriarEvidenciaUseCase { + private readonly evidenciaCollection: EvidenciaCollection + + constructor(dependencies: Dependencies) { + this.evidenciaCollection = dependencies.evidenciaCollection + } + + async execute(input: CreateAttributes): Promise> { + const validated = Evidencia.create({ + ...input, + created_at: new Date(), + id: 0, + updated_at: new Date(), + updated_by: input.created_by + }) + + if (validated.left()) { + return Either.left(validated.value) + } + + return this.evidenciaCollection.create(input) + } +} diff --git a/src/domain/evidencia/Evidencia.ts b/src/domain/evidencia/Evidencia.ts new file mode 100644 index 00000000..3423fea2 --- /dev/null +++ b/src/domain/evidencia/Evidencia.ts @@ -0,0 +1,73 @@ +import { Either } from '@/library/either/Either' + +export interface Attributes { + id: number + evento_id: number + nome: string + arquivo: string + mime_type: string + tamanho: number + capturado_em: Date + created_at: Date + updated_at: Date + created_by: number | null + updated_by: number | null +} + +export type CreateAttributes = Omit + +export class Evidencia { + readonly id: number + readonly evento_id: number + readonly nome: string + readonly arquivo: string + readonly mime_type: string + readonly tamanho: number + readonly capturado_em: Date + readonly created_at: Date + readonly updated_at: Date + readonly created_by: number | null + readonly updated_by: number | null + + private constructor(attributes: Attributes) { + this.id = attributes.id + this.evento_id = attributes.evento_id + this.nome = attributes.nome + this.arquivo = attributes.arquivo + this.mime_type = attributes.mime_type + this.tamanho = attributes.tamanho + this.capturado_em = attributes.capturado_em + this.created_at = attributes.created_at + this.updated_at = attributes.updated_at + this.created_by = attributes.created_by + this.updated_by = attributes.updated_by + } + + static create(attributes: Attributes): Either { + if (!Number.isInteger(attributes.evento_id) || attributes.evento_id <= 0) { + return Either.left(new Error('Evento da evidência é obrigatório')) + } + + if (!attributes.nome.trim()) { + return Either.left(new Error('Nome da evidência não pode ser vazio')) + } + + if (!attributes.arquivo.trim()) { + return Either.left(new Error('Arquivo da evidência é obrigatório')) + } + + if (!attributes.mime_type.trim()) { + return Either.left(new Error('Tipo do arquivo da evidência é obrigatório')) + } + + if (!Number.isInteger(attributes.tamanho) || attributes.tamanho <= 0) { + return Either.left(new Error('Tamanho do arquivo da evidência é inválido')) + } + + if (Number.isNaN(attributes.capturado_em.getTime())) { + return Either.left(new Error('Instante de captura da evidência é inválido')) + } + + return Either.right(new Evidencia(attributes)) + } +} diff --git a/src/domain/evidencia/EvidenciaCollection.ts b/src/domain/evidencia/EvidenciaCollection.ts new file mode 100644 index 00000000..8b4f4a75 --- /dev/null +++ b/src/domain/evidencia/EvidenciaCollection.ts @@ -0,0 +1,21 @@ +import { Either } from '@/library/either/Either' + +import { Attributes, CreateAttributes } from './Evidencia' + +export interface EvidenciaOrder { + column: 'id' | 'nome' | 'capturado_em' + direction: 'asc' | 'desc' +} + +export interface EvidenciaFilters { + evento_id?: number + nome?: string + order?: EvidenciaOrder +} + +export interface EvidenciaCollection { + findAll(filters: EvidenciaFilters): Promise> + findById(id: number): Promise> + create(attributes: CreateAttributes): Promise> + delete(id: number): Promise> +} diff --git a/src/domain/evidencia/RemoverEvidenciaUseCase.ts b/src/domain/evidencia/RemoverEvidenciaUseCase.ts new file mode 100644 index 00000000..f5d56d0a --- /dev/null +++ b/src/domain/evidencia/RemoverEvidenciaUseCase.ts @@ -0,0 +1,42 @@ +import { unlink } from 'node:fs/promises' +import path from 'node:path' + +import { EVIDENCIA_DIR } from '@/config/evidencia' +import { Either } from '@/library/either/Either' + +import { EvidenciaCollection } from './EvidenciaCollection' + +interface Dependencies { + evidenciaCollection: EvidenciaCollection +} + +export class RemoverEvidenciaUseCase { + private readonly evidenciaCollection: EvidenciaCollection + + constructor(dependencies: Dependencies) { + this.evidenciaCollection = dependencies.evidenciaCollection + } + + async execute({ id }: { id: number }): Promise> { + const evidencia = await this.evidenciaCollection.findById(id) + if (evidencia.left()) return Either.left(evidencia.value) + if (!evidencia.value) return Either.right(false) + + const deleted = await this.evidenciaCollection.delete(id) + if (deleted.left()) return Either.left(deleted.value) + + if (deleted.value) { + const { arquivo } = evidencia.value + + await unlink(path.join(EVIDENCIA_DIR, arquivo)).catch((error: unknown) => { + // eslint-disable-next-line no-console -- evidência já apagada do banco; sem log aqui, o arquivo órfão nunca seria detectável depois + console.error( + `Falha ao remover arquivo órfão de evidência (evidencia_id=${id}, arquivo=${arquivo})`, + error + ) + }) + } + + return deleted + } +} diff --git a/src/domain/expedicao/AdcionaParticipanteUseCase.ts b/src/domain/expedicao/AdcionaParticipanteUseCase.ts new file mode 100644 index 00000000..caeb2e0c --- /dev/null +++ b/src/domain/expedicao/AdcionaParticipanteUseCase.ts @@ -0,0 +1,19 @@ +import { Either } from '@/library/either/Either' + +import { ExpedicaoCollection } from './ExpedicaoCollection' + +interface Dependencies { + expedicaoCollection: ExpedicaoCollection +} + +export class AdicionaParticipanteUseCase { + private readonly expedicaoCollection: ExpedicaoCollection + + constructor(dependencies: Dependencies) { + this.expedicaoCollection = dependencies.expedicaoCollection + } + + execute(expedicaoId: number, usuarioId: number): Promise> { + return this.expedicaoCollection.addParticipant(expedicaoId, usuarioId) + } +} diff --git a/src/domain/expedicao/AtualizaExpedicaoUseCase.ts b/src/domain/expedicao/AtualizaExpedicaoUseCase.ts new file mode 100644 index 00000000..d8249880 --- /dev/null +++ b/src/domain/expedicao/AtualizaExpedicaoUseCase.ts @@ -0,0 +1,32 @@ +import { Either } from '@/library/either/Either' + +import { + Attributes, Expedicao, UpdateAttributes +} from './Expedicao' +import { ExpedicaoCollection } from './ExpedicaoCollection' + +interface Dependencies { + expedicaoCollection: ExpedicaoCollection +} + +export class AtualizaExpedicaoUseCase { + private readonly expedicaoCollection: ExpedicaoCollection + + constructor(dependencies: Dependencies) { + this.expedicaoCollection = dependencies.expedicaoCollection + } + + async execute(id: number, changes: UpdateAttributes): Promise> { + const found = await this.expedicaoCollection.findById(id) + if (found.left()) return Either.left(found.value) + if (!found.value) return Either.left(new Error('Expedição não encontrada')) + + const current = Expedicao.create(found.value) + if (current.left()) return Either.left(current.value) + + const updated = current.value.update(changes) + if (updated.left()) return Either.left(updated.value) + + return this.expedicaoCollection.update(id, changes) + } +} diff --git a/src/domain/expedicao/BuscaExpedicaoUseCase.ts b/src/domain/expedicao/BuscaExpedicaoUseCase.ts new file mode 100644 index 00000000..632551f9 --- /dev/null +++ b/src/domain/expedicao/BuscaExpedicaoUseCase.ts @@ -0,0 +1,20 @@ +import { Either } from '@/library/either/Either' + +import { Attributes } from './Expedicao' +import { ExpedicaoCollection } from './ExpedicaoCollection' + +interface Dependencies { + expedicaoCollection: ExpedicaoCollection +} + +export class BuscaExpedicaoUseCase { + private readonly expedicaoCollection: ExpedicaoCollection + + constructor(dependencies: Dependencies) { + this.expedicaoCollection = dependencies.expedicaoCollection + } + + async execute({ id }: { id: number }): Promise> { + return this.expedicaoCollection.findById(id) + } +} diff --git a/src/domain/expedicao/CadastraExpedicaoUseCase.ts b/src/domain/expedicao/CadastraExpedicaoUseCase.ts new file mode 100644 index 00000000..8869c573 --- /dev/null +++ b/src/domain/expedicao/CadastraExpedicaoUseCase.ts @@ -0,0 +1,37 @@ +import { Either } from '@/library/either/Either' + +import { + Attributes, CreateAttributes, Expedicao +} from './Expedicao' +import { ExpedicaoCollection } from './ExpedicaoCollection' + +interface Dependencies { + expedicaoCollection: ExpedicaoCollection +} + +export class CadastraExpedicaoUseCase { + private readonly expedicaoCollection: ExpedicaoCollection + + constructor(dependencies: Dependencies) { + this.expedicaoCollection = dependencies.expedicaoCollection + } + + async execute(dados: CreateAttributes): Promise> { + // Valida as regras de negócio passando pela Entidade. + // Entidade exige id e datas de auditoria na interface, mockado para a validação passar. + const validacao = Expedicao.create({ + ...dados, + id: 0, + created_at: new Date(), + updated_at: new Date(), + updated_by: null + } as Attributes) + + if (validacao.left()) { + return Either.left(validacao.value) + } + + // Adapter para persistir no banco + return await this.expedicaoCollection.create(dados) + } +} diff --git a/src/domain/expedicao/DeletaExpedicaoUseCase.ts b/src/domain/expedicao/DeletaExpedicaoUseCase.ts new file mode 100644 index 00000000..300b1ce0 --- /dev/null +++ b/src/domain/expedicao/DeletaExpedicaoUseCase.ts @@ -0,0 +1,20 @@ +import { + Either +} from '@/library/either/Either' + +import { ExpedicaoCollection } from './ExpedicaoCollection' + +interface Dependencies { + expedicaoCollection: ExpedicaoCollection +} + +export class DeletaExpedicaoUseCase { + private readonly expedicaoCollection: ExpedicaoCollection + constructor(dependencies: Dependencies) { + this.expedicaoCollection = dependencies.expedicaoCollection + } + + async execute({ id }: { id: number }): Promise> { + return this.expedicaoCollection.delete(id) + } +} diff --git a/src/domain/expedicao/Expedicao.ts b/src/domain/expedicao/Expedicao.ts new file mode 100644 index 00000000..f3cc1f7e --- /dev/null +++ b/src/domain/expedicao/Expedicao.ts @@ -0,0 +1,110 @@ +import { Either } from '@/library/either/Either' + +export interface Attributes { + id: number + descricao: string | null + data_inicio: string + data_fim: string + cidade_id: number + created_at: Date + updated_at: Date + created_by: number | null + updated_by: number | null +} + +export interface ParticipanteDetalhado { + id: number + nome: string + email: string +} + +export interface RotaDetalhada { + cidade_id: number + ordem: number + nome_cidade: string + estado: string +} + +export interface ExpedicaoDetalhada extends Attributes { + participantes: ParticipanteDetalhado[] + rotas: RotaDetalhada[] +} + +export type CreateAttributes = + Omit + & { + participantes: number[] + rotas: number[] + } +export interface UpdateAttributes { + descricao: string | null + data_inicio: string + data_fim: string + cidade_id: number + updated_by: number +} +export class Expedicao { + readonly id: number + readonly descricao: string | null + readonly data_inicio: string + readonly data_fim: string + readonly cidade_id: number + readonly created_at: Date + readonly updated_at: Date + readonly created_by: number | null + readonly updated_by: number | null + + private constructor(attributes: Attributes) { + this.id = attributes.id + this.descricao = attributes.descricao + this.data_inicio = attributes.data_inicio + this.data_fim = attributes.data_fim + this.cidade_id = attributes.cidade_id + this.created_at = attributes.created_at + this.updated_at = attributes.updated_at + this.created_by = attributes.created_by + this.updated_by = attributes.updated_by + } + + static create(attributes: Attributes): Either { + if (!attributes.data_inicio) { + return Either.left(new Error('Data de início da expedição é obrigatória')) + } + + if (!attributes.data_fim) { + return Either.left(new Error('Data de fim da expedição é obrigatória')) + } + + if (attributes.data_fim < attributes.data_inicio) { + return Either.left(new Error('Data de fim da expedição não pode ser anterior à data de início')) + } + + if (!Number.isInteger(attributes.cidade_id) || attributes.cidade_id <= 0) { + return Either.left(new Error('Cidade de destino da expedição é obrigatória')) + } + + return Either.right(new Expedicao(attributes)) + } + + toAttributes(): Attributes { + return { + id: this.id, + descricao: this.descricao, + data_inicio: this.data_inicio, + data_fim: this.data_fim, + cidade_id: this.cidade_id, + created_at: this.created_at, + updated_at: this.updated_at, + created_by: this.created_by, + updated_by: this.updated_by + } + } + + update(changes: UpdateAttributes): Either { + return Expedicao.create({ + ...this.toAttributes(), + ...changes, + updated_at: new Date() + }) + } +} diff --git a/src/domain/expedicao/ExpedicaoCollection.ts b/src/domain/expedicao/ExpedicaoCollection.ts new file mode 100644 index 00000000..10d6ec3c --- /dev/null +++ b/src/domain/expedicao/ExpedicaoCollection.ts @@ -0,0 +1,54 @@ +import { Either } from '@/library/either/Either' + +import { + Attributes, CreateAttributes, UpdateAttributes +} from './Expedicao' + +export interface ExpedicaoOrder { + column: 'id' | 'data_inicio' | 'data_fim' + direction: 'asc' | 'desc' +} + +export interface ExpedicaoFilters { + pagina?: number + limite?: number + cidade_id?: number + usuario_id?: number + data_inicio_de?: string + data_inicio_ate?: string + data_fim_de?: string + data_fim_ate?: string + order?: ExpedicaoOrder +} + +export interface Paginated { + itens: T[] + total: number + limite: number + pagina: number +} +export interface ParticipanteExpedicao { + id: number + nome: string +} + +export interface ExpedicaoListItem extends Attributes { + cidade_nome: string | null + estado_sigla: string | null + participantes: ParticipanteExpedicao[] + rotas: number[] +} + +export interface ExpedicaoCollection { + findAll(filters: ExpedicaoFilters): Promise>> + findById(id: number): Promise> + create(attributes: CreateAttributes): Promise> + + addParticipant(expedicaoId: number, usuarioId: number): Promise> + removeParticipant(expedicaoId: number, usuarioId: number): Promise> + + substituteRoute(expedicaoId: number, rotas: number[]): Promise> + + delete(id: number): Promise> + update(id: number, attributes: UpdateAttributes): Promise> +} diff --git a/src/domain/expedicao/ListaExpedicoesUseCase.ts b/src/domain/expedicao/ListaExpedicoesUseCase.ts new file mode 100644 index 00000000..a3974f30 --- /dev/null +++ b/src/domain/expedicao/ListaExpedicoesUseCase.ts @@ -0,0 +1,22 @@ +import { Either } from '@/library/either/Either' + +import { + ExpedicaoCollection, ExpedicaoFilters, ExpedicaoListItem, Paginated +} from './ExpedicaoCollection' + +interface Dependencies { + expedicaoCollection: ExpedicaoCollection +} + +export class ListaExpedicoesUseCase { + private readonly expedicaoCollection: ExpedicaoCollection + + constructor(dependencies: Dependencies) { + this.expedicaoCollection = dependencies.expedicaoCollection + } + + async execute(filters: ExpedicaoFilters): Promise>> { + // repassa os filtros para o adapter realizar a busca + return await this.expedicaoCollection.findAll(filters) + } +} diff --git a/src/domain/expedicao/RemoveParticipanteUseCase.ts b/src/domain/expedicao/RemoveParticipanteUseCase.ts new file mode 100644 index 00000000..c3b3bae5 --- /dev/null +++ b/src/domain/expedicao/RemoveParticipanteUseCase.ts @@ -0,0 +1,19 @@ +import { Either } from '@/library/either/Either' + +import { ExpedicaoCollection } from './ExpedicaoCollection' + +interface Dependencies { + expedicaoCollection: ExpedicaoCollection +} + +export class RemoveParticipanteUseCase { + private readonly expedicaoCollection: ExpedicaoCollection + + constructor(dependencies: Dependencies) { + this.expedicaoCollection = dependencies.expedicaoCollection + } + + execute(expedicaoId: number, usuarioId: number): Promise> { + return this.expedicaoCollection.removeParticipant(expedicaoId, usuarioId) + } +} diff --git a/src/domain/expedicao/SubstituiRotaUseCase.ts b/src/domain/expedicao/SubstituiRotaUseCase.ts new file mode 100644 index 00000000..df4df554 --- /dev/null +++ b/src/domain/expedicao/SubstituiRotaUseCase.ts @@ -0,0 +1,19 @@ +import { Either } from '@/library/either/Either' + +import { ExpedicaoCollection } from './ExpedicaoCollection' + +interface Dependencies { + expedicaoCollection: ExpedicaoCollection +} + +export class SubstituiRotasUseCase { + private readonly expedicaoCollection: ExpedicaoCollection + + constructor(dependencies: Dependencies) { + this.expedicaoCollection = dependencies.expedicaoCollection + } + + execute(expedicaoId: number, rotas: number[]): Promise> { + return this.expedicaoCollection.substituteRoute(expedicaoId, rotas) + } +} diff --git a/src/domain/lembrete/AtualizaLembreteUseCase.ts b/src/domain/lembrete/AtualizaLembreteUseCase.ts new file mode 100644 index 00000000..1809181e --- /dev/null +++ b/src/domain/lembrete/AtualizaLembreteUseCase.ts @@ -0,0 +1,69 @@ +import { Either } from '@/library/either/Either' + +import { + Attributes, FICHA_FIELDS, FichaAttributes, Lembrete, UpdateAttributes +} from './Lembrete' +import { LembreteCollection } from './LembreteCollection' + +interface Dependencies { + lembreteCollection: LembreteCollection +} + +export type Input = Partial & { + id: number + data_coleta?: string + local_coleta?: string + updated_by: number | null +} + +/** + * Campos ausentes no corpo mantêm o valor atual; o estado mesclado passa + * por Lembrete.create() antes de persistir. + */ +export class AtualizaLembreteUseCase { + private readonly lembreteCollection: LembreteCollection + + constructor(dependencies: Dependencies) { + this.lembreteCollection = dependencies.lembreteCollection + } + + async execute(input: Input): Promise> { + const existente = await this.lembreteCollection.findById(input.id) + if (existente.left()) { + return Either.left(existente.value) + } + + if (!existente.value) { + return Either.right(null) + } + + const atual = existente.value + const mesclado: Attributes = { + ...atual, + data_coleta: input.data_coleta ?? atual.data_coleta, + local_coleta: input.local_coleta ?? atual.local_coleta, + updated_by: input.updated_by + } + + for (const campo of FICHA_FIELDS) { + const value = input[campo] + if (value !== undefined) { + mesclado[campo] = value + } + } + + const validado = Lembrete.create(mesclado) + if (validado.left()) { + return Either.left(validado.value) + } + + const patch: UpdateAttributes = { + ...validado.value.ficha, + data_coleta: mesclado.data_coleta, + local_coleta: mesclado.local_coleta, + updated_by: mesclado.updated_by + } + + return this.lembreteCollection.update(input.id, patch) + } +} diff --git a/src/domain/lembrete/BuscaLembreteUseCase.ts b/src/domain/lembrete/BuscaLembreteUseCase.ts new file mode 100644 index 00000000..9d12699c --- /dev/null +++ b/src/domain/lembrete/BuscaLembreteUseCase.ts @@ -0,0 +1,20 @@ +import { Either } from '@/library/either/Either' + +import { Attributes } from './Lembrete' +import { LembreteCollection } from './LembreteCollection' + +interface Dependencies { + lembreteCollection: LembreteCollection +} + +export class BuscaLembreteUseCase { + private readonly lembreteCollection: LembreteCollection + + constructor(dependencies: Dependencies) { + this.lembreteCollection = dependencies.lembreteCollection + } + + execute({ id }: { id: number }): Promise> { + return this.lembreteCollection.findById(id) + } +} diff --git a/src/domain/lembrete/CriaLembreteUseCase.ts b/src/domain/lembrete/CriaLembreteUseCase.ts new file mode 100644 index 00000000..fd449ea7 --- /dev/null +++ b/src/domain/lembrete/CriaLembreteUseCase.ts @@ -0,0 +1,36 @@ +import { Either } from '@/library/either/Either' + +import { + Attributes, CreateAttributes, Lembrete +} from './Lembrete' +import { LembreteCollection } from './LembreteCollection' + +interface Dependencies { + lembreteCollection: LembreteCollection +} + +export class CriaLembreteUseCase { + private readonly lembreteCollection: LembreteCollection + + constructor(dependencies: Dependencies) { + this.lembreteCollection = dependencies.lembreteCollection + } + + async execute(input: CreateAttributes): Promise> { + // Lembrete.create() exige um Attributes completo, usado só para validar + // a regra de negócio (data e local de coleta). + const validated = Lembrete.create({ + ...input, + created_at: new Date(), + id: 0, + updated_at: new Date(), + updated_by: input.created_by + }) + + if (validated.left()) { + return Either.left(validated.value) + } + + return this.lembreteCollection.create(input) + } +} diff --git a/src/domain/lembrete/Lembrete.ts b/src/domain/lembrete/Lembrete.ts new file mode 100644 index 00000000..9af3a48d --- /dev/null +++ b/src/domain/lembrete/Lembrete.ts @@ -0,0 +1,108 @@ +import { Either } from '@/library/either/Either' + +export interface FichaAttributes { + familia: string | null + nome_popular: string | null + nome_cientifico: string | null + municipio: string | null + estado: string | null + referencia_local: string | null + tipo_vegetacao: string | null + solo: string | null + relevo: string | null + substrato: string | null + tronco_com_casca: string | null + associacoes: string | null + folhas: string | null + habito: string | null + frutos: string | null + flores: string | null + luminosidade: string | null +} + +/** + * Lista de campos da ficha do lembrete (clone da ficha de coleta do evento). + */ +export const FICHA_FIELDS = [ + 'familia', + 'nome_popular', + 'nome_cientifico', + 'municipio', + 'estado', + 'referencia_local', + 'tipo_vegetacao', + 'solo', + 'relevo', + 'substrato', + 'tronco_com_casca', + 'associacoes', + 'folhas', + 'habito', + 'frutos', + 'flores', + 'luminosidade' +] as const satisfies ReadonlyArray + +export interface Attributes extends FichaAttributes { + id: number + data_coleta: string + local_coleta: string + created_at: Date + updated_at: Date + created_by: number | null + updated_by: number | null +} + +export type CreateAttributes = Omit + +export type UpdateAttributes = Omit + +const DATA_ISO = /^\d{4}-\d{2}-\d{2}$/ + +/** + * Aceita apenas YYYY-MM-DD de um dia que existe (rejeita 2026-02-30, que o + * Date normalizaria para março). + */ +export function isDataValida(value: string): boolean { + if (!DATA_ISO.test(value)) { + return false + } + const date = new Date(`${value}T00:00:00.000Z`) + return !Number.isNaN(date.getTime()) && date.toISOString().startsWith(value) +} + +export class Lembrete { + readonly id: number + readonly data_coleta: string + readonly local_coleta: string + readonly ficha: FichaAttributes + readonly created_at: Date + readonly updated_at: Date + readonly created_by: number | null + readonly updated_by: number | null + + private constructor(attributes: Attributes) { + this.id = attributes.id + this.data_coleta = attributes.data_coleta + this.local_coleta = attributes.local_coleta + this.ficha = Object.fromEntries( + FICHA_FIELDS.map(campo => [campo, attributes[campo]]) + ) as unknown as FichaAttributes + this.created_at = attributes.created_at + this.updated_at = attributes.updated_at + this.created_by = attributes.created_by + this.updated_by = attributes.updated_by + } + + static create(attributes: Attributes): Either { + if (typeof attributes.data_coleta !== 'string' || !isDataValida(attributes.data_coleta)) { + return Either.left(new Error('Data de coleta do lembrete é obrigatória e deve estar no formato YYYY-MM-DD')) + } + + if (typeof attributes.local_coleta !== 'string' || attributes.local_coleta.trim() === '') { + return Either.left(new Error('Local de coleta do lembrete é obrigatório')) + } + + return Either.right(new Lembrete(attributes)) + } +} diff --git a/src/domain/lembrete/LembreteCollection.ts b/src/domain/lembrete/LembreteCollection.ts new file mode 100644 index 00000000..1a707b1b --- /dev/null +++ b/src/domain/lembrete/LembreteCollection.ts @@ -0,0 +1,33 @@ +import { Either } from '@/library/either/Either' + +import { + Attributes, CreateAttributes, UpdateAttributes +} from './Lembrete' + +export interface LembreteOrder { + column: 'id' | 'data_coleta' + direction: 'asc' | 'desc' +} + +export interface LembreteFilters { + data_coleta_de?: string + data_coleta_ate?: string + order?: LembreteOrder + limite?: number + pagina?: number +} + +export interface Paginated { + itens: T[] + total: number + limite: number + pagina: number +} + +export interface LembreteCollection { + findAll(filters: LembreteFilters): Promise>> + findById(id: number): Promise> + create(attributes: CreateAttributes): Promise> + update(id: number, attributes: UpdateAttributes): Promise> + delete(id: number): Promise> +} diff --git a/src/domain/lembrete/ListaLembretesUseCase.ts b/src/domain/lembrete/ListaLembretesUseCase.ts new file mode 100644 index 00000000..eae330df --- /dev/null +++ b/src/domain/lembrete/ListaLembretesUseCase.ts @@ -0,0 +1,22 @@ +import { Either } from '@/library/either/Either' + +import { Attributes } from './Lembrete' +import { + LembreteCollection, LembreteFilters, Paginated +} from './LembreteCollection' + +interface Dependencies { + lembreteCollection: LembreteCollection +} + +export class ListaLembretesUseCase { + private readonly lembreteCollection: LembreteCollection + + constructor(dependencies: Dependencies) { + this.lembreteCollection = dependencies.lembreteCollection + } + + execute(filters: LembreteFilters): Promise>> { + return this.lembreteCollection.findAll(filters) + } +} diff --git a/src/domain/lembrete/RemoveLembreteUseCase.ts b/src/domain/lembrete/RemoveLembreteUseCase.ts new file mode 100644 index 00000000..3472ef31 --- /dev/null +++ b/src/domain/lembrete/RemoveLembreteUseCase.ts @@ -0,0 +1,19 @@ +import { Either } from '@/library/either/Either' + +import { LembreteCollection } from './LembreteCollection' + +interface Dependencies { + lembreteCollection: LembreteCollection +} + +export class RemoveLembreteUseCase { + private readonly lembreteCollection: LembreteCollection + + constructor(dependencies: Dependencies) { + this.lembreteCollection = dependencies.lembreteCollection + } + + execute({ id }: { id: number }): Promise> { + return this.lembreteCollection.delete(id) + } +} diff --git a/src/factory/ExpedicaoCollectionFactory.ts b/src/factory/ExpedicaoCollectionFactory.ts new file mode 100644 index 00000000..fe19e09e --- /dev/null +++ b/src/factory/ExpedicaoCollectionFactory.ts @@ -0,0 +1,8 @@ +import { ExpedicaoCollectionKnexAdapter } from '@/infrastructure/ExpedicaoCollectionKnexAdapter' +import { singleton } from '@/library/singleton' + +import { createKnexInstance } from './KnexFactory' + +export const createExpedicaoCollection = singleton(() => { + return new ExpedicaoCollectionKnexAdapter({ knex: createKnexInstance() }) +}) diff --git a/src/infrastructure/EventoCollectionKnexAdapter.ts b/src/infrastructure/EventoCollectionKnexAdapter.ts new file mode 100644 index 00000000..52eeb109 --- /dev/null +++ b/src/infrastructure/EventoCollectionKnexAdapter.ts @@ -0,0 +1,262 @@ +import { Knex } from 'knex' + +import { + Attributes, COLETA_FIELDS, ColetaAttributes, CreateAttributes, EventoTipo +} from '@/domain/evento/Evento' +import { + AtualizarEventoAttributes, EventoCollection, EventoFilters, Paginated +} from '@/domain/evento/EventoCollection' +import { Either } from '@/library/either/Either' + +import { CheckViolationError } from './error/CheckViolationError' +import { CollectionError } from './error/CollectionError' +import { ForeignKeyViolationError } from './error/ForeignKeyViolationError' +import { toNullableNumber } from './pg-column' +import { + PG_CHECK_VIOLATION, PG_FOREIGN_KEY_VIOLATION, pgErrorCode +} from './pg-error' + +interface Dependencies { + knex: Knex +} + +interface Row { + id: number + expedicao_id: number + tipo: EventoTipo + capturado_em: Date + latitude: number | null + longitude: number | null + altitude: number | null + observacoes: string | null + created_at: Date + updated_at: Date + created_by: string | null + updated_by: string | null + coleta_evento_id: number | null +} + +function toAttributes(row: Row & Record): Attributes { + let coleta: ColetaAttributes | null = null + + if (row.coleta_evento_id !== null) { + coleta = Object.fromEntries( + COLETA_FIELDS.map(campo => [campo, row[`coleta_${campo}`] ?? null]) + ) as unknown as ColetaAttributes + } + + return { + id: row.id, + expedicao_id: row.expedicao_id, + tipo: row.tipo, + capturado_em: row.capturado_em, + latitude: row.latitude, + longitude: row.longitude, + altitude: row.altitude, + observacoes: row.observacoes, + coleta, + created_at: row.created_at, + updated_at: row.updated_at, + created_by: toNullableNumber(row.created_by), + updated_by: toNullableNumber(row.updated_by) + } +} + +function toInfrastructureError(message: string, error: unknown): Error { + const code = pgErrorCode(error) + + if (code === PG_FOREIGN_KEY_VIOLATION) { + return new ForeignKeyViolationError({ message: 'Expedição não encontrada', cause: error }) + } + + if (code === PG_CHECK_VIOLATION) { + return new CheckViolationError({ message: 'tipo deve ser "DIARIO" ou "COLETA"', cause: error }) + } + + return new CollectionError({ message, cause: error }) +} + +export class EventoCollectionKnexAdapter implements EventoCollection { + private readonly knex: Knex + + constructor(dependencies: Dependencies) { + this.knex = dependencies.knex + } + + private select(trx?: Knex.Transaction): Knex.QueryBuilder { + const knex = trx ?? this.knex + + return knex('eventos') + .leftJoin('eventos_coletas', 'eventos_coletas.evento_id', 'eventos.id') + .select([ + 'eventos.id', + 'eventos.expedicao_id', + 'eventos.tipo', + 'eventos.capturado_em', + 'eventos.latitude', + 'eventos.longitude', + 'eventos.altitude', + 'eventos.observacoes', + 'eventos.created_at', + 'eventos.updated_at', + 'eventos.created_by', + 'eventos.updated_by', + 'eventos_coletas.evento_id as coleta_evento_id', + ...COLETA_FIELDS.map(campo => `eventos_coletas.${campo} as coleta_${campo}`) + ]) + } + + async findAll(filters: EventoFilters): Promise>> { + try { + const query = this.select() + + if (filters.expedicao_id) { + query.where('eventos.expedicao_id', filters.expedicao_id) + } + + if (filters.tipo) { + query.where('eventos.tipo', filters.tipo) + } + + if (filters.capturado_de) { + query.where('eventos.capturado_em', '>=', filters.capturado_de) + } + + if (filters.capturado_ate) { + query.where('eventos.capturado_em', '<=', filters.capturado_ate) + } + + const countQuery = query.clone() + + const countRows = await countQuery + .clearSelect() + .count>('* as count') + const [{ count }] = countRows + + const limite = filters.limite && filters.limite > 0 ? filters.limite : 20 + const pagina = filters.pagina && filters.pagina > 0 ? filters.pagina : 1 + const offset = (pagina - 1) * limite + + query.orderBy('eventos.capturado_em', 'desc') + query.orderBy('eventos.id', 'desc') + query.limit(limite).offset(offset) + + const rows = await query as Array> + return Either.right({ + itens: rows.map(toAttributes), + total: Number(count), + limite, + pagina + }) + } catch (error) { + return Either.left(new CollectionError({ message: 'Failed to list eventos', cause: error })) + } + } + + async findById(id: number): Promise> { + try { + const row = await this.select() + .where('eventos.id', id) + .first() as (Row & Record) | undefined + + return Either.right(row ? toAttributes(row) : null) + } catch (error) { + return Either.left(new CollectionError({ message: 'Failed to find evento by id', cause: error })) + } + } + + /** + * O evento e sua ficha nascem juntos ou não nascem: é a transação que garante + * que todo tipo='COLETA' tem linha em `eventos_coletas`. + */ + async create(attributes: CreateAttributes): Promise> { + try { + const created = await this.knex.transaction(async trx => { + const [{ id }] = await trx('eventos') + .insert({ + expedicao_id: attributes.expedicao_id, + tipo: attributes.tipo, + capturado_em: attributes.capturado_em, + latitude: attributes.latitude, + longitude: attributes.longitude, + altitude: attributes.altitude, + observacoes: attributes.observacoes, + created_by: attributes.created_by, + updated_by: attributes.created_by + }) + .returning>(['id']) + + if (attributes.coleta) { + await trx('eventos_coletas').insert({ evento_id: id, ...attributes.coleta }) + } + + return await this.select(trx) + .where('eventos.id', id) + .first() as Row & Record + }) + + return Either.right(toAttributes(created)) + } catch (error) { + return Either.left(toInfrastructureError('Failed to create evento', error)) + } + } + + /** + * O PUT do agregado: trocar tipo de COLETA para DIARIO apaga a ficha; o + * caminho inverso cria a ficha; manter o tipo e mandar `coleta` faz upsert + * (evento_id é PK de eventos_coletas). + */ + async update(id: number, attributes: AtualizarEventoAttributes): Promise> { + try { + const updated = await this.knex.transaction(async trx => { + const updatedRows = await trx('eventos') + .where({ id }) + .update({ + tipo: attributes.tipo, + capturado_em: attributes.capturado_em, + latitude: attributes.latitude, + longitude: attributes.longitude, + altitude: attributes.altitude, + observacoes: attributes.observacoes, + updated_by: attributes.updated_by, + updated_at: trx.fn.now() + }) + .returning>(['id']) + + if (updatedRows.length === 0) { + return null + } + + if (attributes.coleta) { + await trx('eventos_coletas') + .insert({ evento_id: id, ...attributes.coleta }) + .onConflict('evento_id') + .merge() + } else { + await trx('eventos_coletas').where({ evento_id: id }).delete() + } + + return await this.select(trx) + .where('eventos.id', id) + .first() as (Row & Record) | undefined + }) + + return Either.right(updated ? toAttributes(updated) : null) + } catch (error) { + return Either.left(toInfrastructureError('Failed to update evento', error)) + } + } + + /** + * ON DELETE CASCADE de eventos_coletas.evento_id cuida da ficha; impedindo + * limpeza duplicada. + */ + async delete(id: number): Promise> { + try { + const deletedCount = await this.knex('eventos').where({ id }).delete() + return Either.right(deletedCount > 0) + } catch (error) { + return Either.left(new CollectionError({ message: 'Failed to delete evento', cause: error })) + } + } +} diff --git a/src/infrastructure/EvidenciaCollectionKnexAdapter.ts b/src/infrastructure/EvidenciaCollectionKnexAdapter.ts new file mode 100644 index 00000000..8cf84f6f --- /dev/null +++ b/src/infrastructure/EvidenciaCollectionKnexAdapter.ts @@ -0,0 +1,137 @@ +import { Knex } from 'knex' + +import { Attributes, CreateAttributes } from '@/domain/evidencia/Evidencia' +import { EvidenciaCollection, EvidenciaFilters } from '@/domain/evidencia/EvidenciaCollection' +import { Either } from '@/library/either/Either' + +import { CollectionError } from './error/CollectionError' +import { ForeignKeyViolationError } from './error/ForeignKeyViolationError' +import { toNullableNumber } from './pg-column' +import { PG_FOREIGN_KEY_VIOLATION, pgErrorCode } from './pg-error' + +interface Dependencies { + knex: Knex +} + +interface Row { + id: number + evento_id: number + nome: string + arquivo: string + mime_type: string + tamanho: number + capturado_em: Date + created_at: Date + updated_at: Date + created_by: string | null + updated_by: string | null +} + +const COLUMNS = [ + 'id', + 'evento_id', + 'nome', + 'arquivo', + 'mime_type', + 'tamanho', + 'capturado_em', + 'created_at', + 'updated_at', + 'created_by', + 'updated_by' +] + +function toAttributes(row: Row): Attributes { + return { + id: row.id, + evento_id: row.evento_id, + nome: row.nome, + arquivo: row.arquivo, + mime_type: row.mime_type, + tamanho: Number(row.tamanho), + capturado_em: row.capturado_em, + created_at: row.created_at, + updated_at: row.updated_at, + created_by: toNullableNumber(row.created_by), + updated_by: toNullableNumber(row.updated_by) + } +} + +function toInfrastructureError(message: string, error: unknown): Error { + const code = pgErrorCode(error) + + if (code === PG_FOREIGN_KEY_VIOLATION) { + return new ForeignKeyViolationError({ message: 'Evento não encontrado', cause: error }) + } + + return new CollectionError({ message, cause: error }) +} + +export class EvidenciaCollectionKnexAdapter implements EvidenciaCollection { + private readonly knex: Knex + + constructor(dependencies: Dependencies) { + this.knex = dependencies.knex + } + + async findAll(filters: EvidenciaFilters): Promise> { + try { + const query = this.knex('evidencias').select(COLUMNS) + + if (filters.evento_id) { + query.where({ evento_id: filters.evento_id }) + } + + if (filters.nome) { + query.whereILike('nome', `%${filters.nome}%`) + } + + const order = filters.order ?? { column: 'id' as const, direction: 'desc' as const } + query.orderBy(order.column, order.direction) + + const rows = await query as Row[] + return Either.right(rows.map(toAttributes)) + } catch (error) { + return Either.left(new CollectionError({ message: 'Failed to list evidências', cause: error })) + } + } + + async findById(id: number): Promise> { + try { + const row = await this.knex('evidencias').select(COLUMNS).where({ id }).first() as Row | undefined + return Either.right(row ? toAttributes(row) : null) + } catch (error) { + return Either.left(new CollectionError({ message: 'Failed to find evidência by id', cause: error })) + } + } + + async create(attributes: CreateAttributes): Promise> { + try { + const [row] = await this.knex('evidencias') + .insert({ + evento_id: attributes.evento_id, + nome: attributes.nome, + arquivo: attributes.arquivo, + mime_type: attributes.mime_type, + tamanho: attributes.tamanho, + capturado_em: attributes.capturado_em, + created_by: attributes.created_by, + updated_by: attributes.created_by + }) + .returning(COLUMNS) + + return Either.right(toAttributes(row)) + } catch (error) { + return Either.left(toInfrastructureError('Failed to create evidência', error)) + } + } + + async delete(id: number): Promise> { + try { + const deletedCount = await this.knex('evidencias').where({ id }).delete() + return Either.right(deletedCount > 0) + } catch (error) { + return Either.left(new CollectionError({ message: 'Failed to delete evidencia', cause: error })) + } + } +} diff --git a/src/infrastructure/EvidenciaUploadMiddleware.ts b/src/infrastructure/EvidenciaUploadMiddleware.ts new file mode 100644 index 00000000..94b1f342 --- /dev/null +++ b/src/infrastructure/EvidenciaUploadMiddleware.ts @@ -0,0 +1,50 @@ +import express from 'express' +import multer from 'multer' +import { randomBytes } from 'node:crypto' +import { mkdirSync } from 'node:fs' + +import { + EVIDENCIA_DIR, EVIDENCIA_MAX_FILE_SIZE_BYTES, MIME_TYPE_EXTENSAO, MIME_TYPES_ACEITOS +} from '@/config/evidencia' + +mkdirSync(EVIDENCIA_DIR, { recursive: true }) + +const storage = multer.diskStorage({ + destination: (_request, _file, callback) => { + callback(null, EVIDENCIA_DIR) + }, + filename: (request, file, callback) => { + const eventoId = String(request.params.eventoId) + const expedicaoId = request.evidenciaEvento?.expedicaoId + const timestamp = Date.now() + const sufixo = randomBytes(2).toString('hex') + const extensao = MIME_TYPE_EXTENSAO[file.mimetype] + callback(null, `${expedicaoId}_${eventoId}_${timestamp}-${sufixo}${extensao}`) + } +}) + +const singleUpload = multer({ + storage, + limits: { fileSize: EVIDENCIA_MAX_FILE_SIZE_BYTES }, + fileFilter: (_request, file, callback) => { + if (!MIME_TYPES_ACEITOS.includes(file.mimetype)) { + callback(new Error(`Tipo de arquivo não suportado: ${file.mimetype}`)) + return + } + callback(null, true) + } +}).single('arquivo') + +export const evidenciaUpload: express.RequestHandler = (request, response, next) => { + singleUpload(request, response, (error: unknown) => { + if (error instanceof Error) { + response.status(400).json({ + error: { + statusCode: 400, name: 'BadRequestError', message: error.message + } + }) + return + } + next() + }) +} diff --git a/src/infrastructure/ExpedicaoCollectionKnexAdapter.ts b/src/infrastructure/ExpedicaoCollectionKnexAdapter.ts new file mode 100644 index 00000000..c6fb8726 --- /dev/null +++ b/src/infrastructure/ExpedicaoCollectionKnexAdapter.ts @@ -0,0 +1,345 @@ +import { Knex } from 'knex' + +import { + Attributes, CreateAttributes, UpdateAttributes +} from '@/domain/expedicao/Expedicao' +import { + ExpedicaoCollection, ExpedicaoFilters, ExpedicaoListItem, ParticipanteExpedicao, Paginated +} from '@/domain/expedicao/ExpedicaoCollection' +import { DuplicateParticipantError } from '@/infrastructure/error/DuplicateParticipantError' +import { Either } from '@/library/either/Either' + +import { CollectionError } from './error/CollectionError' +import { toNullableNumber, toNumber } from './pg-column' + +interface Dependencies { + knex: Knex +} + +interface Row { + id: number + descricao: string | null + data_inicio: string + data_fim: string + cidade_id: string + created_at: Date + updated_at: Date + created_by: string | null + updated_by: string | null +} + +interface ListRow extends Row { + cidade_nome: string | null + estado_sigla: string | null +} + +interface ParticipanteRow { + expedicao_id: number + usuario_id: number + nome: string +} + +function toAttributes(row: Row): Attributes { + return { + id: row.id, + descricao: row.descricao, + data_inicio: row.data_inicio, + data_fim: row.data_fim, + cidade_id: toNumber(row.cidade_id), + created_at: row.created_at, + updated_at: row.updated_at, + created_by: toNullableNumber(row.created_by), + updated_by: toNullableNumber(row.updated_by) + } +} + +export class ExpedicaoCollectionKnexAdapter implements ExpedicaoCollection { + private readonly knex: Knex + + constructor(dependencies: Dependencies) { + this.knex = dependencies.knex + } + + private select(trx?: Knex.Transaction): Knex.QueryBuilder { + const knex = trx ?? this.knex + return knex('expedicoes').select([ + 'expedicoes.id', + 'expedicoes.descricao', + knex.raw('to_char(expedicoes.data_inicio, \'YYYY-MM-DD\') as data_inicio'), + knex.raw('to_char(expedicoes.data_fim, \'YYYY-MM-DD\') as data_fim'), + 'expedicoes.cidade_id', + 'expedicoes.created_at', + 'expedicoes.updated_at', + 'expedicoes.created_by', + 'expedicoes.updated_by' + ]) + } + + async findAll(filters: ExpedicaoFilters): Promise>> { + try { + // nome da cidade e sigla da UF vêm junto para o cliente não precisar de uma requisição por cidade + const query = this.select() + .leftJoin('cidades', 'cidades.id', 'expedicoes.cidade_id') + .leftJoin('estados', 'estados.id', 'cidades.estado_id') + .select([ + 'cidades.nome as cidade_nome', + // estados.sigla é char(4): remove o preenchimento com espaços + this.knex.raw('trim(estados.sigla) as estado_sigla') + ]) + + if (filters.cidade_id) query.where('expedicoes.cidade_id', filters.cidade_id) + + if (filters.usuario_id) { + query.whereIn('expedicoes.id', this.knex('expedicoes_participantes') + .select('expedicao_id') + .where('usuario_id', filters.usuario_id)) + } + + if (filters.data_inicio_de) query.where('expedicoes.data_inicio', '>=', filters.data_inicio_de) + if (filters.data_inicio_ate) query.where('expedicoes.data_inicio', '<=', filters.data_inicio_ate) + if (filters.data_fim_de) query.where('expedicoes.data_fim', '>=', filters.data_fim_de) + if (filters.data_fim_ate) query.where('expedicoes.data_fim', '<=', filters.data_fim_ate) + + // contagem de total de registros + const countQuery = query.clone() + const [result] = await countQuery.clearSelect().count<{ count: string | number }[]>('* as count') + const count = result?.count ?? 0 + const total = Number(count) + + // valores padrão da paginação(20 e 1) + const limite = filters.limite && filters.limite > 0 ? filters.limite : 20 + const pagina = filters.pagina && filters.pagina > 0 ? filters.pagina : 1 + const offset = (pagina - 1) * limite + + query.limit(limite).offset(offset) + + // ordenação + const order = filters.order ?? { column: 'id' as const, direction: 'desc' as const } + query.orderBy(`expedicoes.${order.column}`, order.direction) + + const rows = await query as ListRow[] + + // página vazia, já retorna + if (rows.length === 0) { + return Either.right({ + itens: [], total, limite, pagina + }) + } + + const expedicoesIds = rows.map(row => row.id) + + const [participantesRows, rotasRows] = await Promise.all([ + this.knex('expedicoes_participantes') + .join('usuarios', 'usuarios.id', 'expedicoes_participantes.usuario_id') + .select('expedicoes_participantes.expedicao_id', 'expedicoes_participantes.usuario_id', 'usuarios.nome') + .whereIn('expedicoes_participantes.expedicao_id', expedicoesIds) as Promise, + + this.knex<{ expedicao_id: number; cidade_id: number }>('expedicoes_rotas') + .select('expedicao_id', 'cidade_id') + .whereIn('expedicao_id', expedicoesIds) + .orderBy('ordem', 'asc') + ]) + + const itens = rows.map(row => { + return { + ...toAttributes(row), + cidade_nome: row.cidade_nome, + estado_sigla: row.estado_sigla, + participantes: participantesRows + .filter(p => p.expedicao_id === row.id) + .map((p): ParticipanteExpedicao => ({ id: p.usuario_id, nome: p.nome })), + rotas: rotasRows + .filter(r => r.expedicao_id === row.id) + .map(r => r.cidade_id) + } + }) + + return Either.right({ + itens, + total, + limite, + pagina + }) + } catch (error) { + return Either.left(new CollectionError({ message: 'Failed to list expedições', cause: error })) + } + } + + async findById(id: number): Promise> { + try { + const row = await this.select().where('expedicoes.id', id).first() as Row | undefined + + if (!row) { + return Either.right(null) + } + + const participantes = await this.knex('expedicoes_participantes') + .join('usuarios', 'usuarios.id', 'expedicoes_participantes.usuario_id') + .where('expedicoes_participantes.expedicao_id', id) + .select('usuarios.id', 'usuarios.nome', 'usuarios.email') + + const rotas = await this.knex('expedicoes_rotas') + .join('cidades', 'cidades.id', 'expedicoes_rotas.cidade_id') + .where('expedicoes_rotas.expedicao_id', id) + .select( + 'cidades.id as cidade_id', + 'expedicoes_rotas.ordem', + 'cidades.nome as nome_cidade', + 'cidades.estado_id' + ) + .orderBy('expedicoes_rotas.ordem', 'asc') + + return Either.right({ + ...toAttributes(row), + participantes, + rotas + } as unknown as Attributes) + } catch (error) { + return Either.left(new CollectionError({ message: 'Failed to find expedição by id', cause: error })) + } + } + + /** + * Escrita multi-tabela: a expedição e suas junções nascem juntas ou não nascem. + * A posição de cada cidade em `rotas` define a coluna `ordem`. + */ + async create(attributes: CreateAttributes): Promise> { + const { + participantes, rotas, ...expedicao + } = attributes + + try { + const created = await this.knex.transaction(async trx => { + const [{ id }] = await trx('expedicoes') + .insert({ + descricao: expedicao.descricao, + data_inicio: expedicao.data_inicio, + data_fim: expedicao.data_fim, + cidade_id: expedicao.cidade_id, + created_by: expedicao.created_by, + updated_by: expedicao.created_by + }) + .returning<{ id: number }[]>(['id']) + + if (participantes.length > 0) { + await trx('expedicoes_participantes').insert(participantes.map(usuarioId => ({ + expedicao_id: id, + usuario_id: usuarioId, + created_by: expedicao.created_by, + updated_by: expedicao.created_by + }))) + } + + if (rotas.length > 0) { + await trx('expedicoes_rotas').insert(rotas.map((cidadeId, ordem) => ({ + expedicao_id: id, + cidade_id: cidadeId, + ordem, + created_by: expedicao.created_by, + updated_by: expedicao.created_by + }))) + } + + return await this.select(trx).where('expedicoes.id', id).first() as Row + }) + + return Either.right(toAttributes(created)) + } catch (error) { + return Either.left(new CollectionError({ message: 'Failed to create expedição', cause: error })) + } + } + + async delete(id: number): Promise> { + try { + const affectedRows = await this.knex.transaction(async trx => { + // Deleta as tabelas filhas primeiro + await trx('expedicoes_participantes').where('expedicao_id', id).delete() + await trx('expedicoes_rotas').where('expedicao_id', id).delete() + + // Deleta a expedição e converte a tipagem do resultado explicitamente para number + const count = await trx('expedicoes').where('id', id).delete() + return count + }) + + // Se apagou 0 linhas, é porque o ID não existia + if (affectedRows === 0) { + return Either.left(new Error('Expedição não encontrada')) + } + + return Either.right(undefined) + } catch (error) { + return Either.left(new CollectionError({ message: 'Failed to delete expedição', cause: error })) + } + } + + async update(id: number, attributes: UpdateAttributes): Promise> { + try { + await this.knex('expedicoes') + .where({ id }) + .update({ ...attributes, updated_at: this.knex.fn.now() }) + + const row = await this.select().where('expedicoes.id', id).first() as Row | undefined + + if (!row) return Either.left(new Error('Expedição não encontrada')) + + return Either.right(toAttributes(row)) + } catch (error) { + return Either.left(new CollectionError({ message: 'Failed to update expedição', cause: error })) + } + } + + async addParticipant(expedicaoId: number, usuarioId: number): Promise> { + try { + await this.knex('expedicoes_participantes').insert({ + expedicao_id: expedicaoId, + usuario_id: usuarioId + }) + return Either.right(undefined) + } catch (error: unknown) { + const dbError = error as { code?: string } + + if (dbError.code === '23505') { + return Either.left(new DuplicateParticipantError({ + message: 'O usuário já está nesta expedição', + cause: dbError + })) + } + return Either.left(new CollectionError({ message: 'Falha ao adicionar participante', cause: error })) + } + } + + async removeParticipant(expedicaoId: number, usuarioId: number): Promise> { + try { + await this.knex('expedicoes_participantes') + .where({ expedicao_id: expedicaoId, usuario_id: usuarioId }) + .delete() + return Either.right(undefined) + } catch (error) { + return Either.left(new CollectionError({ message: 'Falha ao remover participante', cause: error })) + } + } + + async substituteRoute(expedicaoId: number, rotas: number[]): Promise> { + try { + await this.knex.transaction(async trx => { + // usa uma transaction para garantir que a exclusão e a inserção ocorram sem a perda de dados em caso de falha. + // se qualquer operação falhar, a transação será revertida e nenhuma alteração será feita no banco de dados. + // devido a constraint unique, estamos deletando todas as rotas da expedição e inserindo novamente, + // já na ordem correta e seguindo a constraint. + await trx('expedicoes_rotas').where('expedicao_id', expedicaoId).delete() + + if (rotas.length > 0) { + const insertData = rotas.map((cidadeId, index) => ({ + expedicao_id: expedicaoId, + cidade_id: cidadeId, + ordem: index + })) + await trx('expedicoes_rotas').insert(insertData) + } + }) + return Either.right(undefined) + } catch (error) { + return Either.left(new CollectionError({ message: 'Falha ao substituir rotas', cause: error })) + } + } +} diff --git a/src/infrastructure/ExpressApplication.ts b/src/infrastructure/ExpressApplication.ts index aff748e8..caa0c742 100644 --- a/src/infrastructure/ExpressApplication.ts +++ b/src/infrastructure/ExpressApplication.ts @@ -4,7 +4,7 @@ import http from 'node:http' import { Application } from '@/library/Application' import { - Headers, HttpRequest, HttpResponse, Method, StatusCode + Headers, HttpRequest, HttpResponse, Method, RawMiddleware, StatusCode } from '@/library/http/common' import { HttpError } from '@/library/http/error/HttpError' import { InternalServerError } from '@/library/http/error/InternalServerError' @@ -36,13 +36,22 @@ export class ExpressApplication implements Application { return this } - endpoint(method: Method, path: string, ...handlers: RequestHandler[]): this { + endpoint( + method: Method, + path: string, + handlers: RequestHandler[], + middlewares: RawMiddleware[] = [] + ): this { + const expressMiddlewares = middlewares as express.RequestHandler[] + this.app[method]( path, + ...expressMiddlewares, async (expressRequest: express.Request, expressResponse: express.Response) => { const params = { ...expressRequest.params, - ...expressRequest.query + ...expressRequest.query, + ...(expressRequest.file ? { file: expressRequest.file } : {}) } const headers: Headers = { ...expressRequest.headers as Record, @@ -83,19 +92,19 @@ export class ExpressApplication implements Application { } get(path: string, ...handlers: RequestHandler[]): this { - return this.endpoint(Method.Get, path, ...handlers) + return this.endpoint(Method.Get, path, handlers) } post(path: string, ...handlers: RequestHandler[]): this { - return this.endpoint(Method.Post, path, ...handlers) + return this.endpoint(Method.Post, path, handlers) } put(path: string, ...handlers: RequestHandler[]): this { - return this.endpoint(Method.Put, path, ...handlers) + return this.endpoint(Method.Put, path, handlers) } delete(path: string, ...handlers: RequestHandler[]): this { - return this.endpoint(Method.Delete, path, ...handlers) + return this.endpoint(Method.Delete, path, handlers) } start(port: number): Promise { diff --git a/src/infrastructure/LembreteCollectionKnexAdapter.ts b/src/infrastructure/LembreteCollectionKnexAdapter.ts new file mode 100644 index 00000000..c232871a --- /dev/null +++ b/src/infrastructure/LembreteCollectionKnexAdapter.ts @@ -0,0 +1,171 @@ +import { Knex } from 'knex' + +import { + Attributes, CreateAttributes, FICHA_FIELDS, FichaAttributes, UpdateAttributes +} from '@/domain/lembrete/Lembrete' +import { + LembreteCollection, LembreteFilters, Paginated +} from '@/domain/lembrete/LembreteCollection' +import { Either } from '@/library/either/Either' + +import { CollectionError } from './error/CollectionError' +import { toNullableNumber } from './pg-column' + +interface Dependencies { + knex: Knex +} + +interface Row extends FichaAttributes { + id: number + data_coleta: string + local_coleta: string + created_at: Date + updated_at: Date + created_by: string | null + updated_by: string | null +} + +function toAttributes(row: Row): Attributes { + return { + id: row.id, + data_coleta: row.data_coleta, + local_coleta: row.local_coleta, + ...Object.fromEntries(FICHA_FIELDS.map(campo => [campo, row[campo] ?? null])) as unknown as FichaAttributes, + created_at: row.created_at, + updated_at: row.updated_at, + created_by: toNullableNumber(row.created_by), + updated_by: toNullableNumber(row.updated_by) + } +} + +export class LembreteCollectionKnexAdapter implements LembreteCollection { + private readonly knex: Knex + + constructor(dependencies: Dependencies) { + this.knex = dependencies.knex + } + + private select(trx?: Knex.Transaction): Knex.QueryBuilder { + const knex = trx ?? this.knex + + return knex('lembretes') + .select([ + 'lembretes.id', + // `date` sem conversão vira Date em meia-noite local; devolve como YYYY-MM-DD + knex.raw('to_char(lembretes.data_coleta, \'YYYY-MM-DD\') as data_coleta'), + 'lembretes.local_coleta', + ...FICHA_FIELDS.map(campo => `lembretes.${campo}`), + 'lembretes.created_at', + 'lembretes.updated_at', + 'lembretes.created_by', + 'lembretes.updated_by' + ]) + } + + async findAll(filters: LembreteFilters): Promise>> { + try { + const query = this.select() + + if (filters.data_coleta_de) { + query.where('lembretes.data_coleta', '>=', filters.data_coleta_de) + } + + if (filters.data_coleta_ate) { + query.where('lembretes.data_coleta', '<=', filters.data_coleta_ate) + } + + const countRows = await query.clone() + .clearSelect() + .count>('* as count') + const [{ count }] = countRows + + const limite = filters.limite && filters.limite > 0 ? filters.limite : 20 + const pagina = filters.pagina && filters.pagina > 0 ? filters.pagina : 1 + const offset = (pagina - 1) * limite + + // Padrão: o lembrete mais próximo primeiro + const order = filters.order ?? { column: 'data_coleta', direction: 'asc' } + query.orderBy(`lembretes.${order.column}`, order.direction) + query.orderBy('lembretes.id', order.direction) + query.limit(limite).offset(offset) + + const rows = await query as Row[] + return Either.right({ + itens: rows.map(toAttributes), + total: Number(count), + limite, + pagina + }) + } catch (error) { + return Either.left(new CollectionError({ message: 'Failed to list lembretes', cause: error })) + } + } + + async findById(id: number): Promise> { + try { + const row = await this.select() + .where('lembretes.id', id) + .first() as Row | undefined + + return Either.right(row ? toAttributes(row) : null) + } catch (error) { + return Either.left(new CollectionError({ message: 'Failed to find lembrete by id', cause: error })) + } + } + + async create(attributes: CreateAttributes): Promise> { + try { + const created = await this.knex.transaction(async trx => { + const [{ id }] = await trx('lembretes') + .insert({ + ...attributes, + updated_by: attributes.created_by + }) + .returning>(['id']) + + return await this.select(trx) + .where('lembretes.id', id) + .first() as Row + }) + + return Either.right(toAttributes(created)) + } catch (error) { + return Either.left(new CollectionError({ message: 'Failed to create lembrete', cause: error })) + } + } + + async update(id: number, attributes: UpdateAttributes): Promise> { + try { + const updated = await this.knex.transaction(async trx => { + const updatedRows = await trx('lembretes') + .where({ id }) + .update({ + ...attributes, + updated_at: trx.fn.now() + }) + .returning>(['id']) + + if (updatedRows.length === 0) { + return null + } + + return await this.select(trx) + .where('lembretes.id', id) + .first() as Row | undefined + }) + + return Either.right(updated ? toAttributes(updated) : null) + } catch (error) { + return Either.left(new CollectionError({ message: 'Failed to update lembrete', cause: error })) + } + } + + async delete(id: number): Promise> { + try { + const deletedCount = await this.knex('lembretes').where({ id }).delete() + return Either.right(deletedCount > 0) + } catch (error) { + return Either.left(new CollectionError({ message: 'Failed to delete lembrete', cause: error })) + } + } +} diff --git a/src/infrastructure/error/CheckViolationError.ts b/src/infrastructure/error/CheckViolationError.ts new file mode 100644 index 00000000..70149ae6 --- /dev/null +++ b/src/infrastructure/error/CheckViolationError.ts @@ -0,0 +1,7 @@ +import { InfrastructureError } from './InfrastructureError' + +export class CheckViolationError extends InfrastructureError { + constructor(params: { message: string; cause?: unknown }) { + super(params) + } +} diff --git a/src/infrastructure/error/DuplicateParticipantError.ts b/src/infrastructure/error/DuplicateParticipantError.ts new file mode 100644 index 00000000..3bad74da --- /dev/null +++ b/src/infrastructure/error/DuplicateParticipantError.ts @@ -0,0 +1,7 @@ +import { InfrastructureError } from './InfrastructureError' + +export class DuplicateParticipantError extends InfrastructureError { + constructor(params: { message: string; cause?: unknown }) { + super(params) + } +} diff --git a/src/infrastructure/error/ForeignKeyViolationError.ts b/src/infrastructure/error/ForeignKeyViolationError.ts new file mode 100644 index 00000000..067234d8 --- /dev/null +++ b/src/infrastructure/error/ForeignKeyViolationError.ts @@ -0,0 +1,7 @@ +import { InfrastructureError } from './InfrastructureError' + +export class ForeignKeyViolationError extends InfrastructureError { + constructor(params: { message: string; cause?: unknown }) { + super(params) + } +} diff --git a/src/infrastructure/pg-column.ts b/src/infrastructure/pg-column.ts new file mode 100644 index 00000000..a8626eab --- /dev/null +++ b/src/infrastructure/pg-column.ts @@ -0,0 +1,7 @@ +export function toNumber(value: unknown): number { + return Number(value) +} + +export function toNullableNumber(value: unknown): number | null { + return value === null || value === undefined ? null : Number(value) +} diff --git a/src/infrastructure/pg-error.ts b/src/infrastructure/pg-error.ts new file mode 100644 index 00000000..32f8c0d7 --- /dev/null +++ b/src/infrastructure/pg-error.ts @@ -0,0 +1,15 @@ +/** + * Códigos de erro (SQLSTATE) do Postgres que a camada de infraestrutura + * precisa reconhecer para traduzir Either.left em um status HTTP correto, + * em vez de cair tudo como 500 genérico. + */ +export const PG_FOREIGN_KEY_VIOLATION = '23503' +export const PG_CHECK_VIOLATION = '23514' + +export function pgErrorCode(error: unknown): string | undefined { + if (typeof error === 'object' && error !== null && 'code' in error) { + const code = (error as { code?: unknown }).code + return typeof code === 'string' ? code : undefined + } + return undefined +} diff --git a/src/library/Application.ts b/src/library/Application.ts index 3560eeff..59243e84 100644 --- a/src/library/Application.ts +++ b/src/library/Application.ts @@ -1,13 +1,18 @@ import http from 'node:http' -import { Method } from './http/common' +import { Method, RawMiddleware } from './http/common' import { RequestHandler } from './http/Server' export interface Application { readonly server: http.Server use(...args: unknown[]): this - endpoint(method: Method, path: string, ...handlers: RequestHandler[]): this + endpoint( + method: Method, + path: string, + handlers: RequestHandler[], + middlewares?: RawMiddleware[] + ): this get(path: string, ...handlers: RequestHandler[]): this post(path: string, ...handlers: RequestHandler[]): this put(path: string, ...handlers: RequestHandler[]): this diff --git a/src/library/http/Router.ts b/src/library/http/Router.ts index e4f6eb1b..b9b35ebb 100644 --- a/src/library/http/Router.ts +++ b/src/library/http/Router.ts @@ -1,8 +1,9 @@ -import { Method } from './common' +import { Method, RawMiddleware } from './common' import { RequestHandler } from './Server' export interface Route { method: Method path: string handlers: RequestHandler[] + middlewares?: RawMiddleware[] } diff --git a/src/library/http/common.ts b/src/library/http/common.ts index 54c0733d..1d7a098c 100644 --- a/src/library/http/common.ts +++ b/src/library/http/common.ts @@ -60,3 +60,13 @@ export interface HttpResponse { headers?: Partial body?: Body } + +/** + * Assinatura estrutural de um middleware de framework (ex: Express), usada + * pela camada http/Application agnóstica de framework. `req`/`res` ficam + * como `any` de propósito: o formato real (Request/Response do Express) é + * decidido por quem implementa `Application` — esta camada não deve + * importar `express` diretamente. + */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any -- req/res assumem o shape do framework concreto (ex: Express), decidido por quem implementa `Application` +export type RawMiddleware = (req: any, res: any, next: (err?: unknown) => void) => void diff --git a/src/library/http/error/ConflictError.ts b/src/library/http/error/ConflictError.ts new file mode 100644 index 00000000..b8a99193 --- /dev/null +++ b/src/library/http/error/ConflictError.ts @@ -0,0 +1,7 @@ +import { HttpError } from './HttpError' + +export class ConflictError extends HttpError { + constructor(params: { message: string; report?: unknown; cause?: unknown }) { + super({ ...params, statusCode: 409 }) + } +} diff --git a/src/library/http/error/UnprocessableEntityError.ts b/src/library/http/error/UnprocessableEntityError.ts new file mode 100644 index 00000000..5cf783d7 --- /dev/null +++ b/src/library/http/error/UnprocessableEntityError.ts @@ -0,0 +1,7 @@ +import { HttpError } from './HttpError' + +export class UnprocessableEntityError extends HttpError { + constructor(params: { message: string; report?: unknown; cause?: unknown }) { + super({ ...params, statusCode: 422 }) + } +} diff --git a/test/integration/evento/evento-collection.test.ts b/test/integration/evento/evento-collection.test.ts new file mode 100644 index 00000000..b0d7c778 --- /dev/null +++ b/test/integration/evento/evento-collection.test.ts @@ -0,0 +1,272 @@ +import { + afterAll, beforeAll, describe, expect, test +} from 'vitest' + +import { ColetaAttributes, CreateAttributes } from '@/domain/evento/Evento' +import { EventoCollectionKnexAdapter } from '@/infrastructure/EventoCollectionKnexAdapter' + +import { createTestKnex } from '../setup/app-factory' +import { + cleanupExpedicaoFixtures, ExpedicaoFixtures, seedExpedicaoFixtures +} from '../setup/seeds/expedicao.seed' + +const ficha: ColetaAttributes = { + familia: 'Velloziaceae', + nome_popular: 'canela-de-ema', + nome_cientifico: 'Vellozia squamata', + municipio: 'Serra do Cipó', + estado: 'MG', + referencia_local: 'Trilha da cachoeira', + tipo_vegetacao: 'Campo rupestre', + solo: 'Arenoso', + relevo: 'Ondulado', + substrato: 'Afloramento rochoso', + tronco_com_casca: 'Sim, gretada', + associacoes: 'Lychnophora ericoides', + folhas: 'Lineares', + habito: 'Herbácea', + frutos: 'Ausentes', + flores: 'Lilases', + luminosidade: 'Pleno sol' +} + +describe('EventoCollectionKnexAdapter', () => { + const knex = createTestKnex() + const collection = new EventoCollectionKnexAdapter({ knex }) + + let fixtures: ExpedicaoFixtures + let expedicaoId: number + + beforeAll(async () => { + fixtures = await seedExpedicaoFixtures(knex) + + const [expedicao] = await knex('expedicoes') + .insert({ + descricao: 'XEXP eventos', + data_inicio: '2026-02-14', + data_fim: '2026-02-18', + cidade_id: fixtures.cidades[0] + }) + .returning>(['id']) + + expedicaoId = expedicao.id + }) + + afterAll(async () => { + await knex('expedicoes').where({ id: expedicaoId }).delete() + await cleanupExpedicaoFixtures(knex, fixtures) + await knex.destroy() + }) + + function novoDiario(): CreateAttributes { + return { + expedicao_id: expedicaoId, + tipo: 'DIARIO', + capturado_em: new Date('2026-02-15T13:15:00.000Z'), + latitude: -20.2510, + longitude: -46.4170, + altitude: 1200, + observacoes: 'Área de transição entre cerrado e campo rupestre.', + coleta: null, + created_by: fixtures.usuarios[0] + } + } + + function novaColeta(): CreateAttributes { + return { + ...novoDiario(), + tipo: 'COLETA', + capturado_em: new Date('2026-02-15T14:32:00.000Z'), + observacoes: null, + coleta: ficha + } + } + + test('grava o evento e sua ficha na mesma transação', async () => { + const created = await collection.create(novaColeta()) + + expect(created.right()).toBe(true) + if (!created.right()) return + + try { + expect(created.value.coleta).toEqual(ficha) + expect(created.value).toMatchObject({ + tipo: 'COLETA', latitude: -20.2510, altitude: 1200, created_by: fixtures.usuarios[0] + }) + + const fichas = await knex('eventos_coletas').where({ evento_id: created.value.id }) + expect(fichas).toHaveLength(1) + } finally { + await knex('eventos').where({ id: created.value.id }).delete() + } + }) + + test('um diário não cria linha em eventos_coletas', async () => { + const created = await collection.create(novoDiario()) + + expect(created.right()).toBe(true) + if (!created.right()) return + + try { + expect(created.value.coleta).toBeNull() + expect(created.value.observacoes).toBe('Área de transição entre cerrado e campo rupestre.') + + const fichas = await knex('eventos_coletas').where({ evento_id: created.value.id }) + expect(fichas).toHaveLength(0) + } finally { + await knex('eventos').where({ id: created.value.id }).delete() + } + }) + + test('não grava o evento quando a ficha falha', async () => { + const antes = await knex('eventos').where({ expedicao_id: expedicaoId }).count({ total: '*' }) + + const created = await collection.create({ + ...novaColeta(), + coleta: { ...ficha, coluna_inexistente: 'x' } as unknown as ColetaAttributes + }) + + expect(created.left()).toBe(true) + + const depois = await knex('eventos').where({ expedicao_id: expedicaoId }).count({ total: '*' }) + expect(depois).toEqual(antes) + }) + + test('o banco rejeita um tipo fora de DIARIO/COLETA', async () => { + const created = await collection.create({ ...novoDiario(), tipo: 'FOTO' as 'DIARIO' }) + + expect(created.left()).toBe(true) + }) + + test('preserva o instante de captura, distinto de created_at', async () => { + const created = await collection.create(novaColeta()) + + expect(created.right()).toBe(true) + if (!created.right()) return + + try { + expect(created.value.capturado_em.toISOString()).toBe('2026-02-15T14:32:00.000Z') + expect(created.value.created_at.getTime()).toBeGreaterThan(created.value.capturado_em.getTime()) + } finally { + await knex('eventos').where({ id: created.value.id }).delete() + } + }) + + test('ordena o feed pelo instante de captura, do mais recente para o mais antigo', async () => { + const cedo = await collection.create({ + ...novoDiario(), capturado_em: new Date('2026-02-15T08:00:00.000Z') + }) + const tarde = await collection.create({ + ...novaColeta(), capturado_em: new Date('2026-02-15T18:00:00.000Z') + }) + + expect(cedo.right() && tarde.right()).toBe(true) + if (!cedo.right() || !tarde.right()) return + + try { + const found = await collection.findAll({ expedicao_id: expedicaoId }) + expect(found.right()).toBe(true) + if (!found.right()) return + + expect(found.value.itens.map(evento => evento.id)).toEqual([tarde.value.id, cedo.value.id]) + } finally { + await knex('eventos').whereIn('id', [cedo.value.id, tarde.value.id]).delete() + } + }) + + test('filtra por tipo', async () => { + const diario = await collection.create(novoDiario()) + const coleta = await collection.create(novaColeta()) + + expect(diario.right() && coleta.right()).toBe(true) + if (!diario.right() || !coleta.right()) return + + try { + const found = await collection.findAll({ expedicao_id: expedicaoId, tipo: 'COLETA' }) + expect(found.right()).toBe(true) + if (!found.right()) return + + expect(found.value.itens.map(evento => evento.id)).toEqual([coleta.value.id]) + expect(found.value.itens[0].coleta).toEqual(ficha) + } finally { + await knex('eventos').whereIn('id', [diario.value.id, coleta.value.id]).delete() + } + }) + + test('filtra por intervalo de captura e pagina os resultados', async () => { + const antes = await collection.create({ + ...novoDiario(), capturado_em: new Date('2026-02-14T10:00:00.000Z') + }) + const dentro1 = await collection.create({ + ...novoDiario(), capturado_em: new Date('2026-02-15T10:00:00.000Z') + }) + const dentro2 = await collection.create({ + ...novaColeta(), capturado_em: new Date('2026-02-15T14:00:00.000Z') + }) + const depois = await collection.create({ + ...novoDiario(), capturado_em: new Date('2026-02-16T10:00:00.000Z') + }) + + expect(antes.right() && dentro1.right() && dentro2.right() && depois.right()).toBe(true) + if (!antes.right() || !dentro1.right() || !dentro2.right() || !depois.right()) return + + try { + const found = await collection.findAll({ + expedicao_id: expedicaoId, + capturado_de: new Date('2026-02-15T00:00:00.000Z'), + capturado_ate: new Date('2026-02-15T23:59:59.999Z'), + limite: 1, + pagina: 2 + }) + + expect(found.right()).toBe(true) + if (!found.right()) return + + expect(found.value.total).toBe(2) + expect(found.value.limite).toBe(1) + expect(found.value.pagina).toBe(2) + expect(found.value.itens.map(evento => evento.id)).toEqual([dentro1.value.id]) + } finally { + await knex('eventos') + .whereIn('id', [ + antes.value.id, + dentro1.value.id, + dentro2.value.id, + depois.value.id + ]) + .delete() + } + }) + + test('apaga eventos e fichas junto com a expedição', async () => { + const [expedicao] = await knex('expedicoes') + .insert({ + descricao: 'XEXP cascata', + data_inicio: '2026-03-01', + data_fim: '2026-03-02', + cidade_id: fixtures.cidades[0] + }) + .returning>(['id']) + + const created = await collection.create({ ...novaColeta(), expedicao_id: expedicao.id }) + expect(created.right()).toBe(true) + if (!created.right()) return + + await knex('expedicoes').where({ id: expedicao.id }).delete() + + const found = await collection.findById(created.value.id) + expect(found.right()).toBe(true) + if (!found.right()) return + expect(found.value).toBeNull() + + const fichas = await knex('eventos_coletas').where({ evento_id: created.value.id }) + expect(fichas).toHaveLength(0) + }) + + test('devolve null quando o evento não existe', async () => { + const found = await collection.findById(-1) + expect(found.right()).toBe(true) + if (!found.right()) return + expect(found.value).toBeNull() + }) +}) diff --git a/test/integration/evento/eventos-http.test.ts b/test/integration/evento/eventos-http.test.ts new file mode 100644 index 00000000..ff9df775 --- /dev/null +++ b/test/integration/evento/eventos-http.test.ts @@ -0,0 +1,217 @@ +import { + afterAll, beforeAll, describe, expect, test +} from 'vitest' + +import { ColetaAttributes } from '@/domain/evento/Evento' + +import { createTestApp } from '../setup/app-factory' +import { + cleanupExpedicaoFixtures, ExpedicaoFixtures, seedExpedicaoFixtures +} from '../setup/seeds/expedicao.seed' + +const ficha: ColetaAttributes = { + associacoes: 'Lychnophora ericoides', + estado: 'MG', + familia: 'Velloziaceae', + flores: 'Presentes, lilases', + folhas: 'Lineares, rígidas', + frutos: 'Ausentes', + habito: 'Herbácea', + luminosidade: 'Pleno sol', + municipio: 'Serra do Cipó', + nome_cientifico: 'Vellozia squamata', + nome_popular: 'canela-de-ema', + referencia_local: 'Trilha da cachoeira, 300 m após a ponte', + relevo: 'Ondulado', + solo: 'Arenoso', + substrato: 'Afloramento rochoso', + tipo_vegetacao: 'Campo rupestre', + tronco_com_casca: 'Sim, gretada' +} + +interface EventoBody { + id: number + tipo: string + observacoes: string | null + coleta: Record | null +} + +function asEvento(body: unknown): EventoBody { + return body as EventoBody +} + +describe('Eventos (escrita, HTTP)', () => { + const { agent, knex } = createTestApp() + + let fixtures: ExpedicaoFixtures + let expedicaoId: number + + beforeAll(async () => { + fixtures = await seedExpedicaoFixtures(knex) + + const [expedicao] = await knex('expedicoes') + .insert({ + cidade_id: fixtures.cidades[0], data_fim: '2026-02-18', data_inicio: '2026-02-14', descricao: 'XEVT eventos http' + }) + .returning>(['id']) + + expedicaoId = expedicao.id + }) + + afterAll(async () => { + await knex('expedicoes').where({ id: expedicaoId }).delete() + await cleanupExpedicaoFixtures(knex, fixtures) + await knex.destroy() + }) + + test('POST cria um evento DIARIO', async () => { + const response = await agent + .post(`/api/v2/expedicoes/${expedicaoId}/eventos`) + .send({ + capturado_em: '2026-02-15T14:32:00Z', + latitude: -20.2508, + longitude: -46.4167, + observacoes: 'Área de transição entre cerrado e campo rupestre.', + tipo: 'DIARIO' + }) + .expect(201) + + const body = asEvento(response.body) + + try { + expect(body).toMatchObject({ coleta: null, tipo: 'DIARIO' }) + } finally { + await knex('eventos').where({ id: body.id }).delete() + } + }) + + test('POST cria um evento COLETA com a ficha', async () => { + const response = await agent + .post(`/api/v2/expedicoes/${expedicaoId}/eventos`) + .send({ + capturado_em: '2026-02-15T14:32:00Z', coleta: ficha, latitude: -20.2508, longitude: -46.4167, tipo: 'COLETA' + }) + .expect(201) + + const body = asEvento(response.body) + + try { + expect(body.coleta).toEqual(ficha) + } finally { + await knex('eventos').where({ id: body.id }).delete() + } + }) + + test('POST retorna 400 quando COLETA não envia ficha', async () => { + await agent + .post(`/api/v2/expedicoes/${expedicaoId}/eventos`) + .send({ + capturado_em: '2026-02-15T14:32:00Z', latitude: -20.25, longitude: -46.41, tipo: 'COLETA' + }) + .expect(400) + }) + + test('POST retorna 404 quando a expedição não existe', async () => { + await agent + .post('/api/v2/expedicoes/999999999/eventos') + .send({ + capturado_em: '2026-02-15T14:32:00Z', latitude: -20.25, longitude: -46.41, observacoes: 'x', tipo: 'DIARIO' + }) + .expect(404) + }) + + test('PUT atualiza campos comuns', async () => { + const created = await agent + .post(`/api/v2/expedicoes/${expedicaoId}/eventos`) + .send({ + capturado_em: '2026-02-15T14:32:00Z', latitude: -20.25, longitude: -46.41, observacoes: 'original', tipo: 'DIARIO' + }) + .expect(201) + + const eventoId = asEvento(created.body).id + + try { + const response = await agent + .put(`/api/v2/eventos/${eventoId}`) + .send({ observacoes: 'atualizado' }) + .expect(200) + + expect(asEvento(response.body).observacoes).toBe('atualizado') + } finally { + await knex('eventos').where({ id: eventoId }).delete() + } + }) + + test('PUT troca DIARIO para COLETA criando a ficha', async () => { + const created = await agent + .post(`/api/v2/expedicoes/${expedicaoId}/eventos`) + .send({ + capturado_em: '2026-02-15T14:32:00Z', latitude: -20.25, longitude: -46.41, observacoes: 'original', tipo: 'DIARIO' + }) + .expect(201) + + const eventoId = asEvento(created.body).id + + try { + const response = await agent + .put(`/api/v2/eventos/${eventoId}`) + .send({ coleta: ficha, tipo: 'COLETA' }) + .expect(200) + + expect(asEvento(response.body)).toMatchObject({ coleta: ficha, tipo: 'COLETA' }) + } finally { + await knex('eventos').where({ id: eventoId }).delete() + } + }) + + test('PUT troca COLETA para DIARIO apagando a ficha', async () => { + const created = await agent + .post(`/api/v2/expedicoes/${expedicaoId}/eventos`) + .send({ + capturado_em: '2026-02-15T14:32:00Z', coleta: ficha, latitude: -20.25, longitude: -46.41, tipo: 'COLETA' + }) + .expect(201) + + const eventoId = asEvento(created.body).id + + try { + const response = await agent + .put(`/api/v2/eventos/${eventoId}`) + .send({ observacoes: 'virou diário', tipo: 'DIARIO' }) + .expect(200) + + expect(asEvento(response.body)).toMatchObject({ coleta: null, tipo: 'DIARIO' }) + + const fichas = await knex('eventos_coletas').where({ evento_id: eventoId }) + expect(fichas).toHaveLength(0) + } finally { + await knex('eventos').where({ id: eventoId }).delete() + } + }) + + test('PUT retorna 404 quando o evento não existe', async () => { + await agent.put('/api/v2/eventos/999999999').send({ observacoes: 'x' }).expect(404) + }) + + test('DELETE remove o evento e a ficha (cascade)', async () => { + const created = await agent + .post(`/api/v2/expedicoes/${expedicaoId}/eventos`) + .send({ + capturado_em: '2026-02-15T14:32:00Z', coleta: ficha, latitude: -20.25, longitude: -46.41, tipo: 'COLETA' + }) + .expect(201) + + const eventoId = asEvento(created.body).id + + await agent.delete(`/api/v2/eventos/${eventoId}`).expect(204) + + const eventoRow = await knex('eventos').where({ id: eventoId }).first() + const coletaRow = await knex('eventos_coletas').where({ evento_id: eventoId }).first() + expect(eventoRow).toBeUndefined() + expect(coletaRow).toBeUndefined() + }) + + test('DELETE retorna 404 quando o evento não existe', async () => { + await agent.delete('/api/v2/eventos/999999999').expect(404) + }) +}) diff --git a/test/integration/evento/eventos-routes.test.ts b/test/integration/evento/eventos-routes.test.ts new file mode 100644 index 00000000..8f726b70 --- /dev/null +++ b/test/integration/evento/eventos-routes.test.ts @@ -0,0 +1,106 @@ +import { + afterAll, beforeAll, describe, expect, test +} from 'vitest' + +import { createTestApp } from '../setup/app-factory' +import { + cleanupExpedicaoFixtures, ExpedicaoFixtures, seedExpedicaoFixtures +} from '../setup/seeds/expedicao.seed' + +type ListaEventosResponse = { + itens: Array<{ id: number }> + total: number + limite: number + pagina: number +} + +type EventoResponse = { + id: number + tipo: string +} + +describe('Eventos routes', () => { + const { agent, knex } = createTestApp() + + let fixtures: ExpedicaoFixtures + let expedicaoId: number + + beforeAll(async () => { + fixtures = await seedExpedicaoFixtures(knex) + + const [expedicao] = await knex('expedicoes') + .insert({ + descricao: 'XEXP rotas eventos', + data_inicio: '2026-02-14', + data_fim: '2026-02-18', + cidade_id: fixtures.cidades[0] + }) + .returning>(['id']) + + expedicaoId = expedicao.id + }) + + test('lista os eventos da expedição com paginação e ordenação estável', async () => { + const capturadoEm = '2026-02-15T14:32:00.000Z' + + const inserted = await knex('eventos') + .insert([ + { + expedicao_id: expedicaoId, + tipo: 'DIARIO', + capturado_em: capturadoEm + }, + { + expedicao_id: expedicaoId, + tipo: 'COLETA', + capturado_em: capturadoEm + } + ]) + .returning>(['id']) + + try { + const response = await agent + .get(`/api/v2/expedicoes/${expedicaoId}/eventos`) + .expect(200) + + const body = response.body as ListaEventosResponse + + expect(body.total).toBe(2) + expect(body.limite).toBe(20) + expect(body.pagina).toBe(1) + expect(body.itens.map(evento => evento.id)) + .toEqual([...inserted].map(evento => evento.id).sort((a, b) => b - a)) + } finally { + await knex('eventos').whereIn('id', inserted.map(evento => evento.id)).delete() + } + }) + + test('busca um evento pelo id', async () => { + const [evento] = await knex('eventos') + .insert({ + expedicao_id: expedicaoId, + tipo: 'DIARIO', + capturado_em: '2026-02-16T10:00:00.000Z' + }) + .returning>(['id']) + + try { + const response = await agent + .get(`/api/v2/eventos/${evento.id}`) + .expect(200) + + const body = response.body as EventoResponse + + expect(body.id).toBe(evento.id) + expect(body.tipo).toBe('DIARIO') + } finally { + await knex('eventos').where({ id: evento.id }).delete() + } + }) + + afterAll(async () => { + await knex('expedicoes').where({ id: expedicaoId }).delete() + await cleanupExpedicaoFixtures(knex, fixtures) + await knex.destroy() + }) +}) diff --git a/test/integration/evidencia/evidencia-collection.test.ts b/test/integration/evidencia/evidencia-collection.test.ts new file mode 100644 index 00000000..73fab38e --- /dev/null +++ b/test/integration/evidencia/evidencia-collection.test.ts @@ -0,0 +1,150 @@ +import { + afterAll, beforeAll, describe, expect, test +} from 'vitest' + +import { EvidenciaCollectionKnexAdapter } from '@/infrastructure/EvidenciaCollectionKnexAdapter' + +import { createTestKnex } from '../setup/app-factory' +import { + cleanupExpedicaoFixtures, ExpedicaoFixtures, seedExpedicaoFixtures +} from '../setup/seeds/expedicao.seed' + +describe('EvidenciaCollectionKnexAdapter', () => { + const knex = createTestKnex() + const collection = new EvidenciaCollectionKnexAdapter({ knex }) + + let fixtures: ExpedicaoFixtures + let expedicaoId: number + let eventoId: number + + beforeAll(async () => { + fixtures = await seedExpedicaoFixtures(knex) + + const [expedicao] = await knex('expedicoes') + .insert({ + descricao: 'XEXP evidências', + data_inicio: '2026-02-14', + data_fim: '2026-02-18', + cidade_id: fixtures.cidades[0] + }) + .returning>(['id']) + expedicaoId = expedicao.id + + const [evento] = await knex('eventos') + .insert({ + expedicao_id: expedicaoId, tipo: 'DIARIO', capturado_em: new Date('2026-02-15T13:15:00.000Z') + }) + .returning>(['id']) + eventoId = evento.id + }) + + afterAll(async () => { + await knex('expedicoes').where({ id: expedicaoId }).delete() + await cleanupExpedicaoFixtures(knex, fixtures) + await knex.destroy() + }) + + test('preserva o instante de captura, distinto de created_at', async () => { + const capturadoEm = new Date('2026-02-15T14:30:00.000Z') + + const created = await collection.create({ + evento_id: eventoId, + nome: 'IMG_0042.jpg', + arquivo: '1_20260215143000-a1b2.jpg', + mime_type: 'image/jpeg', + tamanho: 204800, + capturado_em: capturadoEm, + created_by: fixtures.usuarios[0] + }) + + expect(created.right()).toBe(true) + if (!created.right()) return + + try { + expect(created.value.capturado_em.toISOString()).toBe(capturadoEm.toISOString()) + expect(created.value.created_at.getTime()).toBeGreaterThan(capturadoEm.getTime()) + expect(created.value.updated_by).toBe(fixtures.usuarios[0]) + } finally { + await knex('evidencias').where({ id: created.value.id }).delete() + } + }) + + test('lista as evidências de um evento ordenadas por instante de captura', async () => { + const tarde = await collection.create({ + evento_id: eventoId, + nome: 'b.jpg', + arquivo: '1_20260215180000-c3d4.jpg', + mime_type: 'image/jpeg', + tamanho: 1024, + capturado_em: new Date('2026-02-15T18:00:00.000Z'), + created_by: null + }) + const cedo = await collection.create({ + evento_id: eventoId, + nome: 'a.jpg', + arquivo: '1_20260215080000-e5f6.jpg', + mime_type: 'image/jpeg', + tamanho: 1024, + capturado_em: new Date('2026-02-15T08:00:00.000Z'), + created_by: null + }) + + expect(tarde.right() && cedo.right()).toBe(true) + if (!tarde.right() || !cedo.right()) return + + try { + const found = await collection.findAll({ + evento_id: eventoId, + order: { column: 'capturado_em', direction: 'asc' } + }) + + expect(found.right()).toBe(true) + if (!found.right()) return + + expect(found.value.map(evidencia => evidencia.nome)).toEqual(['a.jpg', 'b.jpg']) + } finally { + await knex('evidencias').whereIn('id', [tarde.value.id, cedo.value.id]).delete() + } + }) + + test('rejeita evidência de um evento inexistente', async () => { + const created = await collection.create({ + evento_id: -1, + nome: 'c.jpg', + arquivo: '-1_1700000000000-0000.jpg', + mime_type: 'image/jpeg', + tamanho: 1024, + capturado_em: new Date(), + created_by: null + }) + + expect(created.left()).toBe(true) + }) + + test('apaga as evidências junto com o evento', async () => { + const [evento] = await knex('eventos') + .insert({ + expedicao_id: expedicaoId, tipo: 'COLETA', capturado_em: new Date('2026-02-16T10:00:00.000Z') + }) + .returning>(['id']) + + const created = await collection.create({ + evento_id: evento.id, + nome: 'd.jpg', + arquivo: `${evento.id}_1700000000000-0000.jpg`, + mime_type: 'image/jpeg', + tamanho: 1024, + capturado_em: new Date(), + created_by: null + }) + expect(created.right()).toBe(true) + if (!created.right()) return + + await knex('eventos').where({ id: evento.id }).delete() + + const found = await collection.findById(created.value.id) + expect(found.right()).toBe(true) + if (!found.right()) return + expect(found.value).toBeNull() + }) +}) diff --git a/test/integration/evidencia/evidencia-http.test.ts b/test/integration/evidencia/evidencia-http.test.ts new file mode 100644 index 00000000..31b34050 --- /dev/null +++ b/test/integration/evidencia/evidencia-http.test.ts @@ -0,0 +1,124 @@ +import { existsSync } from 'node:fs' +import { unlink } from 'node:fs/promises' +import { resolve } from 'node:path' +import { + afterAll, beforeAll, describe, expect, test +} from 'vitest' + +import { createTestApp } from '../setup/app-factory' +import { + cleanupExpedicaoFixtures, ExpedicaoFixtures, seedExpedicaoFixtures +} from '../setup/seeds/expedicao.seed' + +interface EvidenciaBody { + id: number + arquivo: string + mime_type: string + tamanho: number + url: string +} + +function asEvidencia(body: unknown): EvidenciaBody { + return body as EvidenciaBody +} + +async function deleteArquivo(arquivo: string): Promise { + const path = resolve('uploads', 'evidencias', arquivo) + if (existsSync(path)) { + await unlink(path) + } +} + +describe('Evidências (upload, HTTP)', () => { + const { agent, knex } = createTestApp() + + let fixtures: ExpedicaoFixtures + let expedicaoId: number + let eventoId: number + + beforeAll(async () => { + fixtures = await seedExpedicaoFixtures(knex) + + const [expedicao] = await knex('expedicoes') + .insert({ + cidade_id: fixtures.cidades[0], data_fim: '2026-02-18', data_inicio: '2026-02-14', descricao: 'XEVD evidências http' + }) + .returning>(['id']) + expedicaoId = expedicao.id + + const [evento] = await knex('eventos') + .insert({ + expedicao_id: expedicaoId, tipo: 'DIARIO', capturado_em: new Date('2026-02-15T13:15:00.000Z') + }) + .returning>(['id']) + eventoId = evento.id + }) + + afterAll(async () => { + await knex('eventos').where({ id: eventoId }).delete() + await knex('expedicoes').where({ id: expedicaoId }).delete() + await cleanupExpedicaoFixtures(knex, fixtures) + await knex.destroy() + }) + + test('POST grava o arquivo em disco e a evidência no banco', async () => { + const response = await agent + .post(`/api/v2/eventos/${eventoId}/evidencias`) + .field('nome', 'IMG_0042.jpg') + .field('capturado_em', '2026-02-15T14:32:00Z') + .attach('arquivo', Buffer.from('conteúdo de teste'), { contentType: 'image/jpeg', filename: 'foto.jpg' }) + .expect(201) + + const body = asEvidencia(response.body) + + try { + expect(body.mime_type).toBe('image/jpeg') + expect(body.tamanho).toBeGreaterThan(0) + expect(body.arquivo).toMatch(new RegExp(`^${expedicaoId}_${eventoId}_\\d+-[0-9a-f]{4}\\.jpg$`)) + expect(body.url).toBe(`/uploads/evidencias/${body.arquivo}`) + expect(existsSync(resolve('uploads', 'evidencias', body.arquivo))).toBe(true) + } finally { + await knex('evidencias').where({ id: body.id }).delete() + await deleteArquivo(body.arquivo) + } + }) + + test('POST rejeita tipo de arquivo não suportado', async () => { + await agent + .post(`/api/v2/eventos/${eventoId}/evidencias`) + .field('nome', 'anotacoes.txt') + .field('capturado_em', '2026-02-15T14:32:00Z') + .attach('arquivo', Buffer.from('texto qualquer'), { contentType: 'text/plain', filename: 'anotacoes.txt' }) + .expect(400) + }) + + test('POST retorna 404 quando o evento não existe', async () => { + await agent + .post('/api/v2/eventos/999999999/evidencias') + .field('nome', 'IMG_0042.jpg') + .field('capturado_em', '2026-02-15T14:32:00Z') + .attach('arquivo', Buffer.from('conteúdo de teste'), { contentType: 'image/jpeg', filename: 'foto.jpg' }) + .expect(404) + }) + + test('GET lista as evidências do evento com a url computada', async () => { + const created = await agent + .post(`/api/v2/eventos/${eventoId}/evidencias`) + .field('nome', 'audio.mp3') + .field('capturado_em', '2026-02-15T14:32:00Z') + .attach('arquivo', Buffer.from('audio de teste'), { contentType: 'audio/mpeg', filename: 'audio.mp3' }) + .expect(201) + + const createdBody = asEvidencia(created.body) + + try { + const response = await agent.get(`/api/v2/eventos/${eventoId}/evidencias`).expect(200) + const [evidencia] = response.body as EvidenciaBody[] + + expect(evidencia).toMatchObject({ id: createdBody.id, url: createdBody.url }) + } finally { + await knex('evidencias').where({ id: createdBody.id }).delete() + await deleteArquivo(createdBody.arquivo) + } + }) +}) diff --git a/test/integration/expedicao/expedicao-collection.participantes.test.ts b/test/integration/expedicao/expedicao-collection.participantes.test.ts new file mode 100644 index 00000000..e1fb8835 --- /dev/null +++ b/test/integration/expedicao/expedicao-collection.participantes.test.ts @@ -0,0 +1,155 @@ +import { + afterAll, beforeAll, describe, expect, test +} from 'vitest' + +import { ExpedicaoCollectionKnexAdapter } from '@/infrastructure/ExpedicaoCollectionKnexAdapter' + +import { createTestKnex } from '../setup/app-factory' +import { + cleanupExpedicaoFixtures, + ExpedicaoFixtures, + seedExpedicaoFixtures +} from '../setup/seeds/expedicao.seed' + +describe('ExpedicaoCollectionKnexAdapter - Sub-recursos (Participantes e Rotas)', () => { + const knex = createTestKnex() + const collection = new ExpedicaoCollectionKnexAdapter({ knex }) + + let fixtures: ExpedicaoFixtures + + beforeAll(async () => { + fixtures = await seedExpedicaoFixtures(knex) + }) + + afterAll(async () => { + await cleanupExpedicaoFixtures(knex, fixtures) + await knex.destroy() + }) + + async function criarExpedicaoParaTeste(prefixo: string) { + return collection.create({ + descricao: `Expedicao [${prefixo}] - Teste Isolado`, + data_inicio: '2026-04-01', + data_fim: '2026-04-10', + cidade_id: fixtures.cidades[0], + created_by: fixtures.usuarios[0], + participantes: [fixtures.usuarios[0]], + rotas: [fixtures.cidades[1]] + }) + } + + describe('Adicionar e Remover Participantes', () => { + test('addParticipant insere um novo usuário na expedição', async () => { + const created = await criarExpedicaoParaTeste('ADD_PART') + expect(created.right()).toBe(true) + if (!created.right()) return + const expedicaoId = created.value.id + + try { + const novoParticipante = fixtures.usuarios[1] + const result = await collection.addParticipant( + expedicaoId, + novoParticipante + ) + expect(result.right()).toBe(true) + const banco = await knex<{ expedicao_id: number; usuario_id: number | string }>('expedicoes_participantes') + .where({ expedicao_id: expedicaoId, usuario_id: novoParticipante }) + .first() + + expect(banco).toBeDefined() + expect(Number(banco?.usuario_id)).toBe(novoParticipante) + } finally { + await knex('expedicoes').where({ id: expedicaoId }).delete() + } + }) + + test('addParticipant falha ao tentar inserir usuário já existente (unique constraint)', async () => { + const created = await criarExpedicaoParaTeste('ADD_FAIL_UNIQUE') + if (!created.right()) return + const expedicaoId = created.value.id + + try { + const participanteExistente = fixtures.usuarios[0] + + const result = await collection.addParticipant( + expedicaoId, + participanteExistente + ) + + expect(result.left()).toBe(true) + if (result.left()) { + expect(result.value.message).toContain('já está nesta expedição') + } + } finally { + await knex('expedicoes').where({ id: expedicaoId }).delete() + } + }) + + test('removeParticipant exclui o usuário da expedição', async () => { + const created = await criarExpedicaoParaTeste('REM_PART') + if (!created.right()) return + const expedicaoId = created.value.id + + try { + const participanteParaRemover = fixtures.usuarios[0] + + const result = await collection.removeParticipant( + expedicaoId, + participanteParaRemover + ) + expect(result.right()).toBe(true) + + const banco = await knex<{ expedicao_id: number; usuario_id: number | string }>('expedicoes_participantes') + .where({ + expedicao_id: expedicaoId, + usuario_id: participanteParaRemover + }) + .first() + + expect(banco).toBeUndefined() + } finally { + await knex('expedicoes').where({ id: expedicaoId }).delete() + } + }) + }) + + describe('Substituir Rotas', () => { + test('substituteRoute remove as antigas e insere as novas na ordem correta', async () => { + const created = await criarExpedicaoParaTeste('SUB_ROTAS') + if (!created.right()) return + const expedicaoId = created.value.id + + try { + const novasRotas = [ + fixtures.cidades[2], + fixtures.cidades[0], + fixtures.cidades[2] + ] + + const result = await collection.substituteRoute( + expedicaoId, + novasRotas + ) + expect(result.right()).toBe(true) + + const rotasNoBanco = await knex<{ expedicao_id: number; ordem: number; cidade_id: number | string }>('expedicoes_rotas') + .where({ expedicao_id: expedicaoId }) + .orderBy('ordem') + + expect(rotasNoBanco.length).toBe(3) + + const arrayValidacao = rotasNoBanco.map((rota: { ordem: number; cidade_id: number | string }) => [ + rota.ordem, + Number(rota.cidade_id) + ]) + expect(arrayValidacao).toEqual([ + [0, novasRotas[0]], + [1, novasRotas[1]], + [2, novasRotas[2]] + ]) + } finally { + await knex('expedicoes').where({ id: expedicaoId }).delete() + } + }) + }) +}) diff --git a/test/integration/expedicao/expedicao-collection.test.ts b/test/integration/expedicao/expedicao-collection.test.ts new file mode 100644 index 00000000..fdaf25a6 --- /dev/null +++ b/test/integration/expedicao/expedicao-collection.test.ts @@ -0,0 +1,262 @@ +import { + afterAll, beforeAll, describe, expect, test +} from 'vitest' + +import { ExpedicaoCollectionKnexAdapter } from '@/infrastructure/ExpedicaoCollectionKnexAdapter' + +import { createTestKnex } from '../setup/app-factory' +import { + cleanupExpedicaoFixtures, ExpedicaoFixtures, seedExpedicaoFixtures +} from '../setup/seeds/expedicao.seed' + +describe('ExpedicaoCollectionKnexAdapter', () => { + const knex = createTestKnex() + const collection = new ExpedicaoCollectionKnexAdapter({ knex }) + + let fixtures: ExpedicaoFixtures + + beforeAll(async () => { + fixtures = await seedExpedicaoFixtures(knex) + }) + + afterAll(async () => { + await knex('expedicoes').whereIn('cidade_id', fixtures.cidades).delete() + await cleanupExpedicaoFixtures(knex, fixtures) + await knex.destroy() + }) + + function novaExpedicao() { + return { + descricao: 'Coleta no litoral', + data_inicio: '2026-03-01', + data_fim: '2026-03-10', + cidade_id: fixtures.cidades[0], + created_by: fixtures.usuarios[0], + participantes: fixtures.usuarios, + rotas: [ + fixtures.cidades[1], + fixtures.cidades[2], + fixtures.cidades[1] + ] + } + } + + test('grava a expedição, seus participantes e suas rotas na mesma transação', async () => { + const created = await collection.create(novaExpedicao()) + + expect(created.right()).toBe(true) + if (!created.right()) return + + try { + expect(created.value).toMatchObject({ + descricao: 'Coleta no litoral', + data_inicio: '2026-03-01', + data_fim: '2026-03-10', + cidade_id: fixtures.cidades[0], + created_by: fixtures.usuarios[0], + updated_by: fixtures.usuarios[0] + }) + + const participantes = await knex('expedicoes_participantes') + .where({ expedicao_id: created.value.id }) + .orderBy('id') as Array<{ usuario_id: string }> + expect(participantes.map(participante => Number(participante.usuario_id))).toEqual(fixtures.usuarios) + + const rotas = await knex('expedicoes_rotas') + .where({ expedicao_id: created.value.id }) + .orderBy('ordem') as Array<{ ordem: number; cidade_id: string }> + expect(rotas.map(rota => [rota.ordem, Number(rota.cidade_id)])).toEqual([ + [0, fixtures.cidades[1]], + [1, fixtures.cidades[2]], + [2, fixtures.cidades[1]] + ]) + } finally { + await knex('expedicoes').where({ id: created.value.id }).delete() + } + }) + + test('não grava nada quando um participante não existe', async () => { + const antes = await knex('expedicoes').whereIn('cidade_id', fixtures.cidades).count({ total: '*' }) + + const created = await collection.create({ + ...novaExpedicao(), + participantes: [fixtures.usuarios[0], -1] + }) + + expect(created.left()).toBe(true) + + const depois = await knex('expedicoes').whereIn('cidade_id', fixtures.cidades).count({ total: '*' }) + expect(depois).toEqual(antes) + }) + + test('devolve a data civil sem deslocamento de fuso', async () => { + const created = await collection.create({ + ...novaExpedicao(), + data_inicio: '2026-01-01', + data_fim: '2026-01-01' + }) + + expect(created.right()).toBe(true) + if (!created.right()) return + + try { + const found = await collection.findById(created.value.id) + expect(found.right()).toBe(true) + if (!found.right()) return + + expect(found.value).toMatchObject({ data_inicio: '2026-01-01', data_fim: '2026-01-01' }) + } finally { + await knex('expedicoes').where({ id: created.value.id }).delete() + } + }) + + test('filtra por participante', async () => { + const comAna = await collection.create({ ...novaExpedicao(), participantes: [fixtures.usuarios[0]] }) + const comBruno = await collection.create({ ...novaExpedicao(), participantes: [fixtures.usuarios[1]] }) + + expect(comAna.right() && comBruno.right()).toBe(true) + if (!comAna.right() || !comBruno.right()) return + + try { + const found = await collection.findAll({ usuario_id: fixtures.usuarios[1] }) + expect(found.right()).toBe(true) + if (!found.right()) return + + expect(found.value.itens.map(expedicao => expedicao.id)).toEqual([comBruno.value.id]) + } finally { + await knex('expedicoes').whereIn('id', [comAna.value.id, comBruno.value.id]).delete() + } + }) + + test('devolve null quando a expedição não existe', async () => { + const found = await collection.findById(-1) + expect(found.right()).toBe(true) + if (!found.right()) return + expect(found.value).toBeNull() + }) + describe('filtros e paginação no findAll', () => { + let ids: number[] = [] + + beforeAll(async () => { + // Cria 3 expedições distintas para testar ordenação, paginação e filtros + const exp1 = await collection.create({ + ...novaExpedicao(), + cidade_id: fixtures.cidades[0], + data_inicio: '2026-05-01', + data_fim: '2026-05-10' + }) + const exp2 = await collection.create({ + ...novaExpedicao(), + cidade_id: fixtures.cidades[1], + data_inicio: '2026-06-01', + data_fim: '2026-06-10' + }) + const exp3 = await collection.create({ + ...novaExpedicao(), + cidade_id: fixtures.cidades[0], // Mesma cidade da exp1 + data_inicio: '2026-07-01', + data_fim: '2026-07-10' + }) + + if (exp1.right() && exp2.right() && exp3.right()) { + ids = [ + exp1.value.id, + exp2.value.id, + exp3.value.id + ] + } + }) + + afterAll(async () => { + await knex('expedicoes').whereIn('id', ids).delete() + }) + + test('devolve metadados de paginação padrão e limite de itens', async () => { + const found = await collection.findAll({ limite: 2, pagina: 1 }) + expect(found.right()).toBe(true) + if (!found.right()) return + + // Deve ter limite 2, estar na página 1 e totalizar no mínimo 3 + expect(found.value.limite).toBe(2) + expect(found.value.pagina).toBe(1) + expect(found.value.total).toBeGreaterThanOrEqual(3) + expect(found.value.itens.length).toBeLessThanOrEqual(2) + }) + + test('filtra por cidade_id', async () => { + const found = await collection.findAll({ cidade_id: fixtures.cidades[1] }) + expect(found.right()).toBe(true) + if (!found.right()) return + + // Deve achar apenas a exp2 + expect(found.value.itens.map(e => e.id)).toContain(ids[1]) + expect(found.value.itens.map(e => e.id)).not.toContain(ids[0]) + expect(found.value.itens.map(e => e.id)).not.toContain(ids[2]) + }) + + test('filtra por intervalo de datas (data_inicio_de e data_fim_ate)', async () => { + // Busca expedições que comecem a partir de junho e terminem até meio de julho + const found = await collection.findAll({ + data_inicio_de: '2026-06-01', + data_fim_ate: '2026-07-15' + }) + expect(found.right()).toBe(true) + if (!found.right()) return + + // Deve achar exp2 (junho) e exp3 (julho), mas ignorar exp1 (maio) + const returnedIds = found.value.itens.map(e => e.id) + expect(returnedIds).toContain(ids[1]) + expect(returnedIds).toContain(ids[2]) + expect(returnedIds).not.toContain(ids[0]) + }) + + test('filtra por data_fim_de (data_fim maior ou igual a uma data)', async () => { + // Busca expedições com data_fim a partir de 2026-06-01 (exp2 e exp3) + const found = await collection.findAll({ + data_fim_de: '2026-06-01' + }) + expect(found.right()).toBe(true) + if (!found.right()) return + + const returnedIds = found.value.itens.map(e => e.id) + expect(returnedIds).toContain(ids[1]) + expect(returnedIds).toContain(ids[2]) + expect(returnedIds).not.toContain(ids[0]) + }) + + test('filtra por data_inicio_ate (data_inicio menor ou igual a uma data)', async () => { + // Busca expedições com data_inicio até 2026-06-01 (exp1 e exp2) + const found = await collection.findAll({ + data_inicio_ate: '2026-06-01' + }) + expect(found.right()).toBe(true) + if (!found.right()) return + + const returnedIds = found.value.itens.map(e => e.id) + expect(returnedIds).toContain(ids[0]) + expect(returnedIds).toContain(ids[1]) + expect(returnedIds).not.toContain(ids[2]) + }) + + test('aplica ordenação (order) corretamente', async () => { + const foundDesc = await collection.findAll({ order: { column: 'data_inicio', direction: 'desc' } }) + expect(foundDesc.right()).toBe(true) + if (!foundDesc.right()) return + + const foundAsc = await collection.findAll({ order: { column: 'data_inicio', direction: 'asc' } }) + expect(foundAsc.right()).toBe(true) + if (!foundAsc.right()) return + + const idxDesc3 = foundDesc.value.itens.findIndex(e => e.id === ids[2]) // Julho + const idxDesc1 = foundDesc.value.itens.findIndex(e => e.id === ids[0]) // Maio + + const idxAsc3 = foundAsc.value.itens.findIndex(e => e.id === ids[2]) // Julho + const idxAsc1 = foundAsc.value.itens.findIndex(e => e.id === ids[0]) // Maio + + // DESC: Julho (exp3) vem antes de Maio (exp1) + expect(idxDesc3).toBeLessThan(idxDesc1) + // ASC: Maio (exp1) vem antes de Julho (exp3) + expect(idxAsc1).toBeLessThan(idxAsc3) + }) + }) +}) diff --git a/test/integration/lembrete/lembrete-collection.test.ts b/test/integration/lembrete/lembrete-collection.test.ts new file mode 100644 index 00000000..e2ef23ab --- /dev/null +++ b/test/integration/lembrete/lembrete-collection.test.ts @@ -0,0 +1,110 @@ +import { + afterAll, describe, expect, test +} from 'vitest' + +import { CreateAttributes } from '@/domain/lembrete/Lembrete' +import { LembreteCollectionKnexAdapter } from '@/infrastructure/LembreteCollectionKnexAdapter' + +import { createTestKnex } from '../setup/app-factory' + +const PREFIXO = 'XLEMB' + +function novoLembrete(overrides: Partial = {}): CreateAttributes { + return { + data_coleta: '2026-11-20', + local_coleta: `${PREFIXO} Serra do Cipó`, + familia: 'Velloziaceae', + nome_popular: 'canela-de-ema', + nome_cientifico: 'Vellozia squamata', + municipio: 'Santana do Riacho', + estado: 'MG', + referencia_local: 'Trilha da cachoeira', + tipo_vegetacao: 'Campo rupestre', + solo: 'Arenoso', + relevo: 'Ondulado', + substrato: 'Afloramento rochoso', + tronco_com_casca: null, + associacoes: null, + folhas: 'Lineares', + habito: 'Herbácea', + frutos: null, + flores: 'Esperadas em novembro', + luminosidade: 'Pleno sol', + created_by: null, + ...overrides + } +} + +describe('LembreteCollectionKnexAdapter', () => { + const knex = createTestKnex() + const collection = new LembreteCollectionKnexAdapter({ knex }) + + afterAll(async () => { + await knex.destroy() + }) + + async function limpar() { + await knex('lembretes').where('local_coleta', 'like', `${PREFIXO}%`).delete() + } + + test('cria, busca, atualiza e remove um lembrete', async () => { + try { + const criado = await collection.create(novoLembrete()) + expect(criado.right()).toBe(true) + if (criado.left()) return + + expect(criado.value.data_coleta).toBe('2026-11-20') + expect(criado.value.familia).toBe('Velloziaceae') + + const encontrado = await collection.findById(criado.value.id) + expect(encontrado.value).toEqual(criado.value) + + const { + id, created_at: _c, updated_at: _u, created_by: _cb, ...resto + } = criado.value + const atualizado = await collection.update(id, { + ...resto, + data_coleta: '2026-12-01', + flores: 'Lilases' + }) + expect(atualizado.right()).toBe(true) + expect(atualizado.value).toMatchObject({ data_coleta: '2026-12-01', flores: 'Lilases' }) + + const removido = await collection.delete(id) + expect(removido.value).toBe(true) + + const depois = await collection.findById(id) + expect(depois.value).toBeNull() + } finally { + await limpar() + } + }) + + test('update e delete de id inexistente não quebram', async () => { + const { created_by: _cb, ...attrs } = novoLembrete() + + const atualizado = await collection.update(999999, { ...attrs, updated_by: null }) + expect(atualizado.right()).toBe(true) + expect(atualizado.value).toBeNull() + + const removido = await collection.delete(999999) + expect(removido.value).toBe(false) + }) + + test('lista filtrando por intervalo de data_coleta, o mais próximo primeiro', async () => { + try { + await collection.create(novoLembrete({ data_coleta: '2030-03-10' })) + await collection.create(novoLembrete({ data_coleta: '2030-01-05' })) + await collection.create(novoLembrete({ data_coleta: '2030-08-01' })) + + const result = await collection.findAll({ data_coleta_de: '2030-01-01', data_coleta_ate: '2030-06-30' }) + + expect(result.right()).toBe(true) + if (result.left()) return + const doTeste = result.value.itens.filter(item => item.local_coleta.startsWith(PREFIXO)) + expect(doTeste.map(item => item.data_coleta)).toEqual(['2030-01-05', '2030-03-10']) + } finally { + await limpar() + } + }) +}) diff --git a/test/integration/setup/app-factory.ts b/test/integration/setup/app-factory.ts index 2eb0a7f4..1b111bb1 100644 --- a/test/integration/setup/app-factory.ts +++ b/test/integration/setup/app-factory.ts @@ -4,7 +4,7 @@ import supertest from 'supertest' import { createApp } from '@/application/create-app' import { ConsoleLogger } from '@/infrastructure/ConsoleLogger' -function createTestKnex(): Knex { +export function createTestKnex(): Knex { return knex({ client: 'postgres', connection: { diff --git a/test/integration/setup/global-setup.ts b/test/integration/setup/global-setup.ts index 12b00421..72b45255 100644 --- a/test/integration/setup/global-setup.ts +++ b/test/integration/setup/global-setup.ts @@ -10,7 +10,14 @@ try { const TABLES = [ 'estados', 'paises', - 'fase_sucessional' + 'fase_sucessional', + 'lembretes', + 'evidencias', + 'eventos_coletas', + 'eventos', + 'expedicoes_rotas', + 'expedicoes_participantes', + 'expedicoes' ] async function truncateTables(knex: Knex): Promise { diff --git a/test/integration/setup/schema.sql b/test/integration/setup/schema.sql index b7bdabda..19f111bf 100644 --- a/test/integration/setup/schema.sql +++ b/test/integration/setup/schema.sql @@ -3029,3 +3029,561 @@ ALTER TABLE ONLY public.variedades -- \unrestrict 6jAa2KinsvKEOg1UbHoVMtEdyuVatRpHS5aFz6F6xH4vaNfTZHZIHyPU01e9TJ8 + + +-- +-- Modulo Expedicao: expedicoes, expedicoes_participantes, expedicoes_rotas, +-- eventos, eventos_coletas, evidencias +-- Migrations 20260914100000..02 e 20260915100000..02 +-- +SET default_tablespace = ''; + +SET default_table_access_method = heap; + +-- +-- Name: eventos; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.eventos ( + id integer NOT NULL, + expedicao_id integer NOT NULL, + tipo text NOT NULL, + capturado_em timestamp with time zone NOT NULL, + latitude double precision, + longitude double precision, + altitude double precision, + observacoes text, + created_at timestamp with time zone DEFAULT CURRENT_TIMESTAMP NOT NULL, + updated_at timestamp with time zone DEFAULT CURRENT_TIMESTAMP NOT NULL, + created_by bigint, + updated_by bigint, + CONSTRAINT eventos_tipo_check CHECK ((tipo = ANY (ARRAY['DIARIO'::text, 'COLETA'::text]))) +); + + +-- +-- Name: eventos_coletas; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.eventos_coletas ( + evento_id integer NOT NULL, + familia text, + nome_popular text, + nome_cientifico text, + municipio text, + estado text, + referencia_local text, + tipo_vegetacao text, + solo text, + relevo text, + substrato text, + tronco_com_casca text, + associacoes text, + folhas text, + habito text, + frutos text, + flores text, + luminosidade text +); + + +-- +-- Name: eventos_id_seq; Type: SEQUENCE; Schema: public; Owner: - +-- + +CREATE SEQUENCE public.eventos_id_seq + AS integer + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +-- +-- Name: eventos_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - +-- + +ALTER SEQUENCE public.eventos_id_seq OWNED BY public.eventos.id; + + +-- +-- Name: evidencias; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.evidencias ( + id integer NOT NULL, + evento_id integer NOT NULL, + nome character varying(255) NOT NULL, + capturado_em timestamp with time zone NOT NULL, + created_at timestamp with time zone DEFAULT CURRENT_TIMESTAMP NOT NULL, + updated_at timestamp with time zone DEFAULT CURRENT_TIMESTAMP NOT NULL, + created_by bigint, + updated_by bigint, + arquivo character varying(255) NOT NULL, + mime_type character varying(100) NOT NULL, + tamanho bigint NOT NULL +); + + +-- +-- Name: evidencias_id_seq; Type: SEQUENCE; Schema: public; Owner: - +-- + +CREATE SEQUENCE public.evidencias_id_seq + AS integer + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +-- +-- Name: evidencias_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - +-- + +ALTER SEQUENCE public.evidencias_id_seq OWNED BY public.evidencias.id; + + +-- +-- Name: expedicoes; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.expedicoes ( + id integer NOT NULL, + descricao text, + data_inicio date NOT NULL, + data_fim date NOT NULL, + cidade_id bigint NOT NULL, + created_at timestamp with time zone DEFAULT CURRENT_TIMESTAMP NOT NULL, + updated_at timestamp with time zone DEFAULT CURRENT_TIMESTAMP NOT NULL, + created_by bigint, + updated_by bigint +); + + +-- +-- Name: expedicoes_id_seq; Type: SEQUENCE; Schema: public; Owner: - +-- + +CREATE SEQUENCE public.expedicoes_id_seq + AS integer + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +-- +-- Name: expedicoes_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - +-- + +ALTER SEQUENCE public.expedicoes_id_seq OWNED BY public.expedicoes.id; + + +-- +-- Name: expedicoes_participantes; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.expedicoes_participantes ( + id integer NOT NULL, + expedicao_id integer NOT NULL, + usuario_id bigint NOT NULL, + created_at timestamp with time zone DEFAULT CURRENT_TIMESTAMP NOT NULL, + updated_at timestamp with time zone DEFAULT CURRENT_TIMESTAMP NOT NULL, + created_by bigint, + updated_by bigint +); + + +-- +-- Name: expedicoes_participantes_id_seq; Type: SEQUENCE; Schema: public; Owner: - +-- + +CREATE SEQUENCE public.expedicoes_participantes_id_seq + AS integer + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +-- +-- Name: expedicoes_participantes_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - +-- + +ALTER SEQUENCE public.expedicoes_participantes_id_seq OWNED BY public.expedicoes_participantes.id; + + +-- +-- Name: expedicoes_rotas; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.expedicoes_rotas ( + id integer NOT NULL, + expedicao_id integer NOT NULL, + cidade_id bigint NOT NULL, + ordem smallint NOT NULL, + created_at timestamp with time zone DEFAULT CURRENT_TIMESTAMP NOT NULL, + updated_at timestamp with time zone DEFAULT CURRENT_TIMESTAMP NOT NULL, + created_by bigint, + updated_by bigint +); + + +-- +-- Name: expedicoes_rotas_id_seq; Type: SEQUENCE; Schema: public; Owner: - +-- + +CREATE SEQUENCE public.expedicoes_rotas_id_seq + AS integer + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +-- +-- Name: expedicoes_rotas_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - +-- + +ALTER SEQUENCE public.expedicoes_rotas_id_seq OWNED BY public.expedicoes_rotas.id; + + +-- +-- Name: eventos id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.eventos ALTER COLUMN id SET DEFAULT nextval('public.eventos_id_seq'::regclass); + + +-- +-- Name: evidencias id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.evidencias ALTER COLUMN id SET DEFAULT nextval('public.evidencias_id_seq'::regclass); + + +-- +-- Name: expedicoes id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.expedicoes ALTER COLUMN id SET DEFAULT nextval('public.expedicoes_id_seq'::regclass); + + +-- +-- Name: expedicoes_participantes id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.expedicoes_participantes ALTER COLUMN id SET DEFAULT nextval('public.expedicoes_participantes_id_seq'::regclass); + + +-- +-- Name: expedicoes_rotas id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.expedicoes_rotas ALTER COLUMN id SET DEFAULT nextval('public.expedicoes_rotas_id_seq'::regclass); + + +-- +-- Name: eventos_coletas eventos_coletas_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.eventos_coletas + ADD CONSTRAINT eventos_coletas_pkey PRIMARY KEY (evento_id); + + +-- +-- Name: eventos eventos_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.eventos + ADD CONSTRAINT eventos_pkey PRIMARY KEY (id); + + +-- +-- Name: evidencias evidencias_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.evidencias + ADD CONSTRAINT evidencias_pkey PRIMARY KEY (id); + + +-- +-- Name: expedicoes_participantes expedicoes_participantes_expedicao_id_usuario_id_unique; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.expedicoes_participantes + ADD CONSTRAINT expedicoes_participantes_expedicao_id_usuario_id_unique UNIQUE (expedicao_id, usuario_id); + + +-- +-- Name: expedicoes_participantes expedicoes_participantes_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.expedicoes_participantes + ADD CONSTRAINT expedicoes_participantes_pkey PRIMARY KEY (id); + + +-- +-- Name: expedicoes expedicoes_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.expedicoes + ADD CONSTRAINT expedicoes_pkey PRIMARY KEY (id); + + +-- +-- Name: expedicoes_rotas expedicoes_rotas_expedicao_id_ordem_unique; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.expedicoes_rotas + ADD CONSTRAINT expedicoes_rotas_expedicao_id_ordem_unique UNIQUE (expedicao_id, ordem); + + +-- +-- Name: expedicoes_rotas expedicoes_rotas_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.expedicoes_rotas + ADD CONSTRAINT expedicoes_rotas_pkey PRIMARY KEY (id); + + +-- +-- Name: eventos_capturado_em_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX eventos_capturado_em_index ON public.eventos USING btree (capturado_em); + + +-- +-- Name: eventos_expedicao_id_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX eventos_expedicao_id_index ON public.eventos USING btree (expedicao_id); + + +-- +-- Name: evidencias_evento_id_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX evidencias_evento_id_index ON public.evidencias USING btree (evento_id); + + +-- +-- Name: expedicoes_cidade_id_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX expedicoes_cidade_id_index ON public.expedicoes USING btree (cidade_id); + + +-- +-- Name: expedicoes_rotas_cidade_id_index; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX expedicoes_rotas_cidade_id_index ON public.expedicoes_rotas USING btree (cidade_id); + + +-- +-- Name: eventos_coletas eventos_coletas_evento_id_foreign; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.eventos_coletas + ADD CONSTRAINT eventos_coletas_evento_id_foreign FOREIGN KEY (evento_id) REFERENCES public.eventos(id) ON UPDATE CASCADE ON DELETE CASCADE; + + +-- +-- Name: eventos eventos_created_by_foreign; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.eventos + ADD CONSTRAINT eventos_created_by_foreign FOREIGN KEY (created_by) REFERENCES public.usuarios(id) ON UPDATE CASCADE; + + +-- +-- Name: eventos eventos_expedicao_id_foreign; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.eventos + ADD CONSTRAINT eventos_expedicao_id_foreign FOREIGN KEY (expedicao_id) REFERENCES public.expedicoes(id) ON UPDATE CASCADE ON DELETE CASCADE; + + +-- +-- Name: eventos eventos_updated_by_foreign; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.eventos + ADD CONSTRAINT eventos_updated_by_foreign FOREIGN KEY (updated_by) REFERENCES public.usuarios(id) ON UPDATE CASCADE; + + +-- +-- Name: evidencias evidencias_created_by_foreign; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.evidencias + ADD CONSTRAINT evidencias_created_by_foreign FOREIGN KEY (created_by) REFERENCES public.usuarios(id) ON UPDATE CASCADE; + + +-- +-- Name: evidencias evidencias_evento_id_foreign; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.evidencias + ADD CONSTRAINT evidencias_evento_id_foreign FOREIGN KEY (evento_id) REFERENCES public.eventos(id) ON UPDATE CASCADE ON DELETE CASCADE; + + +-- +-- Name: evidencias evidencias_updated_by_foreign; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.evidencias + ADD CONSTRAINT evidencias_updated_by_foreign FOREIGN KEY (updated_by) REFERENCES public.usuarios(id) ON UPDATE CASCADE; + + +-- +-- Name: expedicoes expedicoes_cidade_id_foreign; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.expedicoes + ADD CONSTRAINT expedicoes_cidade_id_foreign FOREIGN KEY (cidade_id) REFERENCES public.cidades(id) ON UPDATE CASCADE; + + +-- +-- Name: expedicoes expedicoes_created_by_foreign; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.expedicoes + ADD CONSTRAINT expedicoes_created_by_foreign FOREIGN KEY (created_by) REFERENCES public.usuarios(id) ON UPDATE CASCADE; + + +-- +-- Name: expedicoes_participantes expedicoes_participantes_created_by_foreign; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.expedicoes_participantes + ADD CONSTRAINT expedicoes_participantes_created_by_foreign FOREIGN KEY (created_by) REFERENCES public.usuarios(id) ON UPDATE CASCADE; + + +-- +-- Name: expedicoes_participantes expedicoes_participantes_expedicao_id_foreign; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.expedicoes_participantes + ADD CONSTRAINT expedicoes_participantes_expedicao_id_foreign FOREIGN KEY (expedicao_id) REFERENCES public.expedicoes(id) ON UPDATE CASCADE ON DELETE CASCADE; + + +-- +-- Name: expedicoes_participantes expedicoes_participantes_updated_by_foreign; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.expedicoes_participantes + ADD CONSTRAINT expedicoes_participantes_updated_by_foreign FOREIGN KEY (updated_by) REFERENCES public.usuarios(id) ON UPDATE CASCADE; + + +-- +-- Name: expedicoes_participantes expedicoes_participantes_usuario_id_foreign; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.expedicoes_participantes + ADD CONSTRAINT expedicoes_participantes_usuario_id_foreign FOREIGN KEY (usuario_id) REFERENCES public.usuarios(id) ON UPDATE CASCADE; + + +-- +-- Name: expedicoes_rotas expedicoes_rotas_cidade_id_foreign; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.expedicoes_rotas + ADD CONSTRAINT expedicoes_rotas_cidade_id_foreign FOREIGN KEY (cidade_id) REFERENCES public.cidades(id) ON UPDATE CASCADE; + + +-- +-- Name: expedicoes_rotas expedicoes_rotas_created_by_foreign; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.expedicoes_rotas + ADD CONSTRAINT expedicoes_rotas_created_by_foreign FOREIGN KEY (created_by) REFERENCES public.usuarios(id) ON UPDATE CASCADE; + + +-- +-- Name: expedicoes_rotas expedicoes_rotas_expedicao_id_foreign; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.expedicoes_rotas + ADD CONSTRAINT expedicoes_rotas_expedicao_id_foreign FOREIGN KEY (expedicao_id) REFERENCES public.expedicoes(id) ON UPDATE CASCADE ON DELETE CASCADE; + + +-- +-- Name: expedicoes_rotas expedicoes_rotas_updated_by_foreign; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.expedicoes_rotas + ADD CONSTRAINT expedicoes_rotas_updated_by_foreign FOREIGN KEY (updated_by) REFERENCES public.usuarios(id) ON UPDATE CASCADE; + + +-- +-- Name: expedicoes expedicoes_updated_by_foreign; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.expedicoes + ADD CONSTRAINT expedicoes_updated_by_foreign FOREIGN KEY (updated_by) REFERENCES public.usuarios(id) ON UPDATE CASCADE; + + + +-- +-- Modulo Expedicao: lembretes +-- Migration 20261005120000 +-- + +CREATE TABLE public.lembretes ( + id integer NOT NULL, + data_coleta date NOT NULL, + local_coleta text NOT NULL, + familia text, + nome_popular text, + nome_cientifico text, + municipio text, + estado text, + referencia_local text, + tipo_vegetacao text, + solo text, + relevo text, + substrato text, + tronco_com_casca text, + associacoes text, + folhas text, + habito text, + frutos text, + flores text, + luminosidade text, + created_at timestamp with time zone DEFAULT CURRENT_TIMESTAMP NOT NULL, + updated_at timestamp with time zone DEFAULT CURRENT_TIMESTAMP NOT NULL, + created_by bigint, + updated_by bigint +); + +CREATE SEQUENCE public.lembretes_id_seq + AS integer + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + +ALTER SEQUENCE public.lembretes_id_seq OWNED BY public.lembretes.id; + +ALTER TABLE ONLY public.lembretes ALTER COLUMN id SET DEFAULT nextval('public.lembretes_id_seq'::regclass); + +ALTER TABLE ONLY public.lembretes + ADD CONSTRAINT lembretes_pkey PRIMARY KEY (id); + +CREATE INDEX lembretes_data_coleta_index ON public.lembretes USING btree (data_coleta); + +ALTER TABLE ONLY public.lembretes + ADD CONSTRAINT lembretes_created_by_foreign FOREIGN KEY (created_by) REFERENCES public.usuarios(id) ON UPDATE CASCADE; + +ALTER TABLE ONLY public.lembretes + ADD CONSTRAINT lembretes_updated_by_foreign FOREIGN KEY (updated_by) REFERENCES public.usuarios(id) ON UPDATE CASCADE; diff --git a/test/integration/setup/seeds/expedicao.seed.ts b/test/integration/setup/seeds/expedicao.seed.ts new file mode 100644 index 00000000..d02b1f81 --- /dev/null +++ b/test/integration/setup/seeds/expedicao.seed.ts @@ -0,0 +1,77 @@ +import type { Knex } from 'knex' + +const PREFIXO = 'XEXP' + +export interface ExpedicaoFixtures { + cidades: number[] + usuarios: number[] + herbarioId: number + tipoUsuarioId: number + estadoId: number + paisId: number +} + +export async function seedExpedicaoFixtures(knex: Knex): Promise { + const [pais] = await knex('paises') + .insert({ nome: `${PREFIXO} Brasil`, sigla: 'XEXB' }) + .returning>(['id']) + + const [estado] = await knex('estados') + .insert({ + nome: `${PREFIXO} Paraná`, sigla: 'XEXP', pais_id: pais.id + }) + .returning>(['id']) + + const cidades = await knex('cidades') + .insert([ + { nome: `${PREFIXO} Curitiba`, estado_id: estado.id }, + { nome: `${PREFIXO} Londrina`, estado_id: estado.id }, + { nome: `${PREFIXO} Maringá`, estado_id: estado.id } + ]) + .returning>(['id']) + + const [herbario] = await knex('herbarios') + .insert({ nome: `${PREFIXO} Herbário`, sigla: 'XEXPH' }) + .returning>(['id']) + + const [tipoUsuario] = await knex('tipos_usuarios') + .insert({ tipo: `${PREFIXO} Curador` }) + .returning>(['id']) + + const usuarios = await knex('usuarios') + .insert([ + { + nome: `${PREFIXO} Ana`, + email: 'xexp.ana@example.test', + senha: 'x', + tipo_usuario_id: tipoUsuario.id, + herbario_id: herbario.id + }, + { + nome: `${PREFIXO} Bruno`, + email: 'xexp.bruno@example.test', + senha: 'x', + tipo_usuario_id: tipoUsuario.id, + herbario_id: herbario.id + } + ]) + .returning>(['id']) + + return { + cidades: cidades.map(cidade => Number(cidade.id)), + usuarios: usuarios.map(usuario => Number(usuario.id)), + herbarioId: Number(herbario.id), + tipoUsuarioId: Number(tipoUsuario.id), + estadoId: Number(estado.id), + paisId: Number(pais.id) + } +} + +export async function cleanupExpedicaoFixtures(knex: Knex, fixtures: ExpedicaoFixtures): Promise { + await knex('usuarios').whereIn('id', fixtures.usuarios).delete() + await knex('tipos_usuarios').where({ id: fixtures.tipoUsuarioId }).delete() + await knex('herbarios').where({ id: fixtures.herbarioId }).delete() + await knex('cidades').whereIn('id', fixtures.cidades).delete() + await knex('estados').where({ id: fixtures.estadoId }).delete() + await knex('paises').where({ id: fixtures.paisId }).delete() +} diff --git a/test/unit/application/evento/AtualizarEventoController.test.ts b/test/unit/application/evento/AtualizarEventoController.test.ts new file mode 100644 index 00000000..868b2e5e --- /dev/null +++ b/test/unit/application/evento/AtualizarEventoController.test.ts @@ -0,0 +1,125 @@ +import { + describe, expect, test, vi +} from 'vitest' + +import { AtualizarEventoController } from '@/application/evento/AtualizarEventoController' +import { AtualizarEventoUseCase } from '@/domain/evento/AtualizarEventoUseCase' +import { Attributes } from '@/domain/evento/Evento' +import { CheckViolationError } from '@/infrastructure/error/CheckViolationError' +import { Either } from '@/library/either/Either' +import { + Headers, HttpRequest, Method, StatusCode +} from '@/library/http/common' +import { BadRequestError } from '@/library/http/error/BadRequestError' +import { NotFoundError } from '@/library/http/error/NotFoundError' +import { UnprocessableEntityError } from '@/library/http/error/UnprocessableEntityError' + +const headers = {} as Headers + +const attributes: Attributes = { + altitude: null, + capturado_em: new Date('2026-02-15T14:32:00Z'), + coleta: null, + created_at: new Date(), + created_by: null, + expedicao_id: 1, + id: 10, + latitude: -21, + longitude: -46.4167, + observacoes: 'Solo arenoso', + tipo: 'DIARIO', + updated_at: new Date(), + updated_by: null +} + +function makeRequest(params: Record, body: unknown): HttpRequest { + return { + body, headers, method: Method.Put, params, path: '/v2/eventos/10' + } satisfies HttpRequest +} + +describe('AtualizarEventoController', () => { + test('retorna 200 com o evento atualizado', async () => { + const atualizarEventoUseCase = { + execute: vi.fn().mockResolvedValue(Either.right(attributes)) + } as unknown as AtualizarEventoUseCase + + const controller = new AtualizarEventoController({ atualizarEventoUseCase }) + const response = await controller.handle(makeRequest({ eventoId: '10' }, { latitude: -21 }), vi.fn()) + + expect(atualizarEventoUseCase.execute).toHaveBeenCalledWith({ + id: 10, latitude: -21, updated_by: null + }) + expect('statusCode' in response ? response.statusCode : undefined).toBe(StatusCode.Ok) + expect('body' in response ? response.body : undefined).toEqual(attributes) + }) + + test('só envia os campos presentes no corpo (atualização parcial)', async () => { + const atualizarEventoUseCase = { + execute: vi.fn().mockResolvedValue(Either.right(attributes)) + } as unknown as AtualizarEventoUseCase + + const controller = new AtualizarEventoController({ atualizarEventoUseCase }) + await controller.handle(makeRequest({ eventoId: '10' }, { observacoes: 'novo texto' }), vi.fn()) + + expect(atualizarEventoUseCase.execute).toHaveBeenCalledWith({ + id: 10, observacoes: 'novo texto', updated_by: null + }) + }) + + test('aceita a troca de tipo para DIARIO sem exigir coleta no corpo', async () => { + const atualizarEventoUseCase = { + execute: vi.fn().mockResolvedValue(Either.right(attributes)) + } as unknown as AtualizarEventoUseCase + + const controller = new AtualizarEventoController({ atualizarEventoUseCase }) + await controller.handle(makeRequest({ eventoId: '10' }, { tipo: 'DIARIO' }), vi.fn()) + + expect(atualizarEventoUseCase.execute).toHaveBeenCalledWith({ + id: 10, tipo: 'DIARIO', updated_by: null + }) + }) + + test('retorna 400 quando eventoId é inválido', async () => { + const atualizarEventoUseCase = { execute: vi.fn() } as unknown as AtualizarEventoUseCase + const controller = new AtualizarEventoController({ atualizarEventoUseCase }) + + const response = await controller.handle(makeRequest({ eventoId: 'abc' }, {}), vi.fn()) + + expect(atualizarEventoUseCase.execute).not.toHaveBeenCalled() + expect(response).toBeInstanceOf(BadRequestError) + }) + + test('retorna 404 quando o evento não existe', async () => { + const atualizarEventoUseCase = { + execute: vi.fn().mockResolvedValue(Either.right(null)) + } as unknown as AtualizarEventoUseCase + + const controller = new AtualizarEventoController({ atualizarEventoUseCase }) + const response = await controller.handle(makeRequest({ eventoId: '999' }, { latitude: -21 }), vi.fn()) + + expect(response).toBeInstanceOf(NotFoundError) + }) + + test('retorna 400 quando a validação de domínio falha', async () => { + const atualizarEventoUseCase = { + execute: vi.fn().mockResolvedValue(Either.left(new Error('Latitude do evento deve estar entre -90 e 90'))) + } as unknown as AtualizarEventoUseCase + + const controller = new AtualizarEventoController({ atualizarEventoUseCase }) + const response = await controller.handle(makeRequest({ eventoId: '10' }, { latitude: 999 }), vi.fn()) + + expect(response).toBeInstanceOf(BadRequestError) + }) + + test('retorna 422 quando o banco rejeita o tipo (CheckViolationError)', async () => { + const atualizarEventoUseCase = { + execute: vi.fn().mockResolvedValue(Either.left(new CheckViolationError({ message: 'tipo inválido' }))) + } as unknown as AtualizarEventoUseCase + + const controller = new AtualizarEventoController({ atualizarEventoUseCase }) + const response = await controller.handle(makeRequest({ eventoId: '10' }, { tipo: 'DIARIO' }), vi.fn()) + + expect(response).toBeInstanceOf(UnprocessableEntityError) + }) +}) diff --git a/test/unit/application/evento/BuscarEventoController.test.ts b/test/unit/application/evento/BuscarEventoController.test.ts new file mode 100644 index 00000000..c080e773 --- /dev/null +++ b/test/unit/application/evento/BuscarEventoController.test.ts @@ -0,0 +1,125 @@ +import { + describe, expect, test, vi +} from 'vitest' + +import { BuscarEventoController } from '@/application/evento/BuscarEventoController' +import { BuscarEventoPorIdUseCase } from '@/domain/evento/BuscarEventoPorIdUseCase' +import { Either } from '@/library/either/Either' +import { + Headers, HttpRequest, Method, StatusCode +} from '@/library/http/common' +import { BadRequestError } from '@/library/http/error/BadRequestError' +import { NotFoundError } from '@/library/http/error/NotFoundError' + +describe('BuscarEventoController', () => { + const headers = {} as Headers + + test('returns 200 with body when use case finds event', async () => { + const evento = { + id: 1, + expedicao_id: 10, + tipo: 'DIARIO' as const, + capturado_em: new Date('2026-09-15T10:00:00.000Z'), + latitude: null, + longitude: null, + altitude: null, + observacoes: 'Observação', + coleta: null, + created_at: new Date('2026-09-15T10:00:00.000Z'), + updated_at: new Date('2026-09-15T10:00:00.000Z'), + created_by: null, + updated_by: null + } + + const buscarEventoPorIdUseCase = { + execute: vi.fn().mockResolvedValue(Either.right(evento)) + } as unknown as BuscarEventoPorIdUseCase + + const controller = new BuscarEventoController({ + buscarEventoPorIdUseCase + }) + + const request = { + body: {}, + headers, + method: Method.Get, + params: { eventoId: '1' }, + path: '/eventos/1' + } satisfies HttpRequest + + const response = await controller.handle(request, vi.fn()) + + expect(buscarEventoPorIdUseCase.execute).toHaveBeenCalledWith({ + id: 1 + }) + expect('statusCode' in response ? response.statusCode : undefined).toBe(StatusCode.Ok) + expect('body' in response ? response.body : undefined).toEqual(evento) + }) + + test('returns BadRequest when eventoId is invalid', async () => { + const buscarEventoPorIdUseCase = { + execute: vi.fn() + } as unknown as BuscarEventoPorIdUseCase + + const controller = new BuscarEventoController({ + buscarEventoPorIdUseCase + }) + + const request = { + body: {}, + headers, + method: Method.Get, + params: { eventoId: 'abc' }, + path: '/eventos/abc' + } satisfies HttpRequest + + const response = await controller.handle(request, vi.fn()) + + expect(buscarEventoPorIdUseCase.execute).not.toHaveBeenCalled() + expect(response).toBeInstanceOf(BadRequestError) + }) + + test('returns NotFound when event does not exist', async () => { + const buscarEventoPorIdUseCase = { + execute: vi.fn().mockResolvedValue(Either.right(null)) + } as unknown as BuscarEventoPorIdUseCase + + const controller = new BuscarEventoController({ + buscarEventoPorIdUseCase + }) + + const request = { + body: {}, + headers, + method: Method.Get, + params: { eventoId: '1' }, + path: '/eventos/1' + } satisfies HttpRequest + + const response = await controller.handle(request, vi.fn()) + + expect(response).toBeInstanceOf(NotFoundError) + }) + + test('returns InternalServerError when use case fails', async () => { + const buscarEventoPorIdUseCase = { + execute: vi.fn().mockResolvedValue(Either.left(new Error('boom'))) + } as unknown as BuscarEventoPorIdUseCase + + const controller = new BuscarEventoController({ + buscarEventoPorIdUseCase + }) + + const request = { + body: {}, + headers, + method: Method.Get, + params: { eventoId: '1' }, + path: '/eventos/1' + } satisfies HttpRequest + + const response = await controller.handle(request, vi.fn()) + + expect((response as Error).name).toBe('InternalServerError') + }) +}) diff --git a/test/unit/application/evento/CriarEventoController.test.ts b/test/unit/application/evento/CriarEventoController.test.ts new file mode 100644 index 00000000..cbb94912 --- /dev/null +++ b/test/unit/application/evento/CriarEventoController.test.ts @@ -0,0 +1,133 @@ +import { + describe, expect, test, vi +} from 'vitest' + +import { CriarEventoController } from '@/application/evento/CriarEventoController' +import { CriarEventoUseCase } from '@/domain/evento/CriarEventoUseCase' +import { Attributes } from '@/domain/evento/Evento' +import { CheckViolationError } from '@/infrastructure/error/CheckViolationError' +import { CollectionError } from '@/infrastructure/error/CollectionError' +import { ForeignKeyViolationError } from '@/infrastructure/error/ForeignKeyViolationError' +import { Either } from '@/library/either/Either' +import { + Headers, HttpRequest, Method, StatusCode +} from '@/library/http/common' +import { BadRequestError } from '@/library/http/error/BadRequestError' +import { NotFoundError } from '@/library/http/error/NotFoundError' +import { UnprocessableEntityError } from '@/library/http/error/UnprocessableEntityError' + +const headers = {} as Headers + +const attributes: Attributes = { + altitude: null, + capturado_em: new Date('2026-02-15T14:32:00Z'), + coleta: null, + created_at: new Date(), + created_by: null, + expedicao_id: 1, + id: 10, + latitude: -20.2508, + longitude: -46.4167, + observacoes: 'Solo arenoso', + tipo: 'DIARIO', + updated_at: new Date(), + updated_by: null +} + +const validBody = { + capturado_em: '2026-02-15T14:32:00Z', + latitude: -20.2508, + longitude: -46.4167, + observacoes: 'Solo arenoso', + tipo: 'DIARIO' +} + +function makeRequest(params: Record, body: unknown): HttpRequest { + return { + body, headers, method: Method.Post, params, path: '/v2/expedicoes/1/eventos' + } satisfies HttpRequest +} + +describe('CriarEventoController', () => { + test('retorna 201 com o evento criado', async () => { + const criarEventoUseCase = { + execute: vi.fn().mockResolvedValue(Either.right(attributes)) + } as unknown as CriarEventoUseCase + + const controller = new CriarEventoController({ criarEventoUseCase }) + const response = await controller.handle(makeRequest({ expedicaoId: '1' }, validBody), vi.fn()) + + expect(criarEventoUseCase.execute).toHaveBeenCalledWith(expect.objectContaining({ + expedicao_id: 1, tipo: 'DIARIO' + })) + expect('statusCode' in response ? response.statusCode : undefined).toBe(StatusCode.Created) + expect('body' in response ? response.body : undefined).toEqual(attributes) + }) + + test('retorna 400 quando expedicaoId é inválido', async () => { + const criarEventoUseCase = { execute: vi.fn() } as unknown as CriarEventoUseCase + const controller = new CriarEventoController({ criarEventoUseCase }) + + const response = await controller.handle(makeRequest({ expedicaoId: 'abc' }, validBody), vi.fn()) + + expect(criarEventoUseCase.execute).not.toHaveBeenCalled() + expect(response).toBeInstanceOf(BadRequestError) + }) + + test('retorna 400 quando tipo é inválido', async () => { + const criarEventoUseCase = { execute: vi.fn() } as unknown as CriarEventoUseCase + const controller = new CriarEventoController({ criarEventoUseCase }) + + const response = await controller.handle( + makeRequest({ expedicaoId: '1' }, { ...validBody, tipo: 'FOTO' }), + vi.fn() + ) + + expect(criarEventoUseCase.execute).not.toHaveBeenCalled() + expect(response).toBeInstanceOf(BadRequestError) + }) + + test('retorna 400 quando a validação de domínio falha (Either.left com Error simples)', async () => { + const criarEventoUseCase = { + execute: vi.fn().mockResolvedValue(Either.left(new Error('Evento de coleta exige a ficha de coleta'))) + } as unknown as CriarEventoUseCase + + const controller = new CriarEventoController({ criarEventoUseCase }) + const response = await controller.handle(makeRequest({ expedicaoId: '1' }, validBody), vi.fn()) + + expect(response).toBeInstanceOf(BadRequestError) + }) + + test('retorna 404 quando a expedição não existe (ForeignKeyViolationError)', async () => { + const criarEventoUseCase = { + execute: vi.fn().mockResolvedValue(Either.left(new ForeignKeyViolationError({ message: 'Expedição não encontrada' }))) + } as unknown as CriarEventoUseCase + + const controller = new CriarEventoController({ criarEventoUseCase }) + const response = await controller.handle(makeRequest({ expedicaoId: '999' }, validBody), vi.fn()) + + expect(response).toBeInstanceOf(NotFoundError) + }) + + test('retorna 422 quando o banco rejeita o tipo (CheckViolationError)', async () => { + const criarEventoUseCase = { + execute: vi.fn().mockResolvedValue(Either.left(new CheckViolationError({ message: 'tipo inválido' }))) + } as unknown as CriarEventoUseCase + + const controller = new CriarEventoController({ criarEventoUseCase }) + const response = await controller.handle(makeRequest({ expedicaoId: '1' }, validBody), vi.fn()) + + expect(response).toBeInstanceOf(UnprocessableEntityError) + }) + + test('retorna 500 quando ocorre erro de infraestrutura genérico', async () => { + const criarEventoUseCase = { + execute: vi.fn().mockResolvedValue(Either.left(new CollectionError({ message: 'DB down' }))) + } as unknown as CriarEventoUseCase + + const controller = new CriarEventoController({ criarEventoUseCase }) + const response = await controller.handle(makeRequest({ expedicaoId: '1' }, validBody), vi.fn()) + + expect((response as Error).name).toBe('InternalServerError') + }) +}) diff --git a/test/unit/application/evento/ListaEventosController.test.ts b/test/unit/application/evento/ListaEventosController.test.ts new file mode 100644 index 00000000..40d80593 --- /dev/null +++ b/test/unit/application/evento/ListaEventosController.test.ts @@ -0,0 +1,154 @@ +import { + describe, + expect, + test, + vi +} from 'vitest' + +import { ListaEventosController } from '@/application/evento/ListaEventosController' +import { ListaEventosUseCase } from '@/domain/evento/ListaEventosUseCase' +import { Either } from '@/library/either/Either' +import { + Headers, + HttpRequest, + Method, + StatusCode +} from '@/library/http/common' +import { BadRequestError } from '@/library/http/error/BadRequestError' +import { InternalServerError } from '@/library/http/error/InternalServerError' + +const headers: Headers = { + 'Content-Length': 0, + 'Content-Type': 'application/json' +} + +describe('ListaEventosController', () => { + test('returns 200 with body when use case succeeds', async () => { + const lista = [ + { + id: 1, + expedicao_id: 10, + tipo: 'DIARIO' as const, + capturado_em: new Date('2026-09-01T10:00:00.000Z') + } + ] + + const resultado = { + itens: lista, + total: 1, + limite: 20, + pagina: 1 + } + + const listaEventosUseCase = { + execute: vi.fn().mockResolvedValue(Either.right(resultado)) + } as unknown as ListaEventosUseCase + + const controller = new ListaEventosController({ listaEventosUseCase }) + + const request = { + body: {}, + headers, + method: Method.Get, + params: { + expedicaoId: '10' + }, + path: '/expedicoes/10/eventos' + } satisfies HttpRequest + + const response = await controller.handle(request, vi.fn()) + + expect(response).toEqual({ + statusCode: StatusCode.Ok, + body: resultado + }) + }) + + test('returns BadRequest when expedicaoId is invalid', async () => { + const listaEventosUseCase = { + execute: vi.fn() + } as unknown as ListaEventosUseCase + + const controller = new ListaEventosController({ listaEventosUseCase }) + + const request = { + body: {}, + headers, + method: Method.Get, + params: { + expedicaoId: 'abc' + }, + path: '/expedicoes/abc/eventos' + } satisfies HttpRequest + + const response = await controller.handle(request, vi.fn()) + + expect(response).toBeInstanceOf(BadRequestError) + expect(listaEventosUseCase.execute).not.toHaveBeenCalled() + }) + + test('returns InternalServerError when use case fails', async () => { + const listaEventosUseCase = { + execute: vi.fn().mockResolvedValue( + Either.left(new Error('erro ao listar eventos')) + ) + } as unknown as ListaEventosUseCase + + const controller = new ListaEventosController({ listaEventosUseCase }) + + const request = { + body: {}, + headers, + method: Method.Get, + params: { + expedicaoId: '10' + }, + path: '/expedicoes/10/eventos' + } satisfies HttpRequest + + const response = await controller.handle(request, vi.fn()) + + expect(response).toBeInstanceOf(InternalServerError) + }) + + test('passes filters and pagination to use case', async () => { + const resultado = { + itens: [], + total: 0, + limite: 10, + pagina: 2 + } + + const listaEventosUseCase = { + execute: vi.fn().mockResolvedValue(Either.right(resultado)) + } as unknown as ListaEventosUseCase + + const controller = new ListaEventosController({ listaEventosUseCase }) + + const request = { + body: {}, + headers, + method: Method.Get, + params: { + expedicaoId: '10', + tipo: 'COLETA', + capturado_de: '2026-09-01T00:00:00.000Z', + capturado_ate: '2026-09-15T23:59:59.999Z', + limite: '10', + pagina: '2' + }, + path: '/expedicoes/10/eventos' + } satisfies HttpRequest + + await controller.handle(request, vi.fn()) + + expect(listaEventosUseCase.execute).toHaveBeenCalledWith({ + expedicao_id: 10, + tipo: 'COLETA', + capturado_de: new Date('2026-09-01T00:00:00.000Z'), + capturado_ate: new Date('2026-09-15T23:59:59.999Z'), + limite: 10, + pagina: 2 + }) + }) +}) diff --git a/test/unit/application/evento/RemoverEventoController.test.ts b/test/unit/application/evento/RemoverEventoController.test.ts new file mode 100644 index 00000000..f1a91eec --- /dev/null +++ b/test/unit/application/evento/RemoverEventoController.test.ts @@ -0,0 +1,70 @@ +import { + describe, expect, test, vi +} from 'vitest' + +import { RemoverEventoController } from '@/application/evento/RemoverEventoController' +import { RemoverEventoUseCase } from '@/domain/evento/RemoverEventoUseCase' +import { Either } from '@/library/either/Either' +import { + Headers, HttpRequest, Method, StatusCode +} from '@/library/http/common' +import { BadRequestError } from '@/library/http/error/BadRequestError' +import { NotFoundError } from '@/library/http/error/NotFoundError' + +const headers = {} as Headers + +function makeRequest(params: Record): HttpRequest { + return { + body: {}, + headers, + method: Method.Delete, + params, + path: '/v2/eventos/10' + } satisfies HttpRequest +} + +describe('RemoverEventoController', () => { + test('retorna 204 quando o evento é removido', async () => { + const removerEventoUseCase = { + execute: vi.fn().mockResolvedValue(Either.right(true)) + } as unknown as RemoverEventoUseCase + + const controller = new RemoverEventoController({ removerEventoUseCase }) + const response = await controller.handle(makeRequest({ eventoId: '10' }), vi.fn()) + + expect(removerEventoUseCase.execute).toHaveBeenCalledWith({ id: 10 }) + expect('statusCode' in response ? response.statusCode : undefined).toBe(StatusCode.NoContent) + }) + + test('retorna 404 quando o evento não existe', async () => { + const removerEventoUseCase = { + execute: vi.fn().mockResolvedValue(Either.right(false)) + } as unknown as RemoverEventoUseCase + + const controller = new RemoverEventoController({ removerEventoUseCase }) + const response = await controller.handle(makeRequest({ eventoId: '999' }), vi.fn()) + + expect(response).toBeInstanceOf(NotFoundError) + }) + + test('retorna 400 quando eventoId é inválido', async () => { + const removerEventoUseCase = { execute: vi.fn() } as unknown as RemoverEventoUseCase + const controller = new RemoverEventoController({ removerEventoUseCase }) + + const response = await controller.handle(makeRequest({ eventoId: 'abc' }), vi.fn()) + + expect(removerEventoUseCase.execute).not.toHaveBeenCalled() + expect(response).toBeInstanceOf(BadRequestError) + }) + + test('retorna 500 quando ocorre erro de infraestrutura', async () => { + const removerEventoUseCase = { + execute: vi.fn().mockResolvedValue(Either.left(new Error('DB down'))) + } as unknown as RemoverEventoUseCase + + const controller = new RemoverEventoController({ removerEventoUseCase }) + const response = await controller.handle(makeRequest({ eventoId: '10' }), vi.fn()) + + expect((response as Error).name).toBe('InternalServerError') + }) +}) diff --git a/test/unit/application/expedicao/ListaExpedicoesController.test.ts b/test/unit/application/expedicao/ListaExpedicoesController.test.ts new file mode 100644 index 00000000..7449bbed --- /dev/null +++ b/test/unit/application/expedicao/ListaExpedicoesController.test.ts @@ -0,0 +1,245 @@ +import { + describe, expect, test, vi +} from 'vitest' + +import { + CustomHttpRequest, + ListaExpedicoesController +} from '@/application/expedicao/ListaExpedicoesController' +import { + ExpedicaoFilters, + ExpedicaoListItem, + Paginated +} from '@/domain/expedicao/ExpedicaoCollection' +import { ListaExpedicoesUseCase } from '@/domain/expedicao/ListaExpedicoesUseCase' +import { Either } from '@/library/either/Either' +import { + Headers, Method, StatusCode +} from '@/library/http/common' +import { BadRequestError } from '@/library/http/error/BadRequestError' +import { InternalServerError } from '@/library/http/error/InternalServerError' + +describe('ListaExpedicoesController', () => { + const headers = {} as Headers + + test('returns 200 with body for "Próximas expedições" (data_fim_de and order=data_inicio:asc)', async () => { + const paginatedResult: Paginated = { + itens: [ + { + id: 1, + descricao: 'Próxima expedição', + data_inicio: '2026-10-05', + data_fim: '2026-10-15', + cidade_id: 1, + created_at: new Date('2026-10-01T00:00:00.000Z'), + updated_at: new Date('2026-10-01T00:00:00.000Z'), + created_by: null, + updated_by: null, + cidade_nome: 'Curitiba', + estado_sigla: 'PR', + participantes: [], + rotas: [] + } + ], + total: 1, + limite: 20, + pagina: 1 + } + + const listaExpedicoesUseCase = { + execute: vi.fn().mockResolvedValue(Either.right(paginatedResult)) + } as unknown as ListaExpedicoesUseCase + + const controller = new ListaExpedicoesController({ listaExpedicoesUseCase }) + + const request: CustomHttpRequest = { + body: {}, + headers, + method: Method.Get, + params: { + data_fim_de: '2026-10-04', + order: 'data_inicio:asc' + }, + path: '/v2/expedicoes' + } + + const response = await controller.handle(request, vi.fn()) + + expect(listaExpedicoesUseCase.execute).toHaveBeenCalledWith<[ExpedicaoFilters]>({ + data_fim_de: '2026-10-04', + order: { + column: 'data_inicio', + direction: 'asc' + } + }) + expect('statusCode' in response ? response.statusCode : undefined).toBe(StatusCode.Ok) + expect('body' in response ? response.body : undefined).toEqual(paginatedResult) + }) + + test('returns 200 with body for "Expedições realizadas" (data_fim_ate and order=data_fim:desc)', async () => { + const paginatedResult: Paginated = { + itens: [ + { + id: 2, + descricao: 'Expedição realizada', + data_inicio: '2026-09-01', + data_fim: '2026-09-10', + cidade_id: 1, + created_at: new Date('2026-09-01T00:00:00.000Z'), + updated_at: new Date('2026-09-01T00:00:00.000Z'), + created_by: null, + updated_by: null, + cidade_nome: 'Curitiba', + estado_sigla: 'PR', + participantes: [], + rotas: [] + } + ], + total: 1, + limite: 20, + pagina: 1 + } + + const listaExpedicoesUseCase = { + execute: vi.fn().mockResolvedValue(Either.right(paginatedResult)) + } as unknown as ListaExpedicoesUseCase + + const controller = new ListaExpedicoesController({ listaExpedicoesUseCase }) + + const request: CustomHttpRequest = { + body: {}, + headers, + method: Method.Get, + params: { + data_fim_ate: '2026-10-03', + order: 'data_fim:desc' + }, + path: '/v2/expedicoes' + } + + const response = await controller.handle(request, vi.fn()) + + expect(listaExpedicoesUseCase.execute).toHaveBeenCalledWith<[ExpedicaoFilters]>({ + data_fim_ate: '2026-10-03', + order: { + column: 'data_fim', + direction: 'desc' + } + }) + expect('statusCode' in response ? response.statusCode : undefined).toBe(StatusCode.Ok) + expect('body' in response ? response.body : undefined).toEqual(paginatedResult) + }) + + test('forwards all query parameters correctly', async () => { + const emptyResult: Paginated = { + itens: [], + total: 0, + limite: 10, + pagina: 2 + } + const listaExpedicoesUseCase = { + execute: vi.fn().mockResolvedValue(Either.right(emptyResult)) + } as unknown as ListaExpedicoesUseCase + + const controller = new ListaExpedicoesController({ listaExpedicoesUseCase }) + + const request: CustomHttpRequest = { + body: {}, + headers, + method: Method.Get, + params: { + cidade_id: '123', + usuario_id: '456', + data_inicio_de: '2026-01-01', + data_inicio_ate: '2026-02-01', + data_fim_de: '2026-03-01', + data_fim_ate: '2026-04-01', + order: 'id:asc', + limite: '10', + pagina: '2' + }, + path: '/v2/expedicoes' + } + + const response = await controller.handle(request, vi.fn()) + + expect(listaExpedicoesUseCase.execute).toHaveBeenCalledWith<[ExpedicaoFilters]>({ + cidade_id: 123, + usuario_id: 456, + data_inicio_de: '2026-01-01', + data_inicio_ate: '2026-02-01', + data_fim_de: '2026-03-01', + data_fim_ate: '2026-04-01', + order: { + column: 'id', + direction: 'asc' + }, + limite: 10, + pagina: 2 + }) + expect('statusCode' in response ? response.statusCode : undefined).toBe(StatusCode.Ok) + }) + + test('returns BadRequestError when order format or column is invalid', async () => { + const listaExpedicoesUseCase = { + execute: vi.fn() + } as unknown as ListaExpedicoesUseCase + + const controller = new ListaExpedicoesController({ listaExpedicoesUseCase }) + + const request: CustomHttpRequest = { + body: {}, + headers, + method: Method.Get, + params: { order: 'invalido' }, + path: '/v2/expedicoes' + } + + const response = await controller.handle(request, vi.fn()) + + expect(listaExpedicoesUseCase.execute).not.toHaveBeenCalled() + expect(response).toBeInstanceOf(BadRequestError) + }) + + test('returns InternalServerError when use case returns Either.left', async () => { + const listaExpedicoesUseCase = { + execute: vi.fn().mockResolvedValue(Either.left(new Error('DB failure'))) + } as unknown as ListaExpedicoesUseCase + + const controller = new ListaExpedicoesController({ listaExpedicoesUseCase }) + + const request: CustomHttpRequest = { + body: {}, + headers, + method: Method.Get, + params: {}, + path: '/v2/expedicoes' + } + + const response = await controller.handle(request, vi.fn()) + + expect(response).toBeInstanceOf(InternalServerError) + expect((response as InternalServerError).message).toBe('DB failure') + }) + + test('returns InternalServerError when unexpected exception occurs', async () => { + const listaExpedicoesUseCase = { + execute: vi.fn().mockRejectedValue(new Error('Crash')) + } as unknown as ListaExpedicoesUseCase + + const controller = new ListaExpedicoesController({ listaExpedicoesUseCase }) + + const request: CustomHttpRequest = { + body: {}, + headers, + method: Method.Get, + params: {}, + path: '/v2/expedicoes' + } + + const response = await controller.handle(request, vi.fn()) + + expect(response).toBeInstanceOf(InternalServerError) + expect((response as InternalServerError).message).toBe('Crash') + }) +}) diff --git a/test/unit/application/lembrete/CriaLembreteController.test.ts b/test/unit/application/lembrete/CriaLembreteController.test.ts new file mode 100644 index 00000000..7570d035 --- /dev/null +++ b/test/unit/application/lembrete/CriaLembreteController.test.ts @@ -0,0 +1,90 @@ +import { + describe, expect, test, vi +} from 'vitest' + +import { CriaLembreteController } from '@/application/lembrete/CriaLembreteController' +import { CriaLembreteUseCase } from '@/domain/lembrete/CriaLembreteUseCase' +import { CollectionError } from '@/infrastructure/error/CollectionError' +import { Either } from '@/library/either/Either' +import { + Headers, HttpRequest, Method, StatusCode +} from '@/library/http/common' +import { BadRequestError } from '@/library/http/error/BadRequestError' +import { InternalServerError } from '@/library/http/error/InternalServerError' + +const headers = {} as Headers + +const validBody = { + data_coleta: '2026-11-20', + familia: 'Velloziaceae', + local_coleta: 'Serra do Cipó' +} + +function makeRequest(body: unknown): HttpRequest { + return { + body, headers, method: Method.Post, params: {}, path: '/v2/lembretes' + } satisfies HttpRequest +} + +describe('CriaLembreteController', () => { + test('retorna 201 e preenche com null os campos da ficha ausentes', async () => { + const criado = { id: 1, ...validBody } + const criaLembreteUseCase = { + execute: vi.fn().mockResolvedValue(Either.right(criado)) + } as unknown as CriaLembreteUseCase + + const controller = new CriaLembreteController({ criaLembreteUseCase }) + const response = await controller.handle(makeRequest(validBody), vi.fn()) + + expect(criaLembreteUseCase.execute).toHaveBeenCalledWith(expect.objectContaining({ + data_coleta: '2026-11-20', + familia: 'Velloziaceae', + local_coleta: 'Serra do Cipó', + nome_cientifico: null + })) + expect('statusCode' in response ? response.statusCode : undefined).toBe(StatusCode.Created) + expect('body' in response ? response.body : undefined).toEqual(criado) + }) + + test('retorna 400 quando local_coleta está ausente', async () => { + const criaLembreteUseCase = { execute: vi.fn() } as unknown as CriaLembreteUseCase + const controller = new CriaLembreteController({ criaLembreteUseCase }) + + const response = await controller.handle(makeRequest({ data_coleta: '2026-11-20' }), vi.fn()) + + expect(criaLembreteUseCase.execute).not.toHaveBeenCalled() + expect(response).toBeInstanceOf(BadRequestError) + }) + + test('retorna 400 quando um campo da ficha não é string', async () => { + const criaLembreteUseCase = { execute: vi.fn() } as unknown as CriaLembreteUseCase + const controller = new CriaLembreteController({ criaLembreteUseCase }) + + const response = await controller.handle(makeRequest({ ...validBody, solo: 42 }), vi.fn()) + + expect(criaLembreteUseCase.execute).not.toHaveBeenCalled() + expect(response).toBeInstanceOf(BadRequestError) + }) + + test('retorna 400 quando a validação de domínio falha', async () => { + const criaLembreteUseCase = { + execute: vi.fn().mockResolvedValue(Either.left(new Error('Data de coleta inválida'))) + } as unknown as CriaLembreteUseCase + + const controller = new CriaLembreteController({ criaLembreteUseCase }) + const response = await controller.handle(makeRequest(validBody), vi.fn()) + + expect(response).toBeInstanceOf(BadRequestError) + }) + + test('retorna 500 quando a collection falha', async () => { + const criaLembreteUseCase = { + execute: vi.fn().mockResolvedValue(Either.left(new CollectionError({ message: 'Failed to create lembrete' }))) + } as unknown as CriaLembreteUseCase + + const controller = new CriaLembreteController({ criaLembreteUseCase }) + const response = await controller.handle(makeRequest(validBody), vi.fn()) + + expect(response).toBeInstanceOf(InternalServerError) + }) +}) diff --git a/test/unit/application/lembrete/ListaLembretesController.test.ts b/test/unit/application/lembrete/ListaLembretesController.test.ts new file mode 100644 index 00000000..6cf032ac --- /dev/null +++ b/test/unit/application/lembrete/ListaLembretesController.test.ts @@ -0,0 +1,81 @@ +import { + describe, expect, test, vi +} from 'vitest' + +import { ListaLembretesController } from '@/application/lembrete/ListaLembretesController' +import { ListaLembretesUseCase } from '@/domain/lembrete/ListaLembretesUseCase' +import { Either } from '@/library/either/Either' +import { + Headers, HttpRequest, Method, StatusCode +} from '@/library/http/common' +import { BadRequestError } from '@/library/http/error/BadRequestError' +import { InternalServerError } from '@/library/http/error/InternalServerError' + +const headers = {} as Headers + +function makeRequest(params: Record): HttpRequest { + return { + body: undefined, headers, method: Method.Get, params, path: '/v2/lembretes' + } satisfies HttpRequest +} + +const pagina = { + itens: [], total: 0, limite: 20, pagina: 1 +} + +describe('ListaLembretesController', () => { + test('repassa filtros de data, ordenação e paginação', async () => { + const listaLembretesUseCase = { + execute: vi.fn().mockResolvedValue(Either.right(pagina)) + } as unknown as ListaLembretesUseCase + + const controller = new ListaLembretesController({ listaLembretesUseCase }) + const response = await controller.handle(makeRequest({ + data_coleta_ate: '2026-12-31', + data_coleta_de: '2026-10-05', + limite: '10', + order: 'data_coleta:desc', + pagina: '2' + }), vi.fn()) + + expect(listaLembretesUseCase.execute).toHaveBeenCalledWith({ + data_coleta_ate: '2026-12-31', + data_coleta_de: '2026-10-05', + limite: 10, + order: { column: 'data_coleta', direction: 'desc' }, + pagina: 2 + }) + expect('statusCode' in response ? response.statusCode : undefined).toBe(StatusCode.Ok) + }) + + test('retorna 400 quando data_coleta_de é inválida', async () => { + const listaLembretesUseCase = { execute: vi.fn() } as unknown as ListaLembretesUseCase + const controller = new ListaLembretesController({ listaLembretesUseCase }) + + const response = await controller.handle(makeRequest({ data_coleta_de: 'ontem' }), vi.fn()) + + expect(listaLembretesUseCase.execute).not.toHaveBeenCalled() + expect(response).toBeInstanceOf(BadRequestError) + }) + + test('retorna 400 quando order é inválido', async () => { + const listaLembretesUseCase = { execute: vi.fn() } as unknown as ListaLembretesUseCase + const controller = new ListaLembretesController({ listaLembretesUseCase }) + + const response = await controller.handle(makeRequest({ order: 'familia:asc' }), vi.fn()) + + expect(listaLembretesUseCase.execute).not.toHaveBeenCalled() + expect(response).toBeInstanceOf(BadRequestError) + }) + + test('retorna 500 quando a collection falha', async () => { + const listaLembretesUseCase = { + execute: vi.fn().mockResolvedValue(Either.left(new Error('DB failure'))) + } as unknown as ListaLembretesUseCase + + const controller = new ListaLembretesController({ listaLembretesUseCase }) + const response = await controller.handle(makeRequest({}), vi.fn()) + + expect(response).toBeInstanceOf(InternalServerError) + }) +}) diff --git a/test/unit/domain/evento/AtualizarEventoUseCase.test.ts b/test/unit/domain/evento/AtualizarEventoUseCase.test.ts new file mode 100644 index 00000000..9bd3dec8 --- /dev/null +++ b/test/unit/domain/evento/AtualizarEventoUseCase.test.ts @@ -0,0 +1,192 @@ +import { + describe, expect, test, vi +} from 'vitest' + +import { AtualizarEventoUseCase } from '@/domain/evento/AtualizarEventoUseCase' +import { Attributes, ColetaAttributes } from '@/domain/evento/Evento' +import { EventoCollection } from '@/domain/evento/EventoCollection' +import { Either } from '@/library/either/Either' + +function fichaCompleta(overrides: Partial = {}): ColetaAttributes { + return { + associacoes: null, + estado: null, + familia: null, + flores: null, + folhas: null, + frutos: null, + habito: null, + luminosidade: null, + municipio: null, + nome_cientifico: 'Vellozia squamata', + nome_popular: null, + referencia_local: null, + relevo: null, + solo: null, + substrato: null, + tipo_vegetacao: null, + tronco_com_casca: null, + ...overrides + } +} + +const diarioAttributes: Attributes = { + altitude: null, + capturado_em: new Date('2026-02-15T14:32:00Z'), + coleta: null, + created_at: new Date(), + created_by: null, + expedicao_id: 1, + id: 10, + latitude: -20.2508, + longitude: -46.4167, + observacoes: 'Solo arenoso', + tipo: 'DIARIO', + updated_at: new Date(), + updated_by: null +} + +const coletaAttributes: Attributes = { + ...diarioAttributes, + coleta: fichaCompleta(), + id: 11, + observacoes: null, + tipo: 'COLETA' +} + +const makeMockCollection = (overrides?: Partial): EventoCollection => ({ + create: vi.fn(), + delete: vi.fn(), + findAll: vi.fn(), + findById: vi.fn().mockResolvedValue(Either.right(diarioAttributes)), + update: vi.fn().mockResolvedValue(Either.right(diarioAttributes)), + ...overrides +}) + +describe('AtualizarEventoUseCase', () => { + test('atualiza campos comuns sem tocar no tipo', async () => { + const collection = makeMockCollection() + const useCase = new AtualizarEventoUseCase({ eventoCollection: collection }) + + const result = await useCase.execute({ + id: 10, latitude: -21, updated_by: 5 + }) + + expect(result.right()).toBe(true) + expect(collection.update).toHaveBeenCalledWith(10, { + altitude: null, + capturado_em: diarioAttributes.capturado_em, + coleta: null, + latitude: -21, + longitude: diarioAttributes.longitude, + observacoes: diarioAttributes.observacoes, + tipo: 'DIARIO', + updated_by: 5 + }) + }) + + test('retorna null quando o evento não existe', async () => { + const collection = makeMockCollection({ + findById: vi.fn().mockResolvedValue(Either.right(null)) + }) + const useCase = new AtualizarEventoUseCase({ eventoCollection: collection }) + + const result = await useCase.execute({ id: 999, updated_by: null }) + + expect(result.right()).toBe(true) + expect(result.value).toBeNull() + expect(collection.update).not.toHaveBeenCalled() + }) + + test('rejeita coordenadas fora de faixa sem chamar update', async () => { + const collection = makeMockCollection() + const useCase = new AtualizarEventoUseCase({ eventoCollection: collection }) + + const result = await useCase.execute({ + id: 10, latitude: 200, updated_by: null + }) + + expect(result.left()).toBe(true) + expect(collection.update).not.toHaveBeenCalled() + }) + + test('COLETA -> DIARIO descarta a ficha mesmo sem o cliente mandar coleta', async () => { + const collection = makeMockCollection({ + findById: vi.fn().mockResolvedValue(Either.right(coletaAttributes)), + update: vi.fn().mockResolvedValue(Either.right({ + ...coletaAttributes, coleta: null, tipo: 'DIARIO' + })) + }) + const useCase = new AtualizarEventoUseCase({ eventoCollection: collection }) + + const result = await useCase.execute({ + id: 11, observacoes: 'virou diário', tipo: 'DIARIO', updated_by: null + }) + + expect(result.right()).toBe(true) + expect(collection.update).toHaveBeenCalledWith(11, expect.objectContaining({ + coleta: null, + tipo: 'DIARIO' + })) + }) + + test('DIARIO -> COLETA exige a ficha no corpo da requisição', async () => { + const collection = makeMockCollection() + const useCase = new AtualizarEventoUseCase({ eventoCollection: collection }) + + const result = await useCase.execute({ + id: 10, tipo: 'COLETA', updated_by: null + }) + + expect(result.left()).toBe(true) + expect(collection.update).not.toHaveBeenCalled() + }) + + test('DIARIO -> COLETA com ficha no corpo é aceito', async () => { + const collection = makeMockCollection() + const useCase = new AtualizarEventoUseCase({ eventoCollection: collection }) + + const ficha = fichaCompleta({ habito: 'Herbácea' }) + const result = await useCase.execute({ + coleta: ficha, id: 10, tipo: 'COLETA', updated_by: null + }) + + expect(result.right()).toBe(true) + expect(collection.update).toHaveBeenCalledWith(10, expect.objectContaining({ + coleta: ficha, + tipo: 'COLETA' + })) + }) + + test('mantém o tipo COLETA e mantém a ficha atual quando coleta não é enviado', async () => { + const collection = makeMockCollection({ + findById: vi.fn().mockResolvedValue(Either.right(coletaAttributes)), + update: vi.fn().mockResolvedValue(Either.right(coletaAttributes)) + }) + const useCase = new AtualizarEventoUseCase({ eventoCollection: collection }) + + const result = await useCase.execute({ + id: 11, latitude: -22, updated_by: null + }) + + expect(result.right()).toBe(true) + expect(collection.update).toHaveBeenCalledWith(11, expect.objectContaining({ + coleta: coletaAttributes.coleta, + tipo: 'COLETA' + })) + }) + + test('mantém o tipo COLETA e substitui a ficha inteira quando coleta é enviado', async () => { + const collection = makeMockCollection({ + findById: vi.fn().mockResolvedValue(Either.right(coletaAttributes)) + }) + const useCase = new AtualizarEventoUseCase({ eventoCollection: collection }) + + const novaFicha = fichaCompleta({ habito: 'Arbustiva', nome_cientifico: 'Outra espécie' }) + await useCase.execute({ + coleta: novaFicha, id: 11, updated_by: null + }) + + expect(collection.update).toHaveBeenCalledWith(11, expect.objectContaining({ coleta: novaFicha })) + }) +}) diff --git a/test/unit/domain/evento/AtualizarEventoValidator.test.ts b/test/unit/domain/evento/AtualizarEventoValidator.test.ts new file mode 100644 index 00000000..2fdb9c4e --- /dev/null +++ b/test/unit/domain/evento/AtualizarEventoValidator.test.ts @@ -0,0 +1,116 @@ +import { + describe, expect, test +} from 'vitest' + +import { AtualizarEventoValidator } from '@/domain/evento/AtualizarEventoValidator' +import { Attributes, ColetaAttributes } from '@/domain/evento/Evento' + +function fichaCompleta(overrides: Partial = {}): ColetaAttributes { + return { + associacoes: null, + estado: null, + familia: null, + flores: null, + folhas: null, + frutos: null, + habito: null, + luminosidade: null, + municipio: null, + nome_cientifico: 'Vellozia squamata', + nome_popular: null, + referencia_local: null, + relevo: null, + solo: null, + substrato: null, + tipo_vegetacao: null, + tronco_com_casca: null, + ...overrides + } +} + +const diarioAttributes: Attributes = { + altitude: null, + capturado_em: new Date('2026-02-15T14:32:00Z'), + coleta: null, + created_at: new Date(), + created_by: null, + expedicao_id: 1, + id: 10, + latitude: -20.2508, + longitude: -46.4167, + observacoes: 'Solo arenoso', + tipo: 'DIARIO', + updated_at: new Date(), + updated_by: null +} + +const coletaAttributes: Attributes = { + ...diarioAttributes, + coleta: fichaCompleta(), + id: 11, + observacoes: null, + tipo: 'COLETA' +} + +describe('AtualizarEventoValidator', () => { + const validator = new AtualizarEventoValidator() + + test('mescla campos comuns sem tocar no tipo nem na ficha', () => { + const result = validator.validar(diarioAttributes, { latitude: -21, updated_by: 5 }) + + expect(result.right()).toBe(true) + if (!result.right()) return + expect(result.value).toMatchObject({ + coleta: null, latitude: -21, tipo: 'DIARIO', updated_by: 5 + }) + }) + + test('COLETA -> DIARIO descarta a ficha mesmo sem o cliente mandar coleta', () => { + const result = validator.validar(coletaAttributes, { tipo: 'DIARIO', updated_by: null }) + + expect(result.right()).toBe(true) + if (!result.right()) return + expect(result.value.coleta).toBeNull() + expect(result.value.tipo).toBe('DIARIO') + }) + + test('DIARIO -> COLETA sem ficha no corpo é rejeitado', () => { + const result = validator.validar(diarioAttributes, { tipo: 'COLETA', updated_by: null }) + + expect(result.left()).toBe(true) + }) + + test('DIARIO -> COLETA com ficha no corpo é aceito', () => { + const ficha = fichaCompleta({ habito: 'Herbácea' }) + const result = validator.validar(diarioAttributes, { + coleta: ficha, tipo: 'COLETA', updated_by: null + }) + + expect(result.right()).toBe(true) + if (!result.right()) return + expect(result.value.coleta).toEqual(ficha) + }) + + test('mantém COLETA e mantém a ficha atual quando coleta não é enviado', () => { + const result = validator.validar(coletaAttributes, { latitude: -22, updated_by: null }) + + expect(result.right()).toBe(true) + if (!result.right()) return + expect(result.value.coleta).toEqual(coletaAttributes.coleta) + }) + + test('mantém COLETA e substitui a ficha inteira quando coleta é enviado', () => { + const novaFicha = fichaCompleta({ habito: 'Arbustiva', nome_cientifico: 'Outra espécie' }) + const result = validator.validar(coletaAttributes, { coleta: novaFicha, updated_by: null }) + + expect(result.right()).toBe(true) + if (!result.right()) return + expect(result.value.coleta).toEqual(novaFicha) + }) + + test('rejeita coordenadas fora de faixa (reaproveita Evento.create)', () => { + const result = validator.validar(diarioAttributes, { latitude: 200, updated_by: null }) + + expect(result.left()).toBe(true) + }) +}) diff --git a/test/unit/domain/evento/CriarEventoUseCase.test.ts b/test/unit/domain/evento/CriarEventoUseCase.test.ts new file mode 100644 index 00000000..9ec197c6 --- /dev/null +++ b/test/unit/domain/evento/CriarEventoUseCase.test.ts @@ -0,0 +1,81 @@ +import { + describe, expect, test, vi +} from 'vitest' + +import { CriarEventoUseCase } from '@/domain/evento/CriarEventoUseCase' +import { Attributes, CreateAttributes } from '@/domain/evento/Evento' +import { EventoCollection } from '@/domain/evento/EventoCollection' +import { Either } from '@/library/either/Either' + +const attributes: Attributes = { + altitude: null, + capturado_em: new Date('2026-02-15T14:32:00Z'), + coleta: null, + created_at: new Date(), + created_by: null, + expedicao_id: 1, + id: 10, + latitude: -20.2508, + longitude: -46.4167, + observacoes: 'Solo arenoso', + tipo: 'DIARIO', + updated_at: new Date(), + updated_by: null +} + +const validInput: CreateAttributes = { + altitude: null, + capturado_em: new Date('2026-02-15T14:32:00Z'), + coleta: null, + created_by: null, + expedicao_id: 1, + latitude: -20.2508, + longitude: -46.4167, + observacoes: 'Solo arenoso', + tipo: 'DIARIO' +} + +const makeMockCollection = (overrides?: Partial): EventoCollection => ({ + create: vi.fn().mockResolvedValue(Either.right(attributes)), + delete: vi.fn(), + findAll: vi.fn(), + findById: vi.fn(), + update: vi.fn(), + ...overrides +}) + +describe('CriarEventoUseCase', () => { + test('cria o evento quando os dados são válidos', async () => { + const collection = makeMockCollection() + const useCase = new CriarEventoUseCase({ eventoCollection: collection }) + + const result = await useCase.execute(validInput) + + expect(result.right()).toBe(true) + expect(result.value).toEqual(attributes) + expect(collection.create).toHaveBeenCalledWith(validInput) + }) + + test('não chama a collection quando a validação de domínio falha (COLETA sem ficha)', async () => { + const collection = makeMockCollection() + const useCase = new CriarEventoUseCase({ eventoCollection: collection }) + + const result = await useCase.execute({ ...validInput, tipo: 'COLETA' }) + + expect(result.left()).toBe(true) + expect(collection.create).not.toHaveBeenCalled() + }) + + test('propaga erro de infraestrutura da collection', async () => { + const error = new Error('DB failure') + const collection = makeMockCollection({ + create: vi.fn().mockResolvedValue(Either.left(error)) + }) + const useCase = new CriarEventoUseCase({ eventoCollection: collection }) + + const result = await useCase.execute(validInput) + + expect(result.left()).toBe(true) + expect(result.value).toBe(error) + }) +}) diff --git a/test/unit/domain/evento/Evento.test.ts b/test/unit/domain/evento/Evento.test.ts new file mode 100644 index 00000000..f5fd1a2e --- /dev/null +++ b/test/unit/domain/evento/Evento.test.ts @@ -0,0 +1,101 @@ +import { + describe, expect, test +} from 'vitest' + +import { + Attributes, ColetaAttributes, Evento +} from '@/domain/evento/Evento' + +const ficha: ColetaAttributes = { + familia: 'Velloziaceae', + nome_popular: 'canela-de-ema', + nome_cientifico: 'Vellozia squamata', + municipio: 'Serra do Cipó', + estado: 'MG', + referencia_local: 'Trilha da cachoeira, 300 m após a ponte', + tipo_vegetacao: 'Campo rupestre', + solo: 'Arenoso', + relevo: 'Ondulado', + substrato: 'Afloramento rochoso', + tronco_com_casca: 'Sim, gretada', + associacoes: 'Lychnophora ericoides', + folhas: 'Lineares, rígidas', + habito: 'Herbácea', + frutos: 'Ausentes', + flores: 'Presentes, lilases', + luminosidade: 'Pleno sol' +} + +const diario: Attributes = { + id: 1, + expedicao_id: 10, + tipo: 'DIARIO', + capturado_em: new Date('2026-02-15T13:15:00.000Z'), + latitude: -20.2510, + longitude: -46.4170, + altitude: 1200, + observacoes: 'Área de transição entre cerrado e campo rupestre.', + coleta: null, + created_at: new Date('2026-02-16T00:00:00.000Z'), + updated_at: new Date('2026-02-16T00:00:00.000Z'), + created_by: 7, + updated_by: 7 +} + +const coleta: Attributes = { + ...diario, tipo: 'COLETA', observacoes: null, coleta: ficha +} + +describe('Evento.create', () => { + test('cria um evento de diário sem ficha', () => { + const result = Evento.create(diario) + + expect(result.right()).toBe(true) + expect(result.value).toMatchObject({ tipo: 'DIARIO', coleta: null }) + }) + + test('cria um evento de coleta com a ficha', () => { + const result = Evento.create(coleta) + + expect(result.right()).toBe(true) + expect(result.value).toMatchObject({ tipo: 'COLETA', coleta: { habito: 'Herbácea' } }) + }) + + test('rejeita coleta sem ficha', () => { + const result = Evento.create({ ...coleta, coleta: null }) + + expect(result.left()).toBe(true) + expect(result.value).toBeInstanceOf(Error) + }) + + test('rejeita diário com ficha', () => { + const result = Evento.create({ ...diario, coleta: ficha }) + + expect(result.left()).toBe(true) + }) + + test('rejeita um tipo desconhecido', () => { + expect(Evento.create({ ...diario, tipo: 'FOTO' as 'DIARIO' }).left()).toBe(true) + }) + + test('aceita evento sem coordenadas, porque o GPS pode não fixar', () => { + const result = Evento.create({ + ...diario, latitude: null, longitude: null, altitude: null + }) + + expect(result.right()).toBe(true) + }) + + test('rejeita coordenadas fora de faixa', () => { + expect(Evento.create({ ...diario, latitude: -91 }).left()).toBe(true) + expect(Evento.create({ ...diario, longitude: 181 }).left()).toBe(true) + }) + + test('rejeita instante de captura inválido', () => { + expect(Evento.create({ ...diario, capturado_em: new Date('não é data') }).left()).toBe(true) + }) + + test('rejeita evento sem expedição', () => { + expect(Evento.create({ ...diario, expedicao_id: 0 }).left()).toBe(true) + }) +}) diff --git a/test/unit/domain/evento/RemoverEventoUseCase.test.ts b/test/unit/domain/evento/RemoverEventoUseCase.test.ts new file mode 100644 index 00000000..551247e8 --- /dev/null +++ b/test/unit/domain/evento/RemoverEventoUseCase.test.ts @@ -0,0 +1,148 @@ +import path from 'node:path' +import { + beforeEach, describe, expect, test, vi +} from 'vitest' + +import { EventoCollection } from '@/domain/evento/EventoCollection' +import { RemoverEventoUseCase } from '@/domain/evento/RemoverEventoUseCase' +import { Attributes as EvidenciaAttributes } from '@/domain/evidencia/Evidencia' +import { EvidenciaCollection } from '@/domain/evidencia/EvidenciaCollection' +import { Either } from '@/library/either/Either' + +const { unlinkMock } = vi.hoisted(() => ({ + unlinkMock: vi.fn().mockResolvedValue(undefined) +})) + +vi.mock('node:fs/promises', () => ({ + unlink: unlinkMock +})) + +const UPLOADS_DIR = path.join(process.cwd(), 'uploads', 'evidencias') + +const makeMockEventoCollection = (overrides?: Partial): EventoCollection => ({ + create: vi.fn(), + delete: vi.fn().mockResolvedValue(Either.right(true)), + findAll: vi.fn(), + findById: vi.fn(), + update: vi.fn(), + ...overrides +}) + +const makeMockEvidenciaCollection = (overrides?: Partial): EvidenciaCollection => ({ + create: vi.fn(), + delete: vi.fn(), + findAll: vi.fn().mockResolvedValue(Either.right([])), + findById: vi.fn(), + ...overrides +}) + +const makeEvidencia = (overrides?: Partial): EvidenciaAttributes => ({ + arquivo: 'foto.jpg', + capturado_em: new Date(), + created_at: new Date(), + created_by: null, + evento_id: 10, + id: 1, + mime_type: 'image/jpeg', + nome: 'Foto', + tamanho: 1234, + updated_at: new Date(), + updated_by: null, + ...overrides +}) + +describe('RemoverEventoUseCase', () => { + beforeEach(() => { + unlinkMock.mockReset() + unlinkMock.mockResolvedValue(undefined) + }) + + test('retorna true quando o evento é removido', async () => { + const eventoCollection = makeMockEventoCollection() + const evidenciaCollection = makeMockEvidenciaCollection() + const useCase = new RemoverEventoUseCase({ eventoCollection, evidenciaCollection }) + + const result = await useCase.execute({ id: 10 }) + + expect(result.right()).toBe(true) + expect(result.value).toBe(true) + expect(evidenciaCollection.findAll).toHaveBeenCalledWith({ evento_id: 10 }) + expect(eventoCollection.delete).toHaveBeenCalledWith(10) + }) + + test('retorna false quando o evento não existe', async () => { + const eventoCollection = makeMockEventoCollection({ + delete: vi.fn().mockResolvedValue(Either.right(false)) + }) + const evidenciaCollection = makeMockEvidenciaCollection() + const useCase = new RemoverEventoUseCase({ eventoCollection, evidenciaCollection }) + + const result = await useCase.execute({ id: 999 }) + + expect(result.right()).toBe(true) + expect(result.value).toBe(false) + }) + + test('propaga erro de infraestrutura', async () => { + const error = new Error('DB failure') + const eventoCollection = makeMockEventoCollection({ + delete: vi.fn().mockResolvedValue(Either.left(error)) + }) + const evidenciaCollection = makeMockEvidenciaCollection() + const useCase = new RemoverEventoUseCase({ eventoCollection, evidenciaCollection }) + + const result = await useCase.execute({ id: 10 }) + + expect(result.left()).toBe(true) + expect(result.value).toBe(error) + }) + + test('propaga erro quando busca das evidências falha', async () => { + const error = new Error('DB failure ao buscar evidências') + const eventoCollection = makeMockEventoCollection() + const evidenciaCollection = makeMockEvidenciaCollection({ + findAll: vi.fn().mockResolvedValue(Either.left(error)) + }) + const useCase = new RemoverEventoUseCase({ eventoCollection, evidenciaCollection }) + + const result = await useCase.execute({ id: 10 }) + + expect(result.left()).toBe(true) + expect(result.value).toBe(error) + expect(eventoCollection.delete).not.toHaveBeenCalled() + }) + + test('apaga os arquivos das evidências do evento removido', async () => { + const evidencias = [ + makeEvidencia({ arquivo: 'foto1.jpg', id: 1 }), + makeEvidencia({ arquivo: 'foto2.jpg', id: 2 }) + ] + const eventoCollection = makeMockEventoCollection() + const evidenciaCollection = makeMockEvidenciaCollection({ + findAll: vi.fn().mockResolvedValue(Either.right(evidencias)) + }) + const useCase = new RemoverEventoUseCase({ eventoCollection, evidenciaCollection }) + + const result = await useCase.execute({ id: 10 }) + + expect(result.value).toBe(true) + expect(unlinkMock).toHaveBeenCalledTimes(2) + expect(unlinkMock).toHaveBeenCalledWith(path.join(UPLOADS_DIR, 'foto1.jpg')) + expect(unlinkMock).toHaveBeenCalledWith(path.join(UPLOADS_DIR, 'foto2.jpg')) + }) + + test('não apaga arquivos quando o evento não é removido', async () => { + const evidencias = [makeEvidencia({ arquivo: 'foto1.jpg' })] + const eventoCollection = makeMockEventoCollection({ + delete: vi.fn().mockResolvedValue(Either.right(false)) + }) + const evidenciaCollection = makeMockEvidenciaCollection({ + findAll: vi.fn().mockResolvedValue(Either.right(evidencias)) + }) + const useCase = new RemoverEventoUseCase({ eventoCollection, evidenciaCollection }) + + await useCase.execute({ id: 10 }) + + expect(unlinkMock).not.toHaveBeenCalled() + }) +}) diff --git a/test/unit/domain/evidencia/Evidencia.test.ts b/test/unit/domain/evidencia/Evidencia.test.ts new file mode 100644 index 00000000..b250bdf5 --- /dev/null +++ b/test/unit/domain/evidencia/Evidencia.test.ts @@ -0,0 +1,51 @@ +import { + describe, expect, test +} from 'vitest' + +import { Attributes, Evidencia } from '@/domain/evidencia/Evidencia' + +const base: Attributes = { + id: 1, + evento_id: 10, + nome: 'IMG_0042.jpg', + arquivo: '10_20260215143200-a1b2.jpg', + mime_type: 'image/jpeg', + tamanho: 204800, + capturado_em: new Date('2026-02-15T14:32:00.000Z'), + created_at: new Date('2026-02-16T00:00:00.000Z'), + updated_at: new Date('2026-02-16T00:00:00.000Z'), + created_by: null, + updated_by: null +} + +describe('Evidencia.create', () => { + test('cria a evidência quando nome e instante de captura são válidos', () => { + const result = Evidencia.create(base) + + expect(result.right()).toBe(true) + expect(result.value).toMatchObject({ nome: 'IMG_0042.jpg' }) + }) + + test('rejeita nome vazio ou só com espaços', () => { + expect(Evidencia.create({ ...base, nome: '' }).left()).toBe(true) + expect(Evidencia.create({ ...base, nome: ' ' }).left()).toBe(true) + }) + + test('rejeita evidência sem evento', () => { + expect(Evidencia.create({ ...base, evento_id: 0 }).left()).toBe(true) + }) + + test('rejeita instante de captura inválido', () => { + expect(Evidencia.create({ ...base, capturado_em: new Date('não é data') }).left()).toBe(true) + }) + + test('rejeita arquivo ou mime_type vazios', () => { + expect(Evidencia.create({ ...base, arquivo: '' }).left()).toBe(true) + expect(Evidencia.create({ ...base, mime_type: '' }).left()).toBe(true) + }) + + test('rejeita tamanho inválido', () => { + expect(Evidencia.create({ ...base, tamanho: 0 }).left()).toBe(true) + expect(Evidencia.create({ ...base, tamanho: -1 }).left()).toBe(true) + }) +}) diff --git a/test/unit/domain/expedicao/Expedicao.test.ts b/test/unit/domain/expedicao/Expedicao.test.ts new file mode 100644 index 00000000..d76012cf --- /dev/null +++ b/test/unit/domain/expedicao/Expedicao.test.ts @@ -0,0 +1,56 @@ +import { + describe, expect, test +} from 'vitest' + +import { Attributes, Expedicao } from '@/domain/expedicao/Expedicao' + +const base: Attributes = { + id: 1, + descricao: 'Coleta no litoral', + data_inicio: '2026-03-01', + data_fim: '2026-03-10', + cidade_id: 4106902, + created_at: new Date('2026-03-01T00:00:00.000Z'), + updated_at: new Date('2026-03-01T00:00:00.000Z'), + created_by: 7, + updated_by: 7 +} + +describe('Expedicao.create', () => { + test('cria a expedição quando o período e o destino são válidos', () => { + const result = Expedicao.create(base) + + expect(result.right()).toBe(true) + expect(result.value).toMatchObject({ + data_inicio: '2026-03-01', data_fim: '2026-03-10', cidade_id: 4106902 + }) + }) + + test('aceita expedição de um único dia', () => { + const result = Expedicao.create({ + ...base, data_inicio: '2026-03-01', data_fim: '2026-03-01' + }) + + expect(result.right()).toBe(true) + }) + + test('rejeita data de fim anterior à de início', () => { + const result = Expedicao.create({ + ...base, data_inicio: '2026-03-10', data_fim: '2026-03-01' + }) + + expect(result.left()).toBe(true) + expect(result.value).toBeInstanceOf(Error) + }) + + test('rejeita expedição sem destino', () => { + const result = Expedicao.create({ ...base, cidade_id: 0 }) + + expect(result.left()).toBe(true) + }) + + test('rejeita período incompleto', () => { + expect(Expedicao.create({ ...base, data_inicio: '' }).left()).toBe(true) + expect(Expedicao.create({ ...base, data_fim: '' }).left()).toBe(true) + }) +}) diff --git a/test/unit/domain/expedicao/ListaExpedicoesUseCase.test.ts b/test/unit/domain/expedicao/ListaExpedicoesUseCase.test.ts new file mode 100644 index 00000000..661426a2 --- /dev/null +++ b/test/unit/domain/expedicao/ListaExpedicoesUseCase.test.ts @@ -0,0 +1,85 @@ +import { + describe, expect, test, vi +} from 'vitest' + +import { + ExpedicaoCollection, + ExpedicaoFilters, + ExpedicaoListItem, + Paginated +} from '@/domain/expedicao/ExpedicaoCollection' +import { ListaExpedicoesUseCase } from '@/domain/expedicao/ListaExpedicoesUseCase' +import { Either } from '@/library/either/Either' + +const makeMockCollection = (overrides?: Partial): ExpedicaoCollection => ({ + findAll: vi.fn().mockResolvedValue(Either.right({ + itens: [], + total: 0, + limite: 20, + pagina: 1 + })), + findById: vi.fn(), + create: vi.fn(), + addParticipant: vi.fn(), + removeParticipant: vi.fn(), + substituteRoute: vi.fn(), + delete: vi.fn(), + update: vi.fn(), + ...overrides +}) + +describe('ListaExpedicoesUseCase', () => { + test('returns paginated items when collection succeeds', async () => { + const expected: Paginated = { + itens: [ + { + id: 1, + descricao: 'Expedição 1', + data_inicio: '2026-10-01', + data_fim: '2026-10-10', + cidade_id: 100, + created_at: new Date('2026-10-01T00:00:00.000Z'), + updated_at: new Date('2026-10-01T00:00:00.000Z'), + created_by: null, + updated_by: null, + cidade_nome: 'Curitiba', + estado_sigla: 'PR', + participantes: [], + rotas: [] + } + ], + total: 1, + limite: 20, + pagina: 1 + } + + const collection = makeMockCollection({ + findAll: vi.fn().mockResolvedValue(Either.right(expected)) + }) + + const useCase = new ListaExpedicoesUseCase({ expedicaoCollection: collection }) + const filters: ExpedicaoFilters = { + data_fim_de: '2026-10-04', + order: { column: 'data_inicio', direction: 'asc' } + } + + const result = await useCase.execute(filters) + + expect(collection.findAll).toHaveBeenCalledWith(filters) + expect(result.right()).toBe(true) + expect(result.value).toEqual(expected) + }) + + test('propagates collection error as Either.left', async () => { + const error = new Error('DB query error') + const collection = makeMockCollection({ + findAll: vi.fn().mockResolvedValue(Either.left(error)) + }) + + const useCase = new ListaExpedicoesUseCase({ expedicaoCollection: collection }) + const result = await useCase.execute({}) + + expect(result.left()).toBe(true) + expect(result.value).toBe(error) + }) +}) diff --git a/test/unit/domain/lembrete/AtualizaLembreteUseCase.test.ts b/test/unit/domain/lembrete/AtualizaLembreteUseCase.test.ts new file mode 100644 index 00000000..c28c3583 --- /dev/null +++ b/test/unit/domain/lembrete/AtualizaLembreteUseCase.test.ts @@ -0,0 +1,93 @@ +import { + describe, expect, test, vi +} from 'vitest' + +import { AtualizaLembreteUseCase } from '@/domain/lembrete/AtualizaLembreteUseCase' +import { Attributes } from '@/domain/lembrete/Lembrete' +import { LembreteCollection } from '@/domain/lembrete/LembreteCollection' +import { Either } from '@/library/either/Either' + +const existente: Attributes = { + id: 10, + data_coleta: '2026-11-20', + local_coleta: 'Serra do Cipó', + familia: 'Velloziaceae', + nome_popular: null, + nome_cientifico: 'Vellozia squamata', + municipio: null, + estado: 'MG', + referencia_local: null, + tipo_vegetacao: null, + solo: null, + relevo: null, + substrato: null, + tronco_com_casca: null, + associacoes: null, + folhas: null, + habito: null, + frutos: null, + flores: null, + luminosidade: null, + created_at: new Date(), + updated_at: new Date(), + created_by: null, + updated_by: null +} + +const makeMockCollection = (overrides?: Partial): LembreteCollection => ({ + create: vi.fn(), + delete: vi.fn(), + findAll: vi.fn(), + findById: vi.fn().mockResolvedValue(Either.right(existente)), + update: vi.fn().mockImplementation((_id, patch) => Promise.resolve(Either.right({ ...existente, ...patch }))), + ...overrides +}) + +describe('AtualizaLembreteUseCase', () => { + test('mantém os campos ausentes e troca só os enviados', async () => { + const collection = makeMockCollection() + const useCase = new AtualizaLembreteUseCase({ lembreteCollection: collection }) + + const result = await useCase.execute({ + data_coleta: '2026-12-01', + flores: 'Lilases', + id: 10, + nome_cientifico: null, + updated_by: null + }) + + expect(result.right()).toBe(true) + expect(collection.update).toHaveBeenCalledWith(10, expect.objectContaining({ + data_coleta: '2026-12-01', + familia: 'Velloziaceae', + flores: 'Lilases', + local_coleta: 'Serra do Cipó', + nome_cientifico: null + })) + }) + + test('retorna null quando o lembrete não existe', async () => { + const collection = makeMockCollection({ + findById: vi.fn().mockResolvedValue(Either.right(null)) + }) + const useCase = new AtualizaLembreteUseCase({ lembreteCollection: collection }) + + const result = await useCase.execute({ id: 99, updated_by: null }) + + expect(result.right()).toBe(true) + expect(result.value).toBeNull() + expect(collection.update).not.toHaveBeenCalled() + }) + + test('não persiste quando o estado mesclado é inválido', async () => { + const collection = makeMockCollection() + const useCase = new AtualizaLembreteUseCase({ lembreteCollection: collection }) + + const result = await useCase.execute({ + id: 10, local_coleta: '', updated_by: null + }) + + expect(result.left()).toBe(true) + expect(collection.update).not.toHaveBeenCalled() + }) +}) diff --git a/test/unit/domain/lembrete/CriaLembreteUseCase.test.ts b/test/unit/domain/lembrete/CriaLembreteUseCase.test.ts new file mode 100644 index 00000000..bd596479 --- /dev/null +++ b/test/unit/domain/lembrete/CriaLembreteUseCase.test.ts @@ -0,0 +1,89 @@ +import { + describe, expect, test, vi +} from 'vitest' + +import { CriaLembreteUseCase } from '@/domain/lembrete/CriaLembreteUseCase' +import { Attributes, CreateAttributes } from '@/domain/lembrete/Lembrete' +import { LembreteCollection } from '@/domain/lembrete/LembreteCollection' +import { Either } from '@/library/either/Either' + +const fichaVazia = { + familia: null, + nome_popular: null, + nome_cientifico: null, + municipio: null, + estado: null, + referencia_local: null, + tipo_vegetacao: null, + solo: null, + relevo: null, + substrato: null, + tronco_com_casca: null, + associacoes: null, + folhas: null, + habito: null, + frutos: null, + flores: null, + luminosidade: null +} + +const validInput: CreateAttributes = { + ...fichaVazia, + created_by: null, + data_coleta: '2026-11-20', + local_coleta: 'Serra do Cipó', + nome_cientifico: 'Vellozia squamata' +} + +const attributes: Attributes = { + ...validInput, + created_at: new Date(), + id: 10, + updated_at: new Date(), + updated_by: null +} + +const makeMockCollection = (overrides?: Partial): LembreteCollection => ({ + create: vi.fn().mockResolvedValue(Either.right(attributes)), + delete: vi.fn(), + findAll: vi.fn(), + findById: vi.fn(), + update: vi.fn(), + ...overrides +}) + +describe('CriaLembreteUseCase', () => { + test('cria o lembrete quando os dados são válidos', async () => { + const collection = makeMockCollection() + const useCase = new CriaLembreteUseCase({ lembreteCollection: collection }) + + const result = await useCase.execute(validInput) + + expect(result.right()).toBe(true) + expect(result.value).toEqual(attributes) + expect(collection.create).toHaveBeenCalledWith(validInput) + }) + + test('não chama a collection quando a data de coleta é inválida', async () => { + const collection = makeMockCollection() + const useCase = new CriaLembreteUseCase({ lembreteCollection: collection }) + + const result = await useCase.execute({ ...validInput, data_coleta: 'amanhã' }) + + expect(result.left()).toBe(true) + expect(collection.create).not.toHaveBeenCalled() + }) + + test('propaga erro de infraestrutura da collection', async () => { + const error = new Error('DB failure') + const collection = makeMockCollection({ + create: vi.fn().mockResolvedValue(Either.left(error)) + }) + const useCase = new CriaLembreteUseCase({ lembreteCollection: collection }) + + const result = await useCase.execute(validInput) + + expect(result.left()).toBe(true) + expect(result.value).toBe(error) + }) +}) diff --git a/test/unit/domain/lembrete/Lembrete.test.ts b/test/unit/domain/lembrete/Lembrete.test.ts new file mode 100644 index 00000000..d3a0d725 --- /dev/null +++ b/test/unit/domain/lembrete/Lembrete.test.ts @@ -0,0 +1,76 @@ +import { + describe, expect, test +} from 'vitest' + +import { + Attributes, isDataValida, Lembrete +} from '@/domain/lembrete/Lembrete' + +const lembrete: Attributes = { + id: 1, + data_coleta: '2026-11-20', + local_coleta: 'Serra do Cipó, trilha da cachoeira', + familia: 'Velloziaceae', + nome_popular: 'canela-de-ema', + nome_cientifico: 'Vellozia squamata', + municipio: 'Santana do Riacho', + estado: 'MG', + referencia_local: '300 m após a ponte', + tipo_vegetacao: 'Campo rupestre', + solo: 'Arenoso', + relevo: 'Ondulado', + substrato: 'Afloramento rochoso', + tronco_com_casca: null, + associacoes: null, + folhas: 'Lineares', + habito: 'Herbácea', + frutos: null, + flores: 'Esperadas em novembro', + luminosidade: 'Pleno sol', + created_at: new Date('2026-10-05T00:00:00.000Z'), + updated_at: new Date('2026-10-05T00:00:00.000Z'), + created_by: 7, + updated_by: 7 +} + +describe('Lembrete.create', () => { + test('cria um lembrete com data e local de coleta', () => { + const result = Lembrete.create(lembrete) + + expect(result.right()).toBe(true) + const criado = result.value as Lembrete + expect(criado.data_coleta).toBe('2026-11-20') + expect(criado.ficha.familia).toBe('Velloziaceae') + }) + + test('rejeita data de coleta fora do formato YYYY-MM-DD', () => { + const result = Lembrete.create({ ...lembrete, data_coleta: '20/11/2026' }) + + expect(result.left()).toBe(true) + }) + + test('rejeita data de coleta inexistente', () => { + const result = Lembrete.create({ ...lembrete, data_coleta: '2026-02-30' }) + + expect(result.left()).toBe(true) + }) + + test('rejeita local de coleta vazio', () => { + const result = Lembrete.create({ ...lembrete, local_coleta: ' ' }) + + expect(result.left()).toBe(true) + }) +}) + +describe('isDataValida', () => { + test.each([ + ['2026-11-20', true], + ['2028-02-29', true], + ['2026-02-29', false], + ['2026-13-01', false], + ['2026-11-20T00:00:00Z', false], + ['', false] + ])('%s -> %s', (value, esperado) => { + expect(isDataValida(value)).toBe(esperado) + }) +}) diff --git a/tsconfig.json b/tsconfig.json index 47a195de..842c5767 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -26,12 +26,12 @@ ], "~/*": [ "./src/*" - ], + ] }, "typeRoots": [ "./node_modules/@types", "./src/@types" - ] + ], }, "include": [ "src/", @@ -44,4 +44,4 @@ "mysql/", "dist/" ] -} +} \ No newline at end of file diff --git a/yarn.lock b/yarn.lock index e7f958c0..c82d698c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -54,16 +54,16 @@ integrity sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg== "@babel/parser@^7.25.4": - version "7.29.7" - resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.29.7.tgz#837b87387cbf5ec5530cb634b3c622f68edb9334" - integrity sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg== + version "7.29.9" + resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.29.9.tgz#e7ee0a24f752c5296c6455855ccb72662e67ea96" + integrity sha512-CjXrNHTnvqBVqHgdBysY3vk2T8tpJHb5/RMeHJBTyVa9xgugCB0CJTx/3oO8RV2QRQP391RWpB7D6hLjm8V9uA== dependencies: - "@babel/types" "^7.29.7" + "@babel/types" "^7.29.8" -"@babel/types@^7.25.4", "@babel/types@^7.29.7": - version "7.29.7" - resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.29.7.tgz#8005e31d82712ee7adaef6e23c63b71a62770a92" - integrity sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA== +"@babel/types@^7.25.4", "@babel/types@^7.29.8": + version "7.29.8" + resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.29.8.tgz#1229eef31d85156d70fa3f4cd859376d0eaf6863" + integrity sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg== dependencies: "@babel/helper-string-parser" "^7.29.7" "@babel/helper-validator-identifier" "^7.29.7" @@ -87,9 +87,14 @@ "@esbuild/aix-ppc64@0.28.0": version "0.28.0" - resolved "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.0.tgz#7a289c158e29cbf59ea0afc83cc80f06d1c89402" + resolved "https://registry.yarnpkg.com/@esbuild/aix-ppc64/-/aix-ppc64-0.28.0.tgz#7a289c158e29cbf59ea0afc83cc80f06d1c89402" integrity sha512-lhRUCeuOyJQURhTxl4WkpFTjIsbDayJHih5kZC1giwE+MhIzAb7mEsQMqMf18rHLsrb5qI1tafG20mLxEWcWlA== +"@esbuild/aix-ppc64@0.28.2": + version "0.28.2" + resolved "https://registry.yarnpkg.com/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz#bf6e10303bcf2e7c686975fa52f937ec2728d8bc" + integrity sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ== + "@esbuild/android-arm64@0.27.7": version "0.27.7" resolved "https://registry.yarnpkg.com/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz#f78cb8a3121fc205a53285adb24972db385d185d" @@ -97,9 +102,14 @@ "@esbuild/android-arm64@0.28.0": version "0.28.0" - resolved "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.0.tgz#b8828d9edfa3a92660644eb8de6e4f3c203d7b17" + resolved "https://registry.yarnpkg.com/@esbuild/android-arm64/-/android-arm64-0.28.0.tgz#b8828d9edfa3a92660644eb8de6e4f3c203d7b17" integrity sha512-+WzIXQOSaGs33tLEgYPYe/yQHf0WTU0X42Jca3y8NWMbUVhp7rUnw+vAsRC/QiDrdD31IszMrZy+qwPOPjd+rw== +"@esbuild/android-arm64@0.28.2": + version "0.28.2" + resolved "https://registry.yarnpkg.com/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz#0c6246bc8d2c4d172aac2db3fb1190d72bd65504" + integrity sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A== + "@esbuild/android-arm@0.27.7": version "0.27.7" resolved "https://registry.yarnpkg.com/@esbuild/android-arm/-/android-arm-0.27.7.tgz#593e10a1450bbfcac6cb321f61f468453bac209d" @@ -107,9 +117,14 @@ "@esbuild/android-arm@0.28.0": version "0.28.0" - resolved "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.0.tgz#5ec1847605e05b5dbe5df90db9ff7e3e4c58dca7" + resolved "https://registry.yarnpkg.com/@esbuild/android-arm/-/android-arm-0.28.0.tgz#5ec1847605e05b5dbe5df90db9ff7e3e4c58dca7" integrity sha512-wqh0ByljabXLKHeWXYLqoJ5jKC4XBaw6Hk08OfMrCRd2nP2ZQ5eleDZC41XHyCNgktBGYMbqnrJKq/K/lzPMSQ== +"@esbuild/android-arm@0.28.2": + version "0.28.2" + resolved "https://registry.yarnpkg.com/@esbuild/android-arm/-/android-arm-0.28.2.tgz#2d84ece6a4e2684d92be26ee13d42757d831c381" + integrity sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg== + "@esbuild/android-x64@0.27.7": version "0.27.7" resolved "https://registry.yarnpkg.com/@esbuild/android-x64/-/android-x64-0.27.7.tgz#453143d073326033d2d22caf9e48de4bae274b07" @@ -117,9 +132,14 @@ "@esbuild/android-x64@0.28.0": version "0.28.0" - resolved "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.0.tgz#390642175b88ef82bad4cce03f8ab13fe9b1912e" + resolved "https://registry.yarnpkg.com/@esbuild/android-x64/-/android-x64-0.28.0.tgz#390642175b88ef82bad4cce03f8ab13fe9b1912e" integrity sha512-+VJggoaKhk2VNNqVL7f6S189UzShHC/mR9EE8rDdSkdpN0KflSwWY/gWjDrNxxisg8Fp1ZCD9jLMo4m0OUfeUA== +"@esbuild/android-x64@0.28.2": + version "0.28.2" + resolved "https://registry.yarnpkg.com/@esbuild/android-x64/-/android-x64-0.28.2.tgz#fc38d4d6358d8dc1cf53f09f7589fe436eb64801" + integrity sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q== + "@esbuild/darwin-arm64@0.27.7": version "0.27.7" resolved "https://registry.yarnpkg.com/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz#6f23000fb9b40b7e04b7d0606c0693bd0632f322" @@ -127,9 +147,14 @@ "@esbuild/darwin-arm64@0.28.0": version "0.28.0" - resolved "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.0.tgz#ae45325960d5950cd6951e4f97396f4e1ff7d8d3" + resolved "https://registry.yarnpkg.com/@esbuild/darwin-arm64/-/darwin-arm64-0.28.0.tgz#ae45325960d5950cd6951e4f97396f4e1ff7d8d3" integrity sha512-0T+A9WZm+bZ84nZBtk1ckYsOvyA3x7e2Acj1KdVfV4/2tdG4fzUp91YHx+GArWLtwqp77pBXVCPn2We7Letr0Q== +"@esbuild/darwin-arm64@0.28.2": + version "0.28.2" + resolved "https://registry.yarnpkg.com/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz#f83afeeac1d7dac01c7a2fd012b3e451a0591fcc" + integrity sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw== + "@esbuild/darwin-x64@0.27.7": version "0.27.7" resolved "https://registry.yarnpkg.com/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz#27393dd18bb1263c663979c5f1576e00c2d024be" @@ -137,9 +162,14 @@ "@esbuild/darwin-x64@0.28.0": version "0.28.0" - resolved "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.0.tgz#c079247d589b6b99449659d94f06951b84bff2e4" + resolved "https://registry.yarnpkg.com/@esbuild/darwin-x64/-/darwin-x64-0.28.0.tgz#c079247d589b6b99449659d94f06951b84bff2e4" integrity sha512-fyzLm/DLDl/84OCfp2f/XQ4flmORsjU7VKt8HLjvIXChJoFFOIL6pLJPH4Yhd1n1gGFF9mPwtlN5Wf82DZs+LQ== +"@esbuild/darwin-x64@0.28.2": + version "0.28.2" + resolved "https://registry.yarnpkg.com/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz#510147c055a795588dbbe14fd6b1b8ad0a2f30de" + integrity sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw== + "@esbuild/freebsd-arm64@0.27.7": version "0.27.7" resolved "https://registry.yarnpkg.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz#22e4638fa502d1c0027077324c97640e3adf3a62" @@ -147,9 +177,14 @@ "@esbuild/freebsd-arm64@0.28.0": version "0.28.0" - resolved "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.0.tgz#45c456215a486593c94900297202dc11c880a37a" + resolved "https://registry.yarnpkg.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.0.tgz#45c456215a486593c94900297202dc11c880a37a" integrity sha512-l9GeW5UZBT9k9brBYI+0WDffcRxgHQD8ShN2Ur4xWq/NFzUKm3k5lsH4PdaRgb2w7mI9u61nr2gI2mLI27Nh3Q== +"@esbuild/freebsd-arm64@0.28.2": + version "0.28.2" + resolved "https://registry.yarnpkg.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz#093b9200ecf0b115ba4e5e248a7485c9c5f8bd5e" + integrity sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw== + "@esbuild/freebsd-x64@0.27.7": version "0.27.7" resolved "https://registry.yarnpkg.com/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz#9224b8e4fea924ce2194e3efc3e9aebf822192d6" @@ -157,9 +192,14 @@ "@esbuild/freebsd-x64@0.28.0": version "0.28.0" - resolved "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.0.tgz#0399494c1c85e4388e9b7040bd60d48f2a5b0d2c" + resolved "https://registry.yarnpkg.com/@esbuild/freebsd-x64/-/freebsd-x64-0.28.0.tgz#0399494c1c85e4388e9b7040bd60d48f2a5b0d2c" integrity sha512-BXoQai/A0wPO6Es3yFJ7APCiKGc1tdAEOgeTNy3SsB491S3aHn4S4r3e976eUnPdU+NbdtmBuLncYir2tMU9Nw== +"@esbuild/freebsd-x64@0.28.2": + version "0.28.2" + resolved "https://registry.yarnpkg.com/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz#0be22b6df925d213e841ea87123af5df80b0faf7" + integrity sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg== + "@esbuild/linux-arm64@0.27.7": version "0.27.7" resolved "https://registry.yarnpkg.com/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz#4f5d1c27527d817b35684ae21419e57c2bda0966" @@ -167,9 +207,14 @@ "@esbuild/linux-arm64@0.28.0": version "0.28.0" - resolved "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.0.tgz#d6d9f09ef0de54116bf459a4d53cac7e0952fe39" + resolved "https://registry.yarnpkg.com/@esbuild/linux-arm64/-/linux-arm64-0.28.0.tgz#d6d9f09ef0de54116bf459a4d53cac7e0952fe39" integrity sha512-RVyzfb3FWsGA55n6WY0MEIEPURL1FcbhFE6BffZEMEekfCzCIMtB5yyDcFnVbTnwk+CLAgTujmV/Lgvih56W+A== +"@esbuild/linux-arm64@0.28.2": + version "0.28.2" + resolved "https://registry.yarnpkg.com/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz#1bdbc651cda9ba9995c53ed9c71ceaa65094762d" + integrity sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug== + "@esbuild/linux-arm@0.27.7": version "0.27.7" resolved "https://registry.yarnpkg.com/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz#b9e9d070c8c1c0449cf12b20eac37d70a4595921" @@ -177,9 +222,14 @@ "@esbuild/linux-arm@0.28.0": version "0.28.0" - resolved "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.0.tgz#7b42ffa84c288ae94fdc431c1b28a89e3c3b9278" + resolved "https://registry.yarnpkg.com/@esbuild/linux-arm/-/linux-arm-0.28.0.tgz#7b42ffa84c288ae94fdc431c1b28a89e3c3b9278" integrity sha512-CjaaREJagqJp7iTaNQjjidaNbCKYcd4IDkzbwwxtSvjI7NZm79qiHc8HqciMddQ6CKvJT6aBd8lO9kN/ZudLlw== +"@esbuild/linux-arm@0.28.2": + version "0.28.2" + resolved "https://registry.yarnpkg.com/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz#beb12ad72b84f72d28488cc1b8ee9f7eb141d753" + integrity sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w== + "@esbuild/linux-ia32@0.27.7": version "0.27.7" resolved "https://registry.yarnpkg.com/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz#3f80fb696aa96051a94047f35c85b08b21c36f9e" @@ -187,9 +237,14 @@ "@esbuild/linux-ia32@0.28.0": version "0.28.0" - resolved "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.0.tgz#deb15d112ed8dd605346b6b953d23a21ff81253f" + resolved "https://registry.yarnpkg.com/@esbuild/linux-ia32/-/linux-ia32-0.28.0.tgz#deb15d112ed8dd605346b6b953d23a21ff81253f" integrity sha512-KBnSTt1kxl9x70q+ydterVdl+Cn0H18ngRMRCEQfrbqdUuntQQ0LoMZv47uB97NljZFzY6HcfqEZ2SAyIUTQBQ== +"@esbuild/linux-ia32@0.28.2": + version "0.28.2" + resolved "https://registry.yarnpkg.com/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz#b81f9d55529b45c206a46a138214b1aa6879696b" + integrity sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ== + "@esbuild/linux-loong64@0.27.7": version "0.27.7" resolved "https://registry.yarnpkg.com/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz#9be1f2c28210b13ebb4156221bba356fe1675205" @@ -197,9 +252,14 @@ "@esbuild/linux-loong64@0.28.0": version "0.28.0" - resolved "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.0.tgz#81fb89d07eecc79b157dea61033757726fce0ca4" + resolved "https://registry.yarnpkg.com/@esbuild/linux-loong64/-/linux-loong64-0.28.0.tgz#81fb89d07eecc79b157dea61033757726fce0ca4" integrity sha512-zpSlUce1mnxzgBADvxKXX5sl8aYQHo2ezvMNI8I0lbblJtp8V4odlm3Yzlj7gPyt3T8ReksE6bK+pT3WD+aJRg== +"@esbuild/linux-loong64@0.28.2": + version "0.28.2" + resolved "https://registry.yarnpkg.com/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz#598667241a04c99b76ed6ef940ac50038c419f98" + integrity sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ== + "@esbuild/linux-mips64el@0.27.7": version "0.27.7" resolved "https://registry.yarnpkg.com/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz#4ab5ee67a3dfcbcb5e8fd7883dae6e735b1163b8" @@ -207,9 +267,14 @@ "@esbuild/linux-mips64el@0.28.0": version "0.28.0" - resolved "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.0.tgz#d0e42691b3ff7af9fb2217b70fc01f343bdb62bb" + resolved "https://registry.yarnpkg.com/@esbuild/linux-mips64el/-/linux-mips64el-0.28.0.tgz#d0e42691b3ff7af9fb2217b70fc01f343bdb62bb" integrity sha512-2jIfP6mmjkdmeTlsX/9vmdmhBmKADrWqN7zcdtHIeNSCH1SqIoNI63cYsjQR8J+wGa4Y5izRcSHSm8K3QWmk3w== +"@esbuild/linux-mips64el@0.28.2": + version "0.28.2" + resolved "https://registry.yarnpkg.com/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz#1c51eb9cea903f53d97b5af3b1841db70f5596ca" + integrity sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA== + "@esbuild/linux-ppc64@0.27.7": version "0.27.7" resolved "https://registry.yarnpkg.com/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz#dac78c689f6499459c4321e5c15032c12307e7ea" @@ -217,9 +282,14 @@ "@esbuild/linux-ppc64@0.28.0": version "0.28.0" - resolved "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.0.tgz#389f3e5e98f17d477c467cc87136e1a076eead87" + resolved "https://registry.yarnpkg.com/@esbuild/linux-ppc64/-/linux-ppc64-0.28.0.tgz#389f3e5e98f17d477c467cc87136e1a076eead87" integrity sha512-bc0FE9wWeC0WBm49IQMPSPILRocGTQt3j5KPCA8os6VprfuJ7KD+5PzESSrJ6GmPIPJK965ZJHTUlSA6GNYEhg== +"@esbuild/linux-ppc64@0.28.2": + version "0.28.2" + resolved "https://registry.yarnpkg.com/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz#63dd61f17ceb31a81227f413feac8a71bc2c51f2" + integrity sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ== + "@esbuild/linux-riscv64@0.27.7": version "0.27.7" resolved "https://registry.yarnpkg.com/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz#050f7d3b355c3a98308e935bc4d6325da91b0027" @@ -227,9 +297,14 @@ "@esbuild/linux-riscv64@0.28.0": version "0.28.0" - resolved "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.0.tgz#763bd60d59b242be12da1e67d5729f3024c605fa" + resolved "https://registry.yarnpkg.com/@esbuild/linux-riscv64/-/linux-riscv64-0.28.0.tgz#763bd60d59b242be12da1e67d5729f3024c605fa" integrity sha512-SQPZOwoTTT/HXFXQJG/vBX8sOFagGqvZyXcgLA3NhIqcBv1BJU1d46c0rGcrij2B56Z2rNiSLaZOYW5cUk7yLQ== +"@esbuild/linux-riscv64@0.28.2": + version "0.28.2" + resolved "https://registry.yarnpkg.com/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz#3763b08fde5cf25ab1facb8e7752edfe45fbfc27" + integrity sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA== + "@esbuild/linux-s390x@0.27.7": version "0.27.7" resolved "https://registry.yarnpkg.com/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz#d61f715ce61d43fe5844ad0d8f463f88cbe4fef6" @@ -237,9 +312,14 @@ "@esbuild/linux-s390x@0.28.0": version "0.28.0" - resolved "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.0.tgz#aac6061634872e4677de693bce8030d73b1fd055" + resolved "https://registry.yarnpkg.com/@esbuild/linux-s390x/-/linux-s390x-0.28.0.tgz#aac6061634872e4677de693bce8030d73b1fd055" integrity sha512-SCfR0HN8CEEjnYnySJTd2cw0k9OHB/YFzt5zgJEwa+wL/T/raGWYMBqwDNAC6dqFKmJYZoQBRfHjgwLHGSrn3Q== +"@esbuild/linux-s390x@0.28.2": + version "0.28.2" + resolved "https://registry.yarnpkg.com/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz#1a137ff293a82906eb3176385bd7e8e0e5cfb7cb" + integrity sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg== + "@esbuild/linux-x64@0.27.7": version "0.27.7" resolved "https://registry.yarnpkg.com/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz#ca8e1aa478fc8209257bf3ac8f79c4dc2982f32a" @@ -247,9 +327,14 @@ "@esbuild/linux-x64@0.28.0": version "0.28.0" - resolved "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.0.tgz" + resolved "https://registry.yarnpkg.com/@esbuild/linux-x64/-/linux-x64-0.28.0.tgz#4f2917747188fe77632bcec65b2d84b422419779" integrity sha512-us0dSb9iFxIi8srnpl931Nvs65it/Jd2a2K3qs7fz2WfGPHqzfzZTfec7oxZJRNPXPnNYZtanmRc4AL/JwVzHQ== +"@esbuild/linux-x64@0.28.2": + version "0.28.2" + resolved "https://registry.yarnpkg.com/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz#268b36211c146ca54f8fe12c578a8d6ef8979485" + integrity sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ== + "@esbuild/netbsd-arm64@0.27.7": version "0.27.7" resolved "https://registry.yarnpkg.com/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz#1650f2c1b948deeb3ef948f2fc30614723c09690" @@ -257,9 +342,14 @@ "@esbuild/netbsd-arm64@0.28.0": version "0.28.0" - resolved "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.0.tgz#814df0ae57a0c386814491b8397eeba82094a947" + resolved "https://registry.yarnpkg.com/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.0.tgz#814df0ae57a0c386814491b8397eeba82094a947" integrity sha512-CR/RYotgtCKwtftMwJlUU7xCVNg3lMYZ0RzTmAHSfLCXw3NtZtNpswLEj/Kkf6kEL3Gw+BpOekRX0BYCtklhUw== +"@esbuild/netbsd-arm64@0.28.2": + version "0.28.2" + resolved "https://registry.yarnpkg.com/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz#22571ad951d62bb6accc82d8d1fad5c8c1ac0ba1" + integrity sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw== + "@esbuild/netbsd-x64@0.27.7": version "0.27.7" resolved "https://registry.yarnpkg.com/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz#65772ab342c4b3319bf0705a211050aac1b6e320" @@ -267,9 +357,14 @@ "@esbuild/netbsd-x64@0.28.0": version "0.28.0" - resolved "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.0.tgz#e01bdf7e60fa1a08e46d46d960b0d9bb8ac210af" + resolved "https://registry.yarnpkg.com/@esbuild/netbsd-x64/-/netbsd-x64-0.28.0.tgz#e01bdf7e60fa1a08e46d46d960b0d9bb8ac210af" integrity sha512-nU1yhmYutL+fQ71Kxnhg8uEOdC0pwEW9entHykTgEbna2pw2dkbFSMeqjjyHZoCmt8SBkOSvV+yNmm94aUrrqw== +"@esbuild/netbsd-x64@0.28.2": + version "0.28.2" + resolved "https://registry.yarnpkg.com/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz#42fcc57297eb0a0ca3f5fc475291f4c1a3f7c0de" + integrity sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw== + "@esbuild/openbsd-arm64@0.27.7": version "0.27.7" resolved "https://registry.yarnpkg.com/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz#37ed7cfa66549d7955852fce37d0c3de4e715ea1" @@ -277,9 +372,14 @@ "@esbuild/openbsd-arm64@0.28.0": version "0.28.0" - resolved "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.0.tgz#4a15c36aacca68d2d5a4c90b710c06759f4c1ffa" + resolved "https://registry.yarnpkg.com/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.0.tgz#4a15c36aacca68d2d5a4c90b710c06759f4c1ffa" integrity sha512-cXb5vApOsRsxsEl4mcZ1XY3D4DzcoMxR/nnc4IyqYs0rTI8ZKmW6kyyg+11Z8yvgMfAEldKzP7AdP64HnSC/6g== +"@esbuild/openbsd-arm64@0.28.2": + version "0.28.2" + resolved "https://registry.yarnpkg.com/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz#9eb32af104ac3dacf4edca01f596664aab0c73ef" + integrity sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ== + "@esbuild/openbsd-x64@0.27.7": version "0.27.7" resolved "https://registry.yarnpkg.com/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz#01bf3d385855ef50cb33db7c4b52f957c34cd179" @@ -287,9 +387,14 @@ "@esbuild/openbsd-x64@0.28.0": version "0.28.0" - resolved "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.0.tgz#475e6101498a8ecce3008d7c388111d7a27c17bd" + resolved "https://registry.yarnpkg.com/@esbuild/openbsd-x64/-/openbsd-x64-0.28.0.tgz#475e6101498a8ecce3008d7c388111d7a27c17bd" integrity sha512-8wZM2qqtv9UP3mzy7HiGYNH/zjTA355mpeuA+859TyR+e+Tc08IHYpLJuMsfpDJwoLo1ikIJI8jC3GFjnRClzA== +"@esbuild/openbsd-x64@0.28.2": + version "0.28.2" + resolved "https://registry.yarnpkg.com/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz#febed2402d6088225e91f20fb4ce2522ad0a4efd" + integrity sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw== + "@esbuild/openharmony-arm64@0.27.7": version "0.27.7" resolved "https://registry.yarnpkg.com/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz#6c1f94b34086599aabda4eac8f638294b9877410" @@ -297,9 +402,14 @@ "@esbuild/openharmony-arm64@0.28.0": version "0.28.0" - resolved "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.0.tgz#cfdc3957f0b7a69f1bde129aad17fcc2f6fa033e" + resolved "https://registry.yarnpkg.com/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.0.tgz#cfdc3957f0b7a69f1bde129aad17fcc2f6fa033e" integrity sha512-FLGfyizszcef5C3YtoyQDACyg95+dndv79i2EekILBofh5wpCa1KuBqOWKrEHZg3zrL3t5ouE5jgr94vA+Wb2w== +"@esbuild/openharmony-arm64@0.28.2": + version "0.28.2" + resolved "https://registry.yarnpkg.com/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz#85641c3d466428bfbccea5f21c26836663fef5ce" + integrity sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q== + "@esbuild/sunos-x64@0.27.7": version "0.27.7" resolved "https://registry.yarnpkg.com/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz#4b0dd17ae0a6941d2d0fd35a906392517071a90d" @@ -307,9 +417,14 @@ "@esbuild/sunos-x64@0.28.0": version "0.28.0" - resolved "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.0.tgz#a013c856fecacd1c3aec985c8afe1d1cb017497d" + resolved "https://registry.yarnpkg.com/@esbuild/sunos-x64/-/sunos-x64-0.28.0.tgz#a013c856fecacd1c3aec985c8afe1d1cb017497d" integrity sha512-1ZgjUoEdHZZl/YlV76TSCz9Hqj9h9YmMGAgAPYd+q4SicWNX3G5GCyx9uhQWSLcbvPW8Ni7lj4gDa1T40akdlw== +"@esbuild/sunos-x64@0.28.2": + version "0.28.2" + resolved "https://registry.yarnpkg.com/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz#a736f9d8962481045fc4c3e54f5479f22c870fb4" + integrity sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g== + "@esbuild/win32-arm64@0.27.7": version "0.27.7" resolved "https://registry.yarnpkg.com/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz#34193ab5565d6ff68ca928ac04be75102ccb2e77" @@ -317,9 +432,14 @@ "@esbuild/win32-arm64@0.28.0": version "0.28.0" - resolved "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.0.tgz#eae05e0f35271cad3898b43168d3e9a3bbaf47e5" + resolved "https://registry.yarnpkg.com/@esbuild/win32-arm64/-/win32-arm64-0.28.0.tgz#eae05e0f35271cad3898b43168d3e9a3bbaf47e5" integrity sha512-Q9StnDmQ/enxnpxCCLSg0oo4+34B9TdXpuyPeTedN/6+iXBJ4J+zwfQI28u/Jl40nOYAxGoNi7mFP40RUtkmUA== +"@esbuild/win32-arm64@0.28.2": + version "0.28.2" + resolved "https://registry.yarnpkg.com/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz#ee5ab40fad186201b652a33f8a5eb149e9e42532" + integrity sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ== + "@esbuild/win32-ia32@0.27.7": version "0.27.7" resolved "https://registry.yarnpkg.com/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz#eb67f0e4482515d8c1894ede631c327a4da9fc4d" @@ -327,9 +447,14 @@ "@esbuild/win32-ia32@0.28.0": version "0.28.0" - resolved "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.0.tgz#06161ebc5bf75c08d69feb3c6b22560515913998" + resolved "https://registry.yarnpkg.com/@esbuild/win32-ia32/-/win32-ia32-0.28.0.tgz#06161ebc5bf75c08d69feb3c6b22560515913998" integrity sha512-zF3ag/gfiCe6U2iczcRzSYJKH1DCI+ByzSENHlM2FcDbEeo5Zd2C86Aq0tKUYAJJ1obRP84ymxIAksZUcdztHA== +"@esbuild/win32-ia32@0.28.2": + version "0.28.2" + resolved "https://registry.yarnpkg.com/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz#c40d28a6d99a127da6711f2afd74b11cb63b06a7" + integrity sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA== + "@esbuild/win32-x64@0.27.7": version "0.27.7" resolved "https://registry.yarnpkg.com/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz#8fe30b3088b89b4873c3a6cc87597ae3920c0a8b" @@ -337,13 +462,18 @@ "@esbuild/win32-x64@0.28.0": version "0.28.0" - resolved "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.0.tgz#04d90d5752b4ce65d2b6ac25eba08ff7624fe07c" + resolved "https://registry.yarnpkg.com/@esbuild/win32-x64/-/win32-x64-0.28.0.tgz#04d90d5752b4ce65d2b6ac25eba08ff7624fe07c" integrity sha512-pEl1bO9mfAmIC+tW5btTmrKaujg3zGtUmWNdCw/xs70FBjwAL3o9OEKNHvNmnyylD6ubxUERiEhdsL0xBQ9efw== +"@esbuild/win32-x64@0.28.2": + version "0.28.2" + resolved "https://registry.yarnpkg.com/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz#b21affb804cc167c133d95f45b3a1dc1323b9a87" + integrity sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g== + "@eslint-community/eslint-utils@^4.7.0", "@eslint-community/eslint-utils@^4.8.0", "@eslint-community/eslint-utils@^4.9.0": - version "4.9.1" - resolved "https://registry.yarnpkg.com/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz#4e90af67bc51ddee6cdef5284edf572ec376b595" - integrity sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ== + version "4.10.1" + resolved "https://registry.yarnpkg.com/@eslint-community/eslint-utils/-/eslint-utils-4.10.1.tgz#8911bd72b2c3640a543609e0400b8c4d2e7e7cb6" + integrity sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg== dependencies: eslint-visitor-keys "^3.4.3" @@ -376,9 +506,9 @@ "@types/json-schema" "^7.0.15" "@eslint/eslintrc@^3.3.1": - version "3.3.5" - resolved "https://registry.yarnpkg.com/@eslint/eslintrc/-/eslintrc-3.3.5.tgz#c131793cfc1a7b96f24a83e0a8bbd4b881558c60" - integrity sha512-4IlJx0X0qftVsN5E+/vGujTRIFtwuLbNsVUe7TO6zYPDR1O6nFwvwhIKEKSrl6dZchmYBITazxKoUYOjdtjlRg== + version "3.3.7" + resolved "https://registry.yarnpkg.com/@eslint/eslintrc/-/eslintrc-3.3.7.tgz#76d3dedec4a30ea32df797bf8a0085c1311b7316" + integrity sha512-F42g89Qd5oAWtp0k0nnSrjziAKza7w8SVT4mStc18LZMaRb4J1HQAHLCalEtDCxrTuksx7NU9qsmeLwpOfPqWw== dependencies: ajv "^6.14.0" debug "^4.3.2" @@ -386,7 +516,7 @@ globals "^14.0.0" ignore "^5.2.0" import-fresh "^3.2.1" - js-yaml "^4.1.1" + js-yaml "^4.3.2" minimatch "^3.1.5" strip-json-comments "^3.1.1" @@ -463,7 +593,7 @@ "@isaacs/cliui@^8.0.2": version "8.0.2" - resolved "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz" + resolved "https://registry.yarnpkg.com/@isaacs/cliui/-/cliui-8.0.2.tgz#b37667b7bc181c168782259bab42474fbf52b550" integrity sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA== dependencies: string-width "^5.1.2" @@ -479,9 +609,9 @@ integrity sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw== "@jridgewell/sourcemap-codec@^1.4.14", "@jridgewell/sourcemap-codec@^1.5.5": - version "1.5.5" - resolved "https://registry.yarnpkg.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz#6912b00d2c631c0d15ce1a7ab57cd657f2a8f8ba" - integrity sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og== + version "1.6.0" + resolved "https://registry.yarnpkg.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz#f4c663e862f06dc98ca4d453862c46902789a18d" + integrity sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw== "@jridgewell/trace-mapping@^0.3.23", "@jridgewell/trace-mapping@^0.3.31": version "0.3.31" @@ -496,6 +626,11 @@ resolved "https://registry.yarnpkg.com/@jsdevtools/ono/-/ono-7.1.3.tgz#9df03bbd7c696a5c58885c34aa06da41c8543796" integrity sha512-4JQNk+3mVzK3xh2rqd6RB4J46qUR19azEHBneZyTZM+c456qOrbbM/5xcR8huNCCcbVt7+UmizG6GuUvPvKUYg== +"@napi-rs/lzma-linux-x64-gnu@1.5.1": + version "1.5.1" + resolved "https://registry.yarnpkg.com/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz#e57d4306966078662038094fb38eb9146dc3aea9" + integrity sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ== + "@noble/hashes@^1.1.5": version "1.8.0" resolved "https://registry.yarnpkg.com/@noble/hashes/-/hashes-1.8.0.tgz#cee43d801fcef9644b11b8194857695acd5f815a" @@ -531,7 +666,7 @@ "@pkgjs/parseargs@^0.11.0": version "0.11.0" - resolved "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz" + resolved "https://registry.yarnpkg.com/@pkgjs/parseargs/-/parseargs-0.11.0.tgz#a77ea742fab25775145434eb1d2328cf5013ac33" integrity sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg== "@puppeteer/browsers@2.10.13": @@ -547,130 +682,130 @@ tar-fs "^3.1.1" yargs "^17.7.2" -"@rollup/rollup-android-arm-eabi@4.62.0": - version "4.62.0" - resolved "https://registry.yarnpkg.com/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.0.tgz#634b0258cc501bef2353cee09a887b434826e81f" - integrity sha512-IPIQ55ythEHkfEd9jMEi32OQ7SxURsGA43JI22lj01OLZNt2NUbJX8YUHxkVWyQ6daHPNn0truF5nSj3DQp6YQ== - -"@rollup/rollup-android-arm64@4.62.0": - version "4.62.0" - resolved "https://registry.yarnpkg.com/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.0.tgz#d7804ff9c31c2b8e7c51d966fedac65a4c828578" - integrity sha512-M6s9cr10MibETyo8JsOkq+Lo1+lU6hcvb1MApnUql5qte/5hMEgzlN8/ReIKNfRV8rrqX50W1BX9zoUhC192RA== - -"@rollup/rollup-darwin-arm64@4.62.0": - version "4.62.0" - resolved "https://registry.yarnpkg.com/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.0.tgz#f26d03228e48c8bd55ff6be847242308dbfdb50d" - integrity sha512-BqCoMoIbn0keKys+dEAdBa70EtOwV1bEsQCUgU9FdiZmmMge/Zk7LlkYGqbrdHR+Frnt0E1FOanly+rlwvvQzw== - -"@rollup/rollup-darwin-x64@4.62.0": - version "4.62.0" - resolved "https://registry.yarnpkg.com/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.0.tgz#6e9037ccfc806a749aa044b063256a26ad32339d" - integrity sha512-SIMzST3VFNXDAbeIWDWiFCNM5qncUBDWaEV7NfE7oZbDt2mgfW4MvbKdbYiGOLoM32gbTv608UMd0XktEYSD7w== - -"@rollup/rollup-freebsd-arm64@4.62.0": - version "4.62.0" - resolved "https://registry.yarnpkg.com/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.0.tgz#ff448605b36cc4736a6fea89bd0eb74653f09cbc" - integrity sha512-ezjfSQMP7ArdUsbBwbQIfwAlhE84I2iVnzQNCFSveqV42q+BmKlzVpf7mxv5EchLcoWU4y6/heFzVg1F+hodUQ== - -"@rollup/rollup-freebsd-x64@4.62.0": - version "4.62.0" - resolved "https://registry.yarnpkg.com/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.0.tgz#a30fe00a8651b577966022d1db1fb1bd6776105e" - integrity sha512-9+qTWGW9AZRhnUgwtTwzNwcPlL87ngkeN0LA+q1bADvmY9aNvWaF2TFW8BZgnQPYxpDI7+rMVLivcd4V737TAQ== - -"@rollup/rollup-linux-arm-gnueabihf@4.62.0": - version "4.62.0" - resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.0.tgz#0ba85b63893eb17e11052bd21fe2809afc475a82" - integrity sha512-T1dMEQhXA/jkJ/jyMIw9IovK8bSUq7A8kLIlvZTb/6YIVsp2zLavr4F3oyllHWo7eIVJRyE5n3tUjQJEbE1IuQ== - -"@rollup/rollup-linux-arm-musleabihf@4.62.0": - version "4.62.0" - resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.0.tgz#982bf23fcfe4e8e13002912d4073f56a2eea2a39" - integrity sha512-2as0LgT7qQpyceQq6VUJYnumUMUrgGQCWIiDIN9DE0/tglsk6o66uCB4f3djRawAltvfCNLyZZrsqbPA6inCsA== - -"@rollup/rollup-linux-arm64-gnu@4.62.0": - version "4.62.0" - resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.0.tgz#c94d1e8bd116ea2b569aab37dd04a6ccab74f1ab" - integrity sha512-bVURMg+6eNN9C/yc0aVjooZcwTTtYF4YW3xta5pP0//r3o1V8gXEHXWCndj47w/HhwsFroZrFhR+6uQP5T0n0g== - -"@rollup/rollup-linux-arm64-musl@4.62.0": - version "4.62.0" - resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.0.tgz#a7d79014ba3c5dd2d140309730365d413976db24" - integrity sha512-Ful8pM/2yYI83PViWdFdpZhdI8HJ5qsXANe5atypbHDf+KIBBDsZsbyy8hbXnULVvW9NsTh5DHwbcBftyLTfiw== - -"@rollup/rollup-linux-loong64-gnu@4.62.0": - version "4.62.0" - resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.0.tgz#5dd943c58bda55d8b269426bd60a47dd9c27776e" - integrity sha512-9Gp/DgrkzfUBmNPVTyPTvay+4xEP7M/clXpj3efXBcm6uTIVIgDg4rqUpqKXvLEuFRVuEpSAOkhgNeecvaZ4Cg== - -"@rollup/rollup-linux-loong64-musl@4.62.0": - version "4.62.0" - resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.0.tgz#08b1b9d362c64847306fea979b935e93a2590c4e" - integrity sha512-m9tsJz54LUXkSYM8+8PG81B9IKK5r+2T0clMq4QrS16xFosufU7firBDAZEsDheDs7wTlP7h3++S7lMsU955HA== - -"@rollup/rollup-linux-ppc64-gnu@4.62.0": - version "4.62.0" - resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.0.tgz#1c5de568966d11091281b22bc764ee7adf92667b" - integrity sha512-3UvJ5PNVU16aJf6M3tFI24pWzAl2/ynfbyRN3ICyQajK1lSkrnVYNnLz3v04J32qKa0FczJc22zeToc0lr2A3w== - -"@rollup/rollup-linux-ppc64-musl@4.62.0": - version "4.62.0" - resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.0.tgz#4dde1c9b941748ea49e07cfc96c64b18236225cd" - integrity sha512-vRWUAbYLGHBZS6Q8Msb2sfnf1fvJf+47t8l/TwOerM2qArzy+IeNMTHrYLHXh95h8MoatPHI5hhSZNs+mGXKPg== - -"@rollup/rollup-linux-riscv64-gnu@4.62.0": - version "4.62.0" - resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.0.tgz#21dd1014033b970dd23189d1d4d3cdab45de7f9a" - integrity sha512-c00T5SYENHAt86cfW47URaP3Us5vLC/4QO7GYud1G5VNRffCwwCuBspwqYrriuJB+5m0WFzClCn9wed0FBjKvg== - -"@rollup/rollup-linux-riscv64-musl@4.62.0": - version "4.62.0" - resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.0.tgz#4664e0bae205a3a18eb6407c10054c4b8dd7f381" - integrity sha512-krrCDilhXOwFkSkO3Wm9I/f9H0L92XHHwy2fwxjukxIbh0dem8gZqOW5Y8BsHrpJv5qwlRBV+Wl4ZFyRWhUpwg== - -"@rollup/rollup-linux-s390x-gnu@4.62.0": - version "4.62.0" - resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.0.tgz#b05a6b3af6a0d3c9b9f7be9c253eb4f101a67848" - integrity sha512-7pfYFSTc4/rUC/FtAI0Qp6QthDBCIi6/AuP1xYqFk5vanI6KnL5dWKP60OM/05LOsbwTmIcvr6eXC4CJuJ75IA== - -"@rollup/rollup-linux-x64-gnu@4.62.0": - version "4.62.0" - resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.0.tgz#85dda72aa08cdc256f80f46d881b2a988bb0cce2" - integrity sha512-7SDIalKeIpG0Ifogbbdn58HmSotYMlf23K3dCJEmiVd9Fg36Vmni82iPQec27N3wY4Bvbxftkxz6vSx9OcouTg== - -"@rollup/rollup-linux-x64-musl@4.62.0": - version "4.62.0" - resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.0.tgz#d79f5be62a484b58a8ec4d5ae23acf7b0eb1a8ff" - integrity sha512-eRZevouTH2i1HeAVLqJuLnt256krQkGY0TN6WsTmsIhuzbh457HuWDMakKwmi0Cjadux983CoSr8Lim2QhUIFw== - -"@rollup/rollup-openbsd-x64@4.62.0": - version "4.62.0" - resolved "https://registry.yarnpkg.com/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.0.tgz#8ebafe0d66cde1c8ab0a867cd9dcea89e22ee7b1" - integrity sha512-3oVS7FLGa4U1qcvao9ylGxrjXZyUQqR8UwxEcnUEyPX53O/C/mKDZegNXTdHCP+h3e6ta/f1EN38Yif1mmZHYg== - -"@rollup/rollup-openharmony-arm64@4.62.0": - version "4.62.0" - resolved "https://registry.yarnpkg.com/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.0.tgz#105537bcfcb2fd82796518184e995ae4396bb792" - integrity sha512-yTB9TgfWj5wHe5QgktAgXTLLot1gvEjl1NiPPAUiCs4oPrIWFl5V4nC3GrkNdj9LaAU4s94nVrGbGOCqUpyWsg== - -"@rollup/rollup-win32-arm64-msvc@4.62.0": - version "4.62.0" - resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.0.tgz#08ebcfc01b5b3b106ae074bae3692e94a63b5125" - integrity sha512-5LOhoaesY3doG1c+ac/2JtgREpKoJr5bUHH8tKY0V8di7+uSV6BwLs2PlR0/yzefGOkR+wE7ZolZphHCsyG5Rw== - -"@rollup/rollup-win32-ia32-msvc@4.62.0": - version "4.62.0" - resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.0.tgz#493005cb0fcab009e866ccdbad3c97c512c2bf4b" - integrity sha512-yYkWHhmbhRTWTnWos5HC4GcPQfjlzzCNbM9e/+GXrLuaBXYA3qSDR9f0Vgufd5S8yX81U8jPKp7ZnAjZFMtRnw== - -"@rollup/rollup-win32-x64-gnu@4.62.0": - version "4.62.0" - resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.0.tgz#47b40294def035268329d5ffd5364347bf726e5f" - integrity sha512-SoTb6lPg25xZlA2ibwQ++ahCCnH+FP0qmEuafMJ4gznZKOlXioKEAeJLgCrqjM98ACziXM9V1amFjICVL4IFoA== - -"@rollup/rollup-win32-x64-msvc@4.62.0": - version "4.62.0" - resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.0.tgz#6850434fdb691e9b2408ded9b65ea357bf83636d" - integrity sha512-5L+T1fMX4RIEBoZzT0+sQ0PhTS36NULFmMXtl1TZo44TMAROIMHbZufSOjVWt/Y622BtxgxtaNOokbTDvfsrZA== +"@rollup/rollup-android-arm-eabi@4.63.4": + version "4.63.4" + resolved "https://registry.yarnpkg.com/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.63.4.tgz#e1d06062fb451578b3fb0bdea3360daaa1395f44" + integrity sha512-I+BSHzTAhKN2n7ZwGZsegGcZjDpLqFOMAtJz/u6uFGe0pUFbq56dEHjqJV/ZUdRJtNXNxA+hREUatZBvMR3Oiw== + +"@rollup/rollup-android-arm64@4.63.4": + version "4.63.4" + resolved "https://registry.yarnpkg.com/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.63.4.tgz#2e81dec4e9e879c95c8266e30da67074c4998224" + integrity sha512-pu3BdjS2LtEzRu2elmGzS3fIeWSZy4BMDIaLNwjorO76+k2d0LMluijhsDx3KQyQBQ/lLUZCQA9/s6csvUfuhw== + +"@rollup/rollup-darwin-arm64@4.63.4": + version "4.63.4" + resolved "https://registry.yarnpkg.com/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.63.4.tgz#f48f92f44454aed23d013f7cdb6da0dc2cb598ae" + integrity sha512-xfSrj9MHnWK9GaSqT9U0ImHtH/N8WZlHLx4cZHiuLcqs640hvZ3hLPd5UR2AZS57FaE8HrRUSpltbZdWRxHiDA== + +"@rollup/rollup-darwin-x64@4.63.4": + version "4.63.4" + resolved "https://registry.yarnpkg.com/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.63.4.tgz#39b0b2ad27c49b40aec71fb20210e2e3be5fdbd0" + integrity sha512-bqU99PLJb/dqb3S0GIMdeuyAEETSUgZBoqXYd3Sd+WCsV+MmPhnN6JrotWyir31+QgH7EvvE5/mwGJlEoci8Fw== + +"@rollup/rollup-freebsd-arm64@4.63.4": + version "4.63.4" + resolved "https://registry.yarnpkg.com/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.63.4.tgz#0c85d18f5b9228b7195adbbbc97c1adaf41b8106" + integrity sha512-JinsFZ5G40oXQb+sUuiA5x689vhr6dDYK0H0NL+rwKdL6CqnmYN8PE4ZwfRSoIjrCxqTQG/SLfTtSvHeGxoVlw== + +"@rollup/rollup-freebsd-x64@4.63.4": + version "4.63.4" + resolved "https://registry.yarnpkg.com/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.63.4.tgz#0c6c27471ea950213aad76418f075c96f57e9533" + integrity sha512-GAdA4UxpiNm27cLHr2GqXBpAD0x9FqwYBY7/YSP0Ss0/PNi4k8gbviqpIpYbVSRBaS2ZcegXEzgTQMbRNCwxCw== + +"@rollup/rollup-linux-arm-gnueabihf@4.63.4": + version "4.63.4" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.63.4.tgz#28cba40d417fb3d2c1536941b02143a2bdda72be" + integrity sha512-qDd6NoA1znaLjp4jR5U/KWCdLAKDJNB8W9ChbbDaKbo0xA+Atln5HK6LFCZ4oJQpemtRZA288DCirFRjrspptw== + +"@rollup/rollup-linux-arm-musleabihf@4.63.4": + version "4.63.4" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.63.4.tgz#c9f06aa876d6d312012a2048d9e5533c581a7341" + integrity sha512-WtB5Tz5KTNINb8ZA+8sQ7bmjuS1JrRT7YverYIhUGdWWDlpzVWmIwuZE+jidkEXUn1l0zrEkaIMa8dHF3NGcsA== + +"@rollup/rollup-linux-arm64-gnu@4.63.4": + version "4.63.4" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.63.4.tgz#b26376804ca32b3dcc8f68930f765394fb4e4814" + integrity sha512-VcQ3L1tjnkKzWjryAVaFhHEWcqOfICX9uxVVoDzm2t0DpgKRHd2zOpVrJc0xsWeBZcBFyYROCIBdyR/fS174pg== + +"@rollup/rollup-linux-arm64-musl@4.63.4": + version "4.63.4" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.63.4.tgz#65f6f5deee9d30904b4d52ee3d45679055743cd1" + integrity sha512-6+ZQX6P5s0cMDN2Ypb8Lbm2+/sZYmZjdaYny992ujUU9UKi/4CWoJWsl1pNvjWJHNHGK51m+jKGLlh1ylb2ifQ== + +"@rollup/rollup-linux-loong64-gnu@4.63.4": + version "4.63.4" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.63.4.tgz#15d7e44538e967a3d6132f5b9f27f02346df6850" + integrity sha512-D72ZnvkFkBXOfzMMQLcwfPLyGkKb7HZ9/mf97B7v6/P5Lbv4oFOtSY/uHbS8lH6uKUOxoKiuokdb50XZSzzbJw== + +"@rollup/rollup-linux-loong64-musl@4.63.4": + version "4.63.4" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.63.4.tgz#7afad2b9e41941344883154f754b7f772ef16b01" + integrity sha512-piU6BxeqA3O9KSu3kRCIQQtNqFFaTu21SEV4FwaRZowpnj3bLaWPZHw+xFqCs0XlJ+aOH3PTRWGoglH+mKA/OA== + +"@rollup/rollup-linux-ppc64-gnu@4.63.4": + version "4.63.4" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.63.4.tgz#3f3131b1a9585851b2875c86a49f6e6deb9e68e0" + integrity sha512-/5PGpHwqt2EEEOUs1XwzubE/ucr0dWDQ+to3zqi4Ds7EWpwtQ79wXc4JBoxqj/OwpawTsKWzJxHfSuBOq3DrWA== + +"@rollup/rollup-linux-ppc64-musl@4.63.4": + version "4.63.4" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.63.4.tgz#463aeee26c62983c1fc235708d1b41baf4735f98" + integrity sha512-cX3beZDLWt7G2oJF+nhChiT+qtaihs+S2xi7ziGmVB+2pwPng6D0Ed0HmElQOgv2UsUmSJJLGwpBao/3TDx3VA== + +"@rollup/rollup-linux-riscv64-gnu@4.63.4": + version "4.63.4" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.63.4.tgz#cb495bd8cc978e17593e0d323b36fd95b5eab098" + integrity sha512-1uz2mGWHyptR7DgHHrlbdRAjXK7v7elGZ9lMja910/RP+ZYbX6xAmCiU9UZSX4hqmgtHMv6lr5l3kq1HIOpcag== + +"@rollup/rollup-linux-riscv64-musl@4.63.4": + version "4.63.4" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.63.4.tgz#655b69144b1e786e0bf8ac3b727f0d7fee590a08" + integrity sha512-nLS8topojxyz7SRpKR2IODRpQ0XPZ+xaOXvT3+hqK/Uy8Lo5HFgkkIBiIrCu5tL5YqzTvgovGw55PwpahTAGig== + +"@rollup/rollup-linux-s390x-gnu@4.63.4": + version "4.63.4" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.63.4.tgz#70b175b0361a7898acc294a6b14001e2c3ad99be" + integrity sha512-gs7DRKotr3l3q+jGPQBjH0ng1FjlEDm5ueQrkw5JtQvtLyEIcLASqAEaor56BhkKRzk+IcQzrcanBdb/bBQn8g== + +"@rollup/rollup-linux-x64-gnu@4.63.4": + version "4.63.4" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.63.4.tgz#0c68fa212df249f996b2d75e8fbb3766b1582ee8" + integrity sha512-791ET7W17NnScOZM7h4dX5hYspxE28htPFsb1awY/NRR8+PRNkS53e475rDdxXXDrP+kwnCcNWg9CX5ztn/Aqw== + +"@rollup/rollup-linux-x64-musl@4.63.4": + version "4.63.4" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.63.4.tgz#93af6206edf751469a43c3614164360136540838" + integrity sha512-iwZQRcmj7g88g3tzefIrQY7qvmuA/cfYwhrDtTBhsmukO4U2huVO5W+86XacUMRvdSFVAc6kZUZy21JaRwiB9w== + +"@rollup/rollup-openbsd-x64@4.63.4": + version "4.63.4" + resolved "https://registry.yarnpkg.com/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.63.4.tgz#7c7680c4004fd602b59cd19f3294fa1b89d0c683" + integrity sha512-dVHFp9gRWrdTpnqQuGfCwd7hOQDatK1VCP2iWhLY/cGrOQs/ucFzJ6A5SRqbXX12ZDI8EUuejSM5kwg+ja7Png== + +"@rollup/rollup-openharmony-arm64@4.63.4": + version "4.63.4" + resolved "https://registry.yarnpkg.com/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.63.4.tgz#f389ff4d8ee8a635ff51d76e8ff20b7db2292bce" + integrity sha512-t3NlauOW6gxZVVFcBEnO62Cb4wbyDFL416gTg1uFI/2tgqYQlf69FbSE115Ajre9I+c26Lk4mcmdFUsS/DGifQ== + +"@rollup/rollup-win32-arm64-msvc@4.63.4": + version "4.63.4" + resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.63.4.tgz#55a286f40f9d03c653ceb7183d6c6179ec312c75" + integrity sha512-xWuIaSye5FWZF8+UYtVEcHtRJDN5kN9Kfgxx3Kq8XIov9KSKbc1fiqQCm90SKrgQbUXZelbnUhnlUJmfSE7P9A== + +"@rollup/rollup-win32-ia32-msvc@4.63.4": + version "4.63.4" + resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.63.4.tgz#7eb577b64e2a39115182a87a2ea957608d8331f6" + integrity sha512-9ALJJUOg/ZflMJepVo2PlgsGxSaxN7SQ4Z8GoZfVlarWr6r3rkHUNsd/zAio7p4YMtChSMXPionxej4Hkf6CXQ== + +"@rollup/rollup-win32-x64-gnu@4.63.4": + version "4.63.4" + resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.63.4.tgz#0e7a7cd5f0db2f570fc312687d67baf21283a3f6" + integrity sha512-blj9z5qx/Pv4WU0W1NMFDB97e0JH5ed+aZGywW8WCvp/NhWX/4PFAq5uu6Q0AebNn+Vo6KzUYDT++JzTT5ojlQ== + +"@rollup/rollup-win32-x64-msvc@4.63.4": + version "4.63.4" + resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.63.4.tgz#f8e11d8ac827db7695d50f1def4c00a7f3cd1510" + integrity sha512-Erx822VRBwLa124shbj+wNXe//BOgMEctDV0m1aqTQdNO1S69DgNUCFKC1RCeZfixs1J31l6igk1ziyXErbigQ== "@scarf/scarf@=1.4.0": version "1.4.0" @@ -729,7 +864,7 @@ "@types/cors@2.8.19": version "2.8.19" - resolved "https://registry.npmjs.org/@types/cors/-/cors-2.8.19.tgz#d93ea2673fd8c9f697367f5eeefc2bbfa94f0342" + resolved "https://registry.yarnpkg.com/@types/cors/-/cors-2.8.19.tgz#d93ea2673fd8c9f697367f5eeefc2bbfa94f0342" integrity sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg== dependencies: "@types/node" "*" @@ -741,20 +876,29 @@ "@types/estree@1.0.9", "@types/estree@^1.0.0", "@types/estree@^1.0.6": version "1.0.9" - resolved "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz#cf3f0e876d7bee15a93ab925b82bf570a3904a24" + resolved "https://registry.yarnpkg.com/@types/estree/-/estree-1.0.9.tgz#cf3f0e876d7bee15a93ab925b82bf570a3904a24" integrity sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg== "@types/express-serve-static-core@^5.0.0": - version "5.1.1" - resolved "https://registry.yarnpkg.com/@types/express-serve-static-core/-/express-serve-static-core-5.1.1.tgz#1a77faffee9572d39124933259be2523837d7eaa" - integrity sha512-v4zIMr/cX7/d2BpAEX3KNKL/JrT1s43s96lLvvdTmza1oEvDudCqK9aF/djc/SWgy8Yh0h30TZx5VpzqFCxk5A== + version "5.1.3" + resolved "https://registry.yarnpkg.com/@types/express-serve-static-core/-/express-serve-static-core-5.1.3.tgz#9d34c88c0c9ee62b9a6e4d9f8ab8d7e29688e6b4" + integrity sha512-dPfW8NFiOF4wOHc7+N/QSxlY9cfSsenewGbAz8C8U/MULPd/YZ27LvJUIlzaXie7e6Ove9YunJGgC9tbHD2cKw== dependencies: "@types/node" "*" "@types/qs" "*" "@types/range-parser" "*" "@types/send" "*" -"@types/express@*", "@types/express@5.0.5": +"@types/express@*": + version "5.0.6" + resolved "https://registry.yarnpkg.com/@types/express/-/express-5.0.6.tgz#2d724b2c990dcb8c8444063f3580a903f6d500cc" + integrity sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA== + dependencies: + "@types/body-parser" "*" + "@types/express-serve-static-core" "^5.0.0" + "@types/serve-static" "^2" + +"@types/express@5.0.5": version "5.0.5" resolved "https://registry.yarnpkg.com/@types/express/-/express-5.0.5.tgz#3ba069177caa34ab96585ca23b3984d752300cdc" integrity sha512-LuIQOcb6UmnF7C1PCFmEU1u2hmiHL43fgFQX67sN3H4Z+0Yk0Neo++mFsBjhOAuLzvlQeqAAkeDOZrJs9rzumQ== @@ -803,11 +947,15 @@ "@types/morgan@1.9.10": version "1.9.10" - resolved "https://registry.npmjs.org/@types/morgan/-/morgan-1.9.10.tgz#725c15d95a5e6150237524cd713bc2d68f9edf1a" + resolved "https://registry.yarnpkg.com/@types/morgan/-/morgan-1.9.10.tgz#725c15d95a5e6150237524cd713bc2d68f9edf1a" integrity sha512-sS4A1zheMvsADRVfT0lYbJ4S9lmsey8Zo2F7cnbYjWHP67Q0AwMYuuzLlkIM2N8gAbb9cubhIVFwcIN2XyYCkA== dependencies: "@types/node" "*" +"@types/multer@^2.2.0": + version "2.2.0" + resolved "https://registry.yarnpkg.com/@types/multer/-/multer-2.2.0.tgz#f987c770cbe309d1b5e60c251851d6550f121f61" + integrity sha512-3U1troeqGV8Ntp7Q3klwf4zr23VEoqYVocYXaswm9+8z3O9UHDYAqLxjJ/h550iRADTjKdOdhhasXw6gD6kYtg== "@types/ms@*": version "2.1.0" resolved "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz#052aa67a48eccc4309d7f0191b7e41434b90bb78" @@ -818,12 +966,19 @@ resolved "https://registry.yarnpkg.com/@types/node/-/node-25.9.3.tgz#11dfe7a33e68fa5c560f0aa76cc5595621ef26b9" integrity sha512-603BddQMv3pUcr4U2dhujk83N2tTDVr/34wII2B6bJy6g+8WD6yUb11jszNs0gdi4PesVWl7ABt8nYMVpnLUcg== dependencies: - undici-types ">=7.24.0 <7.24.7" + "@types/express" "*" + +"@types/node@*": + version "26.6.2" + resolved "https://registry.yarnpkg.com/@types/node/-/node-26.6.2.tgz#11bfb8e00bafe728d2113e474a7637becc9ab279" + integrity sha512-X1P21scMv4zGKLYqjdGjaKa7COa0RKVYYZZN/NfvLQ1JegxFhdhpZG/Lyn8AXx6CDUavKAd11v6BvfpkDByK8g== + dependencies: + undici-types "~8.9.0" "@types/pg@^8.16.0": - version "8.20.0" - resolved "https://registry.yarnpkg.com/@types/pg/-/pg-8.20.0.tgz#8bd03d3ac6b19143a8de7d66a9d13da32cd91526" - integrity sha512-bEPFOaMAHTEP1EzpvHTbmwR8UsFyHSKsRisLIHVMXnpNefSbGA1bD6CVy+qKjGSqmZqNqBDV2azOBo8TgkcVow== + version "8.23.1" + resolved "https://registry.yarnpkg.com/@types/pg/-/pg-8.23.1.tgz#7e712ce02cf44ad100862127f10f52e9c6b9d227" + integrity sha512-fKVHpikPdg4GKks3JuLEhvwSyvwzF23hnabPy6DD8ljVbC7+6J5dQzdv4arV6jqq57djnMgs1HKBxX4P8aBI3A== dependencies: "@types/node" "*" pg-protocol "*" @@ -866,7 +1021,15 @@ "@types/mime" "^1" "@types/node" "*" -"@types/serve-static@*", "@types/serve-static@^1": +"@types/serve-static@*", "@types/serve-static@^2": + version "2.2.0" + resolved "https://registry.yarnpkg.com/@types/serve-static/-/serve-static-2.2.0.tgz#d4a447503ead0d1671132d1ab6bd58b805d8de6a" + integrity sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ== + dependencies: + "@types/http-errors" "*" + "@types/node" "*" + +"@types/serve-static@^1": version "1.15.10" resolved "https://registry.yarnpkg.com/@types/serve-static/-/serve-static-1.15.10.tgz#768169145a778f8f5dfcb6360aead414a3994fee" integrity sha512-tRs1dB+g8Itk72rlSI2ZrW6vZg0YrLI81iQSTkMmOqnqCaNr/8Ek4VwWcN5vZgCYWbg/JJSGBlUaYGAOP73qBw== @@ -876,9 +1039,9 @@ "@types/send" "<1" "@types/superagent@^8.1.0", "@types/superagent@^8.1.7": - version "8.1.10" - resolved "https://registry.npmjs.org/@types/superagent/-/superagent-8.1.10.tgz#aa1f600eeee83a7b129e06f8e7fec78fb0ac980a" - integrity sha512-nbt4IWXABhW0jGmmpRzCFNlbmwCTzZ2gTUsNIr+X+ItdqPms+PAJZbWsNzpS2USqXjcoNLQcO6nXo60zcPQiIg== + version "8.1.11" + resolved "https://registry.yarnpkg.com/@types/superagent/-/superagent-8.1.11.tgz#14da75aa2f916dcdd6fb2a90a8fb24a9a1a86d08" + integrity sha512-KA7srSW/HENDtOw9DOqaFLgWuMqN9WgjEw62lh9dpvRaZDkhdOkazASd7X7i2eMUYLHa1U37ZttnePsH5zTDHw== dependencies: "@types/cookiejar" "^2.1.5" "@types/methods" "^1.1.4" @@ -887,7 +1050,7 @@ "@types/supertest@7.2.0": version "7.2.0" - resolved "https://registry.npmjs.org/@types/supertest/-/supertest-7.2.0.tgz#9620bc998d3e26bcbedba7d31da8fe4c82fc1620" + resolved "https://registry.yarnpkg.com/@types/supertest/-/supertest-7.2.0.tgz#9620bc998d3e26bcbedba7d31da8fe4c82fc1620" integrity sha512-uh2Lv57xvggst6lCqNdFAmDSvoMG7M/HDtX4iUCquxQ5EGPtaPM5PL5Hmi7LCvOG8db7YaCPNJEeoI8s/WzIQw== dependencies: "@types/methods" "^1.1.4" @@ -895,7 +1058,7 @@ "@types/swagger-ui-express@^4.1.8": version "4.1.8" - resolved "https://registry.npmjs.org/@types/swagger-ui-express/-/swagger-ui-express-4.1.8.tgz" + resolved "https://registry.yarnpkg.com/@types/swagger-ui-express/-/swagger-ui-express-4.1.8.tgz#3c0e0bf2543c7efb500eaa081bfde6d92f88096c" integrity sha512-AhZV8/EIreHFmBV5wAs0gzJUNq9JbbSXgJLQubCC0jtIo6prnI9MIRRxnU4MZX9RB9yXxF1V4R7jtLl/Wcj31g== dependencies: "@types/express" "*" @@ -957,9 +1120,9 @@ integrity sha512-GLupljMniHNIROP0zE7nCcybptolcH8QZfXOpCfhQDAdwJ/ZTlcaBOYebSOZotpti/3HrHSw7D3PZm75gYFsOA== "@typescript-eslint/tsconfig-utils@^8.46.3": - version "8.61.1" - resolved "https://registry.yarnpkg.com/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.61.1.tgz#ca88080e0cf191d49516d7f300b67aa090d2254f" - integrity sha512-UN/H4di+OO7EWx2ovME+8t31YO+KVnK0RRKEHR3kOt21/Ay8BOq3M1OMvWs5vNiqcFCYGYoxK3MXPZzmMUE+yg== + version "8.70.1" + resolved "https://registry.yarnpkg.com/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.70.1.tgz#e9353a6e5f3498a29b40946ca3e11f6166fef09e" + integrity sha512-jumze1fPI+sDOaM2TWGQdn39PDxTr7TZGeuyLkAbNyx2vtMT3uRnVKChN0hfht5V2TugphJzF6bYXvBcE09qqg== "@typescript-eslint/type-utils@8.46.3": version "8.46.3" @@ -978,9 +1141,9 @@ integrity sha512-G7Ok9WN/ggW7e/tOf8TQYMaxgID3Iujn231hfi0Pc7ZheztIJVpO44ekY00b7akqc6nZcvregk0Jpah3kep6hA== "@typescript-eslint/types@^8.46.1", "@typescript-eslint/types@^8.46.3": - version "8.61.1" - resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-8.61.1.tgz#0c51f518e4e6848371a1c988e859d59eb7522d5a" - integrity sha512-G+CRlPqLv7Bz1IZVs03x5K59F1veqL0EJUROAdGhKsEq8qOiRiZbI+HUojPq5l0fEGOKModD9br6lObhB8zkoA== + version "8.70.1" + resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-8.70.1.tgz#2022b9d07d057174eba25b9bbd289641d262a196" + integrity sha512-Dm1ypdhhrGCTyyehxElhgJ6kgk8MVCv5qXdoOVqPr1uqk42jX8KjrZqhROvdShczA8qrDoYiOWn1ykWlx2k81Q== "@typescript-eslint/typescript-estree@8.46.3": version "8.46.3" @@ -1133,9 +1296,9 @@ acorn-jsx@^5.3.2: integrity sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ== acorn@^8.15.0: - version "8.17.0" - resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.17.0.tgz#1785adb84faf8d8add10369b93826fc2bd08f1fe" - integrity sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg== + version "8.18.0" + resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.18.0.tgz#4faf01b2d6d326bfeed97aea1f52220b5f4c1940" + integrity sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ== agent-base@^7.1.0, agent-base@^7.1.2: version "7.1.4" @@ -1158,9 +1321,9 @@ ansi-regex@^5.0.1: integrity sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ== ansi-regex@^6.2.2: - version "6.2.2" - resolved "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz" - integrity sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg== + version "6.3.0" + resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-6.3.0.tgz#247c8e7b70a1a43b10ce14c0226fcbf58e8815d5" + integrity sha512-WpDfL7NO6j7tH88IDBNVdUJxDh9nmCteAVW9dsep846XdwF4naCBK+/tGLX3KJgcpgMRXCFlTM2hKGoK9FsdrQ== ansi-styles@^3.2.1: version "3.2.1" @@ -1178,7 +1341,7 @@ ansi-styles@^4.0.0, ansi-styles@^4.1.0: ansi-styles@^6.1.0: version "6.2.3" - resolved "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz" + resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-6.2.3.tgz#c044d5dcc521a076413472597a1acb1f103c4041" integrity sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg== append-field@^1.0.0: @@ -1292,9 +1455,9 @@ axios@1.13.2: proxy-from-env "^1.1.0" b4a@^1.6.4, b4a@^1.8.1: - version "1.8.1" - resolved "https://registry.yarnpkg.com/b4a/-/b4a-1.8.1.tgz#7f16334ca80127aeb26064a28841acbf174840a4" - integrity sha512-aiqre1Nr0B/6DgE2N5vwTc+2/oQZ4Wh1t4NznYY4E00y8LCt6NqdRv81so00oo27D8MVKTpUa/MwUUtBLXCoDw== + version "1.9.0" + resolved "https://registry.yarnpkg.com/b4a/-/b4a-1.9.0.tgz#6336313b6e76b299c397afa68676ec6ebc788845" + integrity sha512-dpfcF9fDNR6++cthXR67iyhgqWy9CBouAvIWhIntzBG6cvK/cnIPiZQjBwi/ZqjjBEDGfoNDtmB0kTjroOJ3pQ== balanced-match@^1.0.0: version "1.0.2" @@ -1302,14 +1465,14 @@ balanced-match@^1.0.0: integrity sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw== bare-events@^2.5.4, bare-events@^2.7.0: - version "2.9.1" - resolved "https://registry.yarnpkg.com/bare-events/-/bare-events-2.9.1.tgz#5c86616966343bcb03a1b3155feab253eadbf349" - integrity sha512-Z0oHEHAFDZkffN8Qc39zNZjQlMDkPJRyyyZieU1VH7u8c5S+qHZ2S8ixdKIAxEjfHO7FJxXmJWgteOghVanIsg== + version "2.9.2" + resolved "https://registry.yarnpkg.com/bare-events/-/bare-events-2.9.2.tgz#01d377b64c3c7167b28b52da7add3221aecf2d7f" + integrity sha512-AIPKioV7/Y/8KfZ3AAhjPJxLLbY49S64Ym5DakZlUg75qQiTgUq9hEJoEwa4eUezPUlXRy/i5NpsKvo9jgKmoA== bare-fs@^4.0.1, bare-fs@^4.5.5: - version "4.7.2" - resolved "https://registry.yarnpkg.com/bare-fs/-/bare-fs-4.7.2.tgz#0f59b317e5bdbec6a1489b519a06a203cd3fe035" - integrity sha512-aTvMFUWkBmjzKtEQMDGGDNF8bkfpD5N1b/FCwt7A3wrU4t1o/e/85Wzkluh6JlODCjqVESYCkQCdTXqZ9G7VFg== + version "4.8.1" + resolved "https://registry.yarnpkg.com/bare-fs/-/bare-fs-4.8.1.tgz#1a946560b45844dc37120c00330eae7c8a1c44cf" + integrity sha512-N1nnXdHZAOSstz0XiHikGS4HGMH4CnSwhqWdGQQMqqdvp4Jybm9sE3R1WVnpWVd4SFkc8ryPDBLViNLwiEqECg== dependencies: bare-events "^2.5.4" bare-path "^3.0.0" @@ -1317,31 +1480,24 @@ bare-fs@^4.0.1, bare-fs@^4.5.5: bare-url "^2.2.2" fast-fifo "^1.3.2" -bare-os@^3.0.1: - version "3.9.1" - resolved "https://registry.yarnpkg.com/bare-os/-/bare-os-3.9.1.tgz#660228ca7ffc47a72e96b6047cdd9d8342994e2f" - integrity sha512-6M5XjcnsygQNPMCMPXSK379xrJFiZ/AEMNBmFEmQW8d/789VQATvriyi5r0HYTL9TkQ26rn3kgdTG3aisbrXkQ== - bare-path@^3.0.0: - version "3.0.1" - resolved "https://registry.yarnpkg.com/bare-path/-/bare-path-3.0.1.tgz#c12c81b527936b650e87c5d00264d59ef458082c" - integrity sha512-ghj2DSK/2e99a1anTVPCV4m4YIYtrbXhfM7V3D7XZLOTsybnYyaJloymGqssQc8l/or0UoDyRtNQkmkEF/ysgQ== - dependencies: - bare-os "^3.0.1" + version "3.1.2" + resolved "https://registry.yarnpkg.com/bare-path/-/bare-path-3.1.2.tgz#67261bc74a9ba0105ba139b8638f218c09e8d82e" + integrity sha512-ZyKbsuuqK6Ag0K8pX6V5Txq6XeJRvY+wXucnFGRjiyVYP9YWDpIQugk/b+enRYrEYBJaqLzghRQpXPMR7341Nw== bare-stream@^2.6.4: - version "2.13.3" - resolved "https://registry.yarnpkg.com/bare-stream/-/bare-stream-2.13.3.tgz#f6186c7cbb4bbf53a4560f35e48b16373ba51ce6" - integrity sha512-Kc+brLqvEqGkjyfiwJmImAOqLZL7OsoLKuavx+hJjgVV3nLTOjloJyPMFxjUPerGGHrNH0fLU06jjykMLWrERQ== + version "2.13.4" + resolved "https://registry.yarnpkg.com/bare-stream/-/bare-stream-2.13.4.tgz#61d448a268d1efd992103aae6bd729332f278ac0" + integrity sha512-PcrQ8lVLbiJscNm1Kez+Yp4Gy4AHGcN1lzwjvf5NybWen7VvEgUfyfnXYJ2zNqWnzOfCb1Abq6lH8ti0syQszA== dependencies: b4a "^1.8.1" streamx "^2.25.0" teex "^1.0.1" bare-url@^2.2.2: - version "2.4.5" - resolved "https://registry.yarnpkg.com/bare-url/-/bare-url-2.4.5.tgz#50d205f8f2724eec60fd091ba9cebd675fca63aa" - integrity sha512-K+y9xF1tN+CdPu4qWwr0QiK1Al07eFPGYK5M2pDXcmHdMdgC/tT/bpmMe1hrmRHaidKLkXrC+cRNYf3XVDUhSQ== + version "2.5.4" + resolved "https://registry.yarnpkg.com/bare-url/-/bare-url-2.5.4.tgz#10ab50f39b50335e2aa7672d5941043dbfc7749e" + integrity sha512-Gxa7UVWBr0/edU1b+TJhn/AZvMQUj9OGspvYsaTYQrAbZA4BOTZGL3LiZxvD+CeMlDH4juwD84+eTAp/bLYW5g== dependencies: bare-path "^3.0.0" @@ -1408,17 +1564,17 @@ body-parser@^2.2.0: type-is "^2.1.0" brace-expansion@^1.1.7: - version "1.1.15" - resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-1.1.15.tgz#a6d90d54067236e5f42570a3b7378d594d9b7738" - integrity sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg== + version "1.1.21" + resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-1.1.21.tgz#edf4fab5c64d051aea5a8def49aba1c7522279f3" + integrity sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw== dependencies: balanced-match "^1.0.0" concat-map "0.0.1" brace-expansion@^2.0.2: - version "2.1.1" - resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-2.1.1.tgz#c68b1c4111c76aae3a6fba55d496cee10c39dad8" - integrity sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA== + version "2.1.7" + resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-2.1.7.tgz#14ea3836731823bb24740225ea43b941b436f9d1" + integrity sha512-uZbew1NqdmPDTMJ8ah1y+b+9QEJrfkXFk3RcTQw3X0jW/xRUvFKsg1CfQdSYGdTbXZWExtU3J3ccxtnfw1Fi0g== dependencies: balanced-match "^1.0.0" @@ -1655,10 +1811,10 @@ content-type@^1.0.5: resolved "https://registry.yarnpkg.com/content-type/-/content-type-1.0.5.tgz#8b773162656d1d1086784c8f23a54ce6d73d7918" integrity sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA== -content-type@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/content-type/-/content-type-2.0.0.tgz#2fb3ede69dffa0af78ca7c4ce7589680638b56df" - integrity sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ== +content-type@^2.0.0, content-type@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/content-type/-/content-type-2.1.0.tgz#d9389c43c0a8cf6a355db464d21e07092a40493a" + integrity sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag== convert-hrtime@^5.0.0: version "5.0.0" @@ -1893,7 +2049,7 @@ dunder-proto@^1.0.0, dunder-proto@^1.0.1: eastasianwidth@^0.2.0: version "0.2.0" - resolved "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz" + resolved "https://registry.yarnpkg.com/eastasianwidth/-/eastasianwidth-0.2.0.tgz#696ce2ec0aa0e6ea93a397ffcf24aa7840c827cb" integrity sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA== ecc-jsbn@~0.1.1: @@ -1928,7 +2084,7 @@ emoji-regex@^8.0.0: emoji-regex@^9.2.2: version "9.2.2" - resolved "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz" + resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-9.2.2.tgz#840c8803b0d8047f4ff0cf963176b32d4ef3ed72" integrity sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg== encodeurl@^2.0.0: @@ -2058,11 +2214,12 @@ es-set-tostringtag@^2.1.0: hasown "^2.0.2" es-to-primitive@^1.3.0: - version "1.3.1" - resolved "https://registry.yarnpkg.com/es-to-primitive/-/es-to-primitive-1.3.1.tgz#abd6ef5b12d7c25bcd9eb3a7ef63e568b451ba4a" - integrity sha512-CxN9N56HYfd2m/acc/NOFrZQsN9kU4eh+2kk6A707Kz1krH8tKmfrs5RnftB8WNX80T0NS7vSQsDOlg23diR2g== + version "1.3.4" + resolved "https://registry.yarnpkg.com/es-to-primitive/-/es-to-primitive-1.3.4.tgz#0c854291cf0d7b439d6b9e5771ea837ffaf1f991" + integrity sha512-yPDz7wqpg1/mmHLmS3tcfTfbw5f1eryXvyghYBffGdERwe+mV7ZcWzTR8LR17Kvqt3qfPurjlonmnq3MKXIOXw== dependencies: es-abstract-get "^1.0.0" + es-define-property "^1.0.1" es-errors "^1.3.0" is-callable "^1.2.7" is-date-object "^1.1.0" @@ -2070,7 +2227,7 @@ es-to-primitive@^1.3.0: esbuild@0.28.0: version "0.28.0" - resolved "https://registry.npmjs.org/esbuild/-/esbuild-0.28.0.tgz" + resolved "https://registry.yarnpkg.com/esbuild/-/esbuild-0.28.0.tgz#5dee347ffb3e3874212a35a69836b077b1ce6d96" integrity sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw== optionalDependencies: "@esbuild/aix-ppc64" "0.28.0" @@ -2100,9 +2257,41 @@ esbuild@0.28.0: "@esbuild/win32-ia32" "0.28.0" "@esbuild/win32-x64" "0.28.0" -esbuild@^0.27.0, esbuild@~0.27.0: +"esbuild@^0.27.0 || ^0.28.0": + version "0.28.2" + resolved "https://registry.yarnpkg.com/esbuild/-/esbuild-0.28.2.tgz#0f43bd1bad955b72d24e2261e3abe5957ccf0816" + integrity sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA== + optionalDependencies: + "@esbuild/aix-ppc64" "0.28.2" + "@esbuild/android-arm" "0.28.2" + "@esbuild/android-arm64" "0.28.2" + "@esbuild/android-x64" "0.28.2" + "@esbuild/darwin-arm64" "0.28.2" + "@esbuild/darwin-x64" "0.28.2" + "@esbuild/freebsd-arm64" "0.28.2" + "@esbuild/freebsd-x64" "0.28.2" + "@esbuild/linux-arm" "0.28.2" + "@esbuild/linux-arm64" "0.28.2" + "@esbuild/linux-ia32" "0.28.2" + "@esbuild/linux-loong64" "0.28.2" + "@esbuild/linux-mips64el" "0.28.2" + "@esbuild/linux-ppc64" "0.28.2" + "@esbuild/linux-riscv64" "0.28.2" + "@esbuild/linux-s390x" "0.28.2" + "@esbuild/linux-x64" "0.28.2" + "@esbuild/netbsd-arm64" "0.28.2" + "@esbuild/netbsd-x64" "0.28.2" + "@esbuild/openbsd-arm64" "0.28.2" + "@esbuild/openbsd-x64" "0.28.2" + "@esbuild/openharmony-arm64" "0.28.2" + "@esbuild/sunos-x64" "0.28.2" + "@esbuild/win32-arm64" "0.28.2" + "@esbuild/win32-ia32" "0.28.2" + "@esbuild/win32-x64" "0.28.2" + +esbuild@~0.27.0: version "0.27.7" - resolved "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz" + resolved "https://registry.yarnpkg.com/esbuild/-/esbuild-0.27.7.tgz#bcadce22b2f3fd76f257e3a64f83a64986fea11f" integrity sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w== optionalDependencies: "@esbuild/aix-ppc64" "0.27.7" @@ -2289,9 +2478,9 @@ events-universal@^1.0.0: bare-events "^2.7.0" expect-type@^1.2.2: - version "1.3.0" - resolved "https://registry.yarnpkg.com/expect-type/-/expect-type-1.3.0.tgz#0d58ed361877a31bbc4dd6cf71bbfef7faf6bd68" - integrity sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA== + version "1.4.0" + resolved "https://registry.yarnpkg.com/expect-type/-/expect-type-1.4.0.tgz#24edf7f0cc69a44d008567ba4594ab96f3c3a3d6" + integrity sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA== express-rate-limit@8.2.1: version "8.2.1" @@ -2412,9 +2601,9 @@ fast-safe-stringify@^2.1.1: integrity sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA== fastq@^1.6.0: - version "1.20.1" - resolved "https://registry.yarnpkg.com/fastq/-/fastq-1.20.1.tgz#ca750a10dc925bc8b18839fd203e3ef4b3ced675" - integrity sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw== + version "1.20.3" + resolved "https://registry.yarnpkg.com/fastq/-/fastq-1.20.3.tgz#7ab8731e647b56abcfeee997dae333ca3ee1d04d" + integrity sha512-XKv5nnLs6nLF71NgiKJLIZFLkPyIEuOselLG7ujZnGrRfQK8HpvY+WqKhAJUAdLomwVHErVS4LfxFlPq0/FTAw== dependencies: reusify "^1.0.4" @@ -2473,9 +2662,9 @@ flat-cache@^4.0.0: keyv "^4.5.4" flatted@^3.2.9: - version "3.4.2" - resolved "https://registry.yarnpkg.com/flatted/-/flatted-3.4.2.tgz#f5c23c107f0f37de8dbdf24f13722b3b98d52726" - integrity sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA== + version "3.4.4" + resolved "https://registry.yarnpkg.com/flatted/-/flatted-3.4.4.tgz#aeeca2a506303f0cee61c59e6c9f2a88d2f29fc6" + integrity sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q== follow-redirects@^1.15.6: version "1.16.0" @@ -2491,7 +2680,7 @@ for-each@^0.3.3, for-each@^0.3.5: foreground-child@^3.1.0: version "3.3.1" - resolved "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz" + resolved "https://registry.yarnpkg.com/foreground-child/-/foreground-child-3.3.1.tgz#32e8e9ed1b68a3497befb9ac2b6adf92a638576f" integrity sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw== dependencies: cross-spawn "^7.0.6" @@ -2548,7 +2737,7 @@ fs.realpath@^1.0.0: fsevents@~2.3.2, fsevents@~2.3.3: version "2.3.3" - resolved "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz#cac6407785d03675a2a5e1a5305c697b347d90d6" + resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.3.3.tgz#cac6407785d03675a2a5e1a5305c697b347d90d6" integrity sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw== function-bind@^1.1.2: @@ -2649,9 +2838,9 @@ get-symbol-description@^1.1.0: get-intrinsic "^1.2.6" get-tsconfig@^4.7.5: - version "4.14.0" - resolved "https://registry.yarnpkg.com/get-tsconfig/-/get-tsconfig-4.14.0.tgz#985d85c52a9903864280ccc2448d413fbf1efed8" - integrity sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA== + version "4.14.3" + resolved "https://registry.yarnpkg.com/get-tsconfig/-/get-tsconfig-4.14.3.tgz#7bcc9f3ab5fb6d30ad0a301898c859d34164dcd9" + integrity sha512-++QEw4DIY7WGoukz+/+A/8dGYPT9l9yIadnmSgZ8Rjr3YVSVDipQSO9CdnJo9ePqFqUUqh+wk9uIaoiAwsiPkA== dependencies: resolve-pkg-maps "^1.0.0" @@ -2692,7 +2881,7 @@ glob-parent@^6.0.2: glob@10.5.0: version "10.5.0" - resolved "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz" + resolved "https://registry.yarnpkg.com/glob/-/glob-10.5.0.tgz#8ec0355919cd3338c28428a23d4f24ecc5fe738c" integrity sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg== dependencies: foreground-child "^3.1.0" @@ -2801,7 +2990,7 @@ has-tostringtag@^1.0.2: dependencies: has-symbols "^1.0.3" -hasown@^2.0.2, hasown@^2.0.3, hasown@^2.0.4: +hasown@^2.0.2, hasown@^2.0.4: version "2.0.4" resolved "https://registry.yarnpkg.com/hasown/-/hasown-2.0.4.tgz#8c62d8cb90beb2aad5d0a5b67581ad9854c3f003" integrity sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A== @@ -2872,9 +3061,9 @@ iconv-lite@^0.6.3: safer-buffer ">= 2.1.2 < 3.0.0" iconv-lite@^0.7.0, iconv-lite@^0.7.2, iconv-lite@~0.7.0: - version "0.7.2" - resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.7.2.tgz#d0bdeac3f12b4835b7359c2ad89c422a4d1cc72e" - integrity sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw== + version "0.7.3" + resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.7.3.tgz#84ee12f963e7de50bc01a13e160a078b3b0f415f" + integrity sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ== dependencies: safer-buffer ">= 2.1.2 < 3.0.0" @@ -2884,9 +3073,9 @@ ignore@^5.2.0: integrity sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g== ignore@^7.0.0: - version "7.0.5" - resolved "https://registry.yarnpkg.com/ignore/-/ignore-7.0.5.tgz#4cb5f6cd7d4c7ab0365738c7aea888baa6d7efd9" - integrity sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg== + version "7.0.9" + resolved "https://registry.yarnpkg.com/ignore/-/ignore-7.0.9.tgz#475b2197ade916edab05ade35c691518e8543382" + integrity sha512-brTTsvFRt5C1gGHtPst/281UjPD5t9fBqbgoMPlVWy11ZLTPfu7HxK4ZYqO9H7o/yC9rSTCI85EaQ4OoY12qYw== import-fresh@^3.2.1, import-fresh@^3.3.0: version "3.3.1" @@ -2939,9 +3128,9 @@ ip-address@10.0.1: integrity sha512-NWv9YLW4PoW2B7xtzaS3NCot75m6nK7Icdv0o3lfMceJVRfSoQwqD4wEH5rLwoKJwUiZ/rfpiVBhnaF0FK4HoA== ip-address@^10.1.1: - version "10.2.0" - resolved "https://registry.yarnpkg.com/ip-address/-/ip-address-10.2.0.tgz#805fc178b20c518bd4c8548b24fe30892d7f3206" - integrity sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA== + version "10.7.2" + resolved "https://registry.yarnpkg.com/ip-address/-/ip-address-10.7.2.tgz#5b3b2b7d46c6293861b82dfcd49cc203aa8f2c5c" + integrity sha512-7H/2gFSIitxc0hG3nOI1glS8QLo/EHBFFLk8vEUjXY/xu0AdL8jZ9U1IzO2PUm0d2D/ofQcAifb0g6OBkt8U7w== ip-regex@^5.0.0: version "5.0.0" @@ -3004,11 +3193,11 @@ is-callable@^1.2.7: integrity sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA== is-core-module@^2.16.1: - version "2.16.2" - resolved "https://registry.yarnpkg.com/is-core-module/-/is-core-module-2.16.2.tgz#3e07450a8080ebce3fbf0cac494f4d2ab324e082" - integrity sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA== + version "2.17.0" + resolved "https://registry.yarnpkg.com/is-core-module/-/is-core-module-2.17.0.tgz#75c1c76236c9f7e38ddbfff292e127dc13b21027" + integrity sha512-J/vG0zBCbIKOQFfufSwyXdMrsohyJIUNkrnmo6WZGzoM7tr/lsbfW5b2BvisL6zsyMzK9UxV9L6c7AoFbyXHOA== dependencies: - hasown "^2.0.3" + hasown "^2.0.4" is-data-view@^1.0.1, is-data-view@^1.0.2: version "1.0.2" @@ -3234,7 +3423,7 @@ istanbul-reports@^3.2.0: jackspeak@^3.1.2: version "3.4.3" - resolved "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz" + resolved "https://registry.yarnpkg.com/jackspeak/-/jackspeak-3.4.3.tgz#8833a9d89ab4acde6188942bd1c53b6390ed5a8a" integrity sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw== dependencies: "@isaacs/cliui" "^8.0.2" @@ -3251,10 +3440,10 @@ js-tokens@^4.0.0: resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-4.0.0.tgz#19203fb59991df98e3a287050d4647cdeaf32499" integrity sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ== -js-yaml@^4.1.0, js-yaml@^4.1.1: - version "4.2.0" - resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-4.2.0.tgz#2bd9e85682dd91bd469afb809d816043b3d49524" - integrity sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw== +js-yaml@^4.1.0, js-yaml@^4.3.2: + version "4.3.2" + resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-4.3.2.tgz#8e44fb14a2643c59726bb15787b5f1512cb3d3fb" + integrity sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA== dependencies: argparse "^2.0.1" @@ -3504,9 +3693,9 @@ lru-cache@^7.14.1: integrity sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA== lru.min@^1.0.0, lru.min@^1.1.0: - version "1.1.4" - resolved "https://registry.yarnpkg.com/lru.min/-/lru.min-1.1.4.tgz#6ea1737a8c1ba2300cc87ad46910a4bdffa0117b" - integrity sha512-DqC6n3QQ77zdFpCMASA1a3Jlb64Hv2N2DciFGkO/4L9+q/IpIAuRlKOvCXabtRW6cQf8usbmM6BE/TOPysCdIA== + version "1.1.5" + resolved "https://registry.yarnpkg.com/lru.min/-/lru.min-1.1.5.tgz#bd32483f4e342261d0eb011b06d2219e6b5ef818" + integrity sha512-5J9ysMYUpYIg9RF2vJpy9SinEmSviFSe0GyPpCQ4L5QSkLAgeLXlTAOu2ZwWUU5m+0SBl6gUU1R1ZQB3aKypfA== magic-string@^0.30.19: version "0.30.21" @@ -3542,9 +3731,9 @@ media-typer@0.3.0: integrity sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ== media-typer@^1.1.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/media-typer/-/media-typer-1.1.0.tgz#6ab74b8f2d3320f2064b2a87a38e7931ff3a5561" - integrity sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw== + version "1.1.1" + resolved "https://registry.yarnpkg.com/media-typer/-/media-typer-1.1.1.tgz#6f035400dfe3ab9d5607bc77546ce30cc2f9c6b8" + integrity sha512-yz3xRaG20c6/BOzvYoDaGtPmGscs7YivItZEEqe6GbwNfHuxu9YNmvnEkMzKldAGY4/80pRcQRZSEnhquk9XuQ== memorystream@^0.3.1: version "0.3.1" @@ -3647,9 +3836,9 @@ moment-timezone@^0.5.14, moment-timezone@^0.5.21: moment "^2.29.4" moment@^2.20.0, moment@^2.24.0, moment@^2.29.4: - version "2.30.1" - resolved "https://registry.yarnpkg.com/moment/-/moment-2.30.1.tgz#f8c91c07b7a786e30c59926df530b4eac96974ae" - integrity sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how== + version "2.31.0" + resolved "https://registry.yarnpkg.com/moment/-/moment-2.31.0.tgz#6e19184e8005a9dfc61c9351263d4d6ea2ace7aa" + integrity sha512-0acOTfMiWOheYS4eoWb80yYMb/JLvVv9SHbs2PehaDzfUG0Bw855SKyk0IKTnPGa5+U2bmi3W68l1+sGLX/pvw== morgan@1.10.1: version "1.10.1" @@ -3712,10 +3901,10 @@ named-placeholders@^1.1.3: dependencies: lru.min "^1.1.0" -nanoid@^3.3.12: - version "3.3.12" - resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.12.tgz#ab3d912e217a6d0a514f00a72a16543a28982c05" - integrity sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ== +nanoid@^3.3.18: + version "3.3.19" + resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.19.tgz#336d4aa4bcd4fb24d2cddede7ffeae40bec03f0a" + integrity sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug== natural-compare@^1.4.0: version "1.4.0" @@ -3723,9 +3912,11 @@ natural-compare@^1.4.0: integrity sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw== negotiator@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/negotiator/-/negotiator-1.0.0.tgz#b6c91bb47172d69f93cfd7c357bbb529019b5f6a" - integrity sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg== + version "1.1.0" + resolved "https://registry.yarnpkg.com/negotiator/-/negotiator-1.1.0.tgz#16e003d0db4ac24fd9df168edf871625deeae3df" + integrity sha512-NMPBRMJgiQHjbd8phG3Vebdx4kZ1H121rbl5IkMqeOsahptB9BKo/d7oJ3zTXqTgagn2bWlNSXkh0QUGM31RYg== + dependencies: + content-type "^2.1.0" netmask@^2.0.2: version "2.1.1" @@ -3738,9 +3929,9 @@ nice-try@^1.0.4: integrity sha512-1nh45deeb5olNY7eX82BkPO7SSxR5SSYJiPTrTdFUVYwAl8CKMA5N9PjTYkHiRjisVcxcQ1HXdLhx2qxxJzLNQ== node-addon-api@^8.3.0: - version "8.8.0" - resolved "https://registry.yarnpkg.com/node-addon-api/-/node-addon-api-8.8.0.tgz#b742be05007b37ebc1741bbaf370d22bf25d208a" - integrity sha512-c5Ko1fZJIJmzhFIkhRN76WTq+fC6tWnGy9CXA0fA+XygsWZmEwG8vmbkNqxMyoaa0Tin4djul49NzdVcJJcjeA== + version "8.9.2" + resolved "https://registry.yarnpkg.com/node-addon-api/-/node-addon-api-8.9.2.tgz#db7ac94a13ffd9b55e6cb04584bd5ef8e1d74b18" + integrity sha512-VijLXbi3UACN69I0JVXJsX4tjACjNoQDgv2gTF6sx2wWEi8tkSg2eX8p5gSIFi8z2+DL3oHmY6OyKce38SDolg== node-gyp-build@^4.8.4: version "4.8.4" @@ -3848,11 +4039,12 @@ optionator@^0.9.3: word-wrap "^1.2.5" own-keys@^1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/own-keys/-/own-keys-1.0.1.tgz#e4006910a2bf913585289676eebd6f390cf51358" - integrity sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg== + version "1.0.2" + resolved "https://registry.yarnpkg.com/own-keys/-/own-keys-1.0.2.tgz#31448ec1f781ecb1447f6f6aa0d6534222af59de" + integrity sha512-19YVAg7T+WTrxggPukVq7DjTv6+PJ867TmhCvBsYwmbFCsZd344rq2Ld1p0wo8f8Qrrhgp82c6FJRqdXWtSEhg== dependencies: - get-intrinsic "^1.2.6" + call-bound "^1.0.4" + get-intrinsic "^1.3.0" object-keys "^1.1.1" safe-push-apply "^1.0.0" @@ -3864,9 +4056,9 @@ p-limit@^3.0.2: yocto-queue "^0.1.0" p-limit@^7.3.0: - version "7.3.0" - resolved "https://registry.yarnpkg.com/p-limit/-/p-limit-7.3.0.tgz#821398d91491c6b6a1340ecd09cdc402a9c8d0ee" - integrity sha512-7cIXg/Z0M5WZRblrsOla88S4wAK+zOQQWeBYfV3qJuJXMr+LnbYjaadrFaS0JILfEDPVqHyKnZ1Z/1d6J9VVUw== + version "7.3.3" + resolved "https://registry.yarnpkg.com/p-limit/-/p-limit-7.3.3.tgz#43464e7094180d03bb0f53f576d9f2cd457b2921" + integrity sha512-SKgVvDU5TNIxQ4r30U75BNLC6a+6GOEjngrk/ysM5+Zu3oJuk1SC5ApzIzY/7PjCEjoVPMWJ0zho/4QUiEB7TQ== dependencies: yocto-queue "^1.2.1" @@ -4004,10 +4196,10 @@ pg-connection-string@2.6.2: resolved "https://registry.yarnpkg.com/pg-connection-string/-/pg-connection-string-2.6.2.tgz#713d82053de4e2bd166fab70cd4f26ad36aab475" integrity sha512-ch6OwaeaPYcova4kKZ15sbJ2hKb/VP48ZD2gE7i1J+L4MspCtBMAx8nMgz7bksc7IojCIIWuEhHibSMFH8m8oA== -pg-connection-string@^2.13.0: - version "2.13.0" - resolved "https://registry.yarnpkg.com/pg-connection-string/-/pg-connection-string-2.13.0.tgz#8678113465a5af3cc977dcb51eadc847b27aa2de" - integrity sha512-EMnU9E2fSULdsbErBbMaXJvFeD9B4+nPcM3f+4lsiCR0BHLPrLVjv3DbyM2hgQQviKJaTWIRRTjKjWlHg3p2ig== +pg-connection-string@^2.14.0: + version "2.14.0" + resolved "https://registry.yarnpkg.com/pg-connection-string/-/pg-connection-string-2.14.0.tgz#abc26ee4f37c56c0f3ae0fcf0b0653cc4e1c0fd9" + integrity sha512-XwWDGcLRGCXAR8F/AM5bG7Q+A3Wm2s6QeEjlOKZLlH3UYcguiqCWKyWXVag5TLTIjR7oOJUY8kcADaZgWPyLeg== pg-int8@1.0.1: version "1.0.1" @@ -4019,10 +4211,10 @@ pg-pool@^3.14.0: resolved "https://registry.yarnpkg.com/pg-pool/-/pg-pool-3.14.0.tgz#f35ae4eb846780cad71af24099b3edfa9781ad90" integrity sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw== -pg-protocol@*, pg-protocol@^1.14.0: - version "1.14.0" - resolved "https://registry.yarnpkg.com/pg-protocol/-/pg-protocol-1.14.0.tgz#c1f045b74274b007078c687147141f785f59b8de" - integrity sha512-n5taZ1kO3s9ngDTVxsEznOqCyToTgz0FLuPq0B33COy5pPpuWJpY3/2oRBVETuOgzdqRXfWpM9HIhp2LBBT1BA== +pg-protocol@*, pg-protocol@^1.16.0: + version "1.16.0" + resolved "https://registry.yarnpkg.com/pg-protocol/-/pg-protocol-1.16.0.tgz#cffb008826561ee9770a8a15dc21f269d731b305" + integrity sha512-sILXutLVjCLjcDuOmvhX5e2Z4cS5qG/6Bu3VkpFwdf/633ElGLpEh9bgmuI5I4sqKqkifQiGyiCcx1HdtrK7tg== pg-types@2.2.0, pg-types@^2.2.0: version "2.2.0" @@ -4036,13 +4228,13 @@ pg-types@2.2.0, pg-types@^2.2.0: postgres-interval "^1.1.0" pg@^8.16.3: - version "8.21.0" - resolved "https://registry.yarnpkg.com/pg/-/pg-8.21.0.tgz#d7fa2118d960cec5cc7d2b24525f9850dd5932b0" - integrity sha512-AUP1EYJuHraQGsVoCQVIcM7TEJVGtDzxWtGFZd8rds9d+CCXlU5Js1rYgfLNvxy9iJrpHjGrRjoi/3BT9fRyiA== + version "8.23.0" + resolved "https://registry.yarnpkg.com/pg/-/pg-8.23.0.tgz#5c2026d32bd0cb4fbd9196bac1ecf5ae66607180" + integrity sha512-Ip2EQCngowJLGOfCwkFhPXU7/ljlhn6Rxlmy4XYfL2Y+vyRM59+8uR2xqRWKdYmbXmxCFOAmKxBuSUCdF34qLg== dependencies: - pg-connection-string "^2.13.0" + pg-connection-string "^2.14.0" pg-pool "^3.14.0" - pg-protocol "^1.14.0" + pg-protocol "^1.16.0" pg-types "2.2.0" pgpass "1.0.5" optionalDependencies: @@ -4066,9 +4258,9 @@ picomatch@^2.3.1: integrity sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA== picomatch@^4.0.3, picomatch@^4.0.4: - version "4.0.4" - resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-4.0.4.tgz#fd6f5e00a143086e074dffe4c924b8fb293b0589" - integrity sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A== + version "4.0.7" + resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-4.0.7.tgz#6313360034ccb36b3dc61ecbdff78121f90fe21f" + integrity sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA== pidtree@^0.3.0: version "0.3.1" @@ -4086,11 +4278,11 @@ possible-typed-array-names@^1.0.0, possible-typed-array-names@^1.1.0: integrity sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg== postcss@^8.5.6: - version "8.5.15" - resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.5.15.tgz#d1eaf677a324e9ec02196da2d3fecf4a0b9a735c" - integrity sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A== + version "8.5.28" + resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.5.28.tgz#da4563a99a06e62d6c1cd1acae363224bcaed6e9" + integrity sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A== dependencies: - nanoid "^3.3.12" + nanoid "^3.3.18" picocolors "^1.1.1" source-map-js "^1.2.1" @@ -4127,9 +4319,9 @@ progress@^2.0.3: integrity sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA== proxy-addr@^2.0.7: - version "2.0.7" - resolved "https://registry.yarnpkg.com/proxy-addr/-/proxy-addr-2.0.7.tgz#f19fe69ceab311eeb94b42e70e8c2070f9ba1025" - integrity sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg== + version "2.0.8" + resolved "https://registry.yarnpkg.com/proxy-addr/-/proxy-addr-2.0.8.tgz#624e760fd8ab06b1b8320c2f8506d4422cf8e932" + integrity sha512-5nnx0yGyVUcY6t9RnWcARWtwT9F1D8O9rt08htPvnd49W1IgZtmLkhu9WfMzQj1cFxjHIO6connUNVW5k7AVyQ== dependencies: forwarded "0.2.0" ipaddr.js "1.9.1" @@ -4204,11 +4396,12 @@ q@1.5.1: integrity sha512-kV/CThkXo6xyFEZUugw/+pIOywXcDbFYgSct5cT3gqlbkBE1SJdwy6UQoZvodiWF/ckQLZyDE/Bu1M6gVu5lVw== qs@^6.12.1, qs@^6.14.0, qs@^6.14.1, qs@^6.15.2: - version "6.15.2" - resolved "https://registry.yarnpkg.com/qs/-/qs-6.15.2.tgz#fd55426d710403ddccc45e0f9eab16db7727ece9" - integrity sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw== + version "6.16.0" + resolved "https://registry.yarnpkg.com/qs/-/qs-6.16.0.tgz#c22c723a28a920f3aacdce8289fabd43eccb79fd" + integrity sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA== dependencies: - side-channel "^1.1.0" + es-define-property "^1.0.1" + side-channel "^1.1.1" qs@~6.5.2: version "6.5.5" @@ -4221,9 +4414,9 @@ queue-microtask@^1.2.2: integrity sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A== range-parser@^1.2.1: - version "1.2.1" - resolved "https://registry.yarnpkg.com/range-parser/-/range-parser-1.2.1.tgz#3cf37023d199e1c24d1a55b84800c2f3e6468031" - integrity sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg== + version "1.3.0" + resolved "https://registry.yarnpkg.com/range-parser/-/range-parser-1.3.0.tgz#d7f19be812bb62721472b45d3be219ef09572b47" + integrity sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw== rate-limiter-flexible@11.2.1: version "11.2.1" @@ -4277,7 +4470,7 @@ rechoir@^0.8.0: dependencies: resolve "^1.20.0" -reflect.getprototypeof@^1.0.10, reflect.getprototypeof@^1.0.9: +reflect.getprototypeof@^1.0.10: version "1.0.10" resolved "https://registry.yarnpkg.com/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz#c629219e78a3316d8b604c765ef68996964e7bf9" integrity sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw== @@ -4373,37 +4566,38 @@ reusify@^1.0.4: integrity sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw== rollup@^4.43.0: - version "4.62.0" - resolved "https://registry.yarnpkg.com/rollup/-/rollup-4.62.0.tgz#f68956c966f3c4a51dafbafc5d5388553244191b" - integrity sha512-nc72Wgq62I7rtDV4izT5/aaS0zxy3kttkinf9586ApknY3jZO9NYsmtc24fUckA0X7Q2v+ML4a15pdUlV5V/jA== + version "4.63.4" + resolved "https://registry.yarnpkg.com/rollup/-/rollup-4.63.4.tgz#cff29b8d6d959aee7cb5e44b0dad70074493dbf8" + integrity sha512-4U0liVayNIoLp3GFl1FcI8561WepLnZ1rqfraGh7S9B3Ur5F9S283y8Futii7RUU2C/97tOBmBy7nYvhoiOpbQ== dependencies: "@types/estree" "1.0.9" optionalDependencies: - "@rollup/rollup-android-arm-eabi" "4.62.0" - "@rollup/rollup-android-arm64" "4.62.0" - "@rollup/rollup-darwin-arm64" "4.62.0" - "@rollup/rollup-darwin-x64" "4.62.0" - "@rollup/rollup-freebsd-arm64" "4.62.0" - "@rollup/rollup-freebsd-x64" "4.62.0" - "@rollup/rollup-linux-arm-gnueabihf" "4.62.0" - "@rollup/rollup-linux-arm-musleabihf" "4.62.0" - "@rollup/rollup-linux-arm64-gnu" "4.62.0" - "@rollup/rollup-linux-arm64-musl" "4.62.0" - "@rollup/rollup-linux-loong64-gnu" "4.62.0" - "@rollup/rollup-linux-loong64-musl" "4.62.0" - "@rollup/rollup-linux-ppc64-gnu" "4.62.0" - "@rollup/rollup-linux-ppc64-musl" "4.62.0" - "@rollup/rollup-linux-riscv64-gnu" "4.62.0" - "@rollup/rollup-linux-riscv64-musl" "4.62.0" - "@rollup/rollup-linux-s390x-gnu" "4.62.0" - "@rollup/rollup-linux-x64-gnu" "4.62.0" - "@rollup/rollup-linux-x64-musl" "4.62.0" - "@rollup/rollup-openbsd-x64" "4.62.0" - "@rollup/rollup-openharmony-arm64" "4.62.0" - "@rollup/rollup-win32-arm64-msvc" "4.62.0" - "@rollup/rollup-win32-ia32-msvc" "4.62.0" - "@rollup/rollup-win32-x64-gnu" "4.62.0" - "@rollup/rollup-win32-x64-msvc" "4.62.0" + "@napi-rs/lzma-linux-x64-gnu" "1.5.1" + "@rollup/rollup-android-arm-eabi" "4.63.4" + "@rollup/rollup-android-arm64" "4.63.4" + "@rollup/rollup-darwin-arm64" "4.63.4" + "@rollup/rollup-darwin-x64" "4.63.4" + "@rollup/rollup-freebsd-arm64" "4.63.4" + "@rollup/rollup-freebsd-x64" "4.63.4" + "@rollup/rollup-linux-arm-gnueabihf" "4.63.4" + "@rollup/rollup-linux-arm-musleabihf" "4.63.4" + "@rollup/rollup-linux-arm64-gnu" "4.63.4" + "@rollup/rollup-linux-arm64-musl" "4.63.4" + "@rollup/rollup-linux-loong64-gnu" "4.63.4" + "@rollup/rollup-linux-loong64-musl" "4.63.4" + "@rollup/rollup-linux-ppc64-gnu" "4.63.4" + "@rollup/rollup-linux-ppc64-musl" "4.63.4" + "@rollup/rollup-linux-riscv64-gnu" "4.63.4" + "@rollup/rollup-linux-riscv64-musl" "4.63.4" + "@rollup/rollup-linux-s390x-gnu" "4.63.4" + "@rollup/rollup-linux-x64-gnu" "4.63.4" + "@rollup/rollup-linux-x64-musl" "4.63.4" + "@rollup/rollup-openbsd-x64" "4.63.4" + "@rollup/rollup-openharmony-arm64" "4.63.4" + "@rollup/rollup-win32-arm64-msvc" "4.63.4" + "@rollup/rollup-win32-ia32-msvc" "4.63.4" + "@rollup/rollup-win32-x64-gnu" "4.63.4" + "@rollup/rollup-win32-x64-msvc" "4.63.4" fsevents "~2.3.2" router@^2.2.0: @@ -4478,9 +4672,9 @@ scheduler@^0.27.0: integrity sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g== semver@^7.5.3, semver@^7.5.4, semver@^7.6.0, semver@^7.7.3: - version "7.8.4" - resolved "https://registry.yarnpkg.com/semver/-/semver-7.8.4.tgz#c73eceebae0616934be8dff28a7fd70757c8e696" - integrity sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA== + version "7.8.5" + resolved "https://registry.yarnpkg.com/semver/-/semver-7.8.5.tgz#39b646037dd50c14fb451e7e4cac58ed8b863f69" + integrity sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA== send@^1.1.0, send@^1.2.0: version "1.2.1" @@ -4598,9 +4792,9 @@ shebang-regex@^3.0.0: integrity sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A== shell-quote@^1.6.1: - version "1.8.4" - resolved "https://registry.yarnpkg.com/shell-quote/-/shell-quote-1.8.4.tgz#2edd9a4dcefc96649e2e2cb12f637b1f1d92a190" - integrity sha512-VsC6n6vz1ihYYyZZwX7YZSF5l5x36ca17OC+a69h94YqB7X6XLwf+5MOgynYir2SLFUbl8gIYvBo8K8RoNQ6bQ== + version "1.10.0" + resolved "https://registry.yarnpkg.com/shell-quote/-/shell-quote-1.10.0.tgz#482033e192e4f5c07151521ffa03400ec71b1b0f" + integrity sha512-w1aiOKwKuRgtwAReIIj89puqg+I7GvX4IbLrvmhXbzQsj1+Zwi4VO3+fa6ZF91TWSjIxoEkKnMeHcLEODK5ZXA== shimmer@^1.1.0: version "1.2.1" @@ -4636,7 +4830,7 @@ side-channel-weakmap@^1.0.2: object-inspect "^1.13.3" side-channel-map "^1.0.1" -side-channel@^1.1.0: +side-channel@^1.1.0, side-channel@^1.1.1: version "1.1.1" resolved "https://registry.yarnpkg.com/side-channel/-/side-channel-1.1.1.tgz#ea02c62e05dc4bea67d4442f0fb71ee192f8e0ab" integrity sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ== @@ -4654,7 +4848,7 @@ siginfo@^2.0.0: signal-exit@^4.0.1: version "4.1.0" - resolved "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz" + resolved "https://registry.yarnpkg.com/signal-exit/-/signal-exit-4.1.0.tgz#952188c1cbd546070e2dd20d0f41c0ae0530cb04" integrity sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw== smart-buffer@^4.2.0: @@ -4672,9 +4866,9 @@ socks-proxy-agent@^8.0.5: socks "^2.8.3" socks@^2.8.3: - version "2.8.9" - resolved "https://registry.yarnpkg.com/socks/-/socks-2.8.9.tgz#aa5f130ca0f88a43fa44faf4869c50d22aa27752" - integrity sha512-LJhUYUvItdQ0LkJTmPeaEObWXAqFyfmP85x0tch/ez9cahmhlBBLbIqDFnvBnUJGagb0JbIQrkBs1wJ+yRYpEw== + version "2.8.10" + resolved "https://registry.yarnpkg.com/socks/-/socks-2.8.10.tgz#23aab4dccc1fdae110aaf4fa51cd190552432dc5" + integrity sha512-e0VyvkVTwVYViNovRkZ9aodhxVlyoMn7eJhVUPxZ+eK9P/7CBkxvvsBOHqFPEH416726W8tLXXXjKwqgTErrCQ== dependencies: ip-address "^10.1.1" smart-buffer "^4.2.0" @@ -4711,9 +4905,9 @@ spdx-expression-parse@^3.0.0: spdx-license-ids "^3.0.0" spdx-license-ids@^3.0.0: - version "3.0.23" - resolved "https://registry.yarnpkg.com/spdx-license-ids/-/spdx-license-ids-3.0.23.tgz#b069e687b1291a32f126893ed76a27a745ee2133" - integrity sha512-CWLcCCH7VLu13TgOH+r8p1O/Znwhqv/dbb6lqWy67G+pT1kHmeD/+V36AVb/vq8QMIQwVShJ6Ssl5FPh0fuSdw== + version "3.0.24" + resolved "https://registry.yarnpkg.com/spdx-license-ids/-/spdx-license-ids-3.0.24.tgz#550436df9dc7dcf5c29832ceda40256a9b00de3a" + integrity sha512-cLS9TtWkIQFyLkJ3/5aFQAOHOSKTlOs/7WDut/XPSdjom1fJhUdkepeJAKXa9Y05+FabHd0sti+Et559vDtkpQ== split2@^4.1.0: version "4.2.0" @@ -4769,9 +4963,9 @@ streamsearch@^1.1.0: integrity sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg== streamx@^2.12.5, streamx@^2.15.0, streamx@^2.25.0: - version "2.28.0" - resolved "https://registry.yarnpkg.com/streamx/-/streamx-2.28.0.tgz#035ab56057b7ed2211b51d532e6973f0f99fbf11" - integrity sha512-1Yowhzjf0ivGMrTIkY9hav5TxobO9qIVqUE41fiCGMGgc3CLlf4MY+9AHmZqBWgDTue0fY9zWjYFVyf6Diuobw== + version "2.28.1" + resolved "https://registry.yarnpkg.com/streamx/-/streamx-2.28.1.tgz#376cd42a089505a69bec0efdb5189aa45586141f" + integrity sha512-zEzXb0s5Cds7tqMH6rhZ05lcJydCWiQPEwiNngVqzsxCc962vLY4Uw+mW7od8kDH258k2Uz/JrOkdIAAhSh9VA== dependencies: events-universal "^1.0.0" fast-fifo "^1.3.2" @@ -4788,7 +4982,7 @@ streamx@^2.12.5, streamx@^2.15.0, streamx@^2.25.0: string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: version "4.2.3" - resolved "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" + resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== dependencies: emoji-regex "^8.0.0" @@ -4797,7 +4991,7 @@ string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: string-width@^5.0.1, string-width@^5.1.2: version "5.1.2" - resolved "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz" + resolved "https://registry.yarnpkg.com/string-width/-/string-width-5.1.2.tgz#14f8daec6d81e7221d2a357e668cab73bdbca794" integrity sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA== dependencies: eastasianwidth "^0.2.0" @@ -4863,14 +5057,14 @@ string_decoder@^1.1.1: strip-ansi@^6.0.0, strip-ansi@^6.0.1: version "6.0.1" - resolved "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" + resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== dependencies: ansi-regex "^5.0.1" strip-ansi@^7.0.1: version "7.2.0" - resolved "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz" + resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-7.2.0.tgz#d22a269522836a627af8d04b5c3fd2c7fa3e32e3" integrity sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w== dependencies: ansi-regex "^6.2.2" @@ -4911,7 +5105,7 @@ superagent@^10.0.0, superagent@^10.3.0: supertest@7.2.2: version "7.2.2" - resolved "https://registry.npmjs.org/supertest/-/supertest-7.2.2.tgz#dac3ee25a2aa59942a7f641e50c838a7c8819204" + resolved "https://registry.yarnpkg.com/supertest/-/supertest-7.2.2.tgz#dac3ee25a2aa59942a7f641e50c838a7c8819204" integrity sha512-oK8WG9diS3DlhdUkcFn4tkNIiIbBx9lI2ClF8K+b2/m8Eyv47LSawxUzZQSNKUrVb2KsqeTDCcjAAVPYaSLVTA== dependencies: cookie-signature "^1.2.2" @@ -4957,9 +5151,9 @@ swagger-parser@^10.0.3: "@apidevtools/swagger-parser" "10.0.3" swagger-ui-dist@>=5.0.0: - version "5.32.6" - resolved "https://registry.yarnpkg.com/swagger-ui-dist/-/swagger-ui-dist-5.32.6.tgz#4a6a56786915ea4bfea43f00ff17890df8124d4c" - integrity sha512-75ttZNaYCLoFPnozPZcTUU6mS3wKT8l7WLjU5zJSHFeJa23i5vtnze6IiCl4jDMPeQTXVXIgovq4M11NNfQvSA== + version "5.33.0" + resolved "https://registry.yarnpkg.com/swagger-ui-dist/-/swagger-ui-dist-5.33.0.tgz#db69b90adfa96e1d5ac2f40d1cdc31f3dc663780" + integrity sha512-wpdK+m6BU5yj6pmUdMskZVTSWYG4DLglAx3sIhylloY37i8O37IrH+YEpqdXNfpaTGxILRBFzUqLF2jKqbfI7A== dependencies: "@scarf/scarf" "=1.4.0" @@ -4971,9 +5165,9 @@ swagger-ui-express@5.0.1: swagger-ui-dist ">=5.0.0" tar-fs@^3.1.1: - version "3.1.2" - resolved "https://registry.yarnpkg.com/tar-fs/-/tar-fs-3.1.2.tgz#114b012f54796f31e62f3e57792820a80b83ae6e" - integrity sha512-QGxxTxxyleAdyM3kpFs14ymbYmNFrfY+pHj7Z8FgtbZ7w2//VAgLMac7sT6nRpIHjppXO2AwwEOg0bPFVRcmXw== + version "3.1.3" + resolved "https://registry.yarnpkg.com/tar-fs/-/tar-fs-3.1.3.tgz#05668cc68a30741c3813f9c16593b8dec7dcbcd1" + integrity sha512-/hU4AXnIdZu+Gvl1pk0oI5f5HxWsCJRtY2aFaJdk9VvyL48DWU6iU5WAIPG+wIi1YvWA6eTJvIviP/tMAZZNwQ== dependencies: pump "^3.0.0" tar-stream "^3.1.5" @@ -4982,9 +5176,9 @@ tar-fs@^3.1.1: bare-path "^3.0.0" tar-stream@^3.1.5: - version "3.2.0" - resolved "https://registry.yarnpkg.com/tar-stream/-/tar-stream-3.2.0.tgz#0d0064d9b67ea3c9f5abde155e35faab0df37591" - integrity sha512-ojzvCvVaNp6aOTFmG7jaRD0meowIAuPc3cMMhSgKiVWws1GyHbGd/xvnyuRKcKlMpt3qvxx6r0hreCNITP9hIg== + version "3.2.1" + resolved "https://registry.yarnpkg.com/tar-stream/-/tar-stream-3.2.1.tgz#952d72f7aba68ce5cb802ef2e0b19f1bd989140d" + integrity sha512-nqsEO8zLZJvrOMdEwkA0QdCLFbetHMn95Zqu4fKwX+hkaTWJPZZOrxx/PwtxoK0MMGQmBQNRW3CPs8IFYQz4cQ== dependencies: b4a "^1.6.4" bare-fs "^4.5.5" @@ -4992,9 +5186,9 @@ tar-stream@^3.1.5: streamx "^2.15.0" tarn@^3.0.2: - version "3.0.2" - resolved "https://registry.yarnpkg.com/tarn/-/tarn-3.0.2.tgz#73b6140fbb881b71559c4f8bfde3d9a4b3d27693" - integrity sha512-51LAVKUSZSVfI05vjPESNc5vwqqZpbXCsU+/+wxlOrUjk2SnFTt97v9ZgQrD4YmxYW1Px6w2KjaDitCfkvgxMQ== + version "3.1.2" + resolved "https://registry.yarnpkg.com/tarn/-/tarn-3.1.2.tgz#d2922d34a232c8b027605f3873a9c55318ce4f34" + integrity sha512-3RTvqKZcK/17jnJ8rMKFXbyNogywTs1z0gVPPwFsJGX46rkmUHOdIaSQ/aVO1rS7nH+soiXiWk7rvUXxndm8Dg== teex@^1.0.1: version "1.0.1" @@ -5061,9 +5255,9 @@ tinyglobby@^0.2.15: picomatch "^4.0.4" tinyrainbow@^3.0.3: - version "3.1.0" - resolved "https://registry.yarnpkg.com/tinyrainbow/-/tinyrainbow-3.1.0.tgz#1d8a623893f95cf0a2ddb9e5d11150e191409421" - integrity sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw== + version "3.1.1" + resolved "https://registry.yarnpkg.com/tinyrainbow/-/tinyrainbow-3.1.1.tgz#c0168387d3d8d70b6b3c2c0936de5fee738cea20" + integrity sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw== to-regex-range@^5.0.1: version "5.0.1" @@ -5102,7 +5296,7 @@ tslib@^2.0.1: tsx@4.21.0: version "4.21.0" - resolved "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz" + resolved "https://registry.yarnpkg.com/tsx/-/tsx-4.21.0.tgz#32aa6cf17481e336f756195e6fe04dae3e6308b1" integrity sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw== dependencies: esbuild "~0.27.0" @@ -5167,17 +5361,16 @@ typed-array-byte-length@^1.0.3: is-typed-array "^1.1.14" typed-array-byte-offset@^1.0.4: - version "1.0.4" - resolved "https://registry.yarnpkg.com/typed-array-byte-offset/-/typed-array-byte-offset-1.0.4.tgz#ae3698b8ec91a8ab945016108aef00d5bff12355" - integrity sha512-bTlAFB/FBYMcuX81gbL4OcpH5PmlFHqlCCpAl8AlEzMz5k53oNDvN8p1PNOWLEmI2x4orp3raOFB51tv9X+MFQ== + version "1.0.5" + resolved "https://registry.yarnpkg.com/typed-array-byte-offset/-/typed-array-byte-offset-1.0.5.tgz#c076d6a0db563933154b3c442204b614bf0d7b15" + integrity sha512-0FHJvLPqZ7KJzp17O13jfsAjsqazgrxBu2zEK95PmUz8lv2+GjRuxUInCr2Rk9Dms3ihN21zJ929ZO43yJ95QQ== dependencies: available-typed-arrays "^1.0.7" - call-bind "^1.0.8" - for-each "^0.3.3" + call-bind "^1.0.9" + for-each "^0.3.5" gopd "^1.2.0" - has-proto "^1.2.0" is-typed-array "^1.1.15" - reflect.getprototypeof "^1.0.9" + reflect.getprototypeof "^1.0.10" typed-array-length@^1.0.7: version "1.0.8" @@ -5226,10 +5419,10 @@ unbox-primitive@^1.1.0: has-symbols "^1.1.0" which-boxed-primitive "^1.1.1" -"undici-types@>=7.24.0 <7.24.7": - version "7.24.6" - resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-7.24.6.tgz#61275b485d7fd4e9d269c7cf04ec2873c9cc0f91" - integrity sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg== +undici-types@~8.9.0: + version "8.9.0" + resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-8.9.0.tgz#e240d97c8b5d85e5347ce73d25865c7906c1ec9f" + integrity sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg== unpipe@~1.0.0: version "1.0.0" @@ -5291,11 +5484,11 @@ verror@1.10.0: extsprintf "^1.2.0" "vite@^6.0.0 || ^7.0.0": - version "7.3.5" - resolved "https://registry.yarnpkg.com/vite/-/vite-7.3.5.tgz#90c2d0b7b94a224e7e7dcf22d2912ff0b5291165" - integrity sha512-KuOaNhcnGFN2zIPGA7wRmzF+lJA1sea7rHq17aiJ++9lzY1WWG6Jpwqwe1KNbRVPIqHmr8GLYx7jbrQcN/7/ww== + version "7.3.6" + resolved "https://registry.yarnpkg.com/vite/-/vite-7.3.6.tgz#0547a395e68d3746e9a505f1fd4469fe09b49cc4" + integrity sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg== dependencies: - esbuild "^0.27.0" + esbuild "^0.27.0 || ^0.28.0" fdir "^6.5.0" picomatch "^4.0.3" postcss "^8.5.6" @@ -5376,9 +5569,9 @@ which-collection@^1.0.2: is-weakset "^2.0.3" which-typed-array@^1.1.16, which-typed-array@^1.1.19: - version "1.1.22" - resolved "https://registry.yarnpkg.com/which-typed-array/-/which-typed-array-1.1.22.tgz#8f3cc78aefb40b437346dd40a1dbfa5d1da43fe9" - integrity sha512-fvO4ExWMFsqyhG3AiPAObMuY1lxaqgYcxbc49CNdWDDECOJNgQyvsOWVwbZc+qf3rzRtxojBK+CMEv0Ld5CYpw== + version "1.1.24" + resolved "https://registry.yarnpkg.com/which-typed-array/-/which-typed-array-1.1.24.tgz#211f5877c29e6213d50e8f2dce4ac9ca2d0008cf" + integrity sha512-wk4Mf4pR5mRP7eYuuTBCIQ9d0ud2Fv2jRLQpfgnRjbOxAFHmjKFValgTpitVKzJJS8ajnYQV2Du1SZ8j6b/EUQ== dependencies: available-typed-arrays "^1.0.7" call-bind "^1.0.9" @@ -5440,7 +5633,7 @@ word-wrap@^1.2.5: wrap-ansi@^7.0.0: version "7.0.0" - resolved "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" + resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== dependencies: ansi-styles "^4.0.0" @@ -5449,7 +5642,7 @@ wrap-ansi@^7.0.0: wrap-ansi@^8.1.0: version "8.1.0" - resolved "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz" + resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-8.1.0.tgz#56dc22368ee570face1b49819975d9b9a5ead214" integrity sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ== dependencies: ansi-styles "^6.1.0" @@ -5462,9 +5655,9 @@ wrappy@1: integrity sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ== ws@^8.18.3: - version "8.21.0" - resolved "https://registry.yarnpkg.com/ws/-/ws-8.21.0.tgz#012e413fc07429945121b0c153158c4343086951" - integrity sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g== + version "8.21.3" + resolved "https://registry.yarnpkg.com/ws/-/ws-8.21.3.tgz#660b4faddb6a3e575c86e078126919961f4de4fc" + integrity sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw== xtend@^4.0.0, xtend@^4.0.2: version "4.0.2" @@ -5487,9 +5680,9 @@ yargs-parser@^21.1.1: integrity sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw== yargs@^17.7.2: - version "17.7.2" - resolved "https://registry.yarnpkg.com/yargs/-/yargs-17.7.2.tgz#991df39aca675a192b816e1e0363f9d75d2aa269" - integrity sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w== + version "17.7.3" + resolved "https://registry.yarnpkg.com/yargs/-/yargs-17.7.3.tgz#779dffe6bcafec596a7172e983289a588647faaa" + integrity sha512-GZtjxm/J/4TSxuL3FNYjCmLktBTnIw/rVmKSIyKeYAZpmJB2ig9VauCC5xsa82GNKVKDAqpOn3KVzNt0zmrU0g== dependencies: cliui "^8.0.1" escalade "^3.1.1"