From 807be1c5ee79eea99dbb6f64305edffdf7b61f6a Mon Sep 17 00:00:00 2001 From: WanderMotta Date: Sat, 13 Jun 2026 16:36:29 -0300 Subject: [PATCH] Commit inicial - upload de todos os arquivos da pasta --- .env | 15 + .env.example | 17 + Deploy-Coolify.md | 202 + README.md | 36 + github.bat | 29 + nest-cli.json | 8 + package-lock.json | 10467 ++++++++++++++++ package.json | 102 + prisma/check-password.ts | 15 + .../migration.sql | 401 + .../migration.sql | 84 + .../migration.sql | 5 + .../migration.sql | 3 + .../migration.sql | 2 + .../migration.sql | 2 + .../migration.sql | 2 + .../migration.sql | 9 + .../migration.sql | 44 + .../migration.sql | 20 + .../migration.sql | 4 + .../migration.sql | 17 + prisma/migrations/migration_lock.toml | 3 + prisma/schema.prisma | 561 + prisma/seed-admin.ts | 30 + prisma/seed.ts | 186 + src/app.controller.spec.ts | 22 + src/app.controller.ts | 20 + src/app.module.ts | 62 + src/app.service.ts | 8 + src/common/constants/visibility-map.ts | 89 + src/common/decorators/.gitkeep | 0 .../decorators/require-scope.decorator.ts | 7 + src/common/decorators/roles.decorator.ts | 6 + src/common/decorators/user.decorator.ts | 17 + src/common/enums/.gitkeep | 0 src/common/enums/role.enum.spec.ts | 28 + src/common/enums/role.enum.ts | 14 + src/common/filters/.gitkeep | 0 src/common/filters/http-exception.filter.ts | 40 + src/common/guards/.gitkeep | 0 .../guards/api-key-throttler.guard.spec.ts | 85 + src/common/guards/api-key-throttler.guard.ts | 55 + src/common/guards/api-key.guard.spec.ts | 220 + src/common/guards/api-key.guard.ts | 96 + .../guards/read-only-client.guard.spec.ts | 53 + src/common/guards/read-only-client.guard.ts | 30 + src/common/guards/roles.guard.spec.ts | 47 + src/common/guards/roles.guard.ts | 30 + src/common/interceptors/.gitkeep | 0 ...egration-api-key-usage.interceptor.spec.ts | 96 + .../integration-api-key-usage.interceptor.ts | 70 + .../interceptors/logging.interceptor.ts | 25 + .../profile-visibility.interceptor.spec.ts | 124 + .../profile-visibility.interceptor.ts | 48 + src/common/pipes/.gitkeep | 0 src/common/pipes/validation.pipe.ts | 7 + src/common/utils/.gitkeep | 0 src/common/utils/password-generator.spec.ts | 38 + src/common/utils/password-generator.ts | 30 + src/config/auth/.gitkeep | 0 src/config/auth/auth-config.module.ts | 25 + src/config/auth/jwt-auth.guard.ts | 5 + src/config/auth/jwt.strategy.spec.ts | 38 + src/config/auth/jwt.strategy.ts | 36 + src/config/database/.gitkeep | 0 src/config/database/prisma.module.ts | 9 + src/config/database/prisma.service.spec.ts | 25 + src/config/database/prisma.service.ts | 13 + src/config/env/.gitkeep | 0 src/config/env/env.module.spec.ts | 61 + src/config/env/env.module.ts | 17 + src/config/env/env.service.spec.ts | 184 + src/config/env/env.service.ts | 51 + src/config/env/env.validation.ts | 31 + src/main.ts | 31 + src/modules/.gitkeep | 0 .../allocation-templates.controller.ts | 79 + .../allocation-templates.module.ts | 11 + .../allocation-templates.repository.ts | 126 + .../allocation-templates.service.ts | 118 + .../dto/create-allocation-template.dto.ts | 30 + .../list-allocation-templates-query.dto.ts | 26 + .../dto/update-allocation-template.dto.ts | 23 + src/modules/auth/auth.controller.spec.ts | 72 + src/modules/auth/auth.controller.ts | 53 + src/modules/auth/auth.module.ts | 16 + src/modules/auth/auth.repository.ts | 36 + src/modules/auth/auth.service.spec.ts | 450 + src/modules/auth/auth.service.ts | 172 + src/modules/auth/dto/.gitkeep | 0 src/modules/auth/dto/change-password.dto.ts | 11 + src/modules/auth/dto/forgot-password.dto.ts | 6 + src/modules/auth/dto/login.dto.ts | 10 + src/modules/auth/dto/reset-password.dto.ts | 11 + .../auth/password-policy.service.spec.ts | 34 + src/modules/auth/password-policy.service.ts | 21 + .../auth/password-reset.repository.spec.ts | 102 + src/modules/auth/password-reset.repository.ts | 29 + .../client-profiles.controller.ts | 61 + .../client-profiles/client-profiles.module.ts | 11 + .../client-profiles.repository.ts | 75 + .../client-profiles.service.ts | 72 + .../dto/create-client-profile.dto.ts | 7 + .../dto/list-client-profiles-query.dto.ts | 25 + .../dto/update-client-profile.dto.ts | 8 + src/modules/clients/clients.controller.ts | 109 + src/modules/clients/clients.module.ts | 13 + src/modules/clients/clients.repository.ts | 81 + src/modules/clients/clients.service.ts | 39 + src/modules/clients/dto/.gitkeep | 0 src/modules/clients/dto/create-client.dto.ts | 27 + .../clients/dto/list-clients-query.dto.ts | 25 + src/modules/clients/dto/update-client.dto.ts | 31 + .../contract-items.controller.ts | 64 + .../contract-items/contract-items.module.ts | 13 + .../contract-items.repository.ts | 135 + .../contract-items.service.spec.ts | 310 + .../contract-items/contract-items.service.ts | 173 + .../dto/create-contract-item.dto.ts | 55 + .../dto/list-contract-items-query.dto.ts | 30 + .../dto/update-contract-item.dto.ts | 61 + .../helpers/validate-contract-item-by-type.ts | 37 + src/modules/contracts/contracts.controller.ts | 48 + src/modules/contracts/contracts.module.ts | 11 + src/modules/contracts/contracts.repository.ts | 118 + src/modules/contracts/contracts.service.ts | 90 + src/modules/contracts/dto/.gitkeep | 0 .../contracts/dto/create-contract.dto.ts | 43 + .../contracts/dto/list-contracts-query.dto.ts | 25 + .../contracts/dto/update-contract.dto.ts | 47 + src/modules/dashboard/dashboard.controller.ts | 50 + src/modules/dashboard/dashboard.module.ts | 10 + src/modules/dashboard/dashboard.repository.ts | 138 + .../dashboard/dashboard.service.spec.ts | 157 + src/modules/dashboard/dashboard.service.ts | 76 + src/modules/dashboard/dto/.gitkeep | 0 .../dashboard/dto/dashboard-filter.dto.ts | 11 + .../deliverable-backlog.controller.ts | 78 + .../deliverable-backlog.module.ts | 13 + .../deliverable-backlog.repository.ts | 111 + .../deliverable-backlog.service.spec.ts | 74 + .../deliverable-backlog.service.ts | 197 + src/modules/deliverable-backlog/dto/.gitkeep | 0 .../dto/change-backlog-status.dto.ts | 11 + .../dto/create-backlog-item.dto.ts | 15 + .../dto/reorder-backlog.dto.ts | 17 + .../dto/update-backlog-item.dto.ts | 15 + .../deliverables/allocations.controller.ts | 79 + .../deliverables/allocations.repository.ts | 108 + .../deliverables/allocations.service.spec.ts | 153 + .../deliverables/allocations.service.ts | 319 + .../deliverables/assignments.controller.ts | 59 + .../deliverables/assignments.service.spec.ts | 80 + .../deliverables/assignments.service.ts | 158 + .../required-observation-transitions.ts | 18 + .../constants/status-transitions.ts | 19 + .../deliverable-num-weeks.util.spec.ts | 33 + .../deliverable-num-weeks.util.ts | 7 + ...liverable-value-calculator.service.spec.ts | 189 + .../deliverable-value-calculator.service.ts | 84 + .../deliverables/deliverables.controller.ts | 78 + .../deliverables/deliverables.module.ts | 31 + .../deliverables.repository.spec.ts | 27 + .../deliverables/deliverables.repository.ts | 237 + .../deliverables/deliverables.service.spec.ts | 1283 ++ .../deliverables/deliverables.service.ts | 681 + src/modules/deliverables/dto/.gitkeep | 0 .../bulk-set-deliverable-allocations.dto.ts | 9 + .../deliverables/dto/change-status.dto.ts | 12 + .../deliverables/dto/create-assignment.dto.ts | 23 + .../dto/create-deliverable-allocation.dto.ts | 17 + .../dto/create-deliverable.dto.ts | 48 + .../dto/list-deliverables-query.dto.ts | 50 + .../deliverables/dto/update-assignment.dto.ts | 19 + .../dto/update-deliverable-allocation.dto.ts | 20 + .../dto/update-deliverable.dto.ts | 51 + .../repositories/assignments.repository.ts | 88 + .../status-history.repository.spec.ts | 117 + .../repositories/status-history.repository.ts | 39 + src/modules/health/health.controller.spec.ts | 20 + src/modules/health/health.controller.ts | 9 + src/modules/health/health.module.ts | 7 + .../dto/create-integration-api-key.dto.ts | 41 + .../list-integration-api-keys-query.dto.ts | 27 + .../integration-api-keys.controller.ts | 61 + .../integration-api-keys.module.ts | 12 + .../integration-api-keys.repository.ts | 143 + .../integration-api-keys.service.spec.ts | 147 + .../integration-api-keys.service.ts | 75 + .../dto/list-integration-clients-query.dto.ts | 31 + ...list-integration-deliverables-query.dto.ts | 34 + .../integrations/integrations.controller.ts | 58 + .../integrations/integrations.module.ts | 19 + .../integrations/integrations.repository.ts | 93 + .../integrations/integrations.service.ts | 50 + .../interfaces/mailer-provider.interface.ts | 12 + src/modules/mailer/mailer.module.ts | 23 + src/modules/mailer/mailer.service.spec.ts | 85 + src/modules/mailer/mailer.service.ts | 12 + .../mailer/providers/log-mailer.provider.ts | 10 + .../mailer/providers/smtp-mailer.provider.ts | 37 + src/modules/notes/dto/.gitkeep | 0 src/modules/notes/dto/create-note.dto.ts | 20 + src/modules/notes/dto/list-notes-query.dto.ts | 8 + src/modules/notes/dto/update-note.dto.ts | 20 + src/modules/notes/notes.controller.ts | 54 + src/modules/notes/notes.module.ts | 13 + src/modules/notes/notes.repository.ts | 73 + src/modules/notes/notes.service.spec.ts | 162 + src/modules/notes/notes.service.ts | 102 + src/modules/professionals/dto/.gitkeep | 0 .../dto/create-professional.dto.ts | 23 + .../dto/list-professionals-query.dto.ts | 25 + .../dto/update-professional.dto.ts | 27 + .../professionals/professionals.controller.ts | 42 + .../professionals/professionals.module.ts | 10 + .../professionals/professionals.repository.ts | 79 + .../professionals/professionals.service.ts | 39 + src/modules/projects/dto/.gitkeep | 0 .../projects/dto/create-project.dto.ts | 28 + .../projects/dto/list-projects-query.dto.ts | 25 + .../projects/dto/update-project.dto.ts | 32 + src/modules/projects/projects.controller.ts | 48 + src/modules/projects/projects.module.ts | 11 + src/modules/projects/projects.repository.ts | 115 + src/modules/projects/projects.service.ts | 90 + .../constants/sprint-status-transitions.ts | 8 + src/modules/sprints/dto/.gitkeep | 0 .../sprints/dto/add-deliverables.dto.ts | 7 + src/modules/sprints/dto/create-sprint.dto.ts | 21 + src/modules/sprints/dto/finish-sprint.dto.ts | 27 + .../sprints/dto/list-sprints-query.dto.ts | 30 + src/modules/sprints/dto/update-sprint.dto.ts | 27 + .../sprints/sprint-history.repository.ts | 21 + src/modules/sprints/sprints.controller.ts | 105 + src/modules/sprints/sprints.module.ts | 12 + src/modules/sprints/sprints.repository.ts | 131 + src/modules/sprints/sprints.service.ts | 396 + src/modules/timeline/dto/.gitkeep | 0 .../timeline/dto/list-timeline-query.dto.ts | 22 + src/modules/timeline/timeline.controller.ts | 33 + src/modules/timeline/timeline.module.ts | 13 + src/modules/timeline/timeline.repository.ts | 54 + src/modules/timeline/timeline.service.spec.ts | 114 + src/modules/timeline/timeline.service.ts | 65 + src/modules/users/dto/.gitkeep | 0 src/modules/users/dto/create-user.dto.ts | 35 + src/modules/users/dto/list-users-query.dto.ts | 34 + src/modules/users/dto/reset-password.dto.ts | 3 + src/modules/users/dto/update-user.dto.ts | 24 + src/modules/users/users.controller.spec.ts | 82 + src/modules/users/users.controller.ts | 95 + src/modules/users/users.module.ts | 12 + src/modules/users/users.repository.ts | 110 + src/modules/users/users.service.spec.ts | 615 + src/modules/users/users.service.ts | 232 + .../work-order-status-transitions.ts | 51 + .../work-orders/dto/cancel-work-order.dto.ts | 8 + .../work-orders/dto/create-work-order.dto.ts | 53 + .../dto/list-work-orders-query.dto.ts | 46 + .../work-orders/dto/update-work-order.dto.ts | 57 + .../calculate-work-order-summary.spec.ts | 163 + .../helpers/calculate-work-order-summary.ts | 84 + .../work-order-status-history.repository.ts | 44 + ...ork-order-value-calculator.service.spec.ts | 194 + .../work-order-value-calculator.service.ts | 121 + .../work-orders/work-orders.controller.ts | 88 + src/modules/work-orders/work-orders.module.ts | 18 + .../work-orders/work-orders.repository.ts | 288 + .../work-orders/work-orders.service.spec.ts | 419 + .../work-orders/work-orders.service.ts | 390 + .../work-orders/work-orders.status.spec.ts | 245 + .../work-orders/work-orders.summary.spec.ts | 176 + tsconfig.build.json | 9 + tsconfig.json | 28 + 275 files changed, 29408 insertions(+) create mode 100644 .env create mode 100644 .env.example create mode 100644 Deploy-Coolify.md create mode 100644 README.md create mode 100644 github.bat create mode 100644 nest-cli.json create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 prisma/check-password.ts create mode 100644 prisma/migrations/20260427183727_init_deliverable_schema/migration.sql create mode 100644 prisma/migrations/20260428211648_add_work_order_and_deliverable_type/migration.sql create mode 100644 prisma/migrations/20260429130538_add_contract_item_type/migration.sql create mode 100644 prisma/migrations/20260429200355_add_deliverable_num_weeks_and_timebox_manutencao/migration.sql create mode 100644 prisma/migrations/20260429200404_make_deliverable_num_weeks_required/migration.sql create mode 100644 prisma/migrations/20260429200936_add_timeline_event_valor_recalculado/migration.sql create mode 100644 prisma/migrations/20260429205419_add_work_order_total_value/migration.sql create mode 100644 prisma/migrations/20260430124418_make_deliverable_dates_required/migration.sql create mode 100644 prisma/migrations/20260430193930_add_integration_api_keys/migration.sql create mode 100644 prisma/migrations/20260612174625_add_password_reset_token/migration.sql create mode 100644 prisma/migrations/20260613160730_set_gestor_projetos_as_default_role/migration.sql create mode 100644 prisma/migrations/20260613190340_expand_role_enum_add_client_sub_role/migration.sql create mode 100644 prisma/migrations/migration_lock.toml create mode 100644 prisma/schema.prisma create mode 100644 prisma/seed-admin.ts create mode 100644 prisma/seed.ts create mode 100644 src/app.controller.spec.ts create mode 100644 src/app.controller.ts create mode 100644 src/app.module.ts create mode 100644 src/app.service.ts create mode 100644 src/common/constants/visibility-map.ts create mode 100644 src/common/decorators/.gitkeep create mode 100644 src/common/decorators/require-scope.decorator.ts create mode 100644 src/common/decorators/roles.decorator.ts create mode 100644 src/common/decorators/user.decorator.ts create mode 100644 src/common/enums/.gitkeep create mode 100644 src/common/enums/role.enum.spec.ts create mode 100644 src/common/enums/role.enum.ts create mode 100644 src/common/filters/.gitkeep create mode 100644 src/common/filters/http-exception.filter.ts create mode 100644 src/common/guards/.gitkeep create mode 100644 src/common/guards/api-key-throttler.guard.spec.ts create mode 100644 src/common/guards/api-key-throttler.guard.ts create mode 100644 src/common/guards/api-key.guard.spec.ts create mode 100644 src/common/guards/api-key.guard.ts create mode 100644 src/common/guards/read-only-client.guard.spec.ts create mode 100644 src/common/guards/read-only-client.guard.ts create mode 100644 src/common/guards/roles.guard.spec.ts create mode 100644 src/common/guards/roles.guard.ts create mode 100644 src/common/interceptors/.gitkeep create mode 100644 src/common/interceptors/integration-api-key-usage.interceptor.spec.ts create mode 100644 src/common/interceptors/integration-api-key-usage.interceptor.ts create mode 100644 src/common/interceptors/logging.interceptor.ts create mode 100644 src/common/interceptors/profile-visibility.interceptor.spec.ts create mode 100644 src/common/interceptors/profile-visibility.interceptor.ts create mode 100644 src/common/pipes/.gitkeep create mode 100644 src/common/pipes/validation.pipe.ts create mode 100644 src/common/utils/.gitkeep create mode 100644 src/common/utils/password-generator.spec.ts create mode 100644 src/common/utils/password-generator.ts create mode 100644 src/config/auth/.gitkeep create mode 100644 src/config/auth/auth-config.module.ts create mode 100644 src/config/auth/jwt-auth.guard.ts create mode 100644 src/config/auth/jwt.strategy.spec.ts create mode 100644 src/config/auth/jwt.strategy.ts create mode 100644 src/config/database/.gitkeep create mode 100644 src/config/database/prisma.module.ts create mode 100644 src/config/database/prisma.service.spec.ts create mode 100644 src/config/database/prisma.service.ts create mode 100644 src/config/env/.gitkeep create mode 100644 src/config/env/env.module.spec.ts create mode 100644 src/config/env/env.module.ts create mode 100644 src/config/env/env.service.spec.ts create mode 100644 src/config/env/env.service.ts create mode 100644 src/config/env/env.validation.ts create mode 100644 src/main.ts create mode 100644 src/modules/.gitkeep create mode 100644 src/modules/allocation-templates/allocation-templates.controller.ts create mode 100644 src/modules/allocation-templates/allocation-templates.module.ts create mode 100644 src/modules/allocation-templates/allocation-templates.repository.ts create mode 100644 src/modules/allocation-templates/allocation-templates.service.ts create mode 100644 src/modules/allocation-templates/dto/create-allocation-template.dto.ts create mode 100644 src/modules/allocation-templates/dto/list-allocation-templates-query.dto.ts create mode 100644 src/modules/allocation-templates/dto/update-allocation-template.dto.ts create mode 100644 src/modules/auth/auth.controller.spec.ts create mode 100644 src/modules/auth/auth.controller.ts create mode 100644 src/modules/auth/auth.module.ts create mode 100644 src/modules/auth/auth.repository.ts create mode 100644 src/modules/auth/auth.service.spec.ts create mode 100644 src/modules/auth/auth.service.ts create mode 100644 src/modules/auth/dto/.gitkeep create mode 100644 src/modules/auth/dto/change-password.dto.ts create mode 100644 src/modules/auth/dto/forgot-password.dto.ts create mode 100644 src/modules/auth/dto/login.dto.ts create mode 100644 src/modules/auth/dto/reset-password.dto.ts create mode 100644 src/modules/auth/password-policy.service.spec.ts create mode 100644 src/modules/auth/password-policy.service.ts create mode 100644 src/modules/auth/password-reset.repository.spec.ts create mode 100644 src/modules/auth/password-reset.repository.ts create mode 100644 src/modules/client-profiles/client-profiles.controller.ts create mode 100644 src/modules/client-profiles/client-profiles.module.ts create mode 100644 src/modules/client-profiles/client-profiles.repository.ts create mode 100644 src/modules/client-profiles/client-profiles.service.ts create mode 100644 src/modules/client-profiles/dto/create-client-profile.dto.ts create mode 100644 src/modules/client-profiles/dto/list-client-profiles-query.dto.ts create mode 100644 src/modules/client-profiles/dto/update-client-profile.dto.ts create mode 100644 src/modules/clients/clients.controller.ts create mode 100644 src/modules/clients/clients.module.ts create mode 100644 src/modules/clients/clients.repository.ts create mode 100644 src/modules/clients/clients.service.ts create mode 100644 src/modules/clients/dto/.gitkeep create mode 100644 src/modules/clients/dto/create-client.dto.ts create mode 100644 src/modules/clients/dto/list-clients-query.dto.ts create mode 100644 src/modules/clients/dto/update-client.dto.ts create mode 100644 src/modules/contract-items/contract-items.controller.ts create mode 100644 src/modules/contract-items/contract-items.module.ts create mode 100644 src/modules/contract-items/contract-items.repository.ts create mode 100644 src/modules/contract-items/contract-items.service.spec.ts create mode 100644 src/modules/contract-items/contract-items.service.ts create mode 100644 src/modules/contract-items/dto/create-contract-item.dto.ts create mode 100644 src/modules/contract-items/dto/list-contract-items-query.dto.ts create mode 100644 src/modules/contract-items/dto/update-contract-item.dto.ts create mode 100644 src/modules/contract-items/helpers/validate-contract-item-by-type.ts create mode 100644 src/modules/contracts/contracts.controller.ts create mode 100644 src/modules/contracts/contracts.module.ts create mode 100644 src/modules/contracts/contracts.repository.ts create mode 100644 src/modules/contracts/contracts.service.ts create mode 100644 src/modules/contracts/dto/.gitkeep create mode 100644 src/modules/contracts/dto/create-contract.dto.ts create mode 100644 src/modules/contracts/dto/list-contracts-query.dto.ts create mode 100644 src/modules/contracts/dto/update-contract.dto.ts create mode 100644 src/modules/dashboard/dashboard.controller.ts create mode 100644 src/modules/dashboard/dashboard.module.ts create mode 100644 src/modules/dashboard/dashboard.repository.ts create mode 100644 src/modules/dashboard/dashboard.service.spec.ts create mode 100644 src/modules/dashboard/dashboard.service.ts create mode 100644 src/modules/dashboard/dto/.gitkeep create mode 100644 src/modules/dashboard/dto/dashboard-filter.dto.ts create mode 100644 src/modules/deliverable-backlog/deliverable-backlog.controller.ts create mode 100644 src/modules/deliverable-backlog/deliverable-backlog.module.ts create mode 100644 src/modules/deliverable-backlog/deliverable-backlog.repository.ts create mode 100644 src/modules/deliverable-backlog/deliverable-backlog.service.spec.ts create mode 100644 src/modules/deliverable-backlog/deliverable-backlog.service.ts create mode 100644 src/modules/deliverable-backlog/dto/.gitkeep create mode 100644 src/modules/deliverable-backlog/dto/change-backlog-status.dto.ts create mode 100644 src/modules/deliverable-backlog/dto/create-backlog-item.dto.ts create mode 100644 src/modules/deliverable-backlog/dto/reorder-backlog.dto.ts create mode 100644 src/modules/deliverable-backlog/dto/update-backlog-item.dto.ts create mode 100644 src/modules/deliverables/allocations.controller.ts create mode 100644 src/modules/deliverables/allocations.repository.ts create mode 100644 src/modules/deliverables/allocations.service.spec.ts create mode 100644 src/modules/deliverables/allocations.service.ts create mode 100644 src/modules/deliverables/assignments.controller.ts create mode 100644 src/modules/deliverables/assignments.service.spec.ts create mode 100644 src/modules/deliverables/assignments.service.ts create mode 100644 src/modules/deliverables/constants/required-observation-transitions.ts create mode 100644 src/modules/deliverables/constants/status-transitions.ts create mode 100644 src/modules/deliverables/deliverable-num-weeks.util.spec.ts create mode 100644 src/modules/deliverables/deliverable-num-weeks.util.ts create mode 100644 src/modules/deliverables/deliverable-value-calculator.service.spec.ts create mode 100644 src/modules/deliverables/deliverable-value-calculator.service.ts create mode 100644 src/modules/deliverables/deliverables.controller.ts create mode 100644 src/modules/deliverables/deliverables.module.ts create mode 100644 src/modules/deliverables/deliverables.repository.spec.ts create mode 100644 src/modules/deliverables/deliverables.repository.ts create mode 100644 src/modules/deliverables/deliverables.service.spec.ts create mode 100644 src/modules/deliverables/deliverables.service.ts create mode 100644 src/modules/deliverables/dto/.gitkeep create mode 100644 src/modules/deliverables/dto/bulk-set-deliverable-allocations.dto.ts create mode 100644 src/modules/deliverables/dto/change-status.dto.ts create mode 100644 src/modules/deliverables/dto/create-assignment.dto.ts create mode 100644 src/modules/deliverables/dto/create-deliverable-allocation.dto.ts create mode 100644 src/modules/deliverables/dto/create-deliverable.dto.ts create mode 100644 src/modules/deliverables/dto/list-deliverables-query.dto.ts create mode 100644 src/modules/deliverables/dto/update-assignment.dto.ts create mode 100644 src/modules/deliverables/dto/update-deliverable-allocation.dto.ts create mode 100644 src/modules/deliverables/dto/update-deliverable.dto.ts create mode 100644 src/modules/deliverables/repositories/assignments.repository.ts create mode 100644 src/modules/deliverables/repositories/status-history.repository.spec.ts create mode 100644 src/modules/deliverables/repositories/status-history.repository.ts create mode 100644 src/modules/health/health.controller.spec.ts create mode 100644 src/modules/health/health.controller.ts create mode 100644 src/modules/health/health.module.ts create mode 100644 src/modules/integration-api-keys/dto/create-integration-api-key.dto.ts create mode 100644 src/modules/integration-api-keys/dto/list-integration-api-keys-query.dto.ts create mode 100644 src/modules/integration-api-keys/integration-api-keys.controller.ts create mode 100644 src/modules/integration-api-keys/integration-api-keys.module.ts create mode 100644 src/modules/integration-api-keys/integration-api-keys.repository.ts create mode 100644 src/modules/integration-api-keys/integration-api-keys.service.spec.ts create mode 100644 src/modules/integration-api-keys/integration-api-keys.service.ts create mode 100644 src/modules/integrations/dto/list-integration-clients-query.dto.ts create mode 100644 src/modules/integrations/dto/list-integration-deliverables-query.dto.ts create mode 100644 src/modules/integrations/integrations.controller.ts create mode 100644 src/modules/integrations/integrations.module.ts create mode 100644 src/modules/integrations/integrations.repository.ts create mode 100644 src/modules/integrations/integrations.service.ts create mode 100644 src/modules/mailer/interfaces/mailer-provider.interface.ts create mode 100644 src/modules/mailer/mailer.module.ts create mode 100644 src/modules/mailer/mailer.service.spec.ts create mode 100644 src/modules/mailer/mailer.service.ts create mode 100644 src/modules/mailer/providers/log-mailer.provider.ts create mode 100644 src/modules/mailer/providers/smtp-mailer.provider.ts create mode 100644 src/modules/notes/dto/.gitkeep create mode 100644 src/modules/notes/dto/create-note.dto.ts create mode 100644 src/modules/notes/dto/list-notes-query.dto.ts create mode 100644 src/modules/notes/dto/update-note.dto.ts create mode 100644 src/modules/notes/notes.controller.ts create mode 100644 src/modules/notes/notes.module.ts create mode 100644 src/modules/notes/notes.repository.ts create mode 100644 src/modules/notes/notes.service.spec.ts create mode 100644 src/modules/notes/notes.service.ts create mode 100644 src/modules/professionals/dto/.gitkeep create mode 100644 src/modules/professionals/dto/create-professional.dto.ts create mode 100644 src/modules/professionals/dto/list-professionals-query.dto.ts create mode 100644 src/modules/professionals/dto/update-professional.dto.ts create mode 100644 src/modules/professionals/professionals.controller.ts create mode 100644 src/modules/professionals/professionals.module.ts create mode 100644 src/modules/professionals/professionals.repository.ts create mode 100644 src/modules/professionals/professionals.service.ts create mode 100644 src/modules/projects/dto/.gitkeep create mode 100644 src/modules/projects/dto/create-project.dto.ts create mode 100644 src/modules/projects/dto/list-projects-query.dto.ts create mode 100644 src/modules/projects/dto/update-project.dto.ts create mode 100644 src/modules/projects/projects.controller.ts create mode 100644 src/modules/projects/projects.module.ts create mode 100644 src/modules/projects/projects.repository.ts create mode 100644 src/modules/projects/projects.service.ts create mode 100644 src/modules/sprints/constants/sprint-status-transitions.ts create mode 100644 src/modules/sprints/dto/.gitkeep create mode 100644 src/modules/sprints/dto/add-deliverables.dto.ts create mode 100644 src/modules/sprints/dto/create-sprint.dto.ts create mode 100644 src/modules/sprints/dto/finish-sprint.dto.ts create mode 100644 src/modules/sprints/dto/list-sprints-query.dto.ts create mode 100644 src/modules/sprints/dto/update-sprint.dto.ts create mode 100644 src/modules/sprints/sprint-history.repository.ts create mode 100644 src/modules/sprints/sprints.controller.ts create mode 100644 src/modules/sprints/sprints.module.ts create mode 100644 src/modules/sprints/sprints.repository.ts create mode 100644 src/modules/sprints/sprints.service.ts create mode 100644 src/modules/timeline/dto/.gitkeep create mode 100644 src/modules/timeline/dto/list-timeline-query.dto.ts create mode 100644 src/modules/timeline/timeline.controller.ts create mode 100644 src/modules/timeline/timeline.module.ts create mode 100644 src/modules/timeline/timeline.repository.ts create mode 100644 src/modules/timeline/timeline.service.spec.ts create mode 100644 src/modules/timeline/timeline.service.ts create mode 100644 src/modules/users/dto/.gitkeep create mode 100644 src/modules/users/dto/create-user.dto.ts create mode 100644 src/modules/users/dto/list-users-query.dto.ts create mode 100644 src/modules/users/dto/reset-password.dto.ts create mode 100644 src/modules/users/dto/update-user.dto.ts create mode 100644 src/modules/users/users.controller.spec.ts create mode 100644 src/modules/users/users.controller.ts create mode 100644 src/modules/users/users.module.ts create mode 100644 src/modules/users/users.repository.ts create mode 100644 src/modules/users/users.service.spec.ts create mode 100644 src/modules/users/users.service.ts create mode 100644 src/modules/work-orders/constants/work-order-status-transitions.ts create mode 100644 src/modules/work-orders/dto/cancel-work-order.dto.ts create mode 100644 src/modules/work-orders/dto/create-work-order.dto.ts create mode 100644 src/modules/work-orders/dto/list-work-orders-query.dto.ts create mode 100644 src/modules/work-orders/dto/update-work-order.dto.ts create mode 100644 src/modules/work-orders/helpers/calculate-work-order-summary.spec.ts create mode 100644 src/modules/work-orders/helpers/calculate-work-order-summary.ts create mode 100644 src/modules/work-orders/repositories/work-order-status-history.repository.ts create mode 100644 src/modules/work-orders/work-order-value-calculator.service.spec.ts create mode 100644 src/modules/work-orders/work-order-value-calculator.service.ts create mode 100644 src/modules/work-orders/work-orders.controller.ts create mode 100644 src/modules/work-orders/work-orders.module.ts create mode 100644 src/modules/work-orders/work-orders.repository.ts create mode 100644 src/modules/work-orders/work-orders.service.spec.ts create mode 100644 src/modules/work-orders/work-orders.service.ts create mode 100644 src/modules/work-orders/work-orders.status.spec.ts create mode 100644 src/modules/work-orders/work-orders.summary.spec.ts create mode 100644 tsconfig.build.json create mode 100644 tsconfig.json diff --git a/.env b/.env new file mode 100644 index 0000000..450fd2d --- /dev/null +++ b/.env @@ -0,0 +1,15 @@ +DATABASE_URL="postgres://postgres:%23R11Amixxam%23@31.97.64.165:5432/gestao_iasis" +JWT_SECRET="dev-secret" +PORT=3000 +CORS_ORIGIN=http://localhost:5173 +# PASS_ADMIN_USER= +# Email — MAILER_PROVIDER=log (dev/homologação) | smtp (produção) +MAILER_PROVIDER=log +SMTP_HOST=smtp.gmail.com +SMTP_PORT=587 +SMTP_USER=wandermottasiqueira@gmail.com +SMTP_PASS=qzugwwjudfqoxzed +SMTP_FROM=wander@iasis.tech.com +FRONTEND_URL=https://seudominio.com +# Token de reset de senha (minutos) +PASSWORD_RESET_TOKEN_TTL_MINUTES=60 \ No newline at end of file diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..8ce6b34 --- /dev/null +++ b/.env.example @@ -0,0 +1,17 @@ +# Banco de Dados +DATABASE_URL= +JWT_SECRET= 3 -PORT= +PASS_ADMIN_USER= +# Servidor +PORT=3000 +CORS_ORIGIN=http://localhost:5173 +# Email — MAILER_PROVIDER=log (dev/homologação) | smtp (produção) +MAILER_PROVIDER=log +SMTP_HOST= +SMTP_PORT=587 +SMTP_USER= +SMTP_PASS= +SMTP_FROM= +FRONTEND_URL=https://seudominio.com +# Token de reset de senha (minutos) +PASSWORD_RESET_TOKEN_TTL_MINUTES=60 diff --git a/Deploy-Coolify.md b/Deploy-Coolify.md new file mode 100644 index 0000000..e3931a3 --- /dev/null +++ b/Deploy-Coolify.md @@ -0,0 +1,202 @@ +Sim, dá para usar **Nixpacks**. Você **não deve compactar `node_modules`, `dist`, `.git`, logs, cache, uploads grandes**. + +Compacte somente o código-fonte necessário: + +```txt +meu-projeto/ +├─ frontend/ +│ ├─ package.json +│ ├─ package-lock.json +│ ├─ index.html +│ ├─ vite.config.* +│ ├─ src/ +│ └─ public/ +├─ backend/ +│ ├─ package.json +│ ├─ package-lock.json +│ ├─ src/ +│ └─ .env.example + prisma + nest-cli.json + tsconfig.build.json + tsconfig.json +└─ README.md +``` + +Ignore no `.zip`: + +```txt +node_modules/ +dist/ +build/ +.git/ +.env +.env.local +*.log +.cache/ +.vite/ +coverage/ +uploads/ +tmp/ +``` + +## Recomendado no Coolify + +Crie **2 aplicações separadas**: + +```txt +App 1: frontend React + Vite +App 2: backend API +``` + +--- + +# 1. Deploy do Backend + +No Coolify: + +**Source:** Upload ZIP ou Git +**Build Pack:** Nixpacks +**Base Directory:** `backend` + +Build: + +```bash +npm install +``` + +Build Command, se usar TypeScript: + +```bash +npm run build +``` + +Start Command: + +```bash +npm run start +``` + +Se o backend roda em dev com `npm run dev`, crie um script de produção no `backend/package.json`: + +```json +{ + "scripts": { + "build": "tsc", + "start": "node dist/server.js" + } +} +``` + +Ou, se for JavaScript puro: + +```json +{ + "scripts": { + "start": "node src/server.js" + } +} +``` + +No backend, use a porta via variável: + +```js +const PORT = process.env.PORT || 3000; +app.listen(PORT, "0.0.0.0"); +``` + +No Coolify, configure as variáveis de ambiente do backend, exemplo: + +```env +DATABASE_URL=... +JWT_SECRET=... +NODE_ENV=production +``` + +--- + +# 2. Deploy do Frontend React + Vite + +No Coolify: + +**Build Pack:** Nixpacks +**Base Directory:** `frontend` + +Build: + +```bash +npm install +``` + +Build Command: + +```bash +npm run build +``` + +Start Command: + +```bash +npm run preview -- --host 0.0.0.0 --port $PORT +``` + +No `frontend/package.json`, deixe assim: + +```json +{ + "scripts": { + "build": "vite build", + "preview": "vite preview" + } +} +``` + +Variável para apontar para o backend: + +```env +VITE_API_URL=https://api.seudominio.com +``` + +No código React: + +```js +const API_URL = import.meta.env.VITE_API_URL; +``` + +--- + +# 3. O que compactar + +Compacte a pasta raiz do projeto contendo `frontend` e `backend`, mas sem arquivos pesados. + +Exemplo: + +```bash +zip -r projeto.zip . \ + -x "*/node_modules/*" \ + -x "*/dist/*" \ + -x "*/build/*" \ + -x ".git/*" \ + -x "*/.env*" \ + -x "*/.cache/*" +``` + +Melhor ainda: suba no **GitHub/GitLab** e conecte o repositório no Coolify. É mais fácil de atualizar depois. + +Configuração da imagem: + +Frontend: + +```txt +Install Command: npm install +Build Command: npm run build +Start Command: npm run preview -- --host 0.0.0.0 --port $PORT +``` + +Backend: + +```txt +Install Command: npm install +Build Command: npm run build +Start Command: npm run start +``` diff --git a/README.md b/README.md new file mode 100644 index 0000000..0851136 --- /dev/null +++ b/README.md @@ -0,0 +1,36 @@ +# iasis-gestao-backend + +NestJS 11 REST API for managing service orders, contracts, professionals, and clients. +Uses PostgreSQL via Prisma ORM and JWT authentication with role-based access control (ADMIN, OPERATOR, CLIENT). + +## Requirements + +- Node.js 22 +- PostgreSQL +- Copy `.env.example` to `.env` and set `DATABASE_URL` and `JWT_SECRET` + +## Setup + +```bash +npm ci +npm run prisma:migrate +npm run start:dev +``` + +## Commands + +```bash +npm run start:dev # Dev server with watch mode +npm run build # Compile to dist/ +npm run start:prod # Run compiled production build +npm run lint:fix # ESLint with auto-fix +npm run format # Prettier formatting +npm run test # Unit tests +npm run test:e2e # E2E tests +npm run prisma:migrate # Create and apply DB migrations +npm run prisma:generate # Regenerate Prisma client after schema changes +``` + +## Deployment + +See [DEPLOY.md](DEPLOY.md) for the full Azure App Service deployment guide, including GitHub Actions CI/CD, pre-commit hooks, and database migration instructions. diff --git a/github.bat b/github.bat new file mode 100644 index 0000000..935ca19 --- /dev/null +++ b/github.bat @@ -0,0 +1,29 @@ +@echo off +echo === INICIANDO UPLOAD PARA GITHUB === + +REM Inicializar repositório Git +echo Inicializando repositorio Git... +git init + +REM Adicionar todos os arquivos +echo Adicionando todos os arquivos... +git add . + +REM Fazer commit inicial +echo Realizando commit inicial... +git commit -m "Commit inicial - upload de todos os arquivos da pasta" + +REM Adicionar repositório remoto +echo Conectando ao repositorio remoto... +git remote add origin https://gitea.aplicativopro.com/wander/Backend-Iasis.git + +REM Definir branch principal +echo Definindo branch principal como 'main'... +git branch -M main + +REM Fazer push para o GitHub +echo Fazendo upload para o GitHub... +git push -u origin main --force + +echo === UPLOAD CONCLUIDO COM SUCESSO! === +pause \ No newline at end of file diff --git a/nest-cli.json b/nest-cli.json new file mode 100644 index 0000000..f9aa683 --- /dev/null +++ b/nest-cli.json @@ -0,0 +1,8 @@ +{ + "$schema": "https://json.schemastore.org/nest-cli", + "collection": "@nestjs/schematics", + "sourceRoot": "src", + "compilerOptions": { + "deleteOutDir": true + } +} diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..0668d93 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,10467 @@ +{ + "name": "iasis-gestao-backend", + "version": "0.0.1", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "iasis-gestao-backend", + "version": "0.0.1", + "hasInstallScript": true, + "license": "UNLICENSED", + "dependencies": { + "@nestjs/common": "^11.0.1", + "@nestjs/config": "^4.0.4", + "@nestjs/core": "^11.0.1", + "@nestjs/jwt": "^11.0.2", + "@nestjs/passport": "^11.0.5", + "@nestjs/platform-express": "^11.0.1", + "@nestjs/swagger": "^11.4.2", + "@nestjs/throttler": "^6.5.0", + "@prisma/client": "5.22.0", + "@types/nodemailer": "^8.0.1", + "bcrypt": "^6.0.0", + "class-transformer": "^0.5.1", + "class-validator": "^0.15.1", + "joi": "^18.1.2", + "nodemailer": "^8.0.11", + "passport": "^0.7.0", + "passport-jwt": "^4.0.1", + "prisma": "5.22.0", + "reflect-metadata": "^0.2.2", + "rxjs": "^7.8.1" + }, + "devDependencies": { + "@eslint/eslintrc": "^3.2.0", + "@eslint/js": "^9.18.0", + "@nestjs/cli": "^11.0.0", + "@nestjs/schematics": "^11.0.0", + "@nestjs/testing": "^11.0.1", + "@types/bcrypt": "^6.0.0", + "@types/express": "^5.0.0", + "@types/jest": "^30.0.0", + "@types/node": "^24.0.0", + "@types/passport-jwt": "^4.0.1", + "@types/supertest": "^7.0.0", + "eslint": "^9.18.0", + "eslint-config-prettier": "^10.0.1", + "eslint-plugin-prettier": "^5.2.2", + "globals": "^17.0.0", + "jest": "^30.0.0", + "prettier": "^3.4.2", + "source-map-support": "^0.5.21", + "supertest": "^7.0.0", + "ts-jest": "^29.2.5", + "ts-loader": "^9.5.2", + "ts-node": "^10.9.2", + "tsconfig-paths": "^4.2.0", + "typescript": "^5.7.3", + "typescript-eslint": "^8.20.0" + }, + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/@angular-devkit/core": { + "version": "19.2.24", + "resolved": "https://registry.npmjs.org/@angular-devkit/core/-/core-19.2.24.tgz", + "integrity": "sha512-Kd49warf6U/EyWe5BszF/eebN3zQ3bk7tgfEljAw8q/rX95UUtriJubWvp6pgzHfzBA4jwq8f+QiNZB8eBEXPA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "8.18.0", + "ajv-formats": "3.0.1", + "jsonc-parser": "3.3.1", + "picomatch": "4.0.4", + "rxjs": "7.8.1", + "source-map": "0.7.4" + }, + "engines": { + "node": "^18.19.1 || ^20.11.1 || >=22.0.0", + "npm": "^6.11.0 || ^7.5.6 || >=8.0.0", + "yarn": ">= 1.13.0" + }, + "peerDependencies": { + "chokidar": "^4.0.0" + }, + "peerDependenciesMeta": { + "chokidar": { + "optional": true + } + } + }, + "node_modules/@angular-devkit/core/node_modules/ajv": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", + "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/@angular-devkit/core/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true, + "license": "MIT" + }, + "node_modules/@angular-devkit/core/node_modules/rxjs": { + "version": "7.8.1", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.1.tgz", + "integrity": "sha512-AA3TVj+0A2iuIoQkWEK/tqFjBq2j+6PO6Y0zJcvzLAFhEFIO3HL0vls9hWLncZbAAbK0mar7oZ4V079I/qPMxg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.1.0" + } + }, + "node_modules/@angular-devkit/schematics": { + "version": "19.2.24", + "resolved": "https://registry.npmjs.org/@angular-devkit/schematics/-/schematics-19.2.24.tgz", + "integrity": "sha512-lnw+ZM1Io+cJAkReC0NPDjqObL8NtKzKIkdgEEKC8CUmkhurYhedbicN8Y8NYHgG1uLd2GozW3+/QqPRZaN+Lw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@angular-devkit/core": "19.2.24", + "jsonc-parser": "3.3.1", + "magic-string": "0.30.17", + "ora": "5.4.1", + "rxjs": "7.8.1" + }, + "engines": { + "node": "^18.19.1 || ^20.11.1 || >=22.0.0", + "npm": "^6.11.0 || ^7.5.6 || >=8.0.0", + "yarn": ">= 1.13.0" + } + }, + "node_modules/@angular-devkit/schematics-cli": { + "version": "19.2.24", + "resolved": "https://registry.npmjs.org/@angular-devkit/schematics-cli/-/schematics-cli-19.2.24.tgz", + "integrity": "sha512-bsStZQG67J1HBqTmWxtIcobvgrn32L4UOdL7hGyOru5VxDWPNA8pRnDYavT3hnJeBkJYPoQIw8u7Dm0ecoQprw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@angular-devkit/core": "19.2.24", + "@angular-devkit/schematics": "19.2.24", + "@inquirer/prompts": "7.3.2", + "ansi-colors": "4.1.3", + "symbol-observable": "4.0.0", + "yargs-parser": "21.1.1" + }, + "bin": { + "schematics": "bin/schematics.js" + }, + "engines": { + "node": "^18.19.1 || ^20.11.1 || >=22.0.0", + "npm": "^6.11.0 || ^7.5.6 || >=8.0.0", + "yarn": ">= 1.13.0" + } + }, + "node_modules/@angular-devkit/schematics-cli/node_modules/@inquirer/prompts": { + "version": "7.3.2", + "resolved": "https://registry.npmjs.org/@inquirer/prompts/-/prompts-7.3.2.tgz", + "integrity": "sha512-G1ytyOoHh5BphmEBxSwALin3n1KGNYB6yImbICcRQdzXfOGbuJ9Jske/Of5Sebk339NSGGNfUshnzK8YWkTPsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/checkbox": "^4.1.2", + "@inquirer/confirm": "^5.1.6", + "@inquirer/editor": "^4.2.7", + "@inquirer/expand": "^4.0.9", + "@inquirer/input": "^4.1.6", + "@inquirer/number": "^3.0.9", + "@inquirer/password": "^4.0.9", + "@inquirer/rawlist": "^4.0.9", + "@inquirer/search": "^3.0.9", + "@inquirer/select": "^4.0.9" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@angular-devkit/schematics/node_modules/rxjs": { + "version": "7.8.1", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.1.tgz", + "integrity": "sha512-AA3TVj+0A2iuIoQkWEK/tqFjBq2j+6PO6Y0zJcvzLAFhEFIO3HL0vls9hWLncZbAAbK0mar7oZ4V079I/qPMxg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.1.0" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.28.5", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz", + "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", + "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helpers": "^7.28.6", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/core/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.1", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", + "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", + "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.28.6", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", + "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", + "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-validator-identifier": "^7.28.5", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz", + "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.2.tgz", + "integrity": "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz", + "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-syntax-async-generators": { + "version": "7.8.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-async-generators/-/plugin-syntax-async-generators-7.8.4.tgz", + "integrity": "sha512-tycmZxkGfZaxhMRbXlPXuVFpdWlXpir2W4AMhSJgRKzk/eDlIXOhb2LHWoLpDF7TEHylV5zNhykX6KAgHJmTNw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-bigint": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-bigint/-/plugin-syntax-bigint-7.8.3.tgz", + "integrity": "sha512-wnTnFlG+YxQm3vDxpGE57Pj0srRU4sHE/mDkt1qv2YJJSeUAec2ma4WLUnUPeKjyrfntVwe/N6dCXpU+zL3Npg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-class-properties": { + "version": "7.12.13", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-class-properties/-/plugin-syntax-class-properties-7.12.13.tgz", + "integrity": "sha512-fm4idjKla0YahUNgFNLCB0qySdsoPiZP3iQE3rky0mBUtMZ23yDJ9SJdg6dXTSDnulOVqiF3Hgr9nbXvXTQZYA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.12.13" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-class-static-block": { + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-class-static-block/-/plugin-syntax-class-static-block-7.14.5.tgz", + "integrity": "sha512-b+YyPmr6ldyNnM6sqYeMWE+bgJcJpO6yS4QD7ymxgH34GBPNDM/THBh8iunyvKIZztiwLH4CJZ0RxTk9emgpjw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.14.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-import-attributes": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-attributes/-/plugin-syntax-import-attributes-7.28.6.tgz", + "integrity": "sha512-jiLC0ma9XkQT3TKJ9uYvlakm66Pamywo+qwL+oL8HJOvc6TWdZXVfhqJr8CCzbSGUAbDOzlGHJC1U+vRfLQDvw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-import-meta": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-meta/-/plugin-syntax-import-meta-7.10.4.tgz", + "integrity": "sha512-Yqfm+XDx0+Prh3VSeEQCPU81yC+JWZ2pDPFSS4ZdpfZhp4MkFMaDC1UqseovEKwSUpnIL7+vK+Clp7bfh0iD7g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.10.4" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-json-strings": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-json-strings/-/plugin-syntax-json-strings-7.8.3.tgz", + "integrity": "sha512-lY6kdGpWHvjoe2vk4WrAapEuBR69EMxZl+RoGRhrFGNYVK8mOPAW8VfbT/ZgrFbXlDNiiaxQnAtgVCZ6jv30EA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-jsx": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.28.6.tgz", + "integrity": "sha512-wgEmr06G6sIpqr8YDwA2dSRTE3bJ+V0IfpzfSY3Lfgd7YWOaAdlykvJi13ZKBt8cZHfgH1IXN+CL656W3uUa4w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-logical-assignment-operators": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-logical-assignment-operators/-/plugin-syntax-logical-assignment-operators-7.10.4.tgz", + "integrity": "sha512-d8waShlpFDinQ5MtvGU9xDAOzKH47+FFoney2baFIoMr952hKOLp1HR7VszoZvOsV/4+RRszNY7D17ba0te0ig==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.10.4" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-nullish-coalescing-operator": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-nullish-coalescing-operator/-/plugin-syntax-nullish-coalescing-operator-7.8.3.tgz", + "integrity": "sha512-aSff4zPII1u2QD7y+F8oDsz19ew4IGEJg9SVW+bqwpwtfFleiQDMdzA/R+UlWDzfnHFCxxleFT0PMIrR36XLNQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-numeric-separator": { + "version": "7.10.4", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-numeric-separator/-/plugin-syntax-numeric-separator-7.10.4.tgz", + "integrity": "sha512-9H6YdfkcK/uOnY/K7/aA2xpzaAgkQn37yzWUMRK7OaPOqOpGS1+n0H5hxT9AUw9EsSjPW8SVyMJwYRtWs3X3ug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.10.4" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-object-rest-spread": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-object-rest-spread/-/plugin-syntax-object-rest-spread-7.8.3.tgz", + "integrity": "sha512-XoqMijGZb9y3y2XskN+P1wUGiVwWZ5JmoDRwx5+3GmEplNyVM2s2Dg8ILFQm8rWM48orGy5YpI5Bl8U1y7ydlA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-optional-catch-binding": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-optional-catch-binding/-/plugin-syntax-optional-catch-binding-7.8.3.tgz", + "integrity": "sha512-6VPD0Pc1lpTqw0aKoeRTMiB+kWhAoT24PA+ksWSBrFtl5SIRVpZlwN3NNPQjehA2E/91FV3RjLWoVTglWcSV3Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-optional-chaining": { + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-optional-chaining/-/plugin-syntax-optional-chaining-7.8.3.tgz", + "integrity": "sha512-KoK9ErH1MBlCPxV0VANkXW2/dw4vlbGDrFgz8bmUsBGYkFRcbRwMh6cIJubdPrkxRwuGdtCk0v/wPTKbQgBjkg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-private-property-in-object": { + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-private-property-in-object/-/plugin-syntax-private-property-in-object-7.14.5.tgz", + "integrity": "sha512-0wVnp9dxJ72ZUJDV27ZfbSj6iHLoytYZmh3rFcxNnvsJF3ktkzLDZPy/mA17HGsaQT3/DQsWYX1f1QGWkCoVUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.14.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-top-level-await": { + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-top-level-await/-/plugin-syntax-top-level-await-7.14.5.tgz", + "integrity": "sha512-hx++upLv5U1rgYfwe1xBQUhRmU41NEvpUvrp8jkrSCdvGSnM5/qdRMtylJ6PG5OFkBaHkbTAKTnd3/YyESRHFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.14.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-typescript": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.28.6.tgz", + "integrity": "sha512-+nDNmQye7nlnuuHDboPbGm00Vqg3oO8niRRL27/4LYHUsHYh0zJ1xWOz0uRwNFmM1Avzk8wZbc6rdiYhomzv/A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", + "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", + "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@bcoe/v8-coverage": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz", + "integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@borewit/text-codec": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/@borewit/text-codec/-/text-codec-0.2.2.tgz", + "integrity": "sha512-DDaRehssg1aNrH4+2hnj1B7vnUGEjU6OIlyRdkMd0aUdIUvKXrJfXsy8LVtXAy7DRvYVluWbMspsRhz2lcW0mQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + } + }, + "node_modules/@colors/colors": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.5.0.tgz", + "integrity": "sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=0.1.90" + } + }, + "node_modules/@cspotcode/source-map-support": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", + "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "0.3.9" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@cspotcode/source-map-support/node_modules/@jridgewell/trace-mapping": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", + "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.0.3", + "@jridgewell/sourcemap-codec": "^1.4.10" + } + }, + "node_modules/@emnapi/core": { + "version": "1.9.2", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.2.tgz", + "integrity": "sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.1", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.9.2", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.2.tgz", + "integrity": "sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", + "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.21.2", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.2.tgz", + "integrity": "sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^2.1.7", + "debug": "^4.3.1", + "minimatch": "^3.1.5" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", + "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/core": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", + "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "3.3.5", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.5.tgz", + "integrity": "sha512-4IlJx0X0qftVsN5E+/vGujTRIFtwuLbNsVUe7TO6zYPDR1O6nFwvwhIKEKSrl6dZchmYBITazxKoUYOjdtjlRg==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.14.0", + "debug": "^4.3.2", + "espree": "^10.0.1", + "globals": "^14.0.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.1", + "minimatch": "^3.1.5", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/eslintrc/node_modules/globals": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@eslint/js": { + "version": "9.39.4", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.4.tgz", + "integrity": "sha512-nE7DEIchvtiFTwBw4Lfbu59PG+kCofhjsKaCWzxTpt4lfRjRMqG6uMBzKXuEcyXhOHoUp9riAm7/aWYGhXZ9cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + } + }, + "node_modules/@eslint/object-schema": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", + "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", + "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0", + "levn": "^0.4.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@hapi/address": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/@hapi/address/-/address-5.1.1.tgz", + "integrity": "sha512-A+po2d/dVoY7cYajycYI43ZbYMXukuopIsqCjh5QzsBCipDtdofHntljDlpccMjIfTy6UOkg+5KPriwYch2bXA==", + "license": "BSD-3-Clause", + "dependencies": { + "@hapi/hoek": "^11.0.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@hapi/formula": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@hapi/formula/-/formula-3.0.2.tgz", + "integrity": "sha512-hY5YPNXzw1He7s0iqkRQi+uMGh383CGdyyIGYtB+W5N3KHPXoqychklvHhKCC9M3Xtv0OCs/IHw+r4dcHtBYWw==", + "license": "BSD-3-Clause" + }, + "node_modules/@hapi/hoek": { + "version": "11.0.7", + "resolved": "https://registry.npmjs.org/@hapi/hoek/-/hoek-11.0.7.tgz", + "integrity": "sha512-HV5undWkKzcB4RZUusqOpcgxOaq6VOAH7zhhIr2g3G8NF/MlFO75SjOr2NfuSx0Mh40+1FqCkagKLJRykUWoFQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@hapi/pinpoint": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@hapi/pinpoint/-/pinpoint-2.0.1.tgz", + "integrity": "sha512-EKQmr16tM8s16vTT3cA5L0kZZcTMU5DUOZTuvpnY738m+jyP3JIUj+Mm1xc1rsLkGBQ/gVnfKYPwOmPg1tUR4Q==", + "license": "BSD-3-Clause" + }, + "node_modules/@hapi/tlds": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@hapi/tlds/-/tlds-1.1.6.tgz", + "integrity": "sha512-xdi7A/4NZokvV0ewovme3aUO5kQhW9pQ2YD1hRqZGhhSi5rBv4usHYidVocXSi9eihYsznZxLtAiEYYUL6VBGw==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@hapi/topo": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@hapi/topo/-/topo-6.0.2.tgz", + "integrity": "sha512-KR3rD5inZbGMrHmgPxsJ9dbi6zEK+C3ZwUwTa+eMwWLz7oijWUTWD2pMSNNYJAU6Qq+65NkxXjqHr/7LM2Xkqg==", + "license": "BSD-3-Clause", + "dependencies": { + "@hapi/hoek": "^11.0.2" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.1", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", + "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.7", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.7.tgz", + "integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.1", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@inquirer/ansi": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@inquirer/ansi/-/ansi-1.0.2.tgz", + "integrity": "sha512-S8qNSZiYzFd0wAcyG5AXCvUHC5Sr7xpZ9wZ2py9XR88jUz8wooStVx5M6dRzczbBWjic9NP7+rY0Xi7qqK/aMQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@inquirer/checkbox": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@inquirer/checkbox/-/checkbox-4.3.2.tgz", + "integrity": "sha512-VXukHf0RR1doGe6Sm4F0Em7SWYLTHSsbGfJdS9Ja2bX5/D5uwVOEjr07cncLROdBvmnvCATYEWlHqYmXv2IlQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/ansi": "^1.0.2", + "@inquirer/core": "^10.3.2", + "@inquirer/figures": "^1.0.15", + "@inquirer/type": "^3.0.10", + "yoctocolors-cjs": "^2.1.3" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/confirm": { + "version": "5.1.21", + "resolved": "https://registry.npmjs.org/@inquirer/confirm/-/confirm-5.1.21.tgz", + "integrity": "sha512-KR8edRkIsUayMXV+o3Gv+q4jlhENF9nMYUZs9PA2HzrXeHI8M5uDag70U7RJn9yyiMZSbtF5/UexBtAVtZGSbQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^10.3.2", + "@inquirer/type": "^3.0.10" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/core": { + "version": "10.3.2", + "resolved": "https://registry.npmjs.org/@inquirer/core/-/core-10.3.2.tgz", + "integrity": "sha512-43RTuEbfP8MbKzedNqBrlhhNKVwoK//vUFNW3Q3vZ88BLcrs4kYpGg+B2mm5p2K/HfygoCxuKwJJiv8PbGmE0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/ansi": "^1.0.2", + "@inquirer/figures": "^1.0.15", + "@inquirer/type": "^3.0.10", + "cli-width": "^4.1.0", + "mute-stream": "^2.0.0", + "signal-exit": "^4.1.0", + "wrap-ansi": "^6.2.0", + "yoctocolors-cjs": "^2.1.3" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/editor": { + "version": "4.2.23", + "resolved": "https://registry.npmjs.org/@inquirer/editor/-/editor-4.2.23.tgz", + "integrity": "sha512-aLSROkEwirotxZ1pBaP8tugXRFCxW94gwrQLxXfrZsKkfjOYC1aRvAZuhpJOb5cu4IBTJdsCigUlf2iCOu4ZDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^10.3.2", + "@inquirer/external-editor": "^1.0.3", + "@inquirer/type": "^3.0.10" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/expand": { + "version": "4.0.23", + "resolved": "https://registry.npmjs.org/@inquirer/expand/-/expand-4.0.23.tgz", + "integrity": "sha512-nRzdOyFYnpeYTTR2qFwEVmIWypzdAx/sIkCMeTNTcflFOovfqUk+HcFhQQVBftAh9gmGrpFj6QcGEqrDMDOiew==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^10.3.2", + "@inquirer/type": "^3.0.10", + "yoctocolors-cjs": "^2.1.3" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/external-editor": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@inquirer/external-editor/-/external-editor-1.0.3.tgz", + "integrity": "sha512-RWbSrDiYmO4LbejWY7ttpxczuwQyZLBUyygsA9Nsv95hpzUWwnNTVQmAq3xuh7vNwCp07UTmE5i11XAEExx4RA==", + "dev": true, + "license": "MIT", + "dependencies": { + "chardet": "^2.1.1", + "iconv-lite": "^0.7.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/figures": { + "version": "1.0.15", + "resolved": "https://registry.npmjs.org/@inquirer/figures/-/figures-1.0.15.tgz", + "integrity": "sha512-t2IEY+unGHOzAaVM5Xx6DEWKeXlDDcNPeDyUpsRc6CUhBfU3VQOEl+Vssh7VNp1dR8MdUJBWhuObjXCsVpjN5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@inquirer/input": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@inquirer/input/-/input-4.3.1.tgz", + "integrity": "sha512-kN0pAM4yPrLjJ1XJBjDxyfDduXOuQHrBB8aLDMueuwUGn+vNpF7Gq7TvyVxx8u4SHlFFj4trmj+a2cbpG4Jn1g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^10.3.2", + "@inquirer/type": "^3.0.10" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/number": { + "version": "3.0.23", + "resolved": "https://registry.npmjs.org/@inquirer/number/-/number-3.0.23.tgz", + "integrity": "sha512-5Smv0OK7K0KUzUfYUXDXQc9jrf8OHo4ktlEayFlelCjwMXz0299Y8OrI+lj7i4gCBY15UObk76q0QtxjzFcFcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^10.3.2", + "@inquirer/type": "^3.0.10" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/password": { + "version": "4.0.23", + "resolved": "https://registry.npmjs.org/@inquirer/password/-/password-4.0.23.tgz", + "integrity": "sha512-zREJHjhT5vJBMZX/IUbyI9zVtVfOLiTO66MrF/3GFZYZ7T4YILW5MSkEYHceSii/KtRk+4i3RE7E1CUXA2jHcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/ansi": "^1.0.2", + "@inquirer/core": "^10.3.2", + "@inquirer/type": "^3.0.10" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/prompts": { + "version": "7.10.1", + "resolved": "https://registry.npmjs.org/@inquirer/prompts/-/prompts-7.10.1.tgz", + "integrity": "sha512-Dx/y9bCQcXLI5ooQ5KyvA4FTgeo2jYj/7plWfV5Ak5wDPKQZgudKez2ixyfz7tKXzcJciTxqLeK7R9HItwiByg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/checkbox": "^4.3.2", + "@inquirer/confirm": "^5.1.21", + "@inquirer/editor": "^4.2.23", + "@inquirer/expand": "^4.0.23", + "@inquirer/input": "^4.3.1", + "@inquirer/number": "^3.0.23", + "@inquirer/password": "^4.0.23", + "@inquirer/rawlist": "^4.1.11", + "@inquirer/search": "^3.2.2", + "@inquirer/select": "^4.4.2" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/rawlist": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@inquirer/rawlist/-/rawlist-4.1.11.tgz", + "integrity": "sha512-+LLQB8XGr3I5LZN/GuAHo+GpDJegQwuPARLChlMICNdwW7OwV2izlCSCxN6cqpL0sMXmbKbFcItJgdQq5EBXTw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^10.3.2", + "@inquirer/type": "^3.0.10", + "yoctocolors-cjs": "^2.1.3" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/search": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@inquirer/search/-/search-3.2.2.tgz", + "integrity": "sha512-p2bvRfENXCZdWF/U2BXvnSI9h+tuA8iNqtUKb9UWbmLYCRQxd8WkvwWvYn+3NgYaNwdUkHytJMGG4MMLucI1kA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^10.3.2", + "@inquirer/figures": "^1.0.15", + "@inquirer/type": "^3.0.10", + "yoctocolors-cjs": "^2.1.3" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/select": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/@inquirer/select/-/select-4.4.2.tgz", + "integrity": "sha512-l4xMuJo55MAe+N7Qr4rX90vypFwCajSakx59qe/tMaC1aEHWLyw68wF4o0A4SLAY4E0nd+Vt+EyskeDIqu1M6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/ansi": "^1.0.2", + "@inquirer/core": "^10.3.2", + "@inquirer/figures": "^1.0.15", + "@inquirer/type": "^3.0.10", + "yoctocolors-cjs": "^2.1.3" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/type": { + "version": "3.0.10", + "resolved": "https://registry.npmjs.org/@inquirer/type/-/type-3.0.10.tgz", + "integrity": "sha512-BvziSRxfz5Ov8ch0z/n3oijRSEcEsHnhggm4xFZe93DHcUCTlutlq9Ox4SVENAfcRD22UQq7T/atg9Wr3k09eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@isaacs/cliui": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", + "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^5.1.2", + "string-width-cjs": "npm:string-width@^4.2.0", + "strip-ansi": "^7.0.1", + "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", + "wrap-ansi": "^8.1.0", + "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@isaacs/cliui/node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@isaacs/cliui/node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@isaacs/cliui/node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/wrap-ansi": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/@istanbuljs/load-nyc-config": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz", + "integrity": "sha512-VjeHSlIzpv/NyD3N0YuHfXOPDIixcA1q2ZV98wsMqcYlPmv2n3Yb2lYP9XMElnaFVXg5A7YLTeLu6V84uQDjmQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "camelcase": "^5.3.1", + "find-up": "^4.1.0", + "get-package-type": "^0.1.0", + "js-yaml": "^3.13.1", + "resolve-from": "^5.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/argparse": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", + "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "sprintf-js": "~1.0.2" + } + }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/js-yaml": { + "version": "3.14.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.2.tgz", + "integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^1.0.7", + "esprima": "^4.0.0" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@istanbuljs/load-nyc-config/node_modules/resolve-from": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", + "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@istanbuljs/schema": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.3.tgz", + "integrity": "sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@jest/console": { + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/@jest/console/-/console-30.3.0.tgz", + "integrity": "sha512-PAwCvFJ4696XP2qZj+LAn1BWjZaJ6RjG6c7/lkMaUJnkyMS34ucuIsfqYvfskVNvUI27R/u4P1HMYFnlVXG/Ww==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "30.3.0", + "@types/node": "*", + "chalk": "^4.1.2", + "jest-message-util": "30.3.0", + "jest-util": "30.3.0", + "slash": "^3.0.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/core": { + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/@jest/core/-/core-30.3.0.tgz", + "integrity": "sha512-U5mVPsBxLSO6xYbf+tgkymLx+iAhvZX43/xI1+ej2ZOPnPdkdO1CzDmFKh2mZBn2s4XZixszHeQnzp1gm/DIxw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/console": "30.3.0", + "@jest/pattern": "30.0.1", + "@jest/reporters": "30.3.0", + "@jest/test-result": "30.3.0", + "@jest/transform": "30.3.0", + "@jest/types": "30.3.0", + "@types/node": "*", + "ansi-escapes": "^4.3.2", + "chalk": "^4.1.2", + "ci-info": "^4.2.0", + "exit-x": "^0.2.2", + "graceful-fs": "^4.2.11", + "jest-changed-files": "30.3.0", + "jest-config": "30.3.0", + "jest-haste-map": "30.3.0", + "jest-message-util": "30.3.0", + "jest-regex-util": "30.0.1", + "jest-resolve": "30.3.0", + "jest-resolve-dependencies": "30.3.0", + "jest-runner": "30.3.0", + "jest-runtime": "30.3.0", + "jest-snapshot": "30.3.0", + "jest-util": "30.3.0", + "jest-validate": "30.3.0", + "jest-watcher": "30.3.0", + "pretty-format": "30.3.0", + "slash": "^3.0.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } + } + }, + "node_modules/@jest/diff-sequences": { + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/@jest/diff-sequences/-/diff-sequences-30.3.0.tgz", + "integrity": "sha512-cG51MVnLq1ecVUaQ3fr6YuuAOitHK1S4WUJHnsPFE/quQr33ADUx1FfrTCpMCRxvy0Yr9BThKpDjSlcTi91tMA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/environment": { + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/@jest/environment/-/environment-30.3.0.tgz", + "integrity": "sha512-SlLSF4Be735yQXyh2+mctBOzNDx5s5uLv88/j8Qn1wH679PDcwy67+YdADn8NJnGjzlXtN62asGH/T4vWOkfaw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/fake-timers": "30.3.0", + "@jest/types": "30.3.0", + "@types/node": "*", + "jest-mock": "30.3.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/expect": { + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/@jest/expect/-/expect-30.3.0.tgz", + "integrity": "sha512-76Nlh4xJxk2D/9URCn3wFi98d2hb19uWE1idLsTt2ywhvdOldbw3S570hBgn25P4ICUZ/cBjybrBex2g17IDbg==", + "dev": true, + "license": "MIT", + "dependencies": { + "expect": "30.3.0", + "jest-snapshot": "30.3.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/expect-utils": { + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/@jest/expect-utils/-/expect-utils-30.3.0.tgz", + "integrity": "sha512-j0+W5iQQ8hBh7tHZkTQv3q2Fh/M7Je72cIsYqC4OaktgtO7v1So9UTjp6uPBHIaB6beoF/RRsCgMJKvti0wADA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/get-type": "30.1.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/fake-timers": { + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/@jest/fake-timers/-/fake-timers-30.3.0.tgz", + "integrity": "sha512-WUQDs8SOP9URStX1DzhD425CqbN/HxUYCTwVrT8sTVBfMvFqYt/s61EK5T05qnHu0po6RitXIvP9otZxYDzTGQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "30.3.0", + "@sinonjs/fake-timers": "^15.0.0", + "@types/node": "*", + "jest-message-util": "30.3.0", + "jest-mock": "30.3.0", + "jest-util": "30.3.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/get-type": { + "version": "30.1.0", + "resolved": "https://registry.npmjs.org/@jest/get-type/-/get-type-30.1.0.tgz", + "integrity": "sha512-eMbZE2hUnx1WV0pmURZY9XoXPkUYjpc55mb0CrhtdWLtzMQPFvu/rZkTLZFTsdaVQa+Tr4eWAteqcUzoawq/uA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/globals": { + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/@jest/globals/-/globals-30.3.0.tgz", + "integrity": "sha512-+owLCBBdfpgL3HU+BD5etr1SvbXpSitJK0is1kiYjJxAAJggYMRQz5hSdd5pq1sSggfxPbw2ld71pt4x5wwViA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/environment": "30.3.0", + "@jest/expect": "30.3.0", + "@jest/types": "30.3.0", + "jest-mock": "30.3.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/pattern": { + "version": "30.0.1", + "resolved": "https://registry.npmjs.org/@jest/pattern/-/pattern-30.0.1.tgz", + "integrity": "sha512-gWp7NfQW27LaBQz3TITS8L7ZCQ0TLvtmI//4OwlQRx4rnWxcPNIYjxZpDcN4+UlGxgm3jS5QPz8IPTCkb59wZA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "jest-regex-util": "30.0.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/reporters": { + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/@jest/reporters/-/reporters-30.3.0.tgz", + "integrity": "sha512-a09z89S+PkQnL055bVj8+pe2Caed2PBOaczHcXCykW5ngxX9EWx/1uAwncxc/HiU0oZqfwseMjyhxgRjS49qPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@bcoe/v8-coverage": "^0.2.3", + "@jest/console": "30.3.0", + "@jest/test-result": "30.3.0", + "@jest/transform": "30.3.0", + "@jest/types": "30.3.0", + "@jridgewell/trace-mapping": "^0.3.25", + "@types/node": "*", + "chalk": "^4.1.2", + "collect-v8-coverage": "^1.0.2", + "exit-x": "^0.2.2", + "glob": "^10.5.0", + "graceful-fs": "^4.2.11", + "istanbul-lib-coverage": "^3.0.0", + "istanbul-lib-instrument": "^6.0.0", + "istanbul-lib-report": "^3.0.0", + "istanbul-lib-source-maps": "^5.0.0", + "istanbul-reports": "^3.1.3", + "jest-message-util": "30.3.0", + "jest-util": "30.3.0", + "jest-worker": "30.3.0", + "slash": "^3.0.0", + "string-length": "^4.0.2", + "v8-to-istanbul": "^9.0.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } + } + }, + "node_modules/@jest/reporters/node_modules/brace-expansion": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.3.tgz", + "integrity": "sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/@jest/reporters/node_modules/glob": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@jest/reporters/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/@jest/reporters/node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@jest/reporters/node_modules/path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@jest/schemas": { + "version": "30.0.5", + "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-30.0.5.tgz", + "integrity": "sha512-DmdYgtezMkh3cpU8/1uyXakv3tJRcmcXxBOcO0tbaozPwpmh4YMsnWrQm9ZmZMfa5ocbxzbFk6O4bDPEc/iAnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sinclair/typebox": "^0.34.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/snapshot-utils": { + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/@jest/snapshot-utils/-/snapshot-utils-30.3.0.tgz", + "integrity": "sha512-ORbRN9sf5PP82v3FXNSwmO1OTDR2vzR2YTaR+E3VkSBZ8zadQE6IqYdYEeFH1NIkeB2HIGdF02dapb6K0Mj05g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "30.3.0", + "chalk": "^4.1.2", + "graceful-fs": "^4.2.11", + "natural-compare": "^1.4.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/source-map": { + "version": "30.0.1", + "resolved": "https://registry.npmjs.org/@jest/source-map/-/source-map-30.0.1.tgz", + "integrity": "sha512-MIRWMUUR3sdbP36oyNyhbThLHyJ2eEDClPCiHVbrYAe5g3CHRArIVpBw7cdSB5fr+ofSfIb2Tnsw8iEHL0PYQg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.25", + "callsites": "^3.1.0", + "graceful-fs": "^4.2.11" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/test-result": { + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/@jest/test-result/-/test-result-30.3.0.tgz", + "integrity": "sha512-e/52nJGuD74AKTSe0P4y5wFRlaXP0qmrS17rqOMHeSwm278VyNyXE3gFO/4DTGF9w+65ra3lo3VKj0LBrzmgdQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/console": "30.3.0", + "@jest/types": "30.3.0", + "@types/istanbul-lib-coverage": "^2.0.6", + "collect-v8-coverage": "^1.0.2" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/test-sequencer": { + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/@jest/test-sequencer/-/test-sequencer-30.3.0.tgz", + "integrity": "sha512-dgbWy9b8QDlQeRZcv7LNF+/jFiiYHTKho1xirauZ7kVwY7avjFF6uTT0RqlgudB5OuIPagFdVtfFMosjVbk1eA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/test-result": "30.3.0", + "graceful-fs": "^4.2.11", + "jest-haste-map": "30.3.0", + "slash": "^3.0.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/transform": { + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/@jest/transform/-/transform-30.3.0.tgz", + "integrity": "sha512-TLKY33fSLVd/lKB2YI1pH69ijyUblO/BQvCj566YvnwuzoTNr648iE0j22vRvVNk2HsPwByPxATg3MleS3gf5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.27.4", + "@jest/types": "30.3.0", + "@jridgewell/trace-mapping": "^0.3.25", + "babel-plugin-istanbul": "^7.0.1", + "chalk": "^4.1.2", + "convert-source-map": "^2.0.0", + "fast-json-stable-stringify": "^2.1.0", + "graceful-fs": "^4.2.11", + "jest-haste-map": "30.3.0", + "jest-regex-util": "30.0.1", + "jest-util": "30.3.0", + "pirates": "^4.0.7", + "slash": "^3.0.0", + "write-file-atomic": "^5.0.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/types": { + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-30.3.0.tgz", + "integrity": "sha512-JHm87k7bA33hpBngtU8h6UBub/fqqA9uXfw+21j5Hmk7ooPHlboRNxHq0JcMtC+n8VJGP1mcfnD3Mk+XKe1oSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/pattern": "30.0.1", + "@jest/schemas": "30.0.5", + "@types/istanbul-lib-coverage": "^2.0.6", + "@types/istanbul-reports": "^3.0.4", + "@types/node": "*", + "@types/yargs": "^17.0.33", + "chalk": "^4.1.2" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/source-map": { + "version": "0.3.11", + "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.11.tgz", + "integrity": "sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.25" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@lukeed/csprng": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@lukeed/csprng/-/csprng-1.1.0.tgz", + "integrity": "sha512-Z7C/xXCiGWsg0KuKsHTKJxbWhpI3Vs5GwLfOean7MGyVFGqdRgBbAjOCh6u4bbjPc/8MJ2pZmK/0DLdCbivLDA==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@microsoft/tsdoc": { + "version": "0.16.0", + "resolved": "https://registry.npmjs.org/@microsoft/tsdoc/-/tsdoc-0.16.0.tgz", + "integrity": "sha512-xgAyonlVVS+q7Vc7qLW0UrJU7rSFcETRWsqdXZtjzRU8dF+6CkozTK4V4y1LwOX7j8r/vHphjDeMeGI4tNGeGA==", + "license": "MIT" + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "0.2.12", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz", + "integrity": "sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.4.3", + "@emnapi/runtime": "^1.4.3", + "@tybys/wasm-util": "^0.10.0" + } + }, + "node_modules/@nestjs/cli": { + "version": "11.0.19", + "resolved": "https://registry.npmjs.org/@nestjs/cli/-/cli-11.0.19.tgz", + "integrity": "sha512-9htODqTVVNH4lJqyeIotsAgfeaYngDi020cVCd6JhJRKuOT83c/t4JDSky6+xr0lhHyNTNMgZmulxqcMNZFfrw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@angular-devkit/core": "19.2.24", + "@angular-devkit/schematics": "19.2.24", + "@angular-devkit/schematics-cli": "19.2.24", + "@inquirer/prompts": "7.10.1", + "@nestjs/schematics": "^11.0.1", + "ansis": "4.2.0", + "chokidar": "4.0.3", + "cli-table3": "0.6.5", + "commander": "4.1.1", + "fork-ts-checker-webpack-plugin": "9.1.0", + "glob": "13.0.6", + "node-emoji": "1.11.0", + "ora": "5.4.1", + "tsconfig-paths": "4.2.0", + "tsconfig-paths-webpack-plugin": "4.2.0", + "typescript": "5.9.3", + "webpack": "5.106.0", + "webpack-node-externals": "3.0.0" + }, + "bin": { + "nest": "bin/nest.js" + }, + "engines": { + "node": ">= 20.11" + }, + "peerDependencies": { + "@swc/cli": "^0.1.62 || ^0.3.0 || ^0.4.0 || ^0.5.0 || ^0.6.0 || ^0.7.0 || ^0.8.0", + "@swc/core": "^1.3.62" + }, + "peerDependenciesMeta": { + "@swc/cli": { + "optional": true + }, + "@swc/core": { + "optional": true + } + } + }, + "node_modules/@nestjs/common": { + "version": "11.1.18", + "resolved": "https://registry.npmjs.org/@nestjs/common/-/common-11.1.18.tgz", + "integrity": "sha512-0sLq8Z+TIjLnz1Tqp0C/x9BpLbqpt1qEu0VcH4/fkE0y3F5JxhfK1AdKQ/SPbKhKgwqVDoY4gS8GQr2G6ujaWg==", + "license": "MIT", + "peer": true, + "dependencies": { + "file-type": "21.3.4", + "iterare": "1.2.1", + "load-esm": "1.0.3", + "tslib": "2.8.1", + "uid": "2.0.2" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/nest" + }, + "peerDependencies": { + "class-transformer": ">=0.4.1", + "class-validator": ">=0.13.2", + "reflect-metadata": "^0.1.12 || ^0.2.0", + "rxjs": "^7.1.0" + }, + "peerDependenciesMeta": { + "class-transformer": { + "optional": true + }, + "class-validator": { + "optional": true + } + } + }, + "node_modules/@nestjs/config": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@nestjs/config/-/config-4.0.4.tgz", + "integrity": "sha512-CJPjNitr0bAufSEnRe2N+JbnVmMmDoo6hvKCPzXgZoGwJSmp/dZPk9f/RMbuD/+Q1ZJPjwsRpq0vxna++Knwow==", + "license": "MIT", + "dependencies": { + "dotenv": "17.4.1", + "dotenv-expand": "12.0.3", + "lodash": "4.18.1" + }, + "peerDependencies": { + "@nestjs/common": "^10.0.0 || ^11.0.0", + "rxjs": "^7.1.0" + } + }, + "node_modules/@nestjs/core": { + "version": "11.1.18", + "resolved": "https://registry.npmjs.org/@nestjs/core/-/core-11.1.18.tgz", + "integrity": "sha512-wR3DtGyk/LUAiPtbXDuWJJwVkWElKBY0sqnTzf9d4uM3+X18FRZhK7WFc47czsIGOdWuRsMeLYV+1Z9dO4zDEQ==", + "hasInstallScript": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@nuxt/opencollective": "0.4.1", + "fast-safe-stringify": "2.1.1", + "iterare": "1.2.1", + "path-to-regexp": "8.4.2", + "tslib": "2.8.1", + "uid": "2.0.2" + }, + "engines": { + "node": ">= 20" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/nest" + }, + "peerDependencies": { + "@nestjs/common": "^11.0.0", + "@nestjs/microservices": "^11.0.0", + "@nestjs/platform-express": "^11.0.0", + "@nestjs/websockets": "^11.0.0", + "reflect-metadata": "^0.1.12 || ^0.2.0", + "rxjs": "^7.1.0" + }, + "peerDependenciesMeta": { + "@nestjs/microservices": { + "optional": true + }, + "@nestjs/platform-express": { + "optional": true + }, + "@nestjs/websockets": { + "optional": true + } + } + }, + "node_modules/@nestjs/jwt": { + "version": "11.0.2", + "resolved": "https://registry.npmjs.org/@nestjs/jwt/-/jwt-11.0.2.tgz", + "integrity": "sha512-rK8aE/3/Ma45gAWfCksAXUNbOoSOUudU0Kn3rT39htPF7wsYXtKfjALKeKKJbFrIWbLjsbqfXX5bIJNvgBugGA==", + "license": "MIT", + "dependencies": { + "@types/jsonwebtoken": "9.0.10", + "jsonwebtoken": "9.0.3" + }, + "peerDependencies": { + "@nestjs/common": "^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0" + } + }, + "node_modules/@nestjs/mapped-types": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@nestjs/mapped-types/-/mapped-types-2.1.1.tgz", + "integrity": "sha512-SCCoMEJ6jdeI5h/N+KCVF1+pmg/hmEkNA5nHTS8Gvww7T/LCl4o1gFLinw2iQ60w7slFkszHcGLKGdazVI4F8A==", + "license": "MIT", + "peerDependencies": { + "@nestjs/common": "^10.0.0 || ^11.0.0", + "class-transformer": "^0.4.0 || ^0.5.0", + "class-validator": "^0.13.0 || ^0.14.0 || ^0.15.0", + "reflect-metadata": "^0.1.12 || ^0.2.0" + }, + "peerDependenciesMeta": { + "class-transformer": { + "optional": true + }, + "class-validator": { + "optional": true + } + } + }, + "node_modules/@nestjs/passport": { + "version": "11.0.5", + "resolved": "https://registry.npmjs.org/@nestjs/passport/-/passport-11.0.5.tgz", + "integrity": "sha512-ulQX6mbjlws92PIM15Naes4F4p2JoxGnIJuUsdXQPT+Oo2sqQmENEZXM7eYuimocfHnKlcfZOuyzbA33LwUlOQ==", + "license": "MIT", + "peerDependencies": { + "@nestjs/common": "^10.0.0 || ^11.0.0", + "passport": "^0.5.0 || ^0.6.0 || ^0.7.0" + } + }, + "node_modules/@nestjs/platform-express": { + "version": "11.1.18", + "resolved": "https://registry.npmjs.org/@nestjs/platform-express/-/platform-express-11.1.18.tgz", + "integrity": "sha512-s6GdHMTa3qx0fJewR74Xa30ysPHfBEqxIwZ7BGSTLoAEQ1vTP24urNl+b6+s49NFLEIOyeNho5fN/9/I17QlOw==", + "license": "MIT", + "peer": true, + "dependencies": { + "cors": "2.8.6", + "express": "5.2.1", + "multer": "2.1.1", + "path-to-regexp": "8.4.2", + "tslib": "2.8.1" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/nest" + }, + "peerDependencies": { + "@nestjs/common": "^11.0.0", + "@nestjs/core": "^11.0.0" + } + }, + "node_modules/@nestjs/schematics": { + "version": "11.0.10", + "resolved": "https://registry.npmjs.org/@nestjs/schematics/-/schematics-11.0.10.tgz", + "integrity": "sha512-q9lr0wGwgBHLarD4uno3XiW4JX60WPlg2VTgbqPHl/6bT4u1IEEzj+q9Tad3bVnqL5mlDF3vrZ2tj+x13CJpmw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@angular-devkit/core": "19.2.23", + "@angular-devkit/schematics": "19.2.23", + "comment-json": "4.6.2", + "jsonc-parser": "3.3.1", + "pluralize": "8.0.0" + }, + "peerDependencies": { + "typescript": ">=4.8.2" + } + }, + "node_modules/@nestjs/schematics/node_modules/@angular-devkit/core": { + "version": "19.2.23", + "resolved": "https://registry.npmjs.org/@angular-devkit/core/-/core-19.2.23.tgz", + "integrity": "sha512-RazHPQkUEsNU/OZ75w9UeHxGFMthRiuAW2B/uA7eXExBj/1meHrrBfoCA56ujW2GUxVjRtSrMjylKh4R4meiYA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "8.18.0", + "ajv-formats": "3.0.1", + "jsonc-parser": "3.3.1", + "picomatch": "4.0.4", + "rxjs": "7.8.1", + "source-map": "0.7.4" + }, + "engines": { + "node": "^18.19.1 || ^20.11.1 || >=22.0.0", + "npm": "^6.11.0 || ^7.5.6 || >=8.0.0", + "yarn": ">= 1.13.0" + }, + "peerDependencies": { + "chokidar": "^4.0.0" + }, + "peerDependenciesMeta": { + "chokidar": { + "optional": true + } + } + }, + "node_modules/@nestjs/schematics/node_modules/@angular-devkit/schematics": { + "version": "19.2.23", + "resolved": "https://registry.npmjs.org/@angular-devkit/schematics/-/schematics-19.2.23.tgz", + "integrity": "sha512-Jzs7YM4X6azmHU7Mw5tQSPMuvaqYS8SLnZOJbtiXCy1JyuW9bm/WBBecNHMiuZ8LHXKhvQ6AVX1tKrzF6uiDmw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@angular-devkit/core": "19.2.23", + "jsonc-parser": "3.3.1", + "magic-string": "0.30.17", + "ora": "5.4.1", + "rxjs": "7.8.1" + }, + "engines": { + "node": "^18.19.1 || ^20.11.1 || >=22.0.0", + "npm": "^6.11.0 || ^7.5.6 || >=8.0.0", + "yarn": ">= 1.13.0" + } + }, + "node_modules/@nestjs/schematics/node_modules/ajv": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", + "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/@nestjs/schematics/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true, + "license": "MIT" + }, + "node_modules/@nestjs/schematics/node_modules/rxjs": { + "version": "7.8.1", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.1.tgz", + "integrity": "sha512-AA3TVj+0A2iuIoQkWEK/tqFjBq2j+6PO6Y0zJcvzLAFhEFIO3HL0vls9hWLncZbAAbK0mar7oZ4V079I/qPMxg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.1.0" + } + }, + "node_modules/@nestjs/swagger": { + "version": "11.4.2", + "resolved": "https://registry.npmjs.org/@nestjs/swagger/-/swagger-11.4.2.tgz", + "integrity": "sha512-aBihEogDMj/bLEcaqhkvyX/ZVWUw/bmnhKzR0zwUoyGJikvZyaq7rOPYl/H7Lxkkr3c90SJxyuv1AX2UT1WKlw==", + "license": "MIT", + "dependencies": { + "@microsoft/tsdoc": "0.16.0", + "@nestjs/mapped-types": "2.1.1", + "js-yaml": "4.1.1", + "lodash": "4.18.1", + "path-to-regexp": "8.4.2", + "swagger-ui-dist": "5.32.4" + }, + "peerDependencies": { + "@fastify/static": "^8.0.0 || ^9.0.0", + "@nestjs/common": "^11.0.1", + "@nestjs/core": "^11.0.1", + "class-transformer": "*", + "class-validator": "*", + "reflect-metadata": "^0.1.12 || ^0.2.0" + }, + "peerDependenciesMeta": { + "@fastify/static": { + "optional": true + }, + "class-transformer": { + "optional": true + }, + "class-validator": { + "optional": true + } + } + }, + "node_modules/@nestjs/testing": { + "version": "11.1.18", + "resolved": "https://registry.npmjs.org/@nestjs/testing/-/testing-11.1.18.tgz", + "integrity": "sha512-frzwNlpBgtAzI3hp/qo57DZoRO4RMTH1wST3QUYEhRTHyfPkLpzkWz3jV/mhApXjD0yT56Ptlzn6zuYPLh87Lw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tslib": "2.8.1" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/nest" + }, + "peerDependencies": { + "@nestjs/common": "^11.0.0", + "@nestjs/core": "^11.0.0", + "@nestjs/microservices": "^11.0.0", + "@nestjs/platform-express": "^11.0.0" + }, + "peerDependenciesMeta": { + "@nestjs/microservices": { + "optional": true + }, + "@nestjs/platform-express": { + "optional": true + } + } + }, + "node_modules/@nestjs/throttler": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@nestjs/throttler/-/throttler-6.5.0.tgz", + "integrity": "sha512-9j0ZRfH0QE1qyrj9JjIRDz5gQLPqq9yVC2nHsrosDVAfI5HHw08/aUAWx9DZLSdQf4HDkmhTTEGLrRFHENvchQ==", + "license": "MIT", + "peerDependencies": { + "@nestjs/common": "^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0", + "@nestjs/core": "^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0", + "reflect-metadata": "^0.1.13 || ^0.2.0" + } + }, + "node_modules/@noble/hashes": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", + "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@nuxt/opencollective": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@nuxt/opencollective/-/opencollective-0.4.1.tgz", + "integrity": "sha512-GXD3wy50qYbxCJ652bDrDzgMr3NFEkIS374+IgFQKkCvk9yiYcLvX2XDYr7UyQxf4wK0e+yqDYRubZ0DtOxnmQ==", + "license": "MIT", + "dependencies": { + "consola": "^3.2.3" + }, + "bin": { + "opencollective": "bin/opencollective.js" + }, + "engines": { + "node": "^14.18.0 || >=16.10.0", + "npm": ">=5.10.0" + } + }, + "node_modules/@paralleldrive/cuid2": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/@paralleldrive/cuid2/-/cuid2-2.3.1.tgz", + "integrity": "sha512-XO7cAxhnTZl0Yggq6jOgjiOHhbgcO4NqFqwSmQpjK3b6TEE6Uj/jfSk6wzYyemh3+I0sHirKSetjQwn5cZktFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@noble/hashes": "^1.1.5" + } + }, + "node_modules/@pkgjs/parseargs": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", + "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=14" + } + }, + "node_modules/@pkgr/core": { + "version": "0.2.9", + "resolved": "https://registry.npmjs.org/@pkgr/core/-/core-0.2.9.tgz", + "integrity": "sha512-QNqXyfVS2wm9hweSYD2O7F0G06uurj9kZ96TRQE5Y9hU7+tgdZwIkbAKc5Ocy1HxEY2kuDQa6cQ1WRs/O5LFKA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/pkgr" + } + }, + "node_modules/@prisma/client": { + "version": "5.22.0", + "resolved": "https://registry.npmjs.org/@prisma/client/-/client-5.22.0.tgz", + "integrity": "sha512-M0SVXfyHnQREBKxCgyo7sffrKttwE6R8PMq330MIUF0pTwjUhLbW84pFDlf06B27XyCR++VtjugEnIHdr07SVA==", + "hasInstallScript": true, + "license": "Apache-2.0", + "engines": { + "node": ">=16.13" + }, + "peerDependencies": { + "prisma": "*" + }, + "peerDependenciesMeta": { + "prisma": { + "optional": true + } + } + }, + "node_modules/@prisma/debug": { + "version": "5.22.0", + "resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-5.22.0.tgz", + "integrity": "sha512-AUt44v3YJeggO2ZU5BkXI7M4hu9BF2zzH2iF2V5pyXT/lRTyWiElZ7It+bRH1EshoMRxHgpYg4VB6rCM+mG5jQ==", + "license": "Apache-2.0" + }, + "node_modules/@prisma/engines": { + "version": "5.22.0", + "resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-5.22.0.tgz", + "integrity": "sha512-UNjfslWhAt06kVL3CjkuYpHAWSO6L4kDCVPegV6itt7nD1kSJavd3vhgAEhjglLJJKEdJ7oIqDJ+yHk6qO8gPA==", + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@prisma/debug": "5.22.0", + "@prisma/engines-version": "5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2", + "@prisma/fetch-engine": "5.22.0", + "@prisma/get-platform": "5.22.0" + } + }, + "node_modules/@prisma/engines-version": { + "version": "5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2", + "resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2.tgz", + "integrity": "sha512-2PTmxFR2yHW/eB3uqWtcgRcgAbG1rwG9ZriSvQw+nnb7c4uCr3RAcGMb6/zfE88SKlC1Nj2ziUvc96Z379mHgQ==", + "license": "Apache-2.0" + }, + "node_modules/@prisma/fetch-engine": { + "version": "5.22.0", + "resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-5.22.0.tgz", + "integrity": "sha512-bkrD/Mc2fSvkQBV5EpoFcZ87AvOgDxbG99488a5cexp5Ccny+UM6MAe/UFkUC0wLYD9+9befNOqGiIJhhq+HbA==", + "license": "Apache-2.0", + "dependencies": { + "@prisma/debug": "5.22.0", + "@prisma/engines-version": "5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2", + "@prisma/get-platform": "5.22.0" + } + }, + "node_modules/@prisma/get-platform": { + "version": "5.22.0", + "resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-5.22.0.tgz", + "integrity": "sha512-pHhpQdr1UPFpt+zFfnPazhulaZYCUqeIcPpJViYoq9R+D/yw4fjE+CtnsnKzPYm0ddUbeXUzjGVGIRVgPDCk4Q==", + "license": "Apache-2.0", + "dependencies": { + "@prisma/debug": "5.22.0" + } + }, + "node_modules/@scarf/scarf": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@scarf/scarf/-/scarf-1.4.0.tgz", + "integrity": "sha512-xxeapPiUXdZAE3che6f3xogoJPeZgig6omHEy1rIY5WVsB3H2BHNnZH+gHG6x91SCWyQCzWGsuL2Hh3ClO5/qQ==", + "hasInstallScript": true, + "license": "Apache-2.0" + }, + "node_modules/@sinclair/typebox": { + "version": "0.34.49", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.49.tgz", + "integrity": "sha512-brySQQs7Jtn0joV8Xh9ZV/hZb9Ozb0pmazDIASBkYKCjXrXU3mpcFahmK/z4YDhGkQvP9mWJbVyahdtU5wQA+A==", + "dev": true, + "license": "MIT" + }, + "node_modules/@sinonjs/commons": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-3.0.1.tgz", + "integrity": "sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "type-detect": "4.0.8" + } + }, + "node_modules/@sinonjs/fake-timers": { + "version": "15.3.0", + "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-15.3.0.tgz", + "integrity": "sha512-m2xozxSfCIxjDdvbhIWazlP2i2aha/iUmbl94alpsIbd3iLTfeXgfBVbwyWogB6l++istyGZqamgA/EcqYf+Bg==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@sinonjs/commons": "^3.0.1" + } + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "license": "MIT" + }, + "node_modules/@tokenizer/inflate": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@tokenizer/inflate/-/inflate-0.4.1.tgz", + "integrity": "sha512-2mAv+8pkG6GIZiF1kNg1jAjh27IDxEPKwdGul3snfztFerfPGI1LjDezZp3i7BElXompqEtPmoPx6c2wgtWsOA==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "token-types": "^6.1.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + } + }, + "node_modules/@tokenizer/token": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@tokenizer/token/-/token-0.3.0.tgz", + "integrity": "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A==", + "license": "MIT" + }, + "node_modules/@tsconfig/node10": { + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.12.tgz", + "integrity": "sha512-UCYBaeFvM11aU2y3YPZ//O5Rhj+xKyzy7mvcIoAjASbigy8mHMryP5cK7dgjlz2hWxh1g5pLw084E0a/wlUSFQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tsconfig/node12": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@tsconfig/node12/-/node12-1.0.11.tgz", + "integrity": "sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tsconfig/node14": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@tsconfig/node14/-/node14-1.0.3.tgz", + "integrity": "sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tsconfig/node16": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@tsconfig/node16/-/node16-1.0.4.tgz", + "integrity": "sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", + "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/bcrypt": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/@types/bcrypt/-/bcrypt-6.0.0.tgz", + "integrity": "sha512-/oJGukuH3D2+D+3H4JWLaAsJ/ji86dhRidzZ/Od7H/i8g+aCmvkeCc6Ni/f9uxGLSQVCRZkX2/lqEFG2BvWtlQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/body-parser": { + "version": "1.19.6", + "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", + "integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/connect": "*", + "@types/node": "*" + } + }, + "node_modules/@types/connect": { + "version": "3.4.38", + "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", + "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/cookiejar": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@types/cookiejar/-/cookiejar-2.1.5.tgz", + "integrity": "sha512-he+DHOWReW0nghN24E1WUqM0efK4kI9oTqDm6XmK8ZPe2djZ90BSNdGnIyCLzCPw7/pogPlGbzI2wHGGmi4O/Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/eslint": { + "version": "9.6.1", + "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-9.6.1.tgz", + "integrity": "sha512-FXx2pKgId/WyYo2jXw63kk7/+TY7u7AziEJxJAnSFzHlqTAS3Ync6SvgYAN/k4/PQpnnVuzoMuVnByKK2qp0ag==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@types/estree": "*", + "@types/json-schema": "*" + } + }, + "node_modules/@types/eslint-scope": { + "version": "3.7.7", + "resolved": "https://registry.npmjs.org/@types/eslint-scope/-/eslint-scope-3.7.7.tgz", + "integrity": "sha512-MzMFlSLBqNF2gcHWO0G1vP/YQyfvrxZ0bF+u7mzUdZ1/xK4A4sru+nraZz5i3iEIk1l1uyicaDVTB4QbbEkAYg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/eslint": "*", + "@types/estree": "*" + } + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/express": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.6.tgz", + "integrity": "sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/body-parser": "*", + "@types/express-serve-static-core": "^5.0.0", + "@types/serve-static": "^2" + } + }, + "node_modules/@types/express-serve-static-core": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-5.1.1.tgz", + "integrity": "sha512-v4zIMr/cX7/d2BpAEX3KNKL/JrT1s43s96lLvvdTmza1oEvDudCqK9aF/djc/SWgy8Yh0h30TZx5VpzqFCxk5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "@types/qs": "*", + "@types/range-parser": "*", + "@types/send": "*" + } + }, + "node_modules/@types/http-errors": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz", + "integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/istanbul-lib-coverage": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz", + "integrity": "sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/istanbul-lib-report": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/istanbul-lib-report/-/istanbul-lib-report-3.0.3.tgz", + "integrity": "sha512-NQn7AHQnk/RSLOxrBbGyJM/aVQ+pjj5HCgasFxc0K/KhoATfQ/47AyUl15I2yBUpihjmas+a+VJBOqecrFH+uA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/istanbul-lib-coverage": "*" + } + }, + "node_modules/@types/istanbul-reports": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/istanbul-reports/-/istanbul-reports-3.0.4.tgz", + "integrity": "sha512-pk2B1NWalF9toCRu6gjBzR69syFjP4Od8WRAX+0mmf9lAjCRicLOWc+ZrxZHx/0XRjotgkF9t6iaMJ+aXcOdZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/istanbul-lib-report": "*" + } + }, + "node_modules/@types/jest": { + "version": "30.0.0", + "resolved": "https://registry.npmjs.org/@types/jest/-/jest-30.0.0.tgz", + "integrity": "sha512-XTYugzhuwqWjws0CVz8QpM36+T+Dz5mTEBKhNs/esGLnCIlGdRy+Dq78NRjd7ls7r8BC8ZRMOrKlkO1hU0JOwA==", + "dev": true, + "license": "MIT", + "dependencies": { + "expect": "^30.0.0", + "pretty-format": "^30.0.0" + } + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/jsonwebtoken": { + "version": "9.0.10", + "resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.10.tgz", + "integrity": "sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==", + "license": "MIT", + "dependencies": { + "@types/ms": "*", + "@types/node": "*" + } + }, + "node_modules/@types/methods": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@types/methods/-/methods-1.1.4.tgz", + "integrity": "sha512-ymXWVrDiCxTBE3+RIrrP533E70eA+9qu7zdWoHuOmGujkYtzf4HQF96b8nwHLqhuf4ykX61IGRIB38CC6/sImQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "24.12.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.12.2.tgz", + "integrity": "sha512-A1sre26ke7HDIuY/M23nd9gfB+nrmhtYyMINbjI1zHJxYteKR6qSMX56FsmjMcDb3SMcjJg5BiRRgOCC/yBD0g==", + "license": "MIT", + "peer": true, + "dependencies": { + "undici-types": "~7.16.0" + } + }, + "node_modules/@types/nodemailer": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-8.0.1.tgz", + "integrity": "sha512-PxpaInm8V1JQDd4j0ds5HfvWQk8JupS1C0Picb96QJsrrRDjBH+DlK7L4ZdNSqNULhiZRQHc40nLVShaGxXAMw==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/passport": { + "version": "1.0.17", + "resolved": "https://registry.npmjs.org/@types/passport/-/passport-1.0.17.tgz", + "integrity": "sha512-aciLyx+wDwT2t2/kJGJR2AEeBz0nJU4WuRX04Wu9Dqc5lSUtwu0WERPHYsLhF9PtseiAMPBGNUOtFjxZ56prsg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/express": "*" + } + }, + "node_modules/@types/passport-jwt": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@types/passport-jwt/-/passport-jwt-4.0.1.tgz", + "integrity": "sha512-Y0Ykz6nWP4jpxgEUYq8NoVZeCQPo1ZndJLfapI249g1jHChvRfZRO/LS3tqu26YgAS/laI1qx98sYGz0IalRXQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/jsonwebtoken": "*", + "@types/passport-strategy": "*" + } + }, + "node_modules/@types/passport-strategy": { + "version": "0.2.38", + "resolved": "https://registry.npmjs.org/@types/passport-strategy/-/passport-strategy-0.2.38.tgz", + "integrity": "sha512-GC6eMqqojOooq993Tmnmp7AUTbbQSgilyvpCYQjT+H6JfG/g6RGc7nXEniZlp0zyKJ0WUdOiZWLBZft9Yug1uA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/express": "*", + "@types/passport": "*" + } + }, + "node_modules/@types/qs": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.0.tgz", + "integrity": "sha512-JawvT8iBVWpzTrz3EGw9BTQFg3BQNmwERdKE22vlTxawwtbyUSlMppvZYKLZzB5zgACXdXxbD3m1bXaMqP/9ow==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/range-parser": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", + "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz", + "integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/serve-static": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-2.2.0.tgz", + "integrity": "sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/http-errors": "*", + "@types/node": "*" + } + }, + "node_modules/@types/stack-utils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.3.tgz", + "integrity": "sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/superagent": { + "version": "8.1.9", + "resolved": "https://registry.npmjs.org/@types/superagent/-/superagent-8.1.9.tgz", + "integrity": "sha512-pTVjI73witn+9ILmoJdajHGW2jkSaOzhiFYF1Rd3EQ94kymLqB9PjD9ISg7WaALC7+dCHT0FGe9T2LktLq/3GQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/cookiejar": "^2.1.5", + "@types/methods": "^1.1.4", + "@types/node": "*", + "form-data": "^4.0.0" + } + }, + "node_modules/@types/supertest": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@types/supertest/-/supertest-7.2.0.tgz", + "integrity": "sha512-uh2Lv57xvggst6lCqNdFAmDSvoMG7M/HDtX4iUCquxQ5EGPtaPM5PL5Hmi7LCvOG8db7YaCPNJEeoI8s/WzIQw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/methods": "^1.1.4", + "@types/superagent": "^8.1.0" + } + }, + "node_modules/@types/validator": { + "version": "13.15.10", + "resolved": "https://registry.npmjs.org/@types/validator/-/validator-13.15.10.tgz", + "integrity": "sha512-T8L6i7wCuyoK8A/ZeLYt1+q0ty3Zb9+qbSSvrIVitzT3YjZqkTZ40IbRsPanlB4h1QB3JVL1SYCdR6ngtFYcuA==", + "license": "MIT" + }, + "node_modules/@types/yargs": { + "version": "17.0.35", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.35.tgz", + "integrity": "sha512-qUHkeCyQFxMXg79wQfTtfndEC+N9ZZg76HJftDJp+qH2tV7Gj4OJi7l+PiWwJ+pWtW8GwSmqsDj/oymhrTWXjg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/yargs-parser": "*" + } + }, + "node_modules/@types/yargs-parser": { + "version": "21.0.3", + "resolved": "https://registry.npmjs.org/@types/yargs-parser/-/yargs-parser-21.0.3.tgz", + "integrity": "sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.58.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.58.1.tgz", + "integrity": "sha512-eSkwoemjo76bdXl2MYqtxg51HNwUSkWfODUOQ3PaTLZGh9uIWWFZIjyjaJnex7wXDu+TRx+ATsnSxdN9YWfRTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.58.1", + "@typescript-eslint/type-utils": "8.58.1", + "@typescript-eslint/utils": "8.58.1", + "@typescript-eslint/visitor-keys": "8.58.1", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.58.1", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.58.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.58.1.tgz", + "integrity": "sha512-gGkiNMPqerb2cJSVcruigx9eHBlLG14fSdPdqMoOcBfh+vvn4iCq2C8MzUB89PrxOXk0y3GZ1yIWb9aOzL93bw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@typescript-eslint/scope-manager": "8.58.1", + "@typescript-eslint/types": "8.58.1", + "@typescript-eslint/typescript-estree": "8.58.1", + "@typescript-eslint/visitor-keys": "8.58.1", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.58.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.58.1.tgz", + "integrity": "sha512-gfQ8fk6cxhtptek+/8ZIqw8YrRW5048Gug8Ts5IYcMLCw18iUgrZAEY/D7s4hkI0FxEfGakKuPK/XUMPzPxi5g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.58.1", + "@typescript-eslint/types": "^8.58.1", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.58.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.58.1.tgz", + "integrity": "sha512-TPYUEqJK6avLcEjumWsIuTpuYODTTDAtoMdt8ZZa93uWMTX13Nb8L5leSje1NluammvU+oI3QRr5lLXPgihX3w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.58.1", + "@typescript-eslint/visitor-keys": "8.58.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.58.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.58.1.tgz", + "integrity": "sha512-JAr2hOIct2Q+qk3G+8YFfqkqi7sC86uNryT+2i5HzMa2MPjw4qNFvtjnw1IiA1rP7QhNKVe21mSSLaSjwA1Olw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.58.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.58.1.tgz", + "integrity": "sha512-HUFxvTJVroT+0rXVJC7eD5zol6ID+Sn5npVPWoFuHGg9Ncq5Q4EYstqR+UOqaNRFXi5TYkpXXkLhoCHe3G0+7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.58.1", + "@typescript-eslint/typescript-estree": "8.58.1", + "@typescript-eslint/utils": "8.58.1", + "debug": "^4.4.3", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.58.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.58.1.tgz", + "integrity": "sha512-io/dV5Aw5ezwzfPBBWLoT+5QfVtP8O7q4Kftjn5azJ88bYyp/ZMCsyW1lpKK46EXJcaYMZ1JtYj+s/7TdzmQMw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.58.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.58.1.tgz", + "integrity": "sha512-w4w7WR7GHOjqqPnvAYbazq+Y5oS68b9CzasGtnd6jIeOIeKUzYzupGTB2T4LTPSv4d+WPeccbxuneTFHYgAAWg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.58.1", + "@typescript-eslint/tsconfig-utils": "8.58.1", + "@typescript-eslint/types": "8.58.1", + "@typescript-eslint/visitor-keys": "8.58.1", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", + "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.58.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.58.1.tgz", + "integrity": "sha512-Ln8R0tmWC7pTtLOzgJzYTXSCjJ9rDNHAqTaVONF4FEi2qwce8mD9iSOxOpLFFvWp/wBFlew0mjM1L1ihYWfBdQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.58.1", + "@typescript-eslint/types": "8.58.1", + "@typescript-eslint/typescript-estree": "8.58.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.58.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.58.1.tgz", + "integrity": "sha512-y+vH7QE8ycjoa0bWciFg7OpFcipUuem1ujhrdLtq1gByKwfbC7bPeKsiny9e0urg93DqwGcHey+bGRKCnF1nZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.58.1", + "eslint-visitor-keys": "^5.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@ungap/structured-clone": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz", + "integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==", + "dev": true, + "license": "ISC" + }, + "node_modules/@unrs/resolver-binding-android-arm-eabi": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm-eabi/-/resolver-binding-android-arm-eabi-1.11.1.tgz", + "integrity": "sha512-ppLRUgHVaGRWUx0R0Ut06Mjo9gBaBkg3v/8AxusGLhsIotbBLuRk51rAzqLC8gq6NyyAojEXglNjzf6R948DNw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@unrs/resolver-binding-android-arm64": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm64/-/resolver-binding-android-arm64-1.11.1.tgz", + "integrity": "sha512-lCxkVtb4wp1v+EoN+HjIG9cIIzPkX5OtM03pQYkG+U5O/wL53LC4QbIeazgiKqluGeVEeBlZahHalCaBvU1a2g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@unrs/resolver-binding-darwin-arm64": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-arm64/-/resolver-binding-darwin-arm64-1.11.1.tgz", + "integrity": "sha512-gPVA1UjRu1Y/IsB/dQEsp2V1pm44Of6+LWvbLc9SDk1c2KhhDRDBUkQCYVWe6f26uJb3fOK8saWMgtX8IrMk3g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@unrs/resolver-binding-darwin-x64": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-x64/-/resolver-binding-darwin-x64-1.11.1.tgz", + "integrity": "sha512-cFzP7rWKd3lZaCsDze07QX1SC24lO8mPty9vdP+YVa3MGdVgPmFc59317b2ioXtgCMKGiCLxJ4HQs62oz6GfRQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@unrs/resolver-binding-freebsd-x64": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-freebsd-x64/-/resolver-binding-freebsd-x64-1.11.1.tgz", + "integrity": "sha512-fqtGgak3zX4DCB6PFpsH5+Kmt/8CIi4Bry4rb1ho6Av2QHTREM+47y282Uqiu3ZRF5IQioJQ5qWRV6jduA+iGw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm-gnueabihf": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-gnueabihf/-/resolver-binding-linux-arm-gnueabihf-1.11.1.tgz", + "integrity": "sha512-u92mvlcYtp9MRKmP+ZvMmtPN34+/3lMHlyMj7wXJDeXxuM0Vgzz0+PPJNsro1m3IZPYChIkn944wW8TYgGKFHw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm-musleabihf": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-musleabihf/-/resolver-binding-linux-arm-musleabihf-1.11.1.tgz", + "integrity": "sha512-cINaoY2z7LVCrfHkIcmvj7osTOtm6VVT16b5oQdS4beibX2SYBwgYLmqhBjA1t51CarSaBuX5YNsWLjsqfW5Cw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm64-gnu": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-gnu/-/resolver-binding-linux-arm64-gnu-1.11.1.tgz", + "integrity": "sha512-34gw7PjDGB9JgePJEmhEqBhWvCiiWCuXsL9hYphDF7crW7UgI05gyBAi6MF58uGcMOiOqSJ2ybEeCvHcq0BCmQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm64-musl": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-musl/-/resolver-binding-linux-arm64-musl-1.11.1.tgz", + "integrity": "sha512-RyMIx6Uf53hhOtJDIamSbTskA99sPHS96wxVE/bJtePJJtpdKGXO1wY90oRdXuYOGOTuqjT8ACccMc4K6QmT3w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-ppc64-gnu": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-ppc64-gnu/-/resolver-binding-linux-ppc64-gnu-1.11.1.tgz", + "integrity": "sha512-D8Vae74A4/a+mZH0FbOkFJL9DSK2R6TFPC9M+jCWYia/q2einCubX10pecpDiTmkJVUH+y8K3BZClycD8nCShA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-riscv64-gnu": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-gnu/-/resolver-binding-linux-riscv64-gnu-1.11.1.tgz", + "integrity": "sha512-frxL4OrzOWVVsOc96+V3aqTIQl1O2TjgExV4EKgRY09AJ9leZpEg8Ak9phadbuX0BA4k8U5qtvMSQQGGmaJqcQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-riscv64-musl": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-musl/-/resolver-binding-linux-riscv64-musl-1.11.1.tgz", + "integrity": "sha512-mJ5vuDaIZ+l/acv01sHoXfpnyrNKOk/3aDoEdLO/Xtn9HuZlDD6jKxHlkN8ZhWyLJsRBxfv9GYM2utQ1SChKew==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-s390x-gnu": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-s390x-gnu/-/resolver-binding-linux-s390x-gnu-1.11.1.tgz", + "integrity": "sha512-kELo8ebBVtb9sA7rMe1Cph4QHreByhaZ2QEADd9NzIQsYNQpt9UkM9iqr2lhGr5afh885d/cB5QeTXSbZHTYPg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-x64-gnu": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-gnu/-/resolver-binding-linux-x64-gnu-1.11.1.tgz", + "integrity": "sha512-C3ZAHugKgovV5YvAMsxhq0gtXuwESUKc5MhEtjBpLoHPLYM+iuwSj3lflFwK3DPm68660rZ7G8BMcwSro7hD5w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-x64-musl": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-musl/-/resolver-binding-linux-x64-musl-1.11.1.tgz", + "integrity": "sha512-rV0YSoyhK2nZ4vEswT/QwqzqQXw5I6CjoaYMOX0TqBlWhojUf8P94mvI7nuJTeaCkkds3QE4+zS8Ko+GdXuZtA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-wasm32-wasi": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-wasm32-wasi/-/resolver-binding-wasm32-wasi-1.11.1.tgz", + "integrity": "sha512-5u4RkfxJm+Ng7IWgkzi3qrFOvLvQYnPBmjmZQ8+szTK/b31fQCnleNl1GgEt7nIsZRIf5PLhPwT0WM+q45x/UQ==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@napi-rs/wasm-runtime": "^0.2.11" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@unrs/resolver-binding-win32-arm64-msvc": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-arm64-msvc/-/resolver-binding-win32-arm64-msvc-1.11.1.tgz", + "integrity": "sha512-nRcz5Il4ln0kMhfL8S3hLkxI85BXs3o8EYoattsJNdsX4YUU89iOkVn7g0VHSRxFuVMdM4Q1jEpIId1Ihim/Uw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@unrs/resolver-binding-win32-ia32-msvc": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-ia32-msvc/-/resolver-binding-win32-ia32-msvc-1.11.1.tgz", + "integrity": "sha512-DCEI6t5i1NmAZp6pFonpD5m7i6aFrpofcp4LA2i8IIq60Jyo28hamKBxNrZcyOwVOZkgsRp9O2sXWBWP8MnvIQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@unrs/resolver-binding-win32-x64-msvc": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-x64-msvc/-/resolver-binding-win32-x64-msvc-1.11.1.tgz", + "integrity": "sha512-lrW200hZdbfRtztbygyaq/6jP6AKE8qQN2KvPcJ+x7wiD038YtnYtZ82IMNJ69GJibV7bwL3y9FgK+5w/pYt6g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@webassemblyjs/ast": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.14.1.tgz", + "integrity": "sha512-nuBEDgQfm1ccRp/8bCQrx1frohyufl4JlbMMZ4P1wpeOfDhF6FQkxZJ1b/e+PLwr6X1Nhw6OLme5usuBWYBvuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@webassemblyjs/helper-numbers": "1.13.2", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2" + } + }, + "node_modules/@webassemblyjs/floating-point-hex-parser": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/floating-point-hex-parser/-/floating-point-hex-parser-1.13.2.tgz", + "integrity": "sha512-6oXyTOzbKxGH4steLbLNOu71Oj+C8Lg34n6CqRvqfS2O71BxY6ByfMDRhBytzknj9yGUPVJ1qIKhRlAwO1AovA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@webassemblyjs/helper-api-error": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-api-error/-/helper-api-error-1.13.2.tgz", + "integrity": "sha512-U56GMYxy4ZQCbDZd6JuvvNV/WFildOjsaWD3Tzzvmw/mas3cXzRJPMjP83JqEsgSbyrmaGjBfDtV7KDXV9UzFQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@webassemblyjs/helper-buffer": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-buffer/-/helper-buffer-1.14.1.tgz", + "integrity": "sha512-jyH7wtcHiKssDtFPRB+iQdxlDf96m0E39yb0k5uJVhFGleZFoNw1c4aeIcVUPPbXUVJ94wwnMOAqUHyzoEPVMA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@webassemblyjs/helper-numbers": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-numbers/-/helper-numbers-1.13.2.tgz", + "integrity": "sha512-FE8aCmS5Q6eQYcV3gI35O4J789wlQA+7JrqTTpJqn5emA4U2hvwJmvFRC0HODS+3Ye6WioDklgd6scJ3+PLnEA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@webassemblyjs/floating-point-hex-parser": "1.13.2", + "@webassemblyjs/helper-api-error": "1.13.2", + "@xtuc/long": "4.2.2" + } + }, + "node_modules/@webassemblyjs/helper-wasm-bytecode": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-bytecode/-/helper-wasm-bytecode-1.13.2.tgz", + "integrity": "sha512-3QbLKy93F0EAIXLh0ogEVR6rOubA9AoZ+WRYhNbFyuB70j3dRdwH9g+qXhLAO0kiYGlg3TxDV+I4rQTr/YNXkA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@webassemblyjs/helper-wasm-section": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-section/-/helper-wasm-section-1.14.1.tgz", + "integrity": "sha512-ds5mXEqTJ6oxRoqjhWDU83OgzAYjwsCV8Lo/N+oRsNDmx/ZDpqalmrtgOMkHwxsG0iI//3BwWAErYRHtgn0dZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-buffer": "1.14.1", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2", + "@webassemblyjs/wasm-gen": "1.14.1" + } + }, + "node_modules/@webassemblyjs/ieee754": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/ieee754/-/ieee754-1.13.2.tgz", + "integrity": "sha512-4LtOzh58S/5lX4ITKxnAK2USuNEvpdVV9AlgGQb8rJDHaLeHciwG4zlGr0j/SNWlr7x3vO1lDEsuePvtcDNCkw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@xtuc/ieee754": "^1.2.0" + } + }, + "node_modules/@webassemblyjs/leb128": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/leb128/-/leb128-1.13.2.tgz", + "integrity": "sha512-Lde1oNoIdzVzdkNEAWZ1dZ5orIbff80YPdHx20mrHwHrVNNTjNr8E3xz9BdpcGqRQbAEa+fkrCb+fRFTl/6sQw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@xtuc/long": "4.2.2" + } + }, + "node_modules/@webassemblyjs/utf8": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/utf8/-/utf8-1.13.2.tgz", + "integrity": "sha512-3NQWGjKTASY1xV5m7Hr0iPeXD9+RDobLll3T9d2AO+g3my8xy5peVyjSag4I50mR1bBSN/Ct12lo+R9tJk0NZQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@webassemblyjs/wasm-edit": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-edit/-/wasm-edit-1.14.1.tgz", + "integrity": "sha512-RNJUIQH/J8iA/1NzlE4N7KtyZNHi3w7at7hDjvRNm5rcUXa00z1vRz3glZoULfJ5mpvYhLybmVcwcjGrC1pRrQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-buffer": "1.14.1", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2", + "@webassemblyjs/helper-wasm-section": "1.14.1", + "@webassemblyjs/wasm-gen": "1.14.1", + "@webassemblyjs/wasm-opt": "1.14.1", + "@webassemblyjs/wasm-parser": "1.14.1", + "@webassemblyjs/wast-printer": "1.14.1" + } + }, + "node_modules/@webassemblyjs/wasm-gen": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-gen/-/wasm-gen-1.14.1.tgz", + "integrity": "sha512-AmomSIjP8ZbfGQhumkNvgC33AY7qtMCXnN6bL2u2Js4gVCg8fp735aEiMSBbDR7UQIj90n4wKAFUSEd0QN2Ukg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2", + "@webassemblyjs/ieee754": "1.13.2", + "@webassemblyjs/leb128": "1.13.2", + "@webassemblyjs/utf8": "1.13.2" + } + }, + "node_modules/@webassemblyjs/wasm-opt": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-opt/-/wasm-opt-1.14.1.tgz", + "integrity": "sha512-PTcKLUNvBqnY2U6E5bdOQcSM+oVP/PmrDY9NzowJjislEjwP/C4an2303MCVS2Mg9d3AJpIGdUFIQQWbPds0Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-buffer": "1.14.1", + "@webassemblyjs/wasm-gen": "1.14.1", + "@webassemblyjs/wasm-parser": "1.14.1" + } + }, + "node_modules/@webassemblyjs/wasm-parser": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-parser/-/wasm-parser-1.14.1.tgz", + "integrity": "sha512-JLBl+KZ0R5qB7mCnud/yyX08jWFw5MsoalJ1pQ4EdFlgj9VdXKGuENGsiCIjegI1W7p91rUlcB/LB5yRJKNTcQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-api-error": "1.13.2", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2", + "@webassemblyjs/ieee754": "1.13.2", + "@webassemblyjs/leb128": "1.13.2", + "@webassemblyjs/utf8": "1.13.2" + } + }, + "node_modules/@webassemblyjs/wast-printer": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wast-printer/-/wast-printer-1.14.1.tgz", + "integrity": "sha512-kPSSXE6De1XOR820C90RIo2ogvZG+c3KiHzqUoO/F34Y2shGzesfqv7o57xrxovZJH/MetF5UjroJ/R/3isoiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@xtuc/long": "4.2.2" + } + }, + "node_modules/@xtuc/ieee754": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@xtuc/ieee754/-/ieee754-1.2.0.tgz", + "integrity": "sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@xtuc/long": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@xtuc/long/-/long-4.2.2.tgz", + "integrity": "sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/acorn": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "dev": true, + "license": "MIT", + "peer": true, + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-import-phases": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/acorn-import-phases/-/acorn-import-phases-1.0.4.tgz", + "integrity": "sha512-wKmbr/DDiIXzEOiWrTTUcDm24kQ2vGfZQvM2fwg2vXqR5uW6aapr7ObPtj1th32b9u90/Pf4AItvdTh42fBmVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.13.0" + }, + "peerDependencies": { + "acorn": "^8.14.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/acorn-walk": { + "version": "8.3.5", + "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.5.tgz", + "integrity": "sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw==", + "dev": true, + "license": "MIT", + "dependencies": { + "acorn": "^8.11.0" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/ajv": { + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", + "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/ajv-formats/node_modules/ajv": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", + "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true, + "license": "MIT" + }, + "node_modules/ajv-keywords": { + "version": "3.5.2", + "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-3.5.2.tgz", + "integrity": "sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "ajv": "^6.9.1" + } + }, + "node_modules/ansi-colors": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.3.tgz", + "integrity": "sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/ansi-escapes": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz", + "integrity": "sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "type-fest": "^0.21.3" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/ansis": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/ansis/-/ansis-4.2.0.tgz", + "integrity": "sha512-HqZ5rWlFjGiV0tDm3UxxgNRqsOTniqoKZu0pIAfh7TZQMGuZK+hH0drySty0si0QXj1ieop4+SkSfPZBPPkHig==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + } + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/anymatch/node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/append-field": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz", + "integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==", + "license": "MIT" + }, + "node_modules/arg": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz", + "integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==", + "dev": true, + "license": "MIT" + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "license": "Python-2.0" + }, + "node_modules/array-timsort": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/array-timsort/-/array-timsort-1.0.3.tgz", + "integrity": "sha512-/+3GRL7dDAGEfM6TseQk/U+mi18TU2Ms9I3UlLdUMhz2hbvGNTKdj9xniwXfUqgYhHxRx0+8UnKkvlNwVU+cWQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/asap": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz", + "integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==", + "dev": true, + "license": "MIT" + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/babel-jest": { + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-30.3.0.tgz", + "integrity": "sha512-gRpauEU2KRrCox5Z296aeVHR4jQ98BCnu0IO332D/xpHNOsIH/bgSRk9k6GbKIbBw8vFeN6ctuu6tV8WOyVfYQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/transform": "30.3.0", + "@types/babel__core": "^7.20.5", + "babel-plugin-istanbul": "^7.0.1", + "babel-preset-jest": "30.3.0", + "chalk": "^4.1.2", + "graceful-fs": "^4.2.11", + "slash": "^3.0.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "peerDependencies": { + "@babel/core": "^7.11.0 || ^8.0.0-0" + } + }, + "node_modules/babel-plugin-istanbul": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/babel-plugin-istanbul/-/babel-plugin-istanbul-7.0.1.tgz", + "integrity": "sha512-D8Z6Qm8jCvVXtIRkBnqNHX0zJ37rQcFJ9u8WOS6tkYOsRdHBzypCstaxWiu5ZIlqQtviRYbgnRLSoCEvjqcqbA==", + "dev": true, + "license": "BSD-3-Clause", + "workspaces": [ + "test/babel-8" + ], + "dependencies": { + "@babel/helper-plugin-utils": "^7.0.0", + "@istanbuljs/load-nyc-config": "^1.0.0", + "@istanbuljs/schema": "^0.1.3", + "istanbul-lib-instrument": "^6.0.2", + "test-exclude": "^6.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/babel-plugin-jest-hoist": { + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/babel-plugin-jest-hoist/-/babel-plugin-jest-hoist-30.3.0.tgz", + "integrity": "sha512-+TRkByhsws6sfPjVaitzadk1I0F5sPvOVUH5tyTSzhePpsGIVrdeunHSw/C36QeocS95OOk8lunc4rlu5Anwsg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/babel__core": "^7.20.5" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/babel-preset-current-node-syntax": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/babel-preset-current-node-syntax/-/babel-preset-current-node-syntax-1.2.0.tgz", + "integrity": "sha512-E/VlAEzRrsLEb2+dv8yp3bo4scof3l9nR4lrld+Iy5NyVqgVYUJnDAmunkhPMisRI32Qc4iRiz425d8vM++2fg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/plugin-syntax-async-generators": "^7.8.4", + "@babel/plugin-syntax-bigint": "^7.8.3", + "@babel/plugin-syntax-class-properties": "^7.12.13", + "@babel/plugin-syntax-class-static-block": "^7.14.5", + "@babel/plugin-syntax-import-attributes": "^7.24.7", + "@babel/plugin-syntax-import-meta": "^7.10.4", + "@babel/plugin-syntax-json-strings": "^7.8.3", + "@babel/plugin-syntax-logical-assignment-operators": "^7.10.4", + "@babel/plugin-syntax-nullish-coalescing-operator": "^7.8.3", + "@babel/plugin-syntax-numeric-separator": "^7.10.4", + "@babel/plugin-syntax-object-rest-spread": "^7.8.3", + "@babel/plugin-syntax-optional-catch-binding": "^7.8.3", + "@babel/plugin-syntax-optional-chaining": "^7.8.3", + "@babel/plugin-syntax-private-property-in-object": "^7.14.5", + "@babel/plugin-syntax-top-level-await": "^7.14.5" + }, + "peerDependencies": { + "@babel/core": "^7.0.0 || ^8.0.0-0" + } + }, + "node_modules/babel-preset-jest": { + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/babel-preset-jest/-/babel-preset-jest-30.3.0.tgz", + "integrity": "sha512-6ZcUbWHC+dMz2vfzdNwi87Z1gQsLNK2uLuK1Q89R11xdvejcivlYYwDlEv0FHX3VwEXpbBQ9uufB/MUNpZGfhQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "babel-plugin-jest-hoist": "30.3.0", + "babel-preset-current-node-syntax": "^1.2.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "peerDependencies": { + "@babel/core": "^7.11.0 || ^8.0.0-beta.1" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.16", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.16.tgz", + "integrity": "sha512-Lyf3aK28zpsD1yQMiiHD4RvVb6UdMoo8xzG2XzFIfR9luPzOpcBlAsT/qfB1XWS1bxWT+UtE4WmQgsp297FYOA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/bcrypt": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/bcrypt/-/bcrypt-6.0.0.tgz", + "integrity": "sha512-cU8v/EGSrnH+HnxV2z0J7/blxH8gq7Xh2JFT6Aroax7UohdmiJJlxApMxtKfuI7z68NvvVcmR78k2LbT6efhRg==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "node-addon-api": "^8.3.0", + "node-gyp-build": "^4.8.4" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/bl": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, + "node_modules/body-parser": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", + "integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==", + "license": "MIT", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^1.0.5", + "debug": "^4.4.3", + "http-errors": "^2.0.0", + "iconv-lite": "^0.7.0", + "on-finished": "^2.4.1", + "qs": "^6.14.1", + "raw-body": "^3.0.1", + "type-is": "^2.0.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/brace-expansion": { + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz", + "integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browserslist": { + "version": "4.28.2", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", + "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "peer": true, + "dependencies": { + "baseline-browser-mapping": "^2.10.12", + "caniuse-lite": "^1.0.30001782", + "electron-to-chromium": "^1.5.328", + "node-releases": "^2.0.36", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/bs-logger": { + "version": "0.2.6", + "resolved": "https://registry.npmjs.org/bs-logger/-/bs-logger-0.2.6.tgz", + "integrity": "sha512-pd8DCoxmbgc7hyPKOvxtqNcjYoOsABPQdcCUjGp3d42VR2CX1ORhk2A87oqqu5R1kk+76nsxZupkmyd+MVtCog==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-json-stable-stringify": "2.x" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/bser": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/bser/-/bser-2.1.1.tgz", + "integrity": "sha512-gQxTNE/GAfIIrmHLUE3oJyp5FO6HRBfhjnw4/wMmA63ZGDJnWBmgY/lyQBpnDUkGmAhbSe39tx2d/iTOAfglwQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "node-int64": "^0.4.0" + } + }, + "node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "license": "BSD-3-Clause" + }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "license": "MIT" + }, + "node_modules/busboy": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz", + "integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==", + "dependencies": { + "streamsearch": "^1.1.0" + }, + "engines": { + "node": ">=10.16.0" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/camelcase": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", + "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001787", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001787.tgz", + "integrity": "sha512-mNcrMN9KeI68u7muanUpEejSLghOKlVhRqS/Za2IeyGllJ9I9otGpR9g3nsw7n4W378TE/LyIteA0+/FOZm4Kg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/char-regex": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/char-regex/-/char-regex-1.0.2.tgz", + "integrity": "sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/chardet": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/chardet/-/chardet-2.1.1.tgz", + "integrity": "sha512-PsezH1rqdV9VvyNhxxOW32/d75r01NY7TQCmOqomRo15ZSOKbpTFVsfjghxo6JloQUCGnH4k1LGu0R4yCLlWQQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/chokidar": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", + "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "readdirp": "^4.0.1" + }, + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/chrome-trace-event": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/chrome-trace-event/-/chrome-trace-event-1.0.4.tgz", + "integrity": "sha512-rNjApaLzuwaOTjCiT8lSDdGN1APCiqkChLMJxJPWLunPAt5fy8xgU9/jNOchV84wfIxrA0lRQB7oCT8jrn/wrQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0" + } + }, + "node_modules/ci-info": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.4.0.tgz", + "integrity": "sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/cjs-module-lexer": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-2.2.0.tgz", + "integrity": "sha512-4bHTS2YuzUvtoLjdy+98ykbNB5jS0+07EvFNXerqZQJ89F7DI6ET7OQo/HJuW6K0aVsKA9hj9/RVb2kQVOrPDQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/class-transformer": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/class-transformer/-/class-transformer-0.5.1.tgz", + "integrity": "sha512-SQa1Ws6hUbfC98vKGxZH3KFY0Y1lm5Zm0SY8XX9zbK7FJCyVEac3ATW0RIpwzW+oOfmHE5PMPufDG9hCfoEOMw==", + "license": "MIT", + "peer": true + }, + "node_modules/class-validator": { + "version": "0.15.1", + "resolved": "https://registry.npmjs.org/class-validator/-/class-validator-0.15.1.tgz", + "integrity": "sha512-LqoS80HBBSCVhz/3KloUly0ovokxpdOLR++Al3J3+dHXWt9sTKlKd4eYtoxhxyUjoe5+UcIM+5k9MIxyBWnRTw==", + "license": "MIT", + "peer": true, + "dependencies": { + "@types/validator": "^13.15.3", + "libphonenumber-js": "^1.11.1", + "validator": "^13.15.22" + } + }, + "node_modules/cli-cursor": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-3.1.0.tgz", + "integrity": "sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "restore-cursor": "^3.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cli-spinners": { + "version": "2.9.2", + "resolved": "https://registry.npmjs.org/cli-spinners/-/cli-spinners-2.9.2.tgz", + "integrity": "sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-table3": { + "version": "0.6.5", + "resolved": "https://registry.npmjs.org/cli-table3/-/cli-table3-0.6.5.tgz", + "integrity": "sha512-+W/5efTR7y5HRD7gACw9yQjqMVvEMLBHmboM/kPWam+H+Hmyrgjh6YncVKK122YZkXrLudzTuAukUw9FnMf7IQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "string-width": "^4.2.0" + }, + "engines": { + "node": "10.* || >= 12.*" + }, + "optionalDependencies": { + "@colors/colors": "1.5.0" + } + }, + "node_modules/cli-width": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/cli-width/-/cli-width-4.1.0.tgz", + "integrity": "sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">= 12" + } + }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/cliui/node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/clone": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/clone/-/clone-1.0.4.tgz", + "integrity": "sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/co": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz", + "integrity": "sha512-QVb0dM5HvG+uaxitm8wONl7jltx8dqhfU33DcqtOZcLSVIKSDDLDi7+0LbAKiyI8hD9u42m2YxXSkMGWThaecQ==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">= 1.0.0", + "node": ">= 0.12.0" + } + }, + "node_modules/collect-v8-coverage": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/collect-v8-coverage/-/collect-v8-coverage-1.0.3.tgz", + "integrity": "sha512-1L5aqIkwPfiodaMgQunkF1zRhNqifHBmtbbbxcr6yVxxBnliw4TDOW6NxpO8DJLgJ16OT+Y4ztZqP6p/FtXnAw==", + "dev": true, + "license": "MIT" + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dev": true, + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/commander": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", + "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/comment-json": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/comment-json/-/comment-json-4.6.2.tgz", + "integrity": "sha512-R2rze/hDX30uul4NZoIZ76ImSJLFxn/1/ZxtKC1L77y2X1k+yYu1joKbAtMA2Fg3hZrTOiw0I5mwVMo0cf250w==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-timsort": "^1.0.3", + "esprima": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/component-emitter": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/component-emitter/-/component-emitter-1.3.1.tgz", + "integrity": "sha512-T0+barUSQRTUQASh8bx02dl+DhF54GtIDY13Y3m9oWTklKbb3Wv974meRpeZ3lp1JpLVECWWNHC4vaG2XHXouQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/concat-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz", + "integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==", + "engines": [ + "node >= 6.0" + ], + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.0.2", + "typedarray": "^0.0.6" + } + }, + "node_modules/consola": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/consola/-/consola-3.4.2.tgz", + "integrity": "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==", + "license": "MIT", + "engines": { + "node": "^14.18.0 || >=16.10.0" + } + }, + "node_modules/content-disposition": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", + "integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, + "node_modules/cookiejar": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/cookiejar/-/cookiejar-2.1.4.tgz", + "integrity": "sha512-LDx6oHrK+PhzLKJU9j5S7/Y3jM/mUHvD/DeI1WQmJn652iPC5Y4TBzC9l+5OMOXlyTTA+SmVUPm0HQUwpD5Jqw==", + "dev": true, + "license": "MIT" + }, + "node_modules/cors": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/cosmiconfig": { + "version": "8.3.6", + "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-8.3.6.tgz", + "integrity": "sha512-kcZ6+W5QzcJ3P1Mt+83OUv/oHFqZHIx8DuxG6eZ5RGMERoLqp4BuGjhHLYGK+Kf5XVkQvqBSmAy/nGWN3qDgEA==", + "dev": true, + "license": "MIT", + "dependencies": { + "import-fresh": "^3.3.0", + "js-yaml": "^4.1.0", + "parse-json": "^5.2.0", + "path-type": "^4.0.0" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/d-fischer" + }, + "peerDependencies": { + "typescript": ">=4.9.5" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/create-require": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz", + "integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/dedent": { + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.7.2.tgz", + "integrity": "sha512-WzMx3mW98SN+zn3hgemf4OzdmyNhhhKz5Ay0pUfQiMQ3e1g+xmTJWp/pKdwKVXhdSkAEGIIzqeuWrL3mV/AXbA==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "babel-plugin-macros": "^3.1.0" + }, + "peerDependenciesMeta": { + "babel-plugin-macros": { + "optional": true + } + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/deepmerge": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/defaults": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/defaults/-/defaults-1.0.4.tgz", + "integrity": "sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "clone": "^1.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/detect-newline": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/detect-newline/-/detect-newline-3.1.0.tgz", + "integrity": "sha512-TLz+x/vEXm/Y7P7wn1EJFNLxYpUD4TgMosxY6fAVJUnJMbupHBOncxyWUG9OpTaH9EBD7uFI5LfEgmMOc54DsA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/dezalgo": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/dezalgo/-/dezalgo-1.0.4.tgz", + "integrity": "sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig==", + "dev": true, + "license": "ISC", + "dependencies": { + "asap": "^2.0.0", + "wrappy": "1" + } + }, + "node_modules/diff": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.4.tgz", + "integrity": "sha512-X07nttJQkwkfKfvTPG/KSnE2OMdcUCao6+eXF3wmnIQRn2aPAHH3VxDbDOdegkd6JbPsXqShpvEOHfAT+nCNwQ==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.3.1" + } + }, + "node_modules/dotenv": { + "version": "17.4.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.1.tgz", + "integrity": "sha512-k8DaKGP6r1G30Lx8V4+pCsLzKr8vLmV2paqEj1Y55GdAgJuIqpRp5FfajGF8KtwMxCz9qJc6wUIJnm053d/WCw==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/dotenv-expand": { + "version": "12.0.3", + "resolved": "https://registry.npmjs.org/dotenv-expand/-/dotenv-expand-12.0.3.tgz", + "integrity": "sha512-uc47g4b+4k/M/SeaW1y4OApx+mtLWl92l5LMPP0GNXctZqELk+YGgOPIIC5elYmUH4OuoK3JLhuRUYegeySiFA==", + "license": "BSD-2-Clause", + "dependencies": { + "dotenv": "^16.4.5" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/dotenv-expand/node_modules/dotenv": { + "version": "16.6.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", + "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/eastasianwidth": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", + "dev": true, + "license": "MIT" + }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/electron-to-chromium": { + "version": "1.5.334", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.334.tgz", + "integrity": "sha512-mgjZAz7Jyx1SRCwEpy9wefDS7GvNPazLthHg8eQMJ76wBdGQQDW33TCrUTvQ4wzpmOrv2zrFoD3oNufMdyMpog==", + "dev": true, + "license": "ISC" + }, + "node_modules/emittery": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/emittery/-/emittery-0.13.1.tgz", + "integrity": "sha512-DeWwawk6r5yR9jFgnDKYt4sLS0LmHJJi3ZOnb5/JdbYwj3nW+FxQnHIjhBKz8YLC7oRNPVM9NQ47I3CVx34eqQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sindresorhus/emittery?sponsor=1" + } + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/enhanced-resolve": { + "version": "5.20.1", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.20.1.tgz", + "integrity": "sha512-Qohcme7V1inbAfvjItgw0EaxVX5q2rdVEZHRBrEQdRZTssLDGsL8Lwrznl8oQ/6kuTJONLaDcGjkNP247XEhcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.0" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/error-ex": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", + "integrity": "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-arrayish": "^0.2.1" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-module-lexer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.0.0.tgz", + "integrity": "sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==", + "dev": true, + "license": "MIT" + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "9.39.4", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.4.tgz", + "integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.1", + "@eslint/config-array": "^0.21.2", + "@eslint/config-helpers": "^0.4.2", + "@eslint/core": "^0.17.0", + "@eslint/eslintrc": "^3.3.5", + "@eslint/js": "9.39.4", + "@eslint/plugin-kit": "^0.4.1", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.14.0", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^8.4.0", + "eslint-visitor-keys": "^4.2.1", + "espree": "^10.4.0", + "esquery": "^1.5.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.5", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-config-prettier": { + "version": "10.1.8", + "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-10.1.8.tgz", + "integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==", + "dev": true, + "license": "MIT", + "peer": true, + "bin": { + "eslint-config-prettier": "bin/cli.js" + }, + "funding": { + "url": "https://opencollective.com/eslint-config-prettier" + }, + "peerDependencies": { + "eslint": ">=7.0.0" + } + }, + "node_modules/eslint-plugin-prettier": { + "version": "5.5.5", + "resolved": "https://registry.npmjs.org/eslint-plugin-prettier/-/eslint-plugin-prettier-5.5.5.tgz", + "integrity": "sha512-hscXkbqUZ2sPithAuLm5MXL+Wph+U7wHngPBv9OMWwlP8iaflyxpjTYZkmdgB4/vPIhemRlBEoLrH7UC1n7aUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "prettier-linter-helpers": "^1.0.1", + "synckit": "^0.11.12" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint-plugin-prettier" + }, + "peerDependencies": { + "@types/eslint": ">=8.0.0", + "eslint": ">=8.0.0", + "eslint-config-prettier": ">= 7.0.0 <10.0.0 || >=10.1.0", + "prettier": ">=3.0.0" + }, + "peerDependenciesMeta": { + "@types/eslint": { + "optional": true + }, + "eslint-config-prettier": { + "optional": true + } + } + }, + "node_modules/eslint-scope": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", + "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.15.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esprima": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", + "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", + "dev": true, + "license": "BSD-2-Clause", + "bin": { + "esparse": "bin/esparse.js", + "esvalidate": "bin/esvalidate.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/events": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", + "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.x" + } + }, + "node_modules/execa": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", + "integrity": "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cross-spawn": "^7.0.3", + "get-stream": "^6.0.0", + "human-signals": "^2.1.0", + "is-stream": "^2.0.0", + "merge-stream": "^2.0.0", + "npm-run-path": "^4.0.1", + "onetime": "^5.1.2", + "signal-exit": "^3.0.3", + "strip-final-newline": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sindresorhus/execa?sponsor=1" + } + }, + "node_modules/execa/node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/exit-x": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/exit-x/-/exit-x-0.2.2.tgz", + "integrity": "sha512-+I6B/IkJc1o/2tiURyz/ivu/O0nKNEArIUB5O7zBrlDVJr22SCLH3xTeEry428LvFhRzIA1g8izguxJ/gbNcVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/expect": { + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/expect/-/expect-30.3.0.tgz", + "integrity": "sha512-1zQrciTiQfRdo7qJM1uG4navm8DayFa2TgCSRlzUyNkhcJ6XUZF3hjnpkyr3VhAqPH7i/9GkG7Tv5abz6fqz0Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/expect-utils": "30.3.0", + "@jest/get-type": "30.1.0", + "jest-matcher-utils": "30.3.0", + "jest-message-util": "30.3.0", + "jest-mock": "30.3.0", + "jest-util": "30.3.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/express": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", + "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", + "license": "MIT", + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.2.1", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "depd": "^2.0.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-diff": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/fast-diff/-/fast-diff-1.3.0.tgz", + "integrity": "sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-safe-stringify": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz", + "integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==", + "license": "MIT" + }, + "node_modules/fast-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz", + "integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/fb-watchman": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/fb-watchman/-/fb-watchman-2.0.2.tgz", + "integrity": "sha512-p5161BqbuCaSnB8jIbzQHOlpgsPmK5rJVDfDKO91Axs5NC1uu3HRQm6wt9cd9/+GtQQIO53JdGXXoyDpTAsgYA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "bser": "2.1.1" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/file-type": { + "version": "21.3.4", + "resolved": "https://registry.npmjs.org/file-type/-/file-type-21.3.4.tgz", + "integrity": "sha512-Ievi/yy8DS3ygGvT47PjSfdFoX+2isQueoYP1cntFW1JLYAuS4GD7NUPGg4zv2iZfV52uDyk5w5Z0TdpRS6Q1g==", + "license": "MIT", + "dependencies": { + "@tokenizer/inflate": "^0.4.1", + "strtok3": "^10.3.4", + "token-types": "^6.1.1", + "uint8array-extras": "^1.4.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sindresorhus/file-type?sponsor=1" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/finalhandler": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", + "dev": true, + "license": "ISC" + }, + "node_modules/foreground-child": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", + "dev": true, + "license": "ISC", + "dependencies": { + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/fork-ts-checker-webpack-plugin": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/fork-ts-checker-webpack-plugin/-/fork-ts-checker-webpack-plugin-9.1.0.tgz", + "integrity": "sha512-mpafl89VFPJmhnJ1ssH+8wmM2b50n+Rew5x42NeI2U78aRWgtkEtGmctp7iT16UjquJTjorEmIfESj3DxdW84Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.16.7", + "chalk": "^4.1.2", + "chokidar": "^4.0.1", + "cosmiconfig": "^8.2.0", + "deepmerge": "^4.2.2", + "fs-extra": "^10.0.0", + "memfs": "^3.4.1", + "minimatch": "^3.0.4", + "node-abort-controller": "^3.0.1", + "schema-utils": "^3.1.1", + "semver": "^7.3.5", + "tapable": "^2.2.1" + }, + "engines": { + "node": ">=14.21.3" + }, + "peerDependencies": { + "typescript": ">3.6.0", + "webpack": "^5.11.0" + } + }, + "node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "dev": true, + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/form-data/node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/form-data/node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/formidable": { + "version": "3.5.4", + "resolved": "https://registry.npmjs.org/formidable/-/formidable-3.5.4.tgz", + "integrity": "sha512-YikH+7CUTOtP44ZTnUhR7Ic2UASBPOqmaRkRKxRbywPTe5VxF7RRCck4af9wutiZ/QKM5nME9Bie2fFaPz5Gug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@paralleldrive/cuid2": "^2.2.2", + "dezalgo": "^1.0.4", + "once": "^1.4.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "url": "https://ko-fi.com/tunnckoCore/commissions" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/fs-extra": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", + "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/fs-monkey": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/fs-monkey/-/fs-monkey-1.1.0.tgz", + "integrity": "sha512-QMUezzXWII9EV5aTFXW1UBVUO77wYPpjqIF8/AviUCThNeSYZykpoTixUeaNNBwmCev0AMDWMAni+f8Hxb1IFw==", + "dev": true, + "license": "Unlicense" + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "dev": true, + "license": "ISC" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "dev": true, + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-package-type": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/get-package-type/-/get-package-type-0.1.0.tgz", + "integrity": "sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/get-stream": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", + "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/glob": { + "version": "13.0.6", + "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", + "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "minimatch": "^10.2.2", + "minipass": "^7.1.3", + "path-scurry": "^2.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/glob-to-regexp": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz", + "integrity": "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==", + "dev": true, + "license": "BSD-2-Clause" + }, + "node_modules/glob/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/glob/node_modules/brace-expansion": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", + "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/glob/node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/globals": { + "version": "17.4.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-17.4.0.tgz", + "integrity": "sha512-hjrNztw/VajQwOLsMNT1cbJiH2muO3OROCHnbehc8eY5JyD2gqz4AcMHPqgaOR59DjgUjYAYLeH699g/eWi2jw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/handlebars": { + "version": "4.7.9", + "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.9.tgz", + "integrity": "sha512-4E71E0rpOaQuJR2A3xDZ+GM1HyWYv1clR58tC8emQNeQe3RH7MAzSbat+V0wG78LQBo6m6bzSG/L4pBuCsgnUQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "minimist": "^1.2.5", + "neo-async": "^2.6.2", + "source-map": "^0.6.1", + "wordwrap": "^1.0.0" + }, + "bin": { + "handlebars": "bin/handlebars" + }, + "engines": { + "node": ">=0.4.7" + }, + "optionalDependencies": { + "uglify-js": "^3.1.4" + } + }, + "node_modules/handlebars/node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true, + "license": "MIT" + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/human-signals": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz", + "integrity": "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=10.17.0" + } + }, + "node_modules/iconv-lite": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", + "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/import-local": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/import-local/-/import-local-3.2.0.tgz", + "integrity": "sha512-2SPlun1JUPWoM6t3F0dw0FkCF/jWY8kttcY4f599GLTSjh2OCuuhdTkJQsEcZzBqbXZGKMK2OqW1oZsjtf/gQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pkg-dir": "^4.2.0", + "resolve-cwd": "^3.0.0" + }, + "bin": { + "import-local-fixture": "fixtures/cli.js" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", + "dev": true, + "license": "ISC", + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-arrayish": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", + "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", + "dev": true, + "license": "MIT" + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-generator-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-generator-fn/-/is-generator-fn-2.1.0.tgz", + "integrity": "sha512-cTIB4yPYL/Grw0EaSzASzg6bBy9gqCofvWN8okThAYIxKJZC+udlRAmGbM0XLeniEJSs8uEgHPGuHSe1XsOLSQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-interactive": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-interactive/-/is-interactive-1.0.0.tgz", + "integrity": "sha512-2HvIEKRoqS62guEC+qBjpvRubdX910WCMuJTZ+I9yvqKU2/12eSL549HMwtabb4oupdj2sMP50k+XJfB/8JE6w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "license": "MIT" + }, + "node_modules/is-stream": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", + "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-unicode-supported": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz", + "integrity": "sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/istanbul-lib-coverage": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-instrument": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/istanbul-lib-instrument/-/istanbul-lib-instrument-6.0.3.tgz", + "integrity": "sha512-Vtgk7L/R2JHyyGW07spoFlB8/lpjiOLTjMdms6AFMraYt3BaJauod/NGrfnVG/y4Ix1JEuMRPDPEj2ua+zz1/Q==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@babel/core": "^7.23.9", + "@babel/parser": "^7.23.9", + "@istanbuljs/schema": "^0.1.3", + "istanbul-lib-coverage": "^3.2.0", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-source-maps": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-5.0.6.tgz", + "integrity": "sha512-yg2d+Em4KizZC5niWhQaIomgf5WlL4vOOjZ5xGCmF8SnPE/mDWWXgvRExdcpCgh9lLRRa1/fSYp2ymmbJ1pI+A==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.23", + "debug": "^4.1.1", + "istanbul-lib-coverage": "^3.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-reports": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", + "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/iterare": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/iterare/-/iterare-1.2.1.tgz", + "integrity": "sha512-RKYVTCjAnRthyJes037NX/IiqeidgN1xc3j1RjFfECFp28A1GVwK9nA+i0rJPaHqSZwygLzRnFlzUuHFoWWy+Q==", + "license": "ISC", + "engines": { + "node": ">=6" + } + }, + "node_modules/jackspeak": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", + "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" + } + }, + "node_modules/jest": { + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/jest/-/jest-30.3.0.tgz", + "integrity": "sha512-AkXIIFcaazymvey2i/+F94XRnM6TsVLZDhBMLsd1Sf/W0wzsvvpjeyUrCZD6HGG4SDYPgDJDBKeiJTBb10WzMg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@jest/core": "30.3.0", + "@jest/types": "30.3.0", + "import-local": "^3.2.0", + "jest-cli": "30.3.0" + }, + "bin": { + "jest": "bin/jest.js" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } + } + }, + "node_modules/jest-changed-files": { + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/jest-changed-files/-/jest-changed-files-30.3.0.tgz", + "integrity": "sha512-B/7Cny6cV5At6M25EWDgf9S617lHivamL8vl6KEpJqkStauzcG4e+WPfDgMMF+H4FVH4A2PLRyvgDJan4441QA==", + "dev": true, + "license": "MIT", + "dependencies": { + "execa": "^5.1.1", + "jest-util": "30.3.0", + "p-limit": "^3.1.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-circus": { + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/jest-circus/-/jest-circus-30.3.0.tgz", + "integrity": "sha512-PyXq5szeSfR/4f1lYqCmmQjh0vqDkURUYi9N6whnHjlRz4IUQfMcXkGLeEoiJtxtyPqgUaUUfyQlApXWBSN1RA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/environment": "30.3.0", + "@jest/expect": "30.3.0", + "@jest/test-result": "30.3.0", + "@jest/types": "30.3.0", + "@types/node": "*", + "chalk": "^4.1.2", + "co": "^4.6.0", + "dedent": "^1.6.0", + "is-generator-fn": "^2.1.0", + "jest-each": "30.3.0", + "jest-matcher-utils": "30.3.0", + "jest-message-util": "30.3.0", + "jest-runtime": "30.3.0", + "jest-snapshot": "30.3.0", + "jest-util": "30.3.0", + "p-limit": "^3.1.0", + "pretty-format": "30.3.0", + "pure-rand": "^7.0.0", + "slash": "^3.0.0", + "stack-utils": "^2.0.6" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-cli": { + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/jest-cli/-/jest-cli-30.3.0.tgz", + "integrity": "sha512-l6Tqx+j1fDXJEW5bqYykDQQ7mQg+9mhWXtnj+tQZrTWYHyHoi6Be8HPumDSA+UiX2/2buEgjA58iJzdj146uCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/core": "30.3.0", + "@jest/test-result": "30.3.0", + "@jest/types": "30.3.0", + "chalk": "^4.1.2", + "exit-x": "^0.2.2", + "import-local": "^3.2.0", + "jest-config": "30.3.0", + "jest-util": "30.3.0", + "jest-validate": "30.3.0", + "yargs": "^17.7.2" + }, + "bin": { + "jest": "bin/jest.js" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } + } + }, + "node_modules/jest-config": { + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/jest-config/-/jest-config-30.3.0.tgz", + "integrity": "sha512-WPMAkMAtNDY9P/oKObtsRG/6KTrhtgPJoBTmk20uDn4Uy6/3EJnnaZJre/FMT1KVRx8cve1r7/FlMIOfRVWL4w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.27.4", + "@jest/get-type": "30.1.0", + "@jest/pattern": "30.0.1", + "@jest/test-sequencer": "30.3.0", + "@jest/types": "30.3.0", + "babel-jest": "30.3.0", + "chalk": "^4.1.2", + "ci-info": "^4.2.0", + "deepmerge": "^4.3.1", + "glob": "^10.5.0", + "graceful-fs": "^4.2.11", + "jest-circus": "30.3.0", + "jest-docblock": "30.2.0", + "jest-environment-node": "30.3.0", + "jest-regex-util": "30.0.1", + "jest-resolve": "30.3.0", + "jest-runner": "30.3.0", + "jest-util": "30.3.0", + "jest-validate": "30.3.0", + "parse-json": "^5.2.0", + "pretty-format": "30.3.0", + "slash": "^3.0.0", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "peerDependencies": { + "@types/node": "*", + "esbuild-register": ">=3.4.0", + "ts-node": ">=9.0.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "esbuild-register": { + "optional": true + }, + "ts-node": { + "optional": true + } + } + }, + "node_modules/jest-config/node_modules/brace-expansion": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.3.tgz", + "integrity": "sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/jest-config/node_modules/glob": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/jest-config/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/jest-config/node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/jest-config/node_modules/path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/jest-diff": { + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-30.3.0.tgz", + "integrity": "sha512-n3q4PDQjS4LrKxfWB3Z5KNk1XjXtZTBwQp71OP0Jo03Z6V60x++K5L8k6ZrW8MY8pOFylZvHM0zsjS1RqlHJZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/diff-sequences": "30.3.0", + "@jest/get-type": "30.1.0", + "chalk": "^4.1.2", + "pretty-format": "30.3.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-docblock": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-docblock/-/jest-docblock-30.2.0.tgz", + "integrity": "sha512-tR/FFgZKS1CXluOQzZvNH3+0z9jXr3ldGSD8bhyuxvlVUwbeLOGynkunvlTMxchC5urrKndYiwCFC0DLVjpOCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "detect-newline": "^3.1.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-each": { + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/jest-each/-/jest-each-30.3.0.tgz", + "integrity": "sha512-V8eMndg/aZ+3LnCJgSm13IxS5XSBM22QSZc9BtPK8Dek6pm+hfUNfwBdvsB3d342bo1q7wnSkC38zjX259qZNA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/get-type": "30.1.0", + "@jest/types": "30.3.0", + "chalk": "^4.1.2", + "jest-util": "30.3.0", + "pretty-format": "30.3.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-environment-node": { + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/jest-environment-node/-/jest-environment-node-30.3.0.tgz", + "integrity": "sha512-4i6HItw/JSiJVsC5q0hnKIe/hbYfZLVG9YJ/0pU9Hz2n/9qZe3Rhn5s5CUZA5ORZlcdT/vmAXRMyONXJwPrmYQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/environment": "30.3.0", + "@jest/fake-timers": "30.3.0", + "@jest/types": "30.3.0", + "@types/node": "*", + "jest-mock": "30.3.0", + "jest-util": "30.3.0", + "jest-validate": "30.3.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-haste-map": { + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/jest-haste-map/-/jest-haste-map-30.3.0.tgz", + "integrity": "sha512-mMi2oqG4KRU0R9QEtscl87JzMXfUhbKaFqOxmjb2CKcbHcUGFrJCBWHmnTiUqi6JcnzoBlO4rWfpdl2k/RfLCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "30.3.0", + "@types/node": "*", + "anymatch": "^3.1.3", + "fb-watchman": "^2.0.2", + "graceful-fs": "^4.2.11", + "jest-regex-util": "30.0.1", + "jest-util": "30.3.0", + "jest-worker": "30.3.0", + "picomatch": "^4.0.3", + "walker": "^1.0.8" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "optionalDependencies": { + "fsevents": "^2.3.3" + } + }, + "node_modules/jest-leak-detector": { + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/jest-leak-detector/-/jest-leak-detector-30.3.0.tgz", + "integrity": "sha512-cuKmUUGIjfXZAiGJ7TbEMx0bcqNdPPI6P1V+7aF+m/FUJqFDxkFR4JqkTu8ZOiU5AaX/x0hZ20KaaIPXQzbMGQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/get-type": "30.1.0", + "pretty-format": "30.3.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-matcher-utils": { + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-30.3.0.tgz", + "integrity": "sha512-HEtc9uFQgaUHkC7nLSlQL3Tph4Pjxt/yiPvkIrrDCt9jhoLIgxaubo1G+CFOnmHYMxHwwdaSN7mkIFs6ZK8OhA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/get-type": "30.1.0", + "chalk": "^4.1.2", + "jest-diff": "30.3.0", + "pretty-format": "30.3.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-message-util": { + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-30.3.0.tgz", + "integrity": "sha512-Z/j4Bo+4ySJ+JPJN3b2Qbl9hDq3VrXmnjjGEWD/x0BCXeOXPTV1iZYYzl2X8c1MaCOL+ewMyNBcm88sboE6YWw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@jest/types": "30.3.0", + "@types/stack-utils": "^2.0.3", + "chalk": "^4.1.2", + "graceful-fs": "^4.2.11", + "picomatch": "^4.0.3", + "pretty-format": "30.3.0", + "slash": "^3.0.0", + "stack-utils": "^2.0.6" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-mock": { + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-30.3.0.tgz", + "integrity": "sha512-OTzICK8CpE+t4ndhKrwlIdbM6Pn8j00lvmSmq5ejiO+KxukbLjgOflKWMn3KE34EZdQm5RqTuKj+5RIEniYhog==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "30.3.0", + "@types/node": "*", + "jest-util": "30.3.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-pnp-resolver": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/jest-pnp-resolver/-/jest-pnp-resolver-1.2.3.tgz", + "integrity": "sha512-+3NpwQEnRoIBtx4fyhblQDPgJI0H1IEIkX7ShLUjPGA7TtUTvI1oiKi3SR4oBR0hQhQR80l4WAe5RrXBwWMA8w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "peerDependencies": { + "jest-resolve": "*" + }, + "peerDependenciesMeta": { + "jest-resolve": { + "optional": true + } + } + }, + "node_modules/jest-regex-util": { + "version": "30.0.1", + "resolved": "https://registry.npmjs.org/jest-regex-util/-/jest-regex-util-30.0.1.tgz", + "integrity": "sha512-jHEQgBXAgc+Gh4g0p3bCevgRCVRkB4VB70zhoAE48gxeSr1hfUOsM/C2WoJgVL7Eyg//hudYENbm3Ne+/dRVVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-resolve": { + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/jest-resolve/-/jest-resolve-30.3.0.tgz", + "integrity": "sha512-NRtTAHQlpd15F9rUR36jqwelbrDV/dY4vzNte3S2kxCKUJRYNd5/6nTSbYiak1VX5g8IoFF23Uj5TURkUW8O5g==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^4.1.2", + "graceful-fs": "^4.2.11", + "jest-haste-map": "30.3.0", + "jest-pnp-resolver": "^1.2.3", + "jest-util": "30.3.0", + "jest-validate": "30.3.0", + "slash": "^3.0.0", + "unrs-resolver": "^1.7.11" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-resolve-dependencies": { + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/jest-resolve-dependencies/-/jest-resolve-dependencies-30.3.0.tgz", + "integrity": "sha512-9ev8s3YN6Hsyz9LV75XUwkCVFlwPbaFn6Wp75qnI0wzAINYWY8Fb3+6y59Rwd3QaS3kKXffHXsZMziMavfz/nw==", + "dev": true, + "license": "MIT", + "dependencies": { + "jest-regex-util": "30.0.1", + "jest-snapshot": "30.3.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-runner": { + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/jest-runner/-/jest-runner-30.3.0.tgz", + "integrity": "sha512-gDv6C9LGKWDPLia9TSzZwf4h3kMQCqyTpq+95PODnTRDO0g9os48XIYYkS6D236vjpBir2fF63YmJFtqkS5Duw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/console": "30.3.0", + "@jest/environment": "30.3.0", + "@jest/test-result": "30.3.0", + "@jest/transform": "30.3.0", + "@jest/types": "30.3.0", + "@types/node": "*", + "chalk": "^4.1.2", + "emittery": "^0.13.1", + "exit-x": "^0.2.2", + "graceful-fs": "^4.2.11", + "jest-docblock": "30.2.0", + "jest-environment-node": "30.3.0", + "jest-haste-map": "30.3.0", + "jest-leak-detector": "30.3.0", + "jest-message-util": "30.3.0", + "jest-resolve": "30.3.0", + "jest-runtime": "30.3.0", + "jest-util": "30.3.0", + "jest-watcher": "30.3.0", + "jest-worker": "30.3.0", + "p-limit": "^3.1.0", + "source-map-support": "0.5.13" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-runner/node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/jest-runner/node_modules/source-map-support": { + "version": "0.5.13", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.13.tgz", + "integrity": "sha512-SHSKFHadjVA5oR4PPqhtAVdcBWwRYVd6g6cAXnIbRiIwc2EhPrTuKUBdSLvlEKyIP3GCf89fltvcZiP9MMFA1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, + "node_modules/jest-runtime": { + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/jest-runtime/-/jest-runtime-30.3.0.tgz", + "integrity": "sha512-CgC+hIBJbuh78HEffkhNKcbXAytQViplcl8xupqeIWyKQF50kCQA8J7GeJCkjisC6hpnC9Muf8jV5RdtdFbGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/environment": "30.3.0", + "@jest/fake-timers": "30.3.0", + "@jest/globals": "30.3.0", + "@jest/source-map": "30.0.1", + "@jest/test-result": "30.3.0", + "@jest/transform": "30.3.0", + "@jest/types": "30.3.0", + "@types/node": "*", + "chalk": "^4.1.2", + "cjs-module-lexer": "^2.1.0", + "collect-v8-coverage": "^1.0.2", + "glob": "^10.5.0", + "graceful-fs": "^4.2.11", + "jest-haste-map": "30.3.0", + "jest-message-util": "30.3.0", + "jest-mock": "30.3.0", + "jest-regex-util": "30.0.1", + "jest-resolve": "30.3.0", + "jest-snapshot": "30.3.0", + "jest-util": "30.3.0", + "slash": "^3.0.0", + "strip-bom": "^4.0.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-runtime/node_modules/brace-expansion": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.3.tgz", + "integrity": "sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/jest-runtime/node_modules/glob": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/jest-runtime/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/jest-runtime/node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/jest-runtime/node_modules/path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/jest-snapshot": { + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/jest-snapshot/-/jest-snapshot-30.3.0.tgz", + "integrity": "sha512-f14c7atpb4O2DeNhwcvS810Y63wEn8O1HqK/luJ4F6M4NjvxmAKQwBUWjbExUtMxWJQ0wVgmCKymeJK6NZMnfQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.27.4", + "@babel/generator": "^7.27.5", + "@babel/plugin-syntax-jsx": "^7.27.1", + "@babel/plugin-syntax-typescript": "^7.27.1", + "@babel/types": "^7.27.3", + "@jest/expect-utils": "30.3.0", + "@jest/get-type": "30.1.0", + "@jest/snapshot-utils": "30.3.0", + "@jest/transform": "30.3.0", + "@jest/types": "30.3.0", + "babel-preset-current-node-syntax": "^1.2.0", + "chalk": "^4.1.2", + "expect": "30.3.0", + "graceful-fs": "^4.2.11", + "jest-diff": "30.3.0", + "jest-matcher-utils": "30.3.0", + "jest-message-util": "30.3.0", + "jest-util": "30.3.0", + "pretty-format": "30.3.0", + "semver": "^7.7.2", + "synckit": "^0.11.8" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-util": { + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-30.3.0.tgz", + "integrity": "sha512-/jZDa00a3Sz7rdyu55NLrQCIrbyIkbBxareejQI315f/i8HjYN+ZWsDLLpoQSiUIEIyZF/R8fDg3BmB8AtHttg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "30.3.0", + "@types/node": "*", + "chalk": "^4.1.2", + "ci-info": "^4.2.0", + "graceful-fs": "^4.2.11", + "picomatch": "^4.0.3" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-validate": { + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/jest-validate/-/jest-validate-30.3.0.tgz", + "integrity": "sha512-I/xzC8h5G+SHCb2P2gWkJYrNiTbeL47KvKeW5EzplkyxzBRBw1ssSHlI/jXec0ukH2q7x2zAWQm7015iusg62Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/get-type": "30.1.0", + "@jest/types": "30.3.0", + "camelcase": "^6.3.0", + "chalk": "^4.1.2", + "leven": "^3.1.0", + "pretty-format": "30.3.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-validate/node_modules/camelcase": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz", + "integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/jest-watcher": { + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/jest-watcher/-/jest-watcher-30.3.0.tgz", + "integrity": "sha512-PJ1d9ThtTR8aMiBWUdcownq9mDdLXsQzJayTk4kmaBRHKvwNQn+ANveuhEBUyNI2hR1TVhvQ8D5kHubbzBHR/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/test-result": "30.3.0", + "@jest/types": "30.3.0", + "@types/node": "*", + "ansi-escapes": "^4.3.2", + "chalk": "^4.1.2", + "emittery": "^0.13.1", + "jest-util": "30.3.0", + "string-length": "^4.0.2" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-worker": { + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-30.3.0.tgz", + "integrity": "sha512-DrCKkaQwHexjRUFTmPzs7sHQe0TSj9nvDALKGdwmK5mW9v7j90BudWirKAJHt3QQ9Dhrg1F7DogPzhChppkJpQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "@ungap/structured-clone": "^1.3.0", + "jest-util": "30.3.0", + "merge-stream": "^2.0.0", + "supports-color": "^8.1.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-worker/node_modules/supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/joi": { + "version": "18.1.2", + "resolved": "https://registry.npmjs.org/joi/-/joi-18.1.2.tgz", + "integrity": "sha512-rF5MAmps5esSlhCA+N1b6IYHDw9j/btzGaqfgie522jS02Ju/HXBxamlXVlKEHAxoMKQL77HWI8jlqWsFuekZA==", + "license": "BSD-3-Clause", + "dependencies": { + "@hapi/address": "^5.1.1", + "@hapi/formula": "^3.0.2", + "@hapi/hoek": "^11.0.7", + "@hapi/pinpoint": "^2.0.1", + "@hapi/tlds": "^1.1.1", + "@hapi/topo": "^6.0.2", + "@standard-schema/spec": "^1.1.0" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-parse-even-better-errors": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", + "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/jsonc-parser": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.3.1.tgz", + "integrity": "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/jsonfile": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz", + "integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/jsonwebtoken": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz", + "integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==", + "license": "MIT", + "dependencies": { + "jws": "^4.0.1", + "lodash.includes": "^4.3.0", + "lodash.isboolean": "^3.0.3", + "lodash.isinteger": "^4.0.4", + "lodash.isnumber": "^3.0.3", + "lodash.isplainobject": "^4.0.6", + "lodash.isstring": "^4.0.1", + "lodash.once": "^4.0.0", + "ms": "^2.1.1", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=12", + "npm": ">=6" + } + }, + "node_modules/jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", + "license": "MIT", + "dependencies": { + "jwa": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/leven": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz", + "integrity": "sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/libphonenumber-js": { + "version": "1.12.41", + "resolved": "https://registry.npmjs.org/libphonenumber-js/-/libphonenumber-js-1.12.41.tgz", + "integrity": "sha512-lsmMmGXBxXIK/VMLEj0kL6MtUs1kBGj1nTCzi6zgQoG1DEwqwt2DQyHxcLykceIxAnfE3hya7NuIh6PpC6S3fA==", + "license": "MIT" + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "dev": true, + "license": "MIT" + }, + "node_modules/load-esm": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/load-esm/-/load-esm-1.0.3.tgz", + "integrity": "sha512-v5xlu8eHD1+6r8EHTg6hfmO97LN8ugKtiXcy5e6oN72iD2r6u0RPfLl6fxM+7Wnh2ZRq15o0russMst44WauPA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + }, + { + "type": "buymeacoffee", + "url": "https://buymeacoffee.com/borewit" + } + ], + "license": "MIT", + "engines": { + "node": ">=13.2.0" + } + }, + "node_modules/loader-runner": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/loader-runner/-/loader-runner-4.3.1.tgz", + "integrity": "sha512-IWqP2SCPhyVFTBtRcgMHdzlf9ul25NwaFx4wCEH/KjAXuuHY4yNjvPXsBokp8jCB936PyWRaPKUNh8NvylLp2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.11.5" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", + "license": "MIT" + }, + "node_modules/lodash.includes": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", + "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==", + "license": "MIT" + }, + "node_modules/lodash.isboolean": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", + "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==", + "license": "MIT" + }, + "node_modules/lodash.isinteger": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", + "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==", + "license": "MIT" + }, + "node_modules/lodash.isnumber": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", + "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==", + "license": "MIT" + }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", + "license": "MIT" + }, + "node_modules/lodash.isstring": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", + "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==", + "license": "MIT" + }, + "node_modules/lodash.memoize": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/lodash.memoize/-/lodash.memoize-4.1.2.tgz", + "integrity": "sha512-t7j+NzmgnQzTAYXcsHYLgimltOV1MXHtlOWf6GjL9Kj8GK5FInw5JotxvbOs+IvV1/Dzo04/fCGfLVs7aXb4Ag==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.once": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", + "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==", + "license": "MIT" + }, + "node_modules/log-symbols": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.1.0.tgz", + "integrity": "sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^4.1.0", + "is-unicode-supported": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/magic-string": { + "version": "0.30.17", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.17.tgz", + "integrity": "sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0" + } + }, + "node_modules/make-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/make-error": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz", + "integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==", + "dev": true, + "license": "ISC" + }, + "node_modules/makeerror": { + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/makeerror/-/makeerror-1.0.12.tgz", + "integrity": "sha512-JmqCvUhmt43madlpFzG4BQzG2Z3m6tvQDNKdClZnO3VbIudJYmxsT0FNJMeiB2+JTSlTQTSbU8QdesVmwJcmLg==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tmpl": "1.0.5" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", + "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/memfs": { + "version": "3.5.3", + "resolved": "https://registry.npmjs.org/memfs/-/memfs-3.5.3.tgz", + "integrity": "sha512-UERzLsxzllchadvbPs5aolHh65ISpKpM+ccLbOJ8/vvpBKmAWf+la7dXFy7Mr0ySHbdHrFv5kGFCUHHe6GFEmw==", + "dev": true, + "license": "Unlicense", + "dependencies": { + "fs-monkey": "^1.0.4" + }, + "engines": { + "node": ">= 4.0.0" + } + }, + "node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/merge-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", + "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", + "dev": true, + "license": "MIT" + }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/micromatch/node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/mime": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-2.6.0.tgz", + "integrity": "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==", + "dev": true, + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/mimic-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", + "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/multer": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/multer/-/multer-2.1.1.tgz", + "integrity": "sha512-mo+QTzKlx8R7E5ylSXxWzGoXoZbOsRMpyitcht8By2KHvMbf3tjwosZ/Mu/XYU6UuJ3VZnODIrak5ZrPiPyB6A==", + "license": "MIT", + "dependencies": { + "append-field": "^1.0.0", + "busboy": "^1.6.0", + "concat-stream": "^2.0.0", + "type-is": "^1.6.18" + }, + "engines": { + "node": ">= 10.16.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/multer/node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/multer/node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/multer/node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/multer/node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "license": "MIT", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mute-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-2.0.0.tgz", + "integrity": "sha512-WWdIxpyjEn+FhQJQQv9aQAYlHoNVdzIzUySNV1gHUPDSdZJ3yZn7pAAbQcV7B56Mvu881q9FZV+0Vx2xC44VWA==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/napi-postinstall": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/napi-postinstall/-/napi-postinstall-0.3.4.tgz", + "integrity": "sha512-PHI5f1O0EP5xJ9gQmFGMS6IZcrVvTjpXjz7Na41gTE7eE2hK11lg04CECCYEEjdc17EV4DO+fkGEtt7TpTaTiQ==", + "dev": true, + "license": "MIT", + "bin": { + "napi-postinstall": "lib/cli.js" + }, + "engines": { + "node": "^12.20.0 || ^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/napi-postinstall" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", + "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/neo-async": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz", + "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-abort-controller": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/node-abort-controller/-/node-abort-controller-3.1.1.tgz", + "integrity": "sha512-AGK2yQKIjRuqnc6VkX2Xj5d+QW8xZ87pa1UK6yA6ouUyuxfHuMP6umE5QK7UmTeOAymo+Zx1Fxiuw9rVx8taHQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-addon-api": { + "version": "8.7.0", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.7.0.tgz", + "integrity": "sha512-9MdFxmkKaOYVTV+XVRG8ArDwwQ77XIgIPyKASB1k3JPq3M8fGQQQE3YpMOrKm6g//Ktx8ivZr8xo1Qmtqub+GA==", + "license": "MIT", + "engines": { + "node": "^18 || ^20 || >= 21" + } + }, + "node_modules/node-emoji": { + "version": "1.11.0", + "resolved": "https://registry.npmjs.org/node-emoji/-/node-emoji-1.11.0.tgz", + "integrity": "sha512-wo2DpQkQp7Sjm2A0cq+sN7EHKO6Sl0ctXeBdFZrL9T9+UywORbufTcTZxom8YqpLQt/FqNMUkOpkZrJVYSKD3A==", + "dev": true, + "license": "MIT", + "dependencies": { + "lodash": "^4.17.21" + } + }, + "node_modules/node-gyp-build": { + "version": "4.8.4", + "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz", + "integrity": "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==", + "license": "MIT", + "bin": { + "node-gyp-build": "bin.js", + "node-gyp-build-optional": "optional.js", + "node-gyp-build-test": "build-test.js" + } + }, + "node_modules/node-int64": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz", + "integrity": "sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-releases": { + "version": "2.0.37", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.37.tgz", + "integrity": "sha512-1h5gKZCF+pO/o3Iqt5Jp7wc9rH3eJJ0+nh/CIoiRwjRxde/hAHyLPXYN4V3CqKAbiZPSeJFSWHmJsbkicta0Eg==", + "dev": true, + "license": "MIT" + }, + "node_modules/nodemailer": { + "version": "8.0.11", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-8.0.11.tgz", + "integrity": "sha512-nrO/pDAUKl+wXX+lx16tDLbnm0fW6sK/x8mgohaCpg+CdCEl482bD4tCuAZk2DyliruiNTIZxRCoWkDqJEnAiA==", + "license": "MIT-0", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/npm-run-path": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz", + "integrity": "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/onetime": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", + "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-fn": "^2.1.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/ora": { + "version": "5.4.1", + "resolved": "https://registry.npmjs.org/ora/-/ora-5.4.1.tgz", + "integrity": "sha512-5b6Y85tPxZZ7QytO+BQzysW31HJku27cRIlkbAXaNx+BdcVi+LlRFmVXzeF6a7JCwJpyw5c4b+YSVImQIrBpuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "bl": "^4.1.0", + "chalk": "^4.1.0", + "cli-cursor": "^3.1.0", + "cli-spinners": "^2.5.0", + "is-interactive": "^1.0.0", + "is-unicode-supported": "^0.1.0", + "log-symbols": "^4.1.0", + "strip-ansi": "^6.0.0", + "wcwidth": "^1.0.1" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-try": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/package-json-from-dist": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", + "dev": true, + "license": "BlueOak-1.0.0" + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/parse-json": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", + "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.0.0", + "error-ex": "^1.3.1", + "json-parse-even-better-errors": "^2.3.0", + "lines-and-columns": "^1.1.6" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/passport": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/passport/-/passport-0.7.0.tgz", + "integrity": "sha512-cPLl+qZpSc+ireUvt+IzqbED1cHHkDoVYMo30jbJIdOOjQ1MQYZBPiNvmi8UM6lJuOpTPXJGZQk0DtC4y61MYQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "passport-strategy": "1.x.x", + "pause": "0.0.1", + "utils-merge": "^1.0.1" + }, + "engines": { + "node": ">= 0.4.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/jaredhanson" + } + }, + "node_modules/passport-jwt": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/passport-jwt/-/passport-jwt-4.0.1.tgz", + "integrity": "sha512-UCKMDYhNuGOBE9/9Ycuoyh7vP6jpeTp/+sfMJl7nLff/t6dps+iaeE0hhNkKN8/HZHcJ7lCdOyDxHdDoxoSvdQ==", + "license": "MIT", + "dependencies": { + "jsonwebtoken": "^9.0.0", + "passport-strategy": "^1.0.0" + } + }, + "node_modules/passport-strategy": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/passport-strategy/-/passport-strategy-1.0.0.tgz", + "integrity": "sha512-CB97UUvDKJde2V0KDWWB3lyf6PC3FaZP7YxZ2G8OAtn9p4HI9j9JLP9qjOGZFvyl8uwNT8qM+hGnz/n16NI7oA==", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-scurry": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", + "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^11.0.0", + "minipass": "^7.1.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/path-scurry/node_modules/lru-cache": { + "version": "11.3.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.3.3.tgz", + "integrity": "sha512-JvNw9Y81y33E+BEYPr0U7omo+U9AySnsMsEiXgwT6yqd31VQWTLNQqmT4ou5eqPFUrTfIDFta2wKhB1hyohtAQ==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/path-to-regexp": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", + "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/path-type": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", + "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/pause": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/pause/-/pause-0.0.1.tgz", + "integrity": "sha512-KG8UEiEVkR3wGEb4m5yZkVCzigAD+cVEJck2CzYZO37ZGJfctvVptVO192MwrtPhzONn6go8ylnOdMhKqi4nfg==" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pirates": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", + "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/pkg-dir": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-4.2.0.tgz", + "integrity": "sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "find-up": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/pkg-dir/node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/pkg-dir/node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/pkg-dir/node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/pkg-dir/node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/pluralize": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/pluralize/-/pluralize-8.0.0.tgz", + "integrity": "sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/prettier": { + "version": "3.8.1", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.1.tgz", + "integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==", + "dev": true, + "license": "MIT", + "peer": true, + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/prettier-linter-helpers": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/prettier-linter-helpers/-/prettier-linter-helpers-1.0.1.tgz", + "integrity": "sha512-SxToR7P8Y2lWmv/kTzVLC1t/GDI2WGjMwNhLLE9qtH8Q13C+aEmuRlzDst4Up4s0Wc8sF2M+J57iB3cMLqftfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-diff": "^1.1.2" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/pretty-format": { + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.3.0.tgz", + "integrity": "sha512-oG4T3wCbfeuvljnyAzhBvpN45E8iOTXCU/TD3zXW80HA3dQ4ahdqMkWGiPWZvjpQwlbyHrPTWUAqUzGzv4l1JQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/schemas": "30.0.5", + "ansi-styles": "^5.2.0", + "react-is": "^18.3.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/pretty-format/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/prisma": { + "version": "5.22.0", + "resolved": "https://registry.npmjs.org/prisma/-/prisma-5.22.0.tgz", + "integrity": "sha512-vtpjW3XuYCSnMsNVBjLMNkTj6OZbudcPPTPYHqX0CJfpcdWciI1dM8uHETwmDxxiqEwCIE6WvXucWUetJgfu/A==", + "hasInstallScript": true, + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "@prisma/engines": "5.22.0" + }, + "bin": { + "prisma": "build/index.js" + }, + "engines": { + "node": ">=16.13" + }, + "optionalDependencies": { + "fsevents": "2.3.3" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/pure-rand": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-7.0.1.tgz", + "integrity": "sha512-oTUZM/NAZS8p7ANR3SHh30kXB+zK2r2BPcEn/awJIbOvq82WoMN4p62AWWp3Hhw50G0xMsw1mhIBLqHw64EcNQ==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "license": "MIT" + }, + "node_modules/qs": { + "version": "6.15.1", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.1.tgz", + "integrity": "sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "dev": true, + "license": "MIT" + }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/readdirp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", + "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.18.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/reflect-metadata": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz", + "integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==", + "license": "Apache-2.0", + "peer": true + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/resolve-cwd": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/resolve-cwd/-/resolve-cwd-3.0.0.tgz", + "integrity": "sha512-OrZaX2Mb+rJCpH/6CpSqt9xFVpN++x01XnN2ie9g6P5/3xelLAkXWVADpdz1IHD/KFfEXyE6V0U01OQ3UO2rEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-from": "^5.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/resolve-cwd/node_modules/resolve-from": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", + "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/restore-cursor": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-3.1.0.tgz", + "integrity": "sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "onetime": "^5.1.0", + "signal-exit": "^3.0.2" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/restore-cursor/node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/rxjs": { + "version": "7.8.2", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", + "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "tslib": "^2.1.0" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/schema-utils": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-3.3.0.tgz", + "integrity": "sha512-pN/yOAvcC+5rQ5nERGuwrjLlYvLTbCibnZ1I7B1LaiAz9BRBlE9GMgE/eqV30P7aJQUf7Ddimy/RsbYO/GrVGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/json-schema": "^7.0.8", + "ajv": "^6.12.5", + "ajv-keywords": "^3.5.2" + }, + "engines": { + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.1", + "mime-types": "^3.0.2", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/serve-static": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", + "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", + "license": "MIT", + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/slash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", + "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/source-map": { + "version": "0.7.4", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.4.tgz", + "integrity": "sha512-l3BikUxvPOcn5E74dZiq5BGsTb5yEwhaTSzccU6t4sDOH8NWJCstKO5QT2CvtFoK6F0saL7p9xHAqHOlCPJygA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">= 8" + } + }, + "node_modules/source-map-support": { + "version": "0.5.21", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", + "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, + "node_modules/source-map-support/node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/sprintf-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", + "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/stack-utils": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-2.0.6.tgz", + "integrity": "sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "escape-string-regexp": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/stack-utils/node_modules/escape-string-regexp": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-2.0.0.tgz", + "integrity": "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/streamsearch": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz", + "integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/string-length": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/string-length/-/string-length-4.0.2.tgz", + "integrity": "sha512-+l6rNN5fYHNhZZy41RXsYptCjA2Igmq4EG7kZAYFQI1E1VTXarr6ZPXBg6eq7Y6eK4FEhY6AJlyuFIb/v/S0VQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "char-regex": "^1.0.2", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs": { + "name": "string-width", + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi-cjs": { + "name": "strip-ansi", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-bom": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-4.0.0.tgz", + "integrity": "sha512-3xurFv5tEgii33Zi8Jtp55wEIILR9eh34FAW00PZf+JnSsTmV/ioewSgQl97JHvgjoRGwPShsWm+IdrxB35d0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-final-newline": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-2.0.0.tgz", + "integrity": "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/strtok3": { + "version": "10.3.5", + "resolved": "https://registry.npmjs.org/strtok3/-/strtok3-10.3.5.tgz", + "integrity": "sha512-ki4hZQfh5rX0QDLLkOCj+h+CVNkqmp/CMf8v8kZpkNVK6jGQooMytqzLZYUVYIZcFZ6yDB70EfD8POcFXiF5oA==", + "license": "MIT", + "dependencies": { + "@tokenizer/token": "^0.3.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + } + }, + "node_modules/superagent": { + "version": "10.3.0", + "resolved": "https://registry.npmjs.org/superagent/-/superagent-10.3.0.tgz", + "integrity": "sha512-B+4Ik7ROgVKrQsXTV0Jwp2u+PXYLSlqtDAhYnkkD+zn3yg8s/zjA2MeGayPoY/KICrbitwneDHrjSotxKL+0XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "component-emitter": "^1.3.1", + "cookiejar": "^2.1.4", + "debug": "^4.3.7", + "fast-safe-stringify": "^2.1.1", + "form-data": "^4.0.5", + "formidable": "^3.5.4", + "methods": "^1.1.2", + "mime": "2.6.0", + "qs": "^6.14.1" + }, + "engines": { + "node": ">=14.18.0" + } + }, + "node_modules/supertest": { + "version": "7.2.2", + "resolved": "https://registry.npmjs.org/supertest/-/supertest-7.2.2.tgz", + "integrity": "sha512-oK8WG9diS3DlhdUkcFn4tkNIiIbBx9lI2ClF8K+b2/m8Eyv47LSawxUzZQSNKUrVb2KsqeTDCcjAAVPYaSLVTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cookie-signature": "^1.2.2", + "methods": "^1.1.2", + "superagent": "^10.3.0" + }, + "engines": { + "node": ">=14.18.0" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/swagger-ui-dist": { + "version": "5.32.4", + "resolved": "https://registry.npmjs.org/swagger-ui-dist/-/swagger-ui-dist-5.32.4.tgz", + "integrity": "sha512-0AADFFQNJzExEN49SrD/34Nn9cxNxVLiydYl2MBwSZFPVXNkVwC/EFAjoezGGqE8oDegiDC+p47t8lKObCinMQ==", + "license": "Apache-2.0", + "dependencies": { + "@scarf/scarf": "=1.4.0" + } + }, + "node_modules/symbol-observable": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/symbol-observable/-/symbol-observable-4.0.0.tgz", + "integrity": "sha512-b19dMThMV4HVFynSAM1++gBHAbk2Tc/osgLIBZMKsyqh34jb2e8Os7T6ZW/Bt3pJFdBTd2JwAnAAEQV7rSNvcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10" + } + }, + "node_modules/synckit": { + "version": "0.11.12", + "resolved": "https://registry.npmjs.org/synckit/-/synckit-0.11.12.tgz", + "integrity": "sha512-Bh7QjT8/SuKUIfObSXNHNSK6WHo6J1tHCqJsuaFDP7gP0fkzSfTxI8y85JrppZ0h8l0maIgc2tfuZQ6/t3GtnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@pkgr/core": "^0.2.9" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/synckit" + } + }, + "node_modules/tapable": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.2.tgz", + "integrity": "sha512-1MOpMXuhGzGL5TTCZFItxCc0AARf1EZFQkGqMm7ERKj8+Hgr5oLvJOVFcC+lRmR8hCe2S3jC4T5D7Vg/d7/fhA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/terser": { + "version": "5.46.1", + "resolved": "https://registry.npmjs.org/terser/-/terser-5.46.1.tgz", + "integrity": "sha512-vzCjQO/rgUuK9sf8VJZvjqiqiHFaZLnOiimmUuOKODxWL8mm/xua7viT7aqX7dgPY60otQjUotzFMmCB4VdmqQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@jridgewell/source-map": "^0.3.3", + "acorn": "^8.15.0", + "commander": "^2.20.0", + "source-map-support": "~0.5.20" + }, + "bin": { + "terser": "bin/terser" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/terser-webpack-plugin": { + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-5.4.0.tgz", + "integrity": "sha512-Bn5vxm48flOIfkdl5CaD2+1CiUVbonWQ3KQPyP7/EuIl9Gbzq/gQFOzaMFUEgVjB1396tcK0SG8XcNJ/2kDH8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.25", + "jest-worker": "^27.4.5", + "schema-utils": "^4.3.0", + "terser": "^5.31.1" + }, + "engines": { + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "webpack": "^5.1.0" + }, + "peerDependenciesMeta": { + "@swc/core": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "uglify-js": { + "optional": true + } + } + }, + "node_modules/terser-webpack-plugin/node_modules/ajv": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", + "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/terser-webpack-plugin/node_modules/ajv-formats": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-2.1.1.tgz", + "integrity": "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/terser-webpack-plugin/node_modules/ajv-keywords": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-5.1.0.tgz", + "integrity": "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3" + }, + "peerDependencies": { + "ajv": "^8.8.2" + } + }, + "node_modules/terser-webpack-plugin/node_modules/jest-worker": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-27.5.1.tgz", + "integrity": "sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "merge-stream": "^2.0.0", + "supports-color": "^8.0.0" + }, + "engines": { + "node": ">= 10.13.0" + } + }, + "node_modules/terser-webpack-plugin/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true, + "license": "MIT" + }, + "node_modules/terser-webpack-plugin/node_modules/schema-utils": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.3.3.tgz", + "integrity": "sha512-eflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/OUYdb48v4k4WWHQurA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/json-schema": "^7.0.9", + "ajv": "^8.9.0", + "ajv-formats": "^2.1.1", + "ajv-keywords": "^5.1.0" + }, + "engines": { + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/terser-webpack-plugin/node_modules/supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/terser/node_modules/commander": { + "version": "2.20.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", + "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/test-exclude": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz", + "integrity": "sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==", + "dev": true, + "license": "ISC", + "dependencies": { + "@istanbuljs/schema": "^0.1.2", + "glob": "^7.1.4", + "minimatch": "^3.0.4" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/test-exclude/node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.16", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", + "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tmpl": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz", + "integrity": "sha512-3f0uOEAQwIqGuWW2MVzYg8fV/QNnc/IpuJNG837rLuczAaLVHslWHZQj4IGiEl5Hs3kkbhwL9Ab7Hrsmuj+Smw==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/token-types": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/token-types/-/token-types-6.1.2.tgz", + "integrity": "sha512-dRXchy+C0IgK8WPC6xvCHFRIWYUbqqdEIKPaKo/AcTUNzwLTK6AH7RjdLWsEZcAN/TBdtfUw3PYEgPr5VPr6ww==", + "license": "MIT", + "dependencies": { + "@borewit/text-codec": "^0.2.1", + "@tokenizer/token": "^0.3.0", + "ieee754": "^1.2.1" + }, + "engines": { + "node": ">=14.16" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + } + }, + "node_modules/ts-api-utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, + "node_modules/ts-jest": { + "version": "29.4.9", + "resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-29.4.9.tgz", + "integrity": "sha512-LTb9496gYPMCqjeDLdPrKuXtncudeV1yRZnF4Wo5l3SFi0RYEnYRNgMrFIdg+FHvfzjCyQk1cLncWVqiSX+EvQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "bs-logger": "^0.2.6", + "fast-json-stable-stringify": "^2.1.0", + "handlebars": "^4.7.9", + "json5": "^2.2.3", + "lodash.memoize": "^4.1.2", + "make-error": "^1.3.6", + "semver": "^7.7.4", + "type-fest": "^4.41.0", + "yargs-parser": "^21.1.1" + }, + "bin": { + "ts-jest": "cli.js" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || ^18.0.0 || >=20.0.0" + }, + "peerDependencies": { + "@babel/core": ">=7.0.0-beta.0 <8", + "@jest/transform": "^29.0.0 || ^30.0.0", + "@jest/types": "^29.0.0 || ^30.0.0", + "babel-jest": "^29.0.0 || ^30.0.0", + "jest": "^29.0.0 || ^30.0.0", + "jest-util": "^29.0.0 || ^30.0.0", + "typescript": ">=4.3 <7" + }, + "peerDependenciesMeta": { + "@babel/core": { + "optional": true + }, + "@jest/transform": { + "optional": true + }, + "@jest/types": { + "optional": true + }, + "babel-jest": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jest-util": { + "optional": true + } + } + }, + "node_modules/ts-jest/node_modules/type-fest": { + "version": "4.41.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", + "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ts-loader": { + "version": "9.5.7", + "resolved": "https://registry.npmjs.org/ts-loader/-/ts-loader-9.5.7.tgz", + "integrity": "sha512-/ZNrKgA3K3PtpMYOC71EeMWIloGw3IYEa5/t1cyz2r5/PyUwTXGzYJvcD3kfUvmhlfpz1rhV8B2O6IVTQ0avsg==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^4.1.0", + "enhanced-resolve": "^5.0.0", + "micromatch": "^4.0.0", + "semver": "^7.3.4", + "source-map": "^0.7.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "typescript": "*", + "webpack": "^5.0.0" + } + }, + "node_modules/ts-node": { + "version": "10.9.2", + "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz", + "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@cspotcode/source-map-support": "^0.8.0", + "@tsconfig/node10": "^1.0.7", + "@tsconfig/node12": "^1.0.7", + "@tsconfig/node14": "^1.0.0", + "@tsconfig/node16": "^1.0.2", + "acorn": "^8.4.1", + "acorn-walk": "^8.1.1", + "arg": "^4.1.0", + "create-require": "^1.1.0", + "diff": "^4.0.1", + "make-error": "^1.1.1", + "v8-compile-cache-lib": "^3.0.1", + "yn": "3.1.1" + }, + "bin": { + "ts-node": "dist/bin.js", + "ts-node-cwd": "dist/bin-cwd.js", + "ts-node-esm": "dist/bin-esm.js", + "ts-node-script": "dist/bin-script.js", + "ts-node-transpile-only": "dist/bin-transpile.js", + "ts-script": "dist/bin-script-deprecated.js" + }, + "peerDependencies": { + "@swc/core": ">=1.2.50", + "@swc/wasm": ">=1.2.50", + "@types/node": "*", + "typescript": ">=2.7" + }, + "peerDependenciesMeta": { + "@swc/core": { + "optional": true + }, + "@swc/wasm": { + "optional": true + } + } + }, + "node_modules/tsconfig-paths": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-4.2.0.tgz", + "integrity": "sha512-NoZ4roiN7LnbKn9QqE1amc9DJfzvZXxF4xDavcOWt1BPkdx+m+0gJuPM+S0vCe7zTJMYUP0R8pO2XMr+Y8oLIg==", + "dev": true, + "license": "MIT", + "dependencies": { + "json5": "^2.2.2", + "minimist": "^1.2.6", + "strip-bom": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/tsconfig-paths-webpack-plugin": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/tsconfig-paths-webpack-plugin/-/tsconfig-paths-webpack-plugin-4.2.0.tgz", + "integrity": "sha512-zbem3rfRS8BgeNK50Zz5SIQgXzLafiHjOwUAvk/38/o1jHn/V5QAgVUcz884or7WYcPaH3N2CIfUc2u0ul7UcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^4.1.0", + "enhanced-resolve": "^5.7.0", + "tapable": "^2.2.1", + "tsconfig-paths": "^4.1.2" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/tsconfig-paths/node_modules/strip-bom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", + "integrity": "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/type-detect": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz", + "integrity": "sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/type-fest": { + "version": "0.21.3", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.21.3.tgz", + "integrity": "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/type-is": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz", + "integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==", + "license": "MIT", + "dependencies": { + "content-type": "^1.0.5", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/typedarray": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz", + "integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==", + "license": "MIT" + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "peer": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/typescript-eslint": { + "version": "8.58.1", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.58.1.tgz", + "integrity": "sha512-gf6/oHChByg9HJvhMO1iBexJh12AqqTfnuxscMDOVqfJW3htsdRJI/GfPpHTTcyeB8cSTUY2JcZmVgoyPqcrDg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.58.1", + "@typescript-eslint/parser": "8.58.1", + "@typescript-eslint/typescript-estree": "8.58.1", + "@typescript-eslint/utils": "8.58.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/uglify-js": { + "version": "3.19.3", + "resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.19.3.tgz", + "integrity": "sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ==", + "dev": true, + "license": "BSD-2-Clause", + "optional": true, + "bin": { + "uglifyjs": "bin/uglifyjs" + }, + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/uid": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/uid/-/uid-2.0.2.tgz", + "integrity": "sha512-u3xV3X7uzvi5b1MncmZo3i2Aw222Zk1keqLA1YkHldREkAhAqi65wuPfe7lHx8H/Wzy+8CE7S7uS3jekIM5s8g==", + "license": "MIT", + "dependencies": { + "@lukeed/csprng": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/uint8array-extras": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/uint8array-extras/-/uint8array-extras-1.5.0.tgz", + "integrity": "sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/undici-types": { + "version": "7.16.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", + "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", + "license": "MIT" + }, + "node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/unrs-resolver": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/unrs-resolver/-/unrs-resolver-1.11.1.tgz", + "integrity": "sha512-bSjt9pjaEBnNiGgc9rUiHGKv5l4/TGzDmYw3RhnkJGtLhbnnA/5qJj7x3dNDCRx/PJxu774LlH8lCOlB4hEfKg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "napi-postinstall": "^0.3.0" + }, + "funding": { + "url": "https://opencollective.com/unrs-resolver" + }, + "optionalDependencies": { + "@unrs/resolver-binding-android-arm-eabi": "1.11.1", + "@unrs/resolver-binding-android-arm64": "1.11.1", + "@unrs/resolver-binding-darwin-arm64": "1.11.1", + "@unrs/resolver-binding-darwin-x64": "1.11.1", + "@unrs/resolver-binding-freebsd-x64": "1.11.1", + "@unrs/resolver-binding-linux-arm-gnueabihf": "1.11.1", + "@unrs/resolver-binding-linux-arm-musleabihf": "1.11.1", + "@unrs/resolver-binding-linux-arm64-gnu": "1.11.1", + "@unrs/resolver-binding-linux-arm64-musl": "1.11.1", + "@unrs/resolver-binding-linux-ppc64-gnu": "1.11.1", + "@unrs/resolver-binding-linux-riscv64-gnu": "1.11.1", + "@unrs/resolver-binding-linux-riscv64-musl": "1.11.1", + "@unrs/resolver-binding-linux-s390x-gnu": "1.11.1", + "@unrs/resolver-binding-linux-x64-gnu": "1.11.1", + "@unrs/resolver-binding-linux-x64-musl": "1.11.1", + "@unrs/resolver-binding-wasm32-wasi": "1.11.1", + "@unrs/resolver-binding-win32-arm64-msvc": "1.11.1", + "@unrs/resolver-binding-win32-ia32-msvc": "1.11.1", + "@unrs/resolver-binding-win32-x64-msvc": "1.11.1" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, + "node_modules/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/v8-compile-cache-lib": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz", + "integrity": "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==", + "dev": true, + "license": "MIT" + }, + "node_modules/v8-to-istanbul": { + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.3.0.tgz", + "integrity": "sha512-kiGUalWN+rgBJ/1OHZsBtU4rXZOfj/7rKQxULKlIzwzQSvMJUUNgPwJEEh7gU6xEVxC0ahoOBvN2YI8GH6FNgA==", + "dev": true, + "license": "ISC", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.12", + "@types/istanbul-lib-coverage": "^2.0.1", + "convert-source-map": "^2.0.0" + }, + "engines": { + "node": ">=10.12.0" + } + }, + "node_modules/validator": { + "version": "13.15.35", + "resolved": "https://registry.npmjs.org/validator/-/validator-13.15.35.tgz", + "integrity": "sha512-TQ5pAGhd5whStmqWvYF4OjQROlmv9SMFVt37qoCBdqRffuuklWYQlCNnEs2ZaIBD1kZRNnikiZOS1eqgkar0iw==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/walker": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/walker/-/walker-1.0.8.tgz", + "integrity": "sha512-ts/8E8l5b7kY0vlWLewOkDXMmPdLcVV4GmOQLyxuSswIJsweeFZtAsMF7k1Nszz+TYBQrlYRmzOnr398y1JemQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "makeerror": "1.0.12" + } + }, + "node_modules/watchpack": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.5.1.tgz", + "integrity": "sha512-Zn5uXdcFNIA1+1Ei5McRd+iRzfhENPCe7LeABkJtNulSxjma+l7ltNx55BWZkRlwRnpOgHqxnjyaDgJnNXnqzg==", + "dev": true, + "license": "MIT", + "dependencies": { + "glob-to-regexp": "^0.4.1", + "graceful-fs": "^4.1.2" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/wcwidth": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/wcwidth/-/wcwidth-1.0.1.tgz", + "integrity": "sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==", + "dev": true, + "license": "MIT", + "dependencies": { + "defaults": "^1.0.3" + } + }, + "node_modules/webpack": { + "version": "5.106.0", + "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.106.0.tgz", + "integrity": "sha512-Pkx5joZ9RrdgO5LBkyX1L2ZAJeK/Taz3vqZ9CbcP0wS5LEMx5QkKsEwLl29QJfihZ+DKRBFldzy1O30pJ1MDpA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@types/eslint-scope": "^3.7.7", + "@types/estree": "^1.0.8", + "@types/json-schema": "^7.0.15", + "@webassemblyjs/ast": "^1.14.1", + "@webassemblyjs/wasm-edit": "^1.14.1", + "@webassemblyjs/wasm-parser": "^1.14.1", + "acorn": "^8.16.0", + "acorn-import-phases": "^1.0.3", + "browserslist": "^4.28.1", + "chrome-trace-event": "^1.0.2", + "enhanced-resolve": "^5.20.0", + "es-module-lexer": "^2.0.0", + "eslint-scope": "5.1.1", + "events": "^3.2.0", + "glob-to-regexp": "^0.4.1", + "graceful-fs": "^4.2.11", + "json-parse-even-better-errors": "^2.3.1", + "loader-runner": "^4.3.1", + "mime-types": "^2.1.27", + "neo-async": "^2.6.2", + "schema-utils": "^4.3.3", + "tapable": "^2.3.0", + "terser-webpack-plugin": "^5.3.17", + "watchpack": "^2.5.1", + "webpack-sources": "^3.3.4" + }, + "bin": { + "webpack": "bin/webpack.js" + }, + "engines": { + "node": ">=10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependenciesMeta": { + "webpack-cli": { + "optional": true + } + } + }, + "node_modules/webpack-node-externals": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/webpack-node-externals/-/webpack-node-externals-3.0.0.tgz", + "integrity": "sha512-LnL6Z3GGDPht/AigwRh2dvL9PQPFQ8skEpVrWZXLWBYmqcaojHNN0onvHzie6rq7EWKrrBfPYqNEzTJgiwEQDQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/webpack-sources": { + "version": "3.3.4", + "resolved": "https://registry.npmjs.org/webpack-sources/-/webpack-sources-3.3.4.tgz", + "integrity": "sha512-7tP1PdV4vF+lYPnkMR0jMY5/la2ub5Fc/8VQrrU+lXkiM6C4TjVfGw7iKfyhnTQOsD+6Q/iKw0eFciziRgD58Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/webpack/node_modules/ajv": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", + "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/webpack/node_modules/ajv-formats": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-2.1.1.tgz", + "integrity": "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/webpack/node_modules/ajv-keywords": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-5.1.0.tgz", + "integrity": "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3" + }, + "peerDependencies": { + "ajv": "^8.8.2" + } + }, + "node_modules/webpack/node_modules/eslint-scope": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz", + "integrity": "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^4.1.1" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/webpack/node_modules/estraverse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz", + "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/webpack/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true, + "license": "MIT" + }, + "node_modules/webpack/node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/webpack/node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/webpack/node_modules/schema-utils": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.3.3.tgz", + "integrity": "sha512-eflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/OUYdb48v4k4WWHQurA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/json-schema": "^7.0.9", + "ajv": "^8.9.0", + "ajv-formats": "^2.1.1", + "ajv-keywords": "^5.1.0" + }, + "engines": { + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/wordwrap": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-1.0.0.tgz", + "integrity": "sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/wrap-ansi": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", + "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi-cjs": { + "name": "wrap-ansi", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, + "node_modules/write-file-atomic": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-5.0.1.tgz", + "integrity": "sha512-+QU2zd6OTD8XWIJCbffaiQeH9U73qIqafo1x6V1snCWYGJf6cVE0cDR4D8xRzcEnfI21IFrUPzPGtcPf8AC+Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "imurmurhash": "^0.1.4", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, + "node_modules/yargs": { + "version": "17.7.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", + "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/yn": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz", + "integrity": "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/yoctocolors-cjs": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/yoctocolors-cjs/-/yoctocolors-cjs-2.1.3.tgz", + "integrity": "sha512-U/PBtDf35ff0D8X8D0jfdzHYEPFxAI7jJlxZXwCSez5M3190m+QobIfh+sWDWSHMCWWJN2AWamkegn6vr6YBTw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..1788c8f --- /dev/null +++ b/package.json @@ -0,0 +1,102 @@ +{ + "name": "iasis-gestao-backend", + "version": "0.0.1", + "description": "", + "author": "", + "private": true, + "license": "UNLICENSED", + "engines": { + "node": ">=22.0.0" + }, + "scripts": { + "postinstall": "prisma generate", + "build": "nest build", + "format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"", + "start": "nest start", + "start:dev": "nest start --watch", + "start:debug": "nest start --debug --watch", + "start:prod": "node dist/main", + "lint": "eslint \"{src,apps,libs,test}/**/*.ts\"", + "lint:fix": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix", + "test": "jest", + "test:watch": "jest --watch", + "test:cov": "jest --coverage", + "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand", + "test:e2e": "jest --config ./test/jest-e2e.json", + "prisma:generate": "prisma generate", + "prisma:migrate": "prisma migrate dev", + "prisma:studio": "prisma studio" + }, + "dependencies": { + "@nestjs/common": "^11.0.1", + "@nestjs/config": "^4.0.4", + "@nestjs/core": "^11.0.1", + "@nestjs/jwt": "^11.0.2", + "@nestjs/passport": "^11.0.5", + "@nestjs/platform-express": "^11.0.1", + "@nestjs/swagger": "^11.4.2", + "@nestjs/throttler": "^6.5.0", + "@prisma/client": "5.22.0", + "@types/nodemailer": "^8.0.1", + "bcrypt": "^6.0.0", + "class-transformer": "^0.5.1", + "class-validator": "^0.15.1", + "joi": "^18.1.2", + "nodemailer": "^8.0.11", + "passport": "^0.7.0", + "passport-jwt": "^4.0.1", + "prisma": "5.22.0", + "reflect-metadata": "^0.2.2", + "rxjs": "^7.8.1" + }, + "overrides": { + "fast-uri": "^3.1.2" + }, + "devDependencies": { + "@eslint/eslintrc": "^3.2.0", + "@eslint/js": "^9.18.0", + "@nestjs/cli": "^11.0.0", + "@nestjs/schematics": "^11.0.0", + "@nestjs/testing": "^11.0.1", + "@types/bcrypt": "^6.0.0", + "@types/express": "^5.0.0", + "@types/jest": "^30.0.0", + "@types/node": "^24.0.0", + "@types/passport-jwt": "^4.0.1", + "@types/supertest": "^7.0.0", + "eslint": "^9.18.0", + "eslint-config-prettier": "^10.0.1", + "eslint-plugin-prettier": "^5.2.2", + "globals": "^17.0.0", + "jest": "^30.0.0", + "prettier": "^3.4.2", + "source-map-support": "^0.5.21", + "supertest": "^7.0.0", + "ts-jest": "^29.2.5", + "ts-loader": "^9.5.2", + "ts-node": "^10.9.2", + "tsconfig-paths": "^4.2.0", + "typescript": "^5.7.3", + "typescript-eslint": "^8.20.0" + }, + "prisma": { + "seed": "ts-node prisma/seed.ts" + }, + "jest": { + "moduleFileExtensions": [ + "js", + "json", + "ts" + ], + "rootDir": "src", + "testRegex": ".*\\.spec\\.ts$", + "transform": { + "^.+\\.(t|j)s$": "ts-jest" + }, + "collectCoverageFrom": [ + "**/*.(t|j)s" + ], + "coverageDirectory": "../coverage", + "testEnvironment": "node" + } +} diff --git a/prisma/check-password.ts b/prisma/check-password.ts new file mode 100644 index 0000000..9240ca2 --- /dev/null +++ b/prisma/check-password.ts @@ -0,0 +1,15 @@ +import { PrismaClient } from '@prisma/client'; +import * as bcrypt from 'bcrypt'; + +const prisma = new PrismaClient(); + +async function main() { + const user = await prisma.user.findUnique({ where: { email: 'admin@iasis.com.br' } }); + if (!user) { console.log('Usuário não encontrado'); return; } + + console.log('Hash no banco:', user.password); + const match = await bcrypt.compare('Admin@123456', user.password); + console.log('Senha confere:', match); +} + +main().finally(() => prisma.$disconnect()); diff --git a/prisma/migrations/20260427183727_init_deliverable_schema/migration.sql b/prisma/migrations/20260427183727_init_deliverable_schema/migration.sql new file mode 100644 index 0000000..08cd870 --- /dev/null +++ b/prisma/migrations/20260427183727_init_deliverable_schema/migration.sql @@ -0,0 +1,401 @@ +-- CreateEnum +CREATE TYPE "Role" AS ENUM ('ADMIN', 'OPERATOR', 'CLIENT'); + +-- CreateEnum +CREATE TYPE "DeliverableStatus" AS ENUM ('RASCUNHO', 'EMITIDA', 'EM_EXECUCAO', 'AGUARDANDO_VALIDACAO', 'EM_REVISAO', 'APROVADA', 'GLOSADA', 'ENCERRADA', 'CANCELADA', 'AGUARDANDO_PAGAMENTO', 'PAGA'); + +-- CreateEnum +CREATE TYPE "TimelineEventType" AS ENUM ('CRIACAO', 'EDICAO', 'STATUS_CHANGE', 'ASSIGNMENT', 'BACKLOG', 'ANOTACAO', 'ALOCACAO'); + +-- CreateEnum +CREATE TYPE "BacklogItemStatus" AS ENUM ('PENDENTE', 'ACEITO', 'REJEITADO'); + +-- CreateEnum +CREATE TYPE "NoteType" AS ENUM ('OBSERVACAO', 'RISCO', 'IMPEDIMENTO', 'DECISAO'); + +-- CreateEnum +CREATE TYPE "SprintType" AS ENUM ('DESCOBERTA', 'DESIGN', 'ARQUITETURA', 'CONSTRUCAO'); + +-- CreateEnum +CREATE TYPE "SprintStatus" AS ENUM ('RASCUNHO', 'EM_EXECUCAO', 'FINALIZADA', 'CANCELADA'); + +-- CreateEnum +CREATE TYPE "SprintHistoryEventType" AS ENUM ('ENTREGAVEL_ADICIONADO', 'ENTREGAVEL_REMOVIDO', 'STATUS_CHANGE', 'ENTREGAVEL_MOVIDO'); + +-- CreateTable +CREATE TABLE "users" ( + "id" TEXT NOT NULL, + "name" TEXT NOT NULL, + "email" TEXT NOT NULL, + "password" TEXT NOT NULL, + "role" "Role" NOT NULL DEFAULT 'OPERATOR', + "isActive" BOOLEAN NOT NULL DEFAULT true, + "mustChangePassword" BOOLEAN NOT NULL DEFAULT false, + "client_id" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "users_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "clients" ( + "id" TEXT NOT NULL, + "name" TEXT NOT NULL, + "document" TEXT, + "email" TEXT, + "phone" TEXT, + "contact_name" TEXT, + "description" TEXT, + "is_active" BOOLEAN NOT NULL DEFAULT true, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "clients_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "contract_items" ( + "id" TEXT NOT NULL, + "code" TEXT NOT NULL, + "name" TEXT NOT NULL, + "description" TEXT, + "total_ust" DECIMAL(65,30) NOT NULL, + "ust_value" DECIMAL(65,30), + "timebox_descoberta" DECIMAL(65,30), + "timebox_design" DECIMAL(65,30), + "timebox_arquitetura" DECIMAL(65,30), + "timebox_construcao" DECIMAL(65,30), + "is_active" BOOLEAN NOT NULL DEFAULT true, + "client_id" TEXT NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "contract_items_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "professionals" ( + "id" TEXT NOT NULL, + "name" TEXT NOT NULL, + "document" TEXT, + "email" TEXT, + "phone" TEXT, + "role" TEXT, + "is_active" BOOLEAN NOT NULL DEFAULT true, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "professionals_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "contracts" ( + "id" TEXT NOT NULL, + "client_id" TEXT NOT NULL, + "name" TEXT NOT NULL, + "code" TEXT, + "description" TEXT, + "start_date" TIMESTAMP(3), + "end_date" TIMESTAMP(3), + "ust_value" DECIMAL(65,30), + "is_active" BOOLEAN NOT NULL DEFAULT true, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "contracts_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "projects" ( + "id" TEXT NOT NULL, + "contract_id" TEXT NOT NULL, + "name" TEXT NOT NULL, + "code" TEXT, + "description" TEXT, + "start_date" TIMESTAMP(3), + "end_date" TIMESTAMP(3), + "is_active" BOOLEAN NOT NULL DEFAULT true, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "projects_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "sprints" ( + "id" TEXT NOT NULL, + "name" TEXT NOT NULL, + "code" TEXT, + "type" "SprintType", + "status" "SprintStatus" NOT NULL DEFAULT 'RASCUNHO', + "start_date" TIMESTAMP(3) NOT NULL, + "end_date" TIMESTAMP(3) NOT NULL, + "goal" TEXT, + "is_active" BOOLEAN NOT NULL DEFAULT true, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "sprints_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "deliverables" ( + "id" TEXT NOT NULL, + "code" TEXT NOT NULL, + "title" TEXT NOT NULL, + "description" TEXT, + "status" "DeliverableStatus" NOT NULL DEFAULT 'RASCUNHO', + "client_id" TEXT NOT NULL, + "contract_id" TEXT NOT NULL, + "project_id" TEXT NOT NULL, + "contract_item_id" TEXT NOT NULL, + "sprint_id" TEXT NOT NULL, + "start_date" TIMESTAMP(3), + "expected_end_date" TIMESTAMP(3), + "ust_value" DECIMAL(65,30), + "ust_quantity" DECIMAL(65,30), + "total_value" DECIMAL(65,30), + "is_active" BOOLEAN NOT NULL DEFAULT true, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + "created_by" TEXT, + "updated_by" TEXT, + + CONSTRAINT "deliverables_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "deliverable_status_history" ( + "id" TEXT NOT NULL, + "deliverable_id" TEXT NOT NULL, + "previous_status" "DeliverableStatus", + "new_status" "DeliverableStatus" NOT NULL, + "observation" TEXT, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "created_by" TEXT, + + CONSTRAINT "deliverable_status_history_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "deliverable_assignments" ( + "id" TEXT NOT NULL, + "deliverable_id" TEXT NOT NULL, + "professional_id" TEXT NOT NULL, + "role" TEXT, + "start_date" TIMESTAMP(3), + "end_date" TIMESTAMP(3), + "observation" TEXT, + "is_active" BOOLEAN NOT NULL DEFAULT true, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + "created_by" TEXT, + + CONSTRAINT "deliverable_assignments_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "deliverable_backlog_items" ( + "id" TEXT NOT NULL, + "deliverable_id" TEXT NOT NULL, + "title" TEXT NOT NULL, + "description" TEXT, + "acceptance_criteria" TEXT, + "status" "BacklogItemStatus" NOT NULL DEFAULT 'PENDENTE', + "rejection_reason" TEXT, + "sort_order" INTEGER NOT NULL DEFAULT 0, + "is_active" BOOLEAN NOT NULL DEFAULT true, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + "created_by" TEXT, + "updated_by" TEXT, + + CONSTRAINT "deliverable_backlog_items_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "timeline_events" ( + "id" TEXT NOT NULL, + "deliverable_id" TEXT NOT NULL, + "type" "TimelineEventType" NOT NULL, + "title" TEXT NOT NULL, + "description" TEXT, + "metadata" JSONB, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "created_by" TEXT, + + CONSTRAINT "timeline_events_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "notes" ( + "id" TEXT NOT NULL, + "deliverable_id" TEXT NOT NULL, + "type" "NoteType" NOT NULL, + "title" TEXT NOT NULL, + "description" TEXT, + "is_relevant" BOOLEAN NOT NULL DEFAULT false, + "is_active" BOOLEAN NOT NULL DEFAULT true, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + "created_by" TEXT, + "updated_by" TEXT, + + CONSTRAINT "notes_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "sprint_history" ( + "id" TEXT NOT NULL, + "sprint_id" TEXT NOT NULL, + "event_type" "SprintHistoryEventType" NOT NULL, + "description" TEXT NOT NULL, + "deliverable_id" TEXT, + "target_sprint_id" TEXT, + "previous_status" "SprintStatus", + "new_status" "SprintStatus", + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "created_by" TEXT, + + CONSTRAINT "sprint_history_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "client_profiles" ( + "id" TEXT NOT NULL, + "name" TEXT NOT NULL, + "is_active" BOOLEAN NOT NULL DEFAULT true, + "client_id" TEXT NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "client_profiles_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "allocation_templates" ( + "id" TEXT NOT NULL, + "client_id" TEXT NOT NULL, + "sprint_type" "SprintType" NOT NULL, + "contract_item_id" TEXT NOT NULL, + "is_active" BOOLEAN NOT NULL DEFAULT true, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "allocation_templates_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "allocation_template_items" ( + "id" TEXT NOT NULL, + "template_id" TEXT NOT NULL, + "profile_id" TEXT NOT NULL, + "quantity" INTEGER NOT NULL, + "allocation_percentage" DECIMAL(65,30) NOT NULL, + + CONSTRAINT "allocation_template_items_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "deliverable_allocations" ( + "id" TEXT NOT NULL, + "deliverable_id" TEXT NOT NULL, + "profile_id" TEXT NOT NULL, + "quantity" INTEGER NOT NULL, + "allocation_percentage" DECIMAL(65,30) NOT NULL, + "calculated_value" DECIMAL(65,30), + "is_active" BOOLEAN NOT NULL DEFAULT true, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + "created_by" TEXT, + + CONSTRAINT "deliverable_allocations_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "users_email_key" ON "users"("email"); + +-- CreateIndex +CREATE UNIQUE INDEX "contract_items_code_client_id_key" ON "contract_items"("code", "client_id"); + +-- CreateIndex +CREATE UNIQUE INDEX "deliverables_code_key" ON "deliverables"("code"); + +-- CreateIndex +CREATE UNIQUE INDEX "allocation_templates_client_id_sprint_type_contract_item_id_key" ON "allocation_templates"("client_id", "sprint_type", "contract_item_id"); + +-- AddForeignKey +ALTER TABLE "users" ADD CONSTRAINT "users_client_id_fkey" FOREIGN KEY ("client_id") REFERENCES "clients"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "contract_items" ADD CONSTRAINT "contract_items_client_id_fkey" FOREIGN KEY ("client_id") REFERENCES "clients"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "contracts" ADD CONSTRAINT "contracts_client_id_fkey" FOREIGN KEY ("client_id") REFERENCES "clients"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "projects" ADD CONSTRAINT "projects_contract_id_fkey" FOREIGN KEY ("contract_id") REFERENCES "contracts"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "deliverables" ADD CONSTRAINT "deliverables_client_id_fkey" FOREIGN KEY ("client_id") REFERENCES "clients"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "deliverables" ADD CONSTRAINT "deliverables_contract_id_fkey" FOREIGN KEY ("contract_id") REFERENCES "contracts"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "deliverables" ADD CONSTRAINT "deliverables_project_id_fkey" FOREIGN KEY ("project_id") REFERENCES "projects"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "deliverables" ADD CONSTRAINT "deliverables_contract_item_id_fkey" FOREIGN KEY ("contract_item_id") REFERENCES "contract_items"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "deliverables" ADD CONSTRAINT "deliverables_sprint_id_fkey" FOREIGN KEY ("sprint_id") REFERENCES "sprints"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "deliverable_status_history" ADD CONSTRAINT "deliverable_status_history_deliverable_id_fkey" FOREIGN KEY ("deliverable_id") REFERENCES "deliverables"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "deliverable_assignments" ADD CONSTRAINT "deliverable_assignments_deliverable_id_fkey" FOREIGN KEY ("deliverable_id") REFERENCES "deliverables"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "deliverable_assignments" ADD CONSTRAINT "deliverable_assignments_professional_id_fkey" FOREIGN KEY ("professional_id") REFERENCES "professionals"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "deliverable_backlog_items" ADD CONSTRAINT "deliverable_backlog_items_deliverable_id_fkey" FOREIGN KEY ("deliverable_id") REFERENCES "deliverables"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "timeline_events" ADD CONSTRAINT "timeline_events_deliverable_id_fkey" FOREIGN KEY ("deliverable_id") REFERENCES "deliverables"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "notes" ADD CONSTRAINT "notes_deliverable_id_fkey" FOREIGN KEY ("deliverable_id") REFERENCES "deliverables"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "sprint_history" ADD CONSTRAINT "sprint_history_sprint_id_fkey" FOREIGN KEY ("sprint_id") REFERENCES "sprints"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "sprint_history" ADD CONSTRAINT "sprint_history_target_sprint_id_fkey" FOREIGN KEY ("target_sprint_id") REFERENCES "sprints"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "sprint_history" ADD CONSTRAINT "sprint_history_deliverable_id_fkey" FOREIGN KEY ("deliverable_id") REFERENCES "deliverables"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "client_profiles" ADD CONSTRAINT "client_profiles_client_id_fkey" FOREIGN KEY ("client_id") REFERENCES "clients"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "allocation_templates" ADD CONSTRAINT "allocation_templates_client_id_fkey" FOREIGN KEY ("client_id") REFERENCES "clients"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "allocation_templates" ADD CONSTRAINT "allocation_templates_contract_item_id_fkey" FOREIGN KEY ("contract_item_id") REFERENCES "contract_items"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "allocation_template_items" ADD CONSTRAINT "allocation_template_items_template_id_fkey" FOREIGN KEY ("template_id") REFERENCES "allocation_templates"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "allocation_template_items" ADD CONSTRAINT "allocation_template_items_profile_id_fkey" FOREIGN KEY ("profile_id") REFERENCES "client_profiles"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "deliverable_allocations" ADD CONSTRAINT "deliverable_allocations_deliverable_id_fkey" FOREIGN KEY ("deliverable_id") REFERENCES "deliverables"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "deliverable_allocations" ADD CONSTRAINT "deliverable_allocations_profile_id_fkey" FOREIGN KEY ("profile_id") REFERENCES "client_profiles"("id") ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/prisma/migrations/20260428211648_add_work_order_and_deliverable_type/migration.sql b/prisma/migrations/20260428211648_add_work_order_and_deliverable_type/migration.sql new file mode 100644 index 0000000..b1c0605 --- /dev/null +++ b/prisma/migrations/20260428211648_add_work_order_and_deliverable_type/migration.sql @@ -0,0 +1,84 @@ +/* + Warnings: + + - You are about to drop the column `type` on the `sprints` table. All the data in the column will be lost. + - Added the required column `type` to the `deliverables` table without a default value. This is not possible if the table is not empty. + - Added the required column `work_order_id` to the `deliverables` table without a default value. This is not possible if the table is not empty. + +*/ +-- CreateEnum +CREATE TYPE "WorkOrderStatus" AS ENUM ('RASCUNHO', 'EMITIDA', 'EM_EXECUCAO', 'TOTALMENTE_PAGA', 'CANCELADA'); + +-- CreateEnum +CREATE TYPE "DeliverableType" AS ENUM ('DESCOBERTA', 'DESIGN', 'ARQUITETURA', 'CONSTRUCAO', 'MANUTENCAO', 'LICENCA'); + +-- AlterTable +ALTER TABLE "deliverables" ADD COLUMN "type" "DeliverableType" NOT NULL, +ADD COLUMN "work_order_id" TEXT NOT NULL; + +-- AlterTable +ALTER TABLE "sprints" DROP COLUMN "type"; + +-- CreateTable +CREATE TABLE "work_orders" ( + "id" TEXT NOT NULL, + "code" TEXT NOT NULL, + "name" TEXT NOT NULL, + "description" TEXT, + "contract_id" TEXT NOT NULL, + "contract_item_id" TEXT NOT NULL, + "reserved_ust" DECIMAL(65,30) NOT NULL, + "status" "WorkOrderStatus" NOT NULL DEFAULT 'RASCUNHO', + "start_date" TIMESTAMP(3) NOT NULL, + "end_date" TIMESTAMP(3), + "is_active" BOOLEAN NOT NULL DEFAULT true, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + "created_by" TEXT, + "updated_by" TEXT, + + CONSTRAINT "work_orders_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "work_order_projects" ( + "work_order_id" TEXT NOT NULL, + "project_id" TEXT NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "work_order_projects_pkey" PRIMARY KEY ("work_order_id","project_id") +); + +-- CreateTable +CREATE TABLE "work_order_status_history" ( + "id" TEXT NOT NULL, + "work_order_id" TEXT NOT NULL, + "previous_status" "WorkOrderStatus", + "new_status" "WorkOrderStatus" NOT NULL, + "observation" TEXT, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "created_by" TEXT, + + CONSTRAINT "work_order_status_history_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "work_orders_code_contract_id_key" ON "work_orders"("code", "contract_id"); + +-- AddForeignKey +ALTER TABLE "deliverables" ADD CONSTRAINT "deliverables_work_order_id_fkey" FOREIGN KEY ("work_order_id") REFERENCES "work_orders"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "work_orders" ADD CONSTRAINT "work_orders_contract_id_fkey" FOREIGN KEY ("contract_id") REFERENCES "contracts"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "work_orders" ADD CONSTRAINT "work_orders_contract_item_id_fkey" FOREIGN KEY ("contract_item_id") REFERENCES "contract_items"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "work_order_projects" ADD CONSTRAINT "work_order_projects_work_order_id_fkey" FOREIGN KEY ("work_order_id") REFERENCES "work_orders"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "work_order_projects" ADD CONSTRAINT "work_order_projects_project_id_fkey" FOREIGN KEY ("project_id") REFERENCES "projects"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "work_order_status_history" ADD CONSTRAINT "work_order_status_history_work_order_id_fkey" FOREIGN KEY ("work_order_id") REFERENCES "work_orders"("id") ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/prisma/migrations/20260429130538_add_contract_item_type/migration.sql b/prisma/migrations/20260429130538_add_contract_item_type/migration.sql new file mode 100644 index 0000000..f59735d --- /dev/null +++ b/prisma/migrations/20260429130538_add_contract_item_type/migration.sql @@ -0,0 +1,5 @@ +-- CreateEnum +CREATE TYPE "ContractItemType" AS ENUM ('UST', 'SAAS_LICENSE'); + +-- AlterTable +ALTER TABLE "contract_items" ADD COLUMN "item_type" "ContractItemType" NOT NULL DEFAULT 'UST'; diff --git a/prisma/migrations/20260429200355_add_deliverable_num_weeks_and_timebox_manutencao/migration.sql b/prisma/migrations/20260429200355_add_deliverable_num_weeks_and_timebox_manutencao/migration.sql new file mode 100644 index 0000000..5305380 --- /dev/null +++ b/prisma/migrations/20260429200355_add_deliverable_num_weeks_and_timebox_manutencao/migration.sql @@ -0,0 +1,3 @@ +-- AlterTable +ALTER TABLE "deliverables" ADD COLUMN "num_weeks" INTEGER NOT NULL DEFAULT 1, +ADD COLUMN "timebox_manutencao" DECIMAL(65,30); diff --git a/prisma/migrations/20260429200404_make_deliverable_num_weeks_required/migration.sql b/prisma/migrations/20260429200404_make_deliverable_num_weeks_required/migration.sql new file mode 100644 index 0000000..d26a019 --- /dev/null +++ b/prisma/migrations/20260429200404_make_deliverable_num_weeks_required/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "deliverables" ALTER COLUMN "num_weeks" DROP DEFAULT; diff --git a/prisma/migrations/20260429200936_add_timeline_event_valor_recalculado/migration.sql b/prisma/migrations/20260429200936_add_timeline_event_valor_recalculado/migration.sql new file mode 100644 index 0000000..10db5a4 --- /dev/null +++ b/prisma/migrations/20260429200936_add_timeline_event_valor_recalculado/migration.sql @@ -0,0 +1,2 @@ +-- AlterEnum +ALTER TYPE "TimelineEventType" ADD VALUE 'VALOR_RECALCULADO'; diff --git a/prisma/migrations/20260429205419_add_work_order_total_value/migration.sql b/prisma/migrations/20260429205419_add_work_order_total_value/migration.sql new file mode 100644 index 0000000..0df4a95 --- /dev/null +++ b/prisma/migrations/20260429205419_add_work_order_total_value/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "work_orders" ADD COLUMN "total_value" DECIMAL(65,30); diff --git a/prisma/migrations/20260430124418_make_deliverable_dates_required/migration.sql b/prisma/migrations/20260430124418_make_deliverable_dates_required/migration.sql new file mode 100644 index 0000000..780f5e9 --- /dev/null +++ b/prisma/migrations/20260430124418_make_deliverable_dates_required/migration.sql @@ -0,0 +1,9 @@ +-- Backfill nullable rows before enforcing NOT NULL. +UPDATE "deliverables" SET "start_date" = "created_at" WHERE "start_date" IS NULL; +UPDATE "deliverables" +SET "expected_end_date" = "start_date" + INTERVAL '7 days' +WHERE "expected_end_date" IS NULL; + +-- AlterTable +ALTER TABLE "deliverables" ALTER COLUMN "start_date" SET NOT NULL, +ALTER COLUMN "expected_end_date" SET NOT NULL; diff --git a/prisma/migrations/20260430193930_add_integration_api_keys/migration.sql b/prisma/migrations/20260430193930_add_integration_api_keys/migration.sql new file mode 100644 index 0000000..a89ca4e --- /dev/null +++ b/prisma/migrations/20260430193930_add_integration_api_keys/migration.sql @@ -0,0 +1,44 @@ +-- CreateEnum +CREATE TYPE "IntegrationScope" AS ENUM ('DELIVERABLES_READ', 'CLIENTS_READ'); + +-- CreateTable +CREATE TABLE "integration_api_keys" ( + "id" TEXT NOT NULL, + "name" TEXT NOT NULL, + "hashed_key" TEXT NOT NULL, + "last_four_chars" TEXT NOT NULL, + "scopes" "IntegrationScope"[], + "is_active" BOOLEAN NOT NULL DEFAULT true, + "expires_at" TIMESTAMP(3), + "last_used_at" TIMESTAMP(3), + "rate_limit_per_minute" INTEGER NOT NULL DEFAULT 60, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + "created_by" TEXT, + "updated_by" TEXT, + + CONSTRAINT "integration_api_keys_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "integration_api_key_usage" ( + "id" TEXT NOT NULL, + "api_key_id" TEXT NOT NULL, + "endpoint" TEXT NOT NULL, + "status_code" INTEGER NOT NULL, + "ip_address" TEXT, + "user_agent" TEXT, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "integration_api_key_usage_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "integration_api_keys_hashed_key_key" ON "integration_api_keys"("hashed_key"); + +-- CreateIndex +CREATE INDEX "integration_api_key_usage_api_key_id_idx" ON "integration_api_key_usage"("api_key_id"); + +-- AddForeignKey +ALTER TABLE "integration_api_key_usage" ADD CONSTRAINT "integration_api_key_usage_api_key_id_fkey" FOREIGN KEY ("api_key_id") REFERENCES "integration_api_keys"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + diff --git a/prisma/migrations/20260612174625_add_password_reset_token/migration.sql b/prisma/migrations/20260612174625_add_password_reset_token/migration.sql new file mode 100644 index 0000000..eb552b3 --- /dev/null +++ b/prisma/migrations/20260612174625_add_password_reset_token/migration.sql @@ -0,0 +1,20 @@ +-- CreateTable +CREATE TABLE "password_reset_tokens" ( + "id" TEXT NOT NULL, + "user_id" TEXT NOT NULL, + "token_hash" TEXT NOT NULL, + "expires_at" TIMESTAMP(3) NOT NULL, + "used_at" TIMESTAMP(3), + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "password_reset_tokens_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "password_reset_tokens_token_hash_key" ON "password_reset_tokens"("token_hash"); + +-- CreateIndex +CREATE INDEX "password_reset_tokens_user_id_idx" ON "password_reset_tokens"("user_id"); + +-- AddForeignKey +ALTER TABLE "password_reset_tokens" ADD CONSTRAINT "password_reset_tokens_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/prisma/migrations/20260613160730_set_gestor_projetos_as_default_role/migration.sql b/prisma/migrations/20260613160730_set_gestor_projetos_as_default_role/migration.sql new file mode 100644 index 0000000..57bfc0a --- /dev/null +++ b/prisma/migrations/20260613160730_set_gestor_projetos_as_default_role/migration.sql @@ -0,0 +1,4 @@ +-- AlterTable: change default role from OPERATOR to GESTOR_PROJETOS +-- This must be in a separate migration from the ADD VALUE statements +-- because PostgreSQL requires enum values to be committed before use. +ALTER TABLE "users" ALTER COLUMN "role" SET DEFAULT 'GESTOR_PROJETOS'; diff --git a/prisma/migrations/20260613190340_expand_role_enum_add_client_sub_role/migration.sql b/prisma/migrations/20260613190340_expand_role_enum_add_client_sub_role/migration.sql new file mode 100644 index 0000000..e846fa9 --- /dev/null +++ b/prisma/migrations/20260613190340_expand_role_enum_add_client_sub_role/migration.sql @@ -0,0 +1,17 @@ +-- AlterEnum +-- This migration adds more than one value to an enum. +-- With PostgreSQL versions 11 and earlier, this is not possible +-- in a single migration. This can be worked around by creating +-- multiple migrations, each migration adding only one value to +-- the enum. + + +ALTER TYPE "Role" ADD VALUE 'GESTOR_PROJETOS'; +ALTER TYPE "Role" ADD VALUE 'PO'; +ALTER TYPE "Role" ADD VALUE 'FISCAL_CONTRATO'; +ALTER TYPE "Role" ADD VALUE 'GESTOR_CONTRATO'; + +-- AlterTable: add temporary clientSubRole column only +-- Note: SET DEFAULT 'GESTOR_PROJETOS' must be in a separate migration +-- after the new enum values are committed. +ALTER TABLE "users" ADD COLUMN "client_sub_role" TEXT; diff --git a/prisma/migrations/migration_lock.toml b/prisma/migrations/migration_lock.toml new file mode 100644 index 0000000..fbffa92 --- /dev/null +++ b/prisma/migrations/migration_lock.toml @@ -0,0 +1,3 @@ +# Please do not edit this file manually +# It should be added in your version-control system (i.e. Git) +provider = "postgresql" \ No newline at end of file diff --git a/prisma/schema.prisma b/prisma/schema.prisma new file mode 100644 index 0000000..64d8911 --- /dev/null +++ b/prisma/schema.prisma @@ -0,0 +1,561 @@ +generator client { + provider = "prisma-client-js" + binaryTargets = ["native", "debian-openssl-3.0.x"] +} + +datasource db { + provider = "postgresql" + url = env("DATABASE_URL") +} + +enum Role { + ADMIN + OPERATOR + CLIENT + GESTOR_PROJETOS + PO + FISCAL_CONTRATO + GESTOR_CONTRATO +} + +enum DeliverableStatus { + RASCUNHO + EMITIDA + EM_EXECUCAO + AGUARDANDO_VALIDACAO + EM_REVISAO + APROVADA + GLOSADA + ENCERRADA + CANCELADA + AGUARDANDO_PAGAMENTO + PAGA +} + +enum TimelineEventType { + CRIACAO + EDICAO + STATUS_CHANGE + ASSIGNMENT + BACKLOG + ANOTACAO + ALOCACAO + VALOR_RECALCULADO +} + +enum BacklogItemStatus { + PENDENTE + ACEITO + REJEITADO +} + +enum NoteType { + OBSERVACAO + RISCO + IMPEDIMENTO + DECISAO +} + +enum SprintType { + DESCOBERTA + DESIGN + ARQUITETURA + CONSTRUCAO +} + +enum WorkOrderStatus { + RASCUNHO + EMITIDA + EM_EXECUCAO + TOTALMENTE_PAGA + CANCELADA +} + +enum DeliverableType { + DESCOBERTA + DESIGN + ARQUITETURA + CONSTRUCAO + MANUTENCAO + LICENCA +} + +enum ContractItemType { + UST + SAAS_LICENSE +} + +enum SprintStatus { + RASCUNHO + EM_EXECUCAO + FINALIZADA + CANCELADA +} + +enum SprintHistoryEventType { + ENTREGAVEL_ADICIONADO + ENTREGAVEL_REMOVIDO + STATUS_CHANGE + ENTREGAVEL_MOVIDO +} + +enum IntegrationScope { + DELIVERABLES_READ + CLIENTS_READ +} + +model User { + id String @id @default(uuid()) + name String + email String @unique + password String + role Role @default(GESTOR_PROJETOS) + clientSubRole String? @map("client_sub_role") + isActive Boolean @default(true) + mustChangePassword Boolean @default(false) + clientId String? @map("client_id") + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + client Client? @relation(fields: [clientId], references: [id]) + passwordResetTokens PasswordResetToken[] + + @@map("users") +} + +model PasswordResetToken { + id String @id @default(uuid()) + userId String @map("user_id") + tokenHash String @unique @map("token_hash") + expiresAt DateTime @map("expires_at") + usedAt DateTime? @map("used_at") + createdAt DateTime @default(now()) @map("created_at") + + user User @relation(fields: [userId], references: [id]) + + @@index([userId]) + @@map("password_reset_tokens") +} + +model Client { + id String @id @default(uuid()) + name String + document String? + email String? + phone String? + contactName String? @map("contact_name") + description String? + isActive Boolean @default(true) @map("is_active") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + contracts Contract[] + contractItems ContractItem[] + deliverables Deliverable[] + users User[] + profiles ClientProfile[] + allocationTemplates AllocationTemplate[] + + @@map("clients") +} + +model ContractItem { + id String @id @default(uuid()) + code String + name String + description String? + totalUst Decimal @map("total_ust") + ustValue Decimal? @map("ust_value") + itemType ContractItemType @default(UST) @map("item_type") + timeboxDescoberta Decimal? @map("timebox_descoberta") + timeboxDesign Decimal? @map("timebox_design") + timeboxArquitetura Decimal? @map("timebox_arquitetura") + timeboxConstrucao Decimal? @map("timebox_construcao") + isActive Boolean @default(true) @map("is_active") + clientId String @map("client_id") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + client Client @relation(fields: [clientId], references: [id]) + deliverables Deliverable[] + allocationTemplates AllocationTemplate[] + workOrders WorkOrder[] + + @@unique([code, clientId]) + @@map("contract_items") +} + +model Professional { + id String @id @default(uuid()) + name String + document String? + email String? + phone String? + role String? + isActive Boolean @default(true) @map("is_active") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + deliverableAssignments DeliverableAssignment[] + + @@map("professionals") +} + +model Contract { + id String @id @default(uuid()) + clientId String @map("client_id") + name String + code String? + description String? + startDate DateTime? @map("start_date") + endDate DateTime? @map("end_date") + ustValue Decimal? @map("ust_value") + isActive Boolean @default(true) @map("is_active") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + client Client @relation(fields: [clientId], references: [id]) + projects Project[] + deliverables Deliverable[] + workOrders WorkOrder[] + + @@map("contracts") +} + +model Project { + id String @id @default(uuid()) + contractId String @map("contract_id") + name String + code String? + description String? + startDate DateTime? @map("start_date") + endDate DateTime? @map("end_date") + isActive Boolean @default(true) @map("is_active") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + contract Contract @relation(fields: [contractId], references: [id]) + deliverables Deliverable[] + workOrders WorkOrderProject[] + + @@map("projects") +} + +model Sprint { + id String @id @default(uuid()) + name String + code String? + status SprintStatus @default(RASCUNHO) + startDate DateTime @map("start_date") + endDate DateTime @map("end_date") + goal String? + isActive Boolean @default(true) @map("is_active") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + deliverables Deliverable[] + history SprintHistory[] @relation("SprintHistory") + targetHistory SprintHistory[] @relation("SprintHistoryTarget") + + @@map("sprints") +} + +model Deliverable { + id String @id @default(uuid()) + code String @unique + title String + description String? + status DeliverableStatus @default(RASCUNHO) + type DeliverableType + clientId String @map("client_id") + contractId String @map("contract_id") + projectId String @map("project_id") + contractItemId String @map("contract_item_id") + workOrderId String @map("work_order_id") + sprintId String @map("sprint_id") + startDate DateTime @map("start_date") + expectedEndDate DateTime @map("expected_end_date") + numWeeks Int @map("num_weeks") + timeboxManutencao Decimal? @map("timebox_manutencao") + ustValue Decimal? @map("ust_value") + ustQuantity Decimal? @map("ust_quantity") + totalValue Decimal? @map("total_value") + isActive Boolean @default(true) @map("is_active") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + createdBy String? @map("created_by") + updatedBy String? @map("updated_by") + + client Client @relation(fields: [clientId], references: [id]) + contract Contract @relation(fields: [contractId], references: [id]) + project Project @relation(fields: [projectId], references: [id]) + contractItem ContractItem @relation(fields: [contractItemId], references: [id]) + workOrder WorkOrder @relation(fields: [workOrderId], references: [id]) + sprint Sprint @relation(fields: [sprintId], references: [id]) + statusHistory DeliverableStatusHistory[] + assignments DeliverableAssignment[] + backlogItems DeliverableBacklogItem[] + timelineEvents TimelineEvent[] + notes Note[] + sprintHistory SprintHistory[] + allocations DeliverableAllocation[] + + @@map("deliverables") +} + +model DeliverableStatusHistory { + id String @id @default(uuid()) + deliverableId String @map("deliverable_id") + previousStatus DeliverableStatus? @map("previous_status") + newStatus DeliverableStatus @map("new_status") + observation String? + createdAt DateTime @default(now()) @map("created_at") + createdBy String? @map("created_by") + + deliverable Deliverable @relation(fields: [deliverableId], references: [id]) + + @@map("deliverable_status_history") +} + +model DeliverableAssignment { + id String @id @default(uuid()) + deliverableId String @map("deliverable_id") + professionalId String @map("professional_id") + role String? + startDate DateTime? @map("start_date") + endDate DateTime? @map("end_date") + observation String? + isActive Boolean @default(true) @map("is_active") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + createdBy String? @map("created_by") + + deliverable Deliverable @relation(fields: [deliverableId], references: [id]) + professional Professional @relation(fields: [professionalId], references: [id]) + + @@map("deliverable_assignments") +} + +model DeliverableBacklogItem { + id String @id @default(uuid()) + deliverableId String @map("deliverable_id") + title String + description String? + acceptanceCriteria String? @map("acceptance_criteria") + status BacklogItemStatus @default(PENDENTE) + rejectionReason String? @map("rejection_reason") + sortOrder Int @default(0) @map("sort_order") + isActive Boolean @default(true) @map("is_active") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + createdBy String? @map("created_by") + updatedBy String? @map("updated_by") + + deliverable Deliverable @relation(fields: [deliverableId], references: [id]) + + @@map("deliverable_backlog_items") +} + +model TimelineEvent { + id String @id @default(uuid()) + deliverableId String @map("deliverable_id") + type TimelineEventType + title String + description String? + metadata Json? + createdAt DateTime @default(now()) @map("created_at") + createdBy String? @map("created_by") + + deliverable Deliverable @relation(fields: [deliverableId], references: [id]) + + @@map("timeline_events") +} + +model Note { + id String @id @default(uuid()) + deliverableId String @map("deliverable_id") + type NoteType + title String + description String? + isRelevant Boolean @default(false) @map("is_relevant") + isActive Boolean @default(true) @map("is_active") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + createdBy String? @map("created_by") + updatedBy String? @map("updated_by") + + deliverable Deliverable @relation(fields: [deliverableId], references: [id]) + + @@map("notes") +} + +model SprintHistory { + id String @id @default(uuid()) + sprintId String @map("sprint_id") + eventType SprintHistoryEventType @map("event_type") + description String + deliverableId String? @map("deliverable_id") + targetSprintId String? @map("target_sprint_id") + previousStatus SprintStatus? @map("previous_status") + newStatus SprintStatus? @map("new_status") + createdAt DateTime @default(now()) @map("created_at") + createdBy String? @map("created_by") + + sprint Sprint @relation("SprintHistory", fields: [sprintId], references: [id]) + targetSprint Sprint? @relation("SprintHistoryTarget", fields: [targetSprintId], references: [id]) + deliverable Deliverable? @relation(fields: [deliverableId], references: [id]) + + @@map("sprint_history") +} + +model ClientProfile { + id String @id @default(uuid()) + name String + isActive Boolean @default(true) @map("is_active") + clientId String @map("client_id") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + client Client @relation(fields: [clientId], references: [id]) + allocationTemplateItems AllocationTemplateItem[] + deliverableAllocations DeliverableAllocation[] + + @@map("client_profiles") +} + +model AllocationTemplate { + id String @id @default(uuid()) + clientId String @map("client_id") + sprintType SprintType @map("sprint_type") + contractItemId String @map("contract_item_id") + isActive Boolean @default(true) @map("is_active") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + client Client @relation(fields: [clientId], references: [id]) + contractItem ContractItem @relation(fields: [contractItemId], references: [id]) + items AllocationTemplateItem[] + + @@unique([clientId, sprintType, contractItemId]) + @@map("allocation_templates") +} + +model AllocationTemplateItem { + id String @id @default(uuid()) + templateId String @map("template_id") + profileId String @map("profile_id") + quantity Int + allocationPercentage Decimal @map("allocation_percentage") + + template AllocationTemplate @relation(fields: [templateId], references: [id], onDelete: Cascade) + profile ClientProfile @relation(fields: [profileId], references: [id]) + + @@map("allocation_template_items") +} + +model DeliverableAllocation { + id String @id @default(uuid()) + deliverableId String @map("deliverable_id") + profileId String @map("profile_id") + quantity Int + allocationPercentage Decimal @map("allocation_percentage") + calculatedValue Decimal? @map("calculated_value") + isActive Boolean @default(true) @map("is_active") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + createdBy String? @map("created_by") + + deliverable Deliverable @relation(fields: [deliverableId], references: [id]) + profile ClientProfile @relation(fields: [profileId], references: [id]) + + @@map("deliverable_allocations") +} + +model WorkOrder { + id String @id @default(uuid()) + code String + name String + description String? + contractId String @map("contract_id") + contractItemId String @map("contract_item_id") + reservedUst Decimal @map("reserved_ust") + totalValue Decimal? @map("total_value") + status WorkOrderStatus @default(RASCUNHO) + startDate DateTime @map("start_date") + endDate DateTime? @map("end_date") + isActive Boolean @default(true) @map("is_active") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + createdBy String? @map("created_by") + updatedBy String? @map("updated_by") + + contract Contract @relation(fields: [contractId], references: [id]) + contractItem ContractItem @relation(fields: [contractItemId], references: [id]) + projects WorkOrderProject[] + deliverables Deliverable[] + statusHistory WorkOrderStatusHistory[] + + @@unique([code, contractId]) + @@map("work_orders") +} + +model WorkOrderProject { + workOrderId String @map("work_order_id") + projectId String @map("project_id") + createdAt DateTime @default(now()) @map("created_at") + + workOrder WorkOrder @relation(fields: [workOrderId], references: [id], onDelete: Cascade) + project Project @relation(fields: [projectId], references: [id]) + + @@id([workOrderId, projectId]) + @@map("work_order_projects") +} + +model WorkOrderStatusHistory { + id String @id @default(uuid()) + workOrderId String @map("work_order_id") + previousStatus WorkOrderStatus? @map("previous_status") + newStatus WorkOrderStatus @map("new_status") + observation String? + createdAt DateTime @default(now()) @map("created_at") + createdBy String? @map("created_by") + + workOrder WorkOrder @relation(fields: [workOrderId], references: [id]) + + @@map("work_order_status_history") +} + +model IntegrationApiKey { + id String @id @default(uuid()) + name String + hashedKey String @unique @map("hashed_key") + lastFourChars String @map("last_four_chars") + scopes IntegrationScope[] + isActive Boolean @default(true) @map("is_active") + expiresAt DateTime? @map("expires_at") + lastUsedAt DateTime? @map("last_used_at") + rateLimitPerMinute Int @default(60) @map("rate_limit_per_minute") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + createdBy String? @map("created_by") + updatedBy String? @map("updated_by") + + usages IntegrationApiKeyUsage[] + + @@map("integration_api_keys") +} + +model IntegrationApiKeyUsage { + id String @id @default(uuid()) + apiKeyId String @map("api_key_id") + endpoint String + statusCode Int @map("status_code") + ipAddress String? @map("ip_address") + userAgent String? @map("user_agent") + createdAt DateTime @default(now()) @map("created_at") + + apiKey IntegrationApiKey @relation(fields: [apiKeyId], references: [id]) + + @@index([apiKeyId]) + @@map("integration_api_key_usage") +} diff --git a/prisma/seed-admin.ts b/prisma/seed-admin.ts new file mode 100644 index 0000000..8ad8b37 --- /dev/null +++ b/prisma/seed-admin.ts @@ -0,0 +1,30 @@ +import { PrismaClient } from '@prisma/client'; +import * as bcrypt from 'bcrypt'; + +const prisma = new PrismaClient(); + +async function main() { + const password = await bcrypt.hash('Admin@123456', 10); + + const user = await prisma.user.upsert({ + where: { email: 'admin@iasis.com.br' }, + update: {}, + create: { + name: 'Administrador', + email: 'admin@iasis.com.br', + password, + role: 'ADMIN', + isActive: true, + mustChangePassword: true, + }, + }); + + console.log('Usuário admin criado:', user.email); +} + +main() + .catch((e) => { + console.error(e); + process.exit(1); + }) + .finally(() => prisma.$disconnect()); diff --git a/prisma/seed.ts b/prisma/seed.ts new file mode 100644 index 0000000..bd68c54 --- /dev/null +++ b/prisma/seed.ts @@ -0,0 +1,186 @@ +import { ContractItemType, PrismaClient, Role } from '@prisma/client'; +import * as bcrypt from 'bcrypt'; + +const prisma = new PrismaClient(); + +async function seedAdmin(): Promise { + const passwordHash = await bcrypt.hash(process.env.PASS_ADMIN_USER || '', 10); + + const admin = await prisma.user.upsert({ + where: { email: 'admin@iasis.com.br' }, + update: {}, + create: { + name: 'Admin', + email: 'admin@iasis.com.br', + password: passwordHash, + role: Role.ADMIN, + mustChangePassword: false, + }, + }); + + console.log(`Admin: ${admin.email} (${admin.id})`); +} + +async function seedSesMg(): Promise { + const clientData = { + name: 'Secretaria de Estado de Saúde de Minas Gerais (SES-MG)', + document: '18.715.516/0001-88', + email: 'rafael.paiva@saude.mg.gov.br', + phone: '(31) 39152-0221', + contactName: 'Rafael Matos Paiva', + description: 'Cliente SESMG', + }; + + const existingClient = await prisma.client.findFirst({ + where: { document: clientData.document }, + }); + + const client = existingClient + ? await prisma.client.update({ where: { id: existingClient.id }, data: clientData }) + : await prisma.client.create({ data: clientData }); + + console.log(`Cliente: ${client.name} (${client.id})`); + + const contractData = { + clientId: client.id, + name: 'Contrato SESMG', + code: 'CTSES001', + startDate: new Date('2026-04-01T00:00:00Z'), + }; + + const existingContract = await prisma.contract.findFirst({ + where: { clientId: client.id, code: contractData.code }, + }); + + const contract = existingContract + ? await prisma.contract.update({ where: { id: existingContract.id }, data: contractData }) + : await prisma.contract.create({ data: contractData }); + + console.log(`Contrato: ${contract.code} (${contract.id})`); + + const items = [ + { + code: 'I-01', + name: 'Licença SaaS', + description: 'Licença SaaS', + itemType: ContractItemType.SAAS_LICENSE, + totalUst: 5, + ustValue: 35000000, + timeboxDescoberta: null, + timeboxDesign: null, + timeboxArquitetura: null, + timeboxConstrucao: null, + }, + { + code: 'I-02', + name: 'Desenvolvimento e implantação da tecnologia blockchain', + description: 'Desenvolvimento e implantação da tecnologia blockchain', + itemType: ContractItemType.UST, + totalUst: 50400, + ustValue: 533.33, + timeboxDescoberta: 40, + timeboxDesign: 40, + timeboxArquitetura: 40, + timeboxConstrucao: 40, + }, + { + code: 'I-03', + name: 'Desenv/Manutenção (treinamento/consultoria pós-implantação)', + description: 'Desenvolvimento e Manutenção (treinamento/consultoria pós-implantação)', + itemType: ContractItemType.UST, + totalUst: 4800, + ustValue: 388.88, + timeboxDescoberta: 40, + timeboxDesign: 40, + timeboxArquitetura: 40, + timeboxConstrucao: 40, + }, + { + code: 'I-04', + name: 'Desenvolvimento e Manutenção (manutenção, integrações e automações)', + description: 'Desenvolvimento e Manutenção (manutenção, integrações e automações)', + itemType: ContractItemType.UST, + totalUst: 15120, + ustValue: 533.33, + timeboxDescoberta: 40, + timeboxDesign: 40, + timeboxArquitetura: 40, + timeboxConstrucao: 80, + }, + { + code: 'I-05', + name: 'Serviços Técnicos de Inteligência Artificial (IA)', + description: 'Serviços Técnicos de Inteligência Artificial (IA)', + itemType: ContractItemType.UST, + totalUst: 30240, + ustValue: 555.55, + timeboxDescoberta: 40, + timeboxDesign: 40, + timeboxArquitetura: 40, + timeboxConstrucao: 80, + }, + ]; + + for (const item of items) { + const saved = await prisma.contractItem.upsert({ + where: { code_clientId: { code: item.code, clientId: client.id } }, + update: { ...item, clientId: client.id }, + create: { ...item, clientId: client.id }, + }); + console.log(` Item: ${saved.code} - ${saved.name}`); + } + + const projectData = { + contractId: contract.id, + name: 'ISIS Platform', + code: 'ISIS-001', + description: 'Plataforma de interoperabilidade ISIS', + }; + + const existingProject = await prisma.project.findFirst({ + where: { contractId: contract.id, code: projectData.code }, + }); + + const project = existingProject + ? await prisma.project.update({ where: { id: existingProject.id }, data: projectData }) + : await prisma.project.create({ data: projectData }); + + console.log(`Projeto: ${project.code} (${project.id})`); + + const profiles = [ + 'Especialista de Inteligência (Cientista de Dados)', + 'Analista de Negócio/Processo', + 'Especialista de Negócio (Saúde)', + 'Especialista de Tecnologia / Arquiteto', + 'Gerente de Projeto', + 'Desenvolvedor / Engenheiro de Dados', + 'Especialista de Infraestrutura', + 'Scrum Master', + ]; + + for (const name of profiles) { + const existing = await prisma.clientProfile.findFirst({ + where: { clientId: client.id, name }, + }); + const profile = existing + ? await prisma.clientProfile.update({ where: { id: existing.id }, data: { name } }) + : await prisma.clientProfile.create({ data: { name, clientId: client.id } }); + console.log(` Perfil: ${profile.name}`); + } +} + +async function main(): Promise { + await seedAdmin(); + await seedSesMg(); + console.log('Seed concluído.'); +} + +main() + .catch((e) => { + console.error(e); + process.exit(1); + }) + .finally(async () => { + await prisma.$disconnect(); + }); + diff --git a/src/app.controller.spec.ts b/src/app.controller.spec.ts new file mode 100644 index 0000000..d22f389 --- /dev/null +++ b/src/app.controller.spec.ts @@ -0,0 +1,22 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { AppController } from './app.controller'; +import { AppService } from './app.service'; + +describe('AppController', () => { + let appController: AppController; + + beforeEach(async () => { + const app: TestingModule = await Test.createTestingModule({ + controllers: [AppController], + providers: [AppService], + }).compile(); + + appController = app.get(AppController); + }); + + describe('root', () => { + it('should return "Hello World!"', () => { + expect(appController.getHello()).toBe('Hello World!'); + }); + }); +}); diff --git a/src/app.controller.ts b/src/app.controller.ts new file mode 100644 index 0000000..0c54a2c --- /dev/null +++ b/src/app.controller.ts @@ -0,0 +1,20 @@ +import { Controller, Get, Req, UseGuards } from '@nestjs/common'; +import type { Request } from 'express'; +import { JwtAuthGuard } from './config/auth/jwt-auth.guard'; +import { AppService } from './app.service'; + +@Controller() +export class AppController { + constructor(private readonly appService: AppService) {} + + @Get() + getHello(): string { + return this.appService.getHello(); + } + + @UseGuards(JwtAuthGuard) + @Get('protected') + getProtected(@Req() req: Request): { message: string; user: unknown } { + return { message: 'authorized', user: req.user }; + } +} diff --git a/src/app.module.ts b/src/app.module.ts new file mode 100644 index 0000000..1ce8cd4 --- /dev/null +++ b/src/app.module.ts @@ -0,0 +1,62 @@ +import { APP_INTERCEPTOR } from '@nestjs/core'; +import { Module } from '@nestjs/common'; +import { AppController } from './app.controller'; +import { AppService } from './app.service'; +import { AuthConfigModule } from './config/auth/auth-config.module'; +import { PrismaModule } from './config/database/prisma.module'; +import { EnvModule } from './config/env/env.module'; +import { AuthModule } from './modules/auth/auth.module'; +import { UsersModule } from './modules/users/users.module'; +import { ProfessionalsModule } from './modules/professionals/professionals.module'; +import { ClientsModule } from './modules/clients/clients.module'; +import { AllocationTemplatesModule } from './modules/allocation-templates/allocation-templates.module'; +import { ClientProfilesModule } from './modules/client-profiles/client-profiles.module'; +import { ContractItemsModule } from './modules/contract-items/contract-items.module'; +import { ContractsModule } from './modules/contracts/contracts.module'; +import { ProjectsModule } from './modules/projects/projects.module'; +import { SprintsModule } from './modules/sprints/sprints.module'; +import { DeliverablesModule } from './modules/deliverables/deliverables.module'; +import { WorkOrdersModule } from './modules/work-orders/work-orders.module'; +import { DeliverableBacklogModule } from './modules/deliverable-backlog/deliverable-backlog.module'; +import { TimelineModule } from './modules/timeline/timeline.module'; +import { NotesModule } from './modules/notes/notes.module'; +import { DashboardModule } from './modules/dashboard/dashboard.module'; +import { HealthModule } from './modules/health/health.module'; +import { IntegrationApiKeysModule } from './modules/integration-api-keys/integration-api-keys.module'; +import { IntegrationsModule } from './modules/integrations/integrations.module'; +import { MailerModule } from './modules/mailer/mailer.module'; +import { ProfileVisibilityInterceptor } from './common/interceptors/profile-visibility.interceptor'; + +@Module({ + imports: [ + EnvModule, + PrismaModule, + AuthConfigModule, + MailerModule, + AuthModule, + UsersModule, + ProfessionalsModule, + ClientsModule, + AllocationTemplatesModule, + ClientProfilesModule, + ContractItemsModule, + ContractsModule, + ProjectsModule, + SprintsModule, + DeliverablesModule, + WorkOrdersModule, + DeliverableBacklogModule, + TimelineModule, + NotesModule, + DashboardModule, + HealthModule, + IntegrationApiKeysModule, + IntegrationsModule, + ], + controllers: [AppController], + providers: [ + AppService, + { provide: APP_INTERCEPTOR, useClass: ProfileVisibilityInterceptor }, + ], +}) +export class AppModule {} diff --git a/src/app.service.ts b/src/app.service.ts new file mode 100644 index 0000000..927d7cc --- /dev/null +++ b/src/app.service.ts @@ -0,0 +1,8 @@ +import { Injectable } from '@nestjs/common'; + +@Injectable() +export class AppService { + getHello(): string { + return 'Hello World!'; + } +} diff --git a/src/common/constants/visibility-map.ts b/src/common/constants/visibility-map.ts new file mode 100644 index 0000000..8f39b3b --- /dev/null +++ b/src/common/constants/visibility-map.ts @@ -0,0 +1,89 @@ +import { Role } from '../enums/role.enum'; + +export const FINANCIAL_FIELDS = new Set([ + 'totalValue', + 'ustValue', + 'ustQuantity', + 'reservedUst', + 'calculatedValue', + 'timeboxDescoberta', + 'timeboxDesign', + 'timeboxArquitetura', + 'timeboxConstrucao', + 'timeboxManutencao', + 'numWeeks', +]); + +// Whitelist of fields allowed per profile. +// ADMIN has no restrictions (handled inline — not in this map). +// GESTOR_PROJETOS sees all non-financial fields (same as ADMIN minus FINANCIAL_FIELDS). +// For CLIENT_ROLES: explicit whitelist per RF-19 (default-deny — field absent = invisible). +// Fields not listed for a client role are silently removed from responses. +export const VISIBILITY_MAP: Partial>> = { + [Role.GESTOR_PROJETOS]: new Set([ + // Identity / metadata + 'id', 'code', 'name', 'title', 'description', 'status', 'type', + 'isActive', 'createdAt', 'updatedAt', 'createdBy', 'updatedBy', + // Date fields + 'startDate', 'endDate', 'expectedEndDate', + // Relation IDs + 'clientId', 'contractId', 'projectId', 'contractItemId', 'workOrderId', 'sprintId', + // Relation objects (non-financial) + 'client', 'contract', 'project', 'contractItem', 'workOrder', 'sprint', + 'statusHistory', 'assignments', 'backlogItems', 'timelineEvents', 'notes', + 'sprintHistory', 'allocations', 'deliverables', 'projects', 'statusHistory', + 'contractItems', 'users', 'profiles', 'allocationTemplates', + // User fields + 'email', 'role', 'clientSubRole', 'mustChangePassword', + // WorkOrder non-financial + 'contractItemId', 'workOrders', + // Sprint + 'goal', 'sprintType', + // ContractItem non-financial + 'totalUst', 'itemType', + // Client + 'document', 'phone', 'contactName', + // Professional + 'professional', 'professionals', + // Backlog + 'acceptanceCriteria', 'rejectionReason', 'sortOrder', + // Note + 'noteType', 'isRelevant', + // Allocation + 'profileId', 'quantity', 'allocationPercentage', 'profile', + // Token / integration + 'token', 'scopes', 'lastFourChars', 'expiresAt', 'lastUsedAt', 'rateLimitPerMinute', + ]), + + [Role.PO]: new Set([ + 'id', 'code', 'title', 'description', 'status', 'type', + 'isActive', 'createdAt', 'updatedAt', + 'startDate', 'expectedEndDate', + 'clientId', 'contractId', 'projectId', + 'client', 'contract', 'project', + 'backlogItems', 'timelineEvents', + 'acceptanceCriteria', 'rejectionReason', 'sortOrder', + 'name', 'email', 'role', + ]), + + [Role.FISCAL_CONTRATO]: new Set([ + 'id', 'code', 'title', 'description', 'status', 'type', + 'isActive', 'createdAt', 'updatedAt', + 'startDate', 'expectedEndDate', + 'clientId', 'contractId', 'projectId', + 'client', 'contract', 'project', + 'statusHistory', 'timelineEvents', + 'name', 'email', 'role', + ]), + + [Role.GESTOR_CONTRATO]: new Set([ + 'id', 'code', 'name', 'description', 'status', + 'isActive', 'createdAt', 'updatedAt', + 'startDate', 'endDate', + 'contractId', 'contractItemId', + 'contract', 'contractItem', 'projects', 'deliverables', 'statusHistory', + // ContractItem non-financial + 'totalUst', 'itemType', + 'email', 'role', + ]), +}; diff --git a/src/common/decorators/.gitkeep b/src/common/decorators/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/src/common/decorators/require-scope.decorator.ts b/src/common/decorators/require-scope.decorator.ts new file mode 100644 index 0000000..99682b8 --- /dev/null +++ b/src/common/decorators/require-scope.decorator.ts @@ -0,0 +1,7 @@ +import { SetMetadata } from '@nestjs/common'; +import { IntegrationScope } from '@prisma/client'; + +export const REQUIRE_SCOPE_KEY = 'requireScope'; + +export const RequireScope = (scope: IntegrationScope): MethodDecorator & ClassDecorator => + SetMetadata(REQUIRE_SCOPE_KEY, scope); diff --git a/src/common/decorators/roles.decorator.ts b/src/common/decorators/roles.decorator.ts new file mode 100644 index 0000000..769a0de --- /dev/null +++ b/src/common/decorators/roles.decorator.ts @@ -0,0 +1,6 @@ +import { SetMetadata } from '@nestjs/common'; +import { Role } from '../enums/role.enum'; + +export const ROLES_KEY = 'roles'; + +export const Roles = (...roles: Role[]) => SetMetadata(ROLES_KEY, roles); diff --git a/src/common/decorators/user.decorator.ts b/src/common/decorators/user.decorator.ts new file mode 100644 index 0000000..bd23ce1 --- /dev/null +++ b/src/common/decorators/user.decorator.ts @@ -0,0 +1,17 @@ +import { createParamDecorator, ExecutionContext } from '@nestjs/common'; +import { Request } from 'express'; +import { Role } from '../enums/role.enum'; + +export interface CurrentUserPayload { + id: string; + email: string; + role: Role; + clientId: string | null; +} + +export const CurrentUser = createParamDecorator( + (_data: unknown, ctx: ExecutionContext): CurrentUserPayload => { + const request = ctx.switchToHttp().getRequest(); + return request.user; + }, +); diff --git a/src/common/enums/.gitkeep b/src/common/enums/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/src/common/enums/role.enum.spec.ts b/src/common/enums/role.enum.spec.ts new file mode 100644 index 0000000..c46242f --- /dev/null +++ b/src/common/enums/role.enum.spec.ts @@ -0,0 +1,28 @@ +import { Role, ISIS_ROLES, CLIENT_ROLES, ALL_ROLES } from './role.enum'; + +describe('Role enum groups', () => { + it('ISIS_ROLES and CLIENT_ROLES have no overlap', () => { + const intersection = ISIS_ROLES.filter((r) => (CLIENT_ROLES as readonly Role[]).includes(r)); + expect(intersection).toHaveLength(0); + }); + + it('ALL_ROLES contains exactly the 5 active profiles', () => { + expect(ALL_ROLES).toHaveLength(5); + expect(ALL_ROLES).toContain(Role.ADMIN); + expect(ALL_ROLES).toContain(Role.GESTOR_PROJETOS); + expect(ALL_ROLES).toContain(Role.PO); + expect(ALL_ROLES).toContain(Role.FISCAL_CONTRATO); + expect(ALL_ROLES).toContain(Role.GESTOR_CONTRATO); + }); + + it('ALL_ROLES does not contain legacy roles', () => { + expect(ALL_ROLES).not.toContain(Role.OPERATOR); + expect(ALL_ROLES).not.toContain(Role.CLIENT); + }); + + it('ISIS_ROLES union CLIENT_ROLES equals ALL_ROLES', () => { + const union = new Set([...ISIS_ROLES, ...CLIENT_ROLES]); + const allRolesSet = new Set(ALL_ROLES); + expect(union).toEqual(allRolesSet); + }); +}); diff --git a/src/common/enums/role.enum.ts b/src/common/enums/role.enum.ts new file mode 100644 index 0000000..bafcdde --- /dev/null +++ b/src/common/enums/role.enum.ts @@ -0,0 +1,14 @@ +export enum Role { + ADMIN = 'ADMIN', + GESTOR_PROJETOS = 'GESTOR_PROJETOS', + PO = 'PO', + FISCAL_CONTRATO = 'FISCAL_CONTRATO', + GESTOR_CONTRATO = 'GESTOR_CONTRATO', + // Legacy values kept during migration; removed in migration-finalize (Tarefa 5.0) + OPERATOR = 'OPERATOR', + CLIENT = 'CLIENT', +} + +export const ISIS_ROLES = [Role.ADMIN, Role.GESTOR_PROJETOS] as const; +export const CLIENT_ROLES = [Role.PO, Role.FISCAL_CONTRATO, Role.GESTOR_CONTRATO] as const; +export const ALL_ROLES = [...ISIS_ROLES, ...CLIENT_ROLES] as const; diff --git a/src/common/filters/.gitkeep b/src/common/filters/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/src/common/filters/http-exception.filter.ts b/src/common/filters/http-exception.filter.ts new file mode 100644 index 0000000..936001b --- /dev/null +++ b/src/common/filters/http-exception.filter.ts @@ -0,0 +1,40 @@ +import { ArgumentsHost, Catch, ExceptionFilter, HttpException, HttpStatus } from '@nestjs/common'; +import { Request, Response } from 'express'; + +@Catch() +export class HttpExceptionFilter implements ExceptionFilter { + catch(exception: unknown, host: ArgumentsHost): void { + const ctx = host.switchToHttp(); + const response = ctx.getResponse(); + const request = ctx.getRequest(); + + let statusCode = HttpStatus.INTERNAL_SERVER_ERROR; + let message: string | string[] = 'Internal server error'; + let errors: string[] = []; + + if (exception instanceof HttpException) { + statusCode = exception.getStatus(); + const exceptionResponse = exception.getResponse(); + + if (typeof exceptionResponse === 'string') { + message = exceptionResponse; + } else if (typeof exceptionResponse === 'object' && exceptionResponse !== null) { + const body = exceptionResponse as Record; + if (Array.isArray(body.message)) { + errors = body.message as string[]; + message = 'Validation error'; + } else if (typeof body.message === 'string') { + message = body.message; + } + } + } + + response.status(statusCode).json({ + statusCode, + message, + errors, + timestamp: new Date().toISOString(), + path: request.url, + }); + } +} diff --git a/src/common/guards/.gitkeep b/src/common/guards/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/src/common/guards/api-key-throttler.guard.spec.ts b/src/common/guards/api-key-throttler.guard.spec.ts new file mode 100644 index 0000000..52507aa --- /dev/null +++ b/src/common/guards/api-key-throttler.guard.spec.ts @@ -0,0 +1,85 @@ +import { ExecutionContext, HttpException } from '@nestjs/common'; +import { ApiKeyThrottlerGuard } from './api-key-throttler.guard'; + +interface MockResponse { + setHeader: jest.Mock; + statusCode?: number; +} + +function buildContext( + apiKeyId: string | null, + rateLimit: number, +): { + context: ExecutionContext; + response: MockResponse; +} { + const response: MockResponse = { setHeader: jest.fn() }; + const request = apiKeyId ? { apiKey: { id: apiKeyId, rateLimitPerMinute: rateLimit } } : {}; + const context = { + switchToHttp: () => ({ + getRequest: () => request, + getResponse: () => response, + }), + } as unknown as ExecutionContext; + return { context, response }; +} + +describe('ApiKeyThrottlerGuard', () => { + let guard: ApiKeyThrottlerGuard; + + beforeEach(() => { + guard = new ApiKeyThrottlerGuard(); + }); + + it('permite requisições dentro do limite', () => { + for (let i = 0; i < 5; i += 1) { + const { context } = buildContext('k1', 5); + expect(guard.canActivate(context)).toBe(true); + } + }); + + it('rejeita 6ª requisição quando limite=5 e adiciona Retry-After', () => { + for (let i = 0; i < 5; i += 1) { + const { context } = buildContext('k1', 5); + guard.canActivate(context); + } + const { context, response } = buildContext('k1', 5); + const lastResponse: MockResponse = response; + expect(() => guard.canActivate(context)).toThrow(HttpException); + expect(lastResponse.setHeader).toHaveBeenCalledWith('Retry-After', expect.any(String)); + }); + + it('contabiliza chaves diferentes separadamente', () => { + for (let i = 0; i < 3; i += 1) { + guard.canActivate(buildContext('k1', 3).context); + } + expect(() => guard.canActivate(buildContext('k1', 3).context)).toThrow(HttpException); + expect(guard.canActivate(buildContext('k2', 3).context)).toBe(true); + }); + + it('reseta janela após expirar', () => { + jest.useFakeTimers(); + try { + for (let i = 0; i < 2; i += 1) { + guard.canActivate(buildContext('k1', 2).context); + } + expect(() => guard.canActivate(buildContext('k1', 2).context)).toThrow(HttpException); + jest.advanceTimersByTime(60_001); + expect(guard.canActivate(buildContext('k1', 2).context)).toBe(true); + } finally { + jest.useRealTimers(); + } + }); + + it('respeita rateLimitPerMinute específico da chave', () => { + const limit = 10; + for (let i = 0; i < limit; i += 1) { + expect(guard.canActivate(buildContext('k1', limit).context)).toBe(true); + } + expect(() => guard.canActivate(buildContext('k1', limit).context)).toThrow(HttpException); + }); + + it('passa quando request.apiKey ausente (sem-op)', () => { + expect(guard.canActivate(buildContext(null, 60).context)).toBe(true); + }); +}); diff --git a/src/common/guards/api-key-throttler.guard.ts b/src/common/guards/api-key-throttler.guard.ts new file mode 100644 index 0000000..a321c56 --- /dev/null +++ b/src/common/guards/api-key-throttler.guard.ts @@ -0,0 +1,55 @@ +import { + CanActivate, + ExecutionContext, + HttpException, + HttpStatus, + Injectable, +} from '@nestjs/common'; +import { Response } from 'express'; +import { RequestWithApiKey } from './api-key.guard'; + +interface Bucket { + count: number; + resetAt: number; +} + +const WINDOW_MS = 60_000; + +@Injectable() +export class ApiKeyThrottlerGuard implements CanActivate { + private readonly buckets = new Map(); + + canActivate(context: ExecutionContext): boolean { + const http = context.switchToHttp(); + const request = http.getRequest(); + const response = http.getResponse(); + const apiKey = request.apiKey; + + if (!apiKey) return true; + + const limit = apiKey.rateLimitPerMinute > 0 ? apiKey.rateLimitPerMinute : 60; + const now = Date.now(); + const bucket = this.buckets.get(apiKey.id); + + if (!bucket || bucket.resetAt <= now) { + this.buckets.set(apiKey.id, { count: 1, resetAt: now + WINDOW_MS }); + return true; + } + + if (bucket.count >= limit) { + const retryAfterSeconds = Math.max(1, Math.ceil((bucket.resetAt - now) / 1000)); + response.setHeader('Retry-After', String(retryAfterSeconds)); + throw new HttpException( + { statusCode: HttpStatus.TOO_MANY_REQUESTS, message: 'Limite de requisições excedido' }, + HttpStatus.TOO_MANY_REQUESTS, + ); + } + + bucket.count += 1; + return true; + } + + reset(): void { + this.buckets.clear(); + } +} diff --git a/src/common/guards/api-key.guard.spec.ts b/src/common/guards/api-key.guard.spec.ts new file mode 100644 index 0000000..cf07814 --- /dev/null +++ b/src/common/guards/api-key.guard.spec.ts @@ -0,0 +1,220 @@ +import { ExecutionContext, ForbiddenException, UnauthorizedException } from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; +import { IntegrationScope } from '@prisma/client'; +import { createHash } from 'crypto'; +import { ApiKeyGuard } from './api-key.guard'; +import { + IntegrationApiKeyForGuard, + IntegrationApiKeysRepository, +} from '../../modules/integration-api-keys/integration-api-keys.repository'; + +function hashOf(plain: string): string { + return createHash('sha256').update(plain).digest('hex'); +} + +interface BuildContextOptions { + headers?: Record; + scopeRequired?: IntegrationScope; +} + +function buildContext({ headers = {}, scopeRequired }: BuildContextOptions): { + context: ExecutionContext; + request: { headers: Record; apiKey?: unknown }; + reflector: Reflector; +} { + const request: { headers: Record; apiKey?: unknown } = { + headers, + }; + const reflector = { + getAllAndOverride: jest.fn().mockReturnValue(scopeRequired), + } as unknown as Reflector; + const context = { + getHandler: jest.fn(), + getClass: jest.fn(), + switchToHttp: () => ({ getRequest: () => request }), + } as unknown as ExecutionContext; + return { context, request, reflector }; +} + +function makeRepository( + overrides: Partial> = {}, +): jest.Mocked { + return { + findByHashedKey: jest.fn(), + touchLastUsed: jest.fn().mockResolvedValue(undefined), + create: jest.fn(), + findManyPaginated: jest.fn(), + findById: jest.fn(), + revoke: jest.fn(), + logUsage: jest.fn(), + ...overrides, + } as unknown as jest.Mocked; +} + +const validKey: IntegrationApiKeyForGuard = { + id: 'key-1', + scopes: [IntegrationScope.DELIVERABLES_READ], + isActive: true, + expiresAt: null, + rateLimitPerMinute: 60, + hashedKey: hashOf('valid-plain'), +}; + +describe('ApiKeyGuard', () => { + it('rejeita com 401 quando header X-API-Key ausente', async () => { + const repository = makeRepository(); + const { context, reflector } = buildContext({ + scopeRequired: IntegrationScope.DELIVERABLES_READ, + }); + const guard = new ApiKeyGuard(reflector, repository); + + await expect(guard.canActivate(context)).rejects.toBeInstanceOf(UnauthorizedException); + }); + + it('permite chave válida com escopo correto e popula request.apiKey', async () => { + const repository = makeRepository({ + findByHashedKey: jest.fn().mockResolvedValue(validKey), + }); + const { context, request, reflector } = buildContext({ + headers: { 'x-api-key': 'valid-plain' }, + scopeRequired: IntegrationScope.DELIVERABLES_READ, + }); + const guard = new ApiKeyGuard(reflector, repository); + + await expect(guard.canActivate(context)).resolves.toBe(true); + expect(request.apiKey).toEqual(validKey); + expect(repository.touchLastUsed).toHaveBeenCalledWith('key-1'); + }); + + it('rejeita com 401 quando chave não encontrada', async () => { + const repository = makeRepository({ + findByHashedKey: jest.fn().mockResolvedValue(null), + }); + const { context, reflector } = buildContext({ + headers: { 'x-api-key': 'unknown-plain' }, + scopeRequired: IntegrationScope.DELIVERABLES_READ, + }); + const guard = new ApiKeyGuard(reflector, repository); + + await expect(guard.canActivate(context)).rejects.toBeInstanceOf(UnauthorizedException); + }); + + it('rejeita com 401 quando chave inativa', async () => { + const repository = makeRepository({ + findByHashedKey: jest.fn().mockResolvedValue({ ...validKey, isActive: false }), + }); + const { context, reflector } = buildContext({ + headers: { 'x-api-key': 'valid-plain' }, + scopeRequired: IntegrationScope.DELIVERABLES_READ, + }); + const guard = new ApiKeyGuard(reflector, repository); + + await expect(guard.canActivate(context)).rejects.toBeInstanceOf(UnauthorizedException); + }); + + it('rejeita com 401 quando chave expirada', async () => { + const past = new Date(Date.now() - 60_000); + const repository = makeRepository({ + findByHashedKey: jest.fn().mockResolvedValue({ ...validKey, expiresAt: past }), + }); + const { context, reflector } = buildContext({ + headers: { 'x-api-key': 'valid-plain' }, + scopeRequired: IntegrationScope.DELIVERABLES_READ, + }); + const guard = new ApiKeyGuard(reflector, repository); + + await expect(guard.canActivate(context)).rejects.toBeInstanceOf(UnauthorizedException); + }); + + it('rejeita com 403 quando escopo insuficiente', async () => { + const repository = makeRepository({ + findByHashedKey: jest.fn().mockResolvedValue(validKey), + }); + const { context, reflector } = buildContext({ + headers: { 'x-api-key': 'valid-plain' }, + scopeRequired: IntegrationScope.CLIENTS_READ, + }); + const guard = new ApiKeyGuard(reflector, repository); + + await expect(guard.canActivate(context)).rejects.toBeInstanceOf(ForbiddenException); + }); + + it('rejeita com 403 quando handler não declara @RequireScope (fail-closed)', async () => { + const repository = makeRepository({ + findByHashedKey: jest.fn().mockResolvedValue(validKey), + }); + const { context, reflector } = buildContext({ + headers: { 'x-api-key': 'valid-plain' }, + scopeRequired: undefined, + }); + const guard = new ApiKeyGuard(reflector, repository); + + await expect(guard.canActivate(context)).rejects.toBeInstanceOf(ForbiddenException); + }); + + it('atualiza lastUsedAt em sucesso (fire-and-forget)', async () => { + const repository = makeRepository({ + findByHashedKey: jest.fn().mockResolvedValue(validKey), + }); + const { context, reflector } = buildContext({ + headers: { 'x-api-key': 'valid-plain' }, + scopeRequired: IntegrationScope.DELIVERABLES_READ, + }); + const guard = new ApiKeyGuard(reflector, repository); + + await guard.canActivate(context); + expect(repository.touchLastUsed).toHaveBeenCalledTimes(1); + }); + + it('falha em touchLastUsed não derruba a requisição', async () => { + const repository = makeRepository({ + findByHashedKey: jest.fn().mockResolvedValue(validKey), + touchLastUsed: jest.fn().mockRejectedValue(new Error('db down')), + }); + const { context, reflector } = buildContext({ + headers: { 'x-api-key': 'valid-plain' }, + scopeRequired: IntegrationScope.DELIVERABLES_READ, + }); + const guard = new ApiKeyGuard(reflector, repository); + + await expect(guard.canActivate(context)).resolves.toBe(true); + }); + + it('latência similar para chave válida vs inválida (timing attack mitigation)', async () => { + const repository = makeRepository({ + findByHashedKey: jest.fn().mockImplementation((hash: string) => { + if (hash === validKey.hashedKey) return Promise.resolve(validKey); + return Promise.resolve(null); + }), + }); + const guardOk = new ApiKeyGuard( + buildContext({ + headers: { 'x-api-key': 'valid-plain' }, + scopeRequired: IntegrationScope.DELIVERABLES_READ, + }).reflector, + repository, + ); + + const okStart = process.hrtime.bigint(); + await guardOk.canActivate( + buildContext({ + headers: { 'x-api-key': 'valid-plain' }, + scopeRequired: IntegrationScope.DELIVERABLES_READ, + }).context, + ); + const okElapsed = Number(process.hrtime.bigint() - okStart) / 1e6; + + const badStart = process.hrtime.bigint(); + await expect( + guardOk.canActivate( + buildContext({ + headers: { 'x-api-key': 'wrong-plain' }, + scopeRequired: IntegrationScope.DELIVERABLES_READ, + }).context, + ), + ).rejects.toBeInstanceOf(UnauthorizedException); + const badElapsed = Number(process.hrtime.bigint() - badStart) / 1e6; + + expect(Math.abs(okElapsed - badElapsed)).toBeLessThan(50); + }); +}); diff --git a/src/common/guards/api-key.guard.ts b/src/common/guards/api-key.guard.ts new file mode 100644 index 0000000..d9a795d --- /dev/null +++ b/src/common/guards/api-key.guard.ts @@ -0,0 +1,96 @@ +import { + CanActivate, + ExecutionContext, + ForbiddenException, + Injectable, + Logger, + UnauthorizedException, +} from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; +import { IntegrationScope } from '@prisma/client'; +import { createHash, timingSafeEqual } from 'crypto'; +import { Request } from 'express'; +import { REQUIRE_SCOPE_KEY } from '../decorators/require-scope.decorator'; +import { + IntegrationApiKeyForGuard, + IntegrationApiKeysRepository, +} from '../../modules/integration-api-keys/integration-api-keys.repository'; + +export interface RequestWithApiKey extends Request { + apiKey?: IntegrationApiKeyForGuard; +} + +@Injectable() +export class ApiKeyGuard implements CanActivate { + private readonly logger = new Logger(ApiKeyGuard.name); + + constructor( + private readonly reflector: Reflector, + private readonly repository: IntegrationApiKeysRepository, + ) {} + + async canActivate(context: ExecutionContext): Promise { + const request = context.switchToHttp().getRequest(); + const headerValue = this.extractHeader(request); + + if (!headerValue) { + throw new UnauthorizedException('API Key ausente'); + } + + const computedHash = createHash('sha256').update(headerValue).digest('hex'); + const apiKey = await this.repository.findByHashedKey(computedHash); + + if (!apiKey) { + throw new UnauthorizedException('API Key inválida'); + } + + if (!this.constantTimeEqualsHex(computedHash, apiKey.hashedKey)) { + throw new UnauthorizedException('API Key inválida'); + } + + if (!apiKey.isActive) { + throw new UnauthorizedException('API Key revogada'); + } + + if (apiKey.expiresAt && apiKey.expiresAt.getTime() < Date.now()) { + throw new UnauthorizedException('API Key expirada'); + } + + const requiredScope = this.reflector.getAllAndOverride( + REQUIRE_SCOPE_KEY, + [context.getHandler(), context.getClass()], + ); + + if (!requiredScope) { + throw new ForbiddenException('Endpoint sem escopo declarado'); + } + + if (!apiKey.scopes.includes(requiredScope)) { + throw new ForbiddenException('Escopo insuficiente'); + } + + request.apiKey = apiKey; + + void this.repository.touchLastUsed(apiKey.id).catch((err: unknown) => { + this.logger.warn(`Falha ao atualizar lastUsedAt: ${(err as Error).message}`); + }); + + return true; + } + + private extractHeader(request: Request): string | null { + const raw = request.headers['x-api-key']; + if (!raw) return null; + if (Array.isArray(raw)) return raw[0] ?? null; + return raw; + } + + private constantTimeEqualsHex(a: string, b: string): boolean { + if (a.length !== b.length) return false; + try { + return timingSafeEqual(Buffer.from(a, 'hex'), Buffer.from(b, 'hex')); + } catch { + return false; + } + } +} diff --git a/src/common/guards/read-only-client.guard.spec.ts b/src/common/guards/read-only-client.guard.spec.ts new file mode 100644 index 0000000..0c01888 --- /dev/null +++ b/src/common/guards/read-only-client.guard.spec.ts @@ -0,0 +1,53 @@ +import { ExecutionContext, ForbiddenException } from '@nestjs/common'; +import { ReadOnlyClientGuard } from './read-only-client.guard'; +import { Role } from '../enums/role.enum'; + +function buildContext(role: Role, method: string): ExecutionContext { + return { + switchToHttp: () => ({ + getRequest: () => ({ user: { role }, method }), + }), + } as unknown as ExecutionContext; +} + +describe('ReadOnlyClientGuard', () => { + let guard: ReadOnlyClientGuard; + + beforeEach(() => { + guard = new ReadOnlyClientGuard(); + }); + + it('deve permitir GET para CLIENT', () => { + expect(guard.canActivate(buildContext(Role.CLIENT, 'GET'))).toBe(true); + }); + + it('deve bloquear POST para CLIENT', () => { + expect(() => guard.canActivate(buildContext(Role.CLIENT, 'POST'))).toThrow(ForbiddenException); + }); + + it('deve bloquear PATCH para CLIENT', () => { + expect(() => guard.canActivate(buildContext(Role.CLIENT, 'PATCH'))).toThrow(ForbiddenException); + }); + + it('deve bloquear PUT para CLIENT', () => { + expect(() => guard.canActivate(buildContext(Role.CLIENT, 'PUT'))).toThrow(ForbiddenException); + }); + + it('deve bloquear DELETE para CLIENT', () => { + expect(() => guard.canActivate(buildContext(Role.CLIENT, 'DELETE'))).toThrow( + ForbiddenException, + ); + }); + + it('deve permitir POST para ADMIN', () => { + expect(guard.canActivate(buildContext(Role.ADMIN, 'POST'))).toBe(true); + }); + + it('deve permitir POST para OPERATOR', () => { + expect(guard.canActivate(buildContext(Role.OPERATOR, 'POST'))).toBe(true); + }); + + it('deve permitir PATCH para ADMIN', () => { + expect(guard.canActivate(buildContext(Role.ADMIN, 'PATCH'))).toBe(true); + }); +}); diff --git a/src/common/guards/read-only-client.guard.ts b/src/common/guards/read-only-client.guard.ts new file mode 100644 index 0000000..9eb3262 --- /dev/null +++ b/src/common/guards/read-only-client.guard.ts @@ -0,0 +1,30 @@ +/** + * ReadOnlyClientGuard — bloqueia operações de escrita para usuários CLIENT. + * + * Deve ser usado junto com JwtAuthGuard (que popula request.user). + * Permite apenas GET para role CLIENT. Outros roles passam livremente. + * + * Exemplo: + * @UseGuards(JwtAuthGuard, ReadOnlyClientGuard, RolesGuard) + * @Roles(Role.ADMIN, Role.OPERATOR, Role.CLIENT) + */ +import { Injectable, CanActivate, ExecutionContext, ForbiddenException } from '@nestjs/common'; +import { Role, CLIENT_ROLES } from '../enums/role.enum'; + +@Injectable() +export class ReadOnlyClientGuard implements CanActivate { + canActivate(context: ExecutionContext): boolean { + const request = context.switchToHttp().getRequest<{ user?: { role: Role }; method: string }>(); + const user = request.user; + + if (!user || !(CLIENT_ROLES as readonly Role[]).includes(user.role)) { + return true; + } + + if (request.method !== 'GET') { + throw new ForbiddenException('Acesso somente leitura para perfil cliente'); + } + + return true; + } +} diff --git a/src/common/guards/roles.guard.spec.ts b/src/common/guards/roles.guard.spec.ts new file mode 100644 index 0000000..26ea203 --- /dev/null +++ b/src/common/guards/roles.guard.spec.ts @@ -0,0 +1,47 @@ +import { ExecutionContext } from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; +import { RolesGuard } from './roles.guard'; +import { Role } from '../enums/role.enum'; + +describe('RolesGuard', () => { + let guard: RolesGuard; + let reflector: jest.Mocked; + + beforeEach(() => { + reflector = { getAllAndOverride: jest.fn() } as unknown as jest.Mocked; + guard = new RolesGuard(reflector); + }); + + it('retorna true para rota sem @Roles (qualquer usuário autenticado)', () => { + reflector.getAllAndOverride.mockReturnValue(undefined); + const context = { + getHandler: jest.fn(), + getClass: jest.fn(), + switchToHttp: () => ({ getRequest: () => ({ user: { role: Role.OPERATOR } }) }), + } as unknown as ExecutionContext; + + expect(guard.canActivate(context)).toBe(true); + }); + + it('retorna true para usuário ADMIN em rota @Roles(Role.ADMIN)', () => { + reflector.getAllAndOverride.mockReturnValue([Role.ADMIN]); + const context = { + getHandler: jest.fn(), + getClass: jest.fn(), + switchToHttp: () => ({ getRequest: () => ({ user: { role: Role.ADMIN } }) }), + } as unknown as ExecutionContext; + + expect(guard.canActivate(context)).toBe(true); + }); + + it('retorna false para usuário OPERATOR em rota @Roles(Role.ADMIN)', () => { + reflector.getAllAndOverride.mockReturnValue([Role.ADMIN]); + const context = { + getHandler: jest.fn(), + getClass: jest.fn(), + switchToHttp: () => ({ getRequest: () => ({ user: { role: Role.OPERATOR } }) }), + } as unknown as ExecutionContext; + + expect(guard.canActivate(context)).toBe(false); + }); +}); diff --git a/src/common/guards/roles.guard.ts b/src/common/guards/roles.guard.ts new file mode 100644 index 0000000..710aa27 --- /dev/null +++ b/src/common/guards/roles.guard.ts @@ -0,0 +1,30 @@ +/** + * RolesGuard — must always be used together with JwtAuthGuard. + * + * Example: + * @UseGuards(JwtAuthGuard, RolesGuard) + * @Roles(Role.ADMIN) + * @Get() + * findAll() { ... } + */ +import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; +import { ROLES_KEY } from '../decorators/roles.decorator'; +import { Role } from '../enums/role.enum'; + +@Injectable() +export class RolesGuard implements CanActivate { + constructor(private reflector: Reflector) {} + + canActivate(context: ExecutionContext): boolean { + const requiredRoles = this.reflector.getAllAndOverride(ROLES_KEY, [ + context.getHandler(), + context.getClass(), + ]); + + if (!requiredRoles || requiredRoles.length === 0) return true; + + const { user } = context.switchToHttp().getRequest<{ user?: { role: Role } }>(); + return requiredRoles.includes(user?.role as Role); + } +} diff --git a/src/common/interceptors/.gitkeep b/src/common/interceptors/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/src/common/interceptors/integration-api-key-usage.interceptor.spec.ts b/src/common/interceptors/integration-api-key-usage.interceptor.spec.ts new file mode 100644 index 0000000..1276785 --- /dev/null +++ b/src/common/interceptors/integration-api-key-usage.interceptor.spec.ts @@ -0,0 +1,96 @@ +import { CallHandler, ExecutionContext, ForbiddenException } from '@nestjs/common'; +import { firstValueFrom, of, throwError } from 'rxjs'; +import { IntegrationApiKeyUsageInterceptor } from './integration-api-key-usage.interceptor'; +import { IntegrationApiKeysRepository } from '../../modules/integration-api-keys/integration-api-keys.repository'; + +function buildContext(opts: { + apiKeyId?: string; + method?: string; + url?: string; + ip?: string; + userAgent?: string; + statusCode?: number; +}): ExecutionContext { + const request = { + method: opts.method ?? 'GET', + originalUrl: opts.url ?? '/api/v1/integrations/clients', + url: opts.url ?? '/api/v1/integrations/clients', + ip: opts.ip ?? '127.0.0.1', + socket: { remoteAddress: opts.ip ?? '127.0.0.1' }, + headers: { 'user-agent': opts.userAgent ?? 'test-agent' }, + apiKey: opts.apiKeyId ? { id: opts.apiKeyId } : undefined, + }; + const response = { statusCode: opts.statusCode ?? 200 }; + return { + switchToHttp: () => ({ getRequest: () => request, getResponse: () => response }), + } as unknown as ExecutionContext; +} + +function makeRepository(): jest.Mocked { + return { + logUsage: jest.fn().mockResolvedValue(undefined), + } as unknown as jest.Mocked; +} + +describe('IntegrationApiKeyUsageInterceptor', () => { + it('registra uso em sucesso', async () => { + const repository = makeRepository(); + const interceptor = new IntegrationApiKeyUsageInterceptor(repository); + const context = buildContext({ apiKeyId: 'k1', statusCode: 200 }); + const next: CallHandler = { handle: () => of({ data: [] }) }; + + const result = await firstValueFrom(interceptor.intercept(context, next)); + await new Promise((r) => setImmediate(r)); + + expect(result).toEqual({ data: [] }); + expect(repository.logUsage).toHaveBeenCalledWith( + expect.objectContaining({ + apiKeyId: 'k1', + endpoint: 'GET /api/v1/integrations/clients', + statusCode: 200, + ipAddress: '127.0.0.1', + userAgent: 'test-agent', + }), + ); + }); + + it('registra uso quando handler lança HttpException', async () => { + const repository = makeRepository(); + const interceptor = new IntegrationApiKeyUsageInterceptor(repository); + const context = buildContext({ apiKeyId: 'k1', statusCode: 200 }); + const err = new ForbiddenException('Escopo insuficiente'); + const next: CallHandler = { handle: () => throwError(() => err) }; + + await expect(firstValueFrom(interceptor.intercept(context, next))).rejects.toBe(err); + await new Promise((r) => setImmediate(r)); + + expect(repository.logUsage).toHaveBeenCalledWith( + expect.objectContaining({ apiKeyId: 'k1', statusCode: 403 }), + ); + }); + + it('não registra quando request.apiKey ausente', async () => { + const repository = makeRepository(); + const interceptor = new IntegrationApiKeyUsageInterceptor(repository); + const context = buildContext({ apiKeyId: undefined }); + const next: CallHandler = { handle: () => of({}) }; + + await firstValueFrom(interceptor.intercept(context, next)); + await new Promise((r) => setImmediate(r)); + + expect(repository.logUsage).not.toHaveBeenCalled(); + }); + + it('falha em logUsage não derruba a requisição', async () => { + const repository = makeRepository(); + repository.logUsage.mockRejectedValue(new Error('db down')); + const interceptor = new IntegrationApiKeyUsageInterceptor(repository); + const context = buildContext({ apiKeyId: 'k1' }); + const next: CallHandler = { handle: () => of({ ok: true }) }; + + const result = await firstValueFrom(interceptor.intercept(context, next)); + await new Promise((r) => setImmediate(r)); + + expect(result).toEqual({ ok: true }); + }); +}); diff --git a/src/common/interceptors/integration-api-key-usage.interceptor.ts b/src/common/interceptors/integration-api-key-usage.interceptor.ts new file mode 100644 index 0000000..abd7f53 --- /dev/null +++ b/src/common/interceptors/integration-api-key-usage.interceptor.ts @@ -0,0 +1,70 @@ +import { + CallHandler, + ExecutionContext, + HttpException, + Injectable, + Logger, + NestInterceptor, +} from '@nestjs/common'; +import { Response } from 'express'; +import { Observable, catchError, tap, throwError } from 'rxjs'; +import { RequestWithApiKey } from '../guards/api-key.guard'; +import { IntegrationApiKeysRepository } from '../../modules/integration-api-keys/integration-api-keys.repository'; + +@Injectable() +export class IntegrationApiKeyUsageInterceptor implements NestInterceptor { + private readonly logger = new Logger(IntegrationApiKeyUsageInterceptor.name); + + constructor(private readonly repository: IntegrationApiKeysRepository) {} + + intercept(context: ExecutionContext, next: CallHandler): Observable { + const http = context.switchToHttp(); + const request = http.getRequest(); + const response = http.getResponse(); + + const endpoint = `${request.method} ${request.originalUrl ?? request.url}`; + const ipAddress = (request.ip ?? request.socket?.remoteAddress) || null; + const userAgent = request.headers['user-agent'] ?? null; + + return next.handle().pipe( + tap(() => { + const apiKey = request.apiKey; + if (!apiKey) return; + this.persistUsage({ + apiKeyId: apiKey.id, + endpoint, + statusCode: response.statusCode, + ipAddress, + userAgent, + }); + }), + catchError((err: unknown) => { + const apiKey = request.apiKey; + if (apiKey) { + const statusCode = + err instanceof HttpException ? err.getStatus() : response.statusCode || 500; + this.persistUsage({ + apiKeyId: apiKey.id, + endpoint, + statusCode, + ipAddress, + userAgent, + }); + } + return throwError(() => err); + }), + ); + } + + private persistUsage(payload: { + apiKeyId: string; + endpoint: string; + statusCode: number; + ipAddress: string | null; + userAgent: string | null; + }): void { + void this.repository.logUsage(payload).catch((err: unknown) => { + this.logger.warn(`Falha ao registrar uso da API Key: ${(err as Error).message}`); + }); + } +} diff --git a/src/common/interceptors/logging.interceptor.ts b/src/common/interceptors/logging.interceptor.ts new file mode 100644 index 0000000..7a530fa --- /dev/null +++ b/src/common/interceptors/logging.interceptor.ts @@ -0,0 +1,25 @@ +import { CallHandler, ExecutionContext, Injectable, Logger, NestInterceptor } from '@nestjs/common'; +import { Request, Response } from 'express'; +import { Observable } from 'rxjs'; +import { tap } from 'rxjs/operators'; + +@Injectable() +export class LoggingInterceptor implements NestInterceptor { + private readonly logger = new Logger('HTTP'); + + intercept(context: ExecutionContext, next: CallHandler): Observable { + const req = context.switchToHttp().getRequest(); + const { method, url } = req; + const start = Date.now(); + + this.logger.log(`[${method}] ${url}`); + + return next.handle().pipe( + tap(() => { + const res = context.switchToHttp().getResponse(); + const ms = Date.now() - start; + this.logger.log(`[${method}] ${url} — ${res.statusCode} — ${ms}ms`); + }), + ); + } +} diff --git a/src/common/interceptors/profile-visibility.interceptor.spec.ts b/src/common/interceptors/profile-visibility.interceptor.spec.ts new file mode 100644 index 0000000..4114929 --- /dev/null +++ b/src/common/interceptors/profile-visibility.interceptor.spec.ts @@ -0,0 +1,124 @@ +import { ExecutionContext } from '@nestjs/common'; +import { of } from 'rxjs'; +import { ProfileVisibilityInterceptor } from './profile-visibility.interceptor'; +import { Role } from '../enums/role.enum'; +import { FINANCIAL_FIELDS } from '../constants/visibility-map'; + +function buildContext(role: Role | undefined, url = '/deliverables'): ExecutionContext { + return { + switchToHttp: () => ({ + getRequest: () => ({ user: role ? { role } : undefined, url }), + }), + } as unknown as ExecutionContext; +} + +function buildCallHandler(data: unknown) { + return { handle: () => of(data) }; +} + +describe('ProfileVisibilityInterceptor', () => { + let interceptor: ProfileVisibilityInterceptor; + + beforeEach(() => { + interceptor = new ProfileVisibilityInterceptor(); + }); + + it('ADMIN receives data intact', (done) => { + const data = { id: '1', totalValue: 100, ustValue: 50 }; + interceptor + .intercept(buildContext(Role.ADMIN), buildCallHandler(data)) + .subscribe((result) => { + expect(result).toEqual(data); + done(); + }); + }); + + it('PO does not receive totalValue or ustValue', (done) => { + const data = { id: '1', title: 'Test', totalValue: 100, ustValue: 50 }; + interceptor + .intercept(buildContext(Role.PO), buildCallHandler(data)) + .subscribe((result) => { + expect(result).not.toHaveProperty('totalValue'); + expect(result).not.toHaveProperty('ustValue'); + expect(result).toHaveProperty('id'); + done(); + }); + }); + + it('GESTOR_PROJETOS does not receive FINANCIAL_FIELDS', (done) => { + const data: Record = { id: '1', title: 'Test' }; + for (const field of FINANCIAL_FIELDS) { + data[field] = 999; + } + interceptor + .intercept(buildContext(Role.GESTOR_PROJETOS), buildCallHandler(data)) + .subscribe((result) => { + for (const field of FINANCIAL_FIELDS) { + expect(result).not.toHaveProperty(field); + } + expect(result).toHaveProperty('id'); + done(); + }); + }); + + it('filters nested objects recursively', (done) => { + // client IS in the PO whitelist; totalValue inside client should be stripped + const data = { + id: '1', + title: 'Test', + client: { id: 'c-1', name: 'ACME', totalValue: 500 }, + }; + interceptor + .intercept(buildContext(Role.PO), buildCallHandler(data)) + .subscribe((result: Record) => { + const client = result['client'] as Record; + expect(client).toBeDefined(); + expect(client).not.toHaveProperty('totalValue'); + expect(client).toHaveProperty('id'); + done(); + }); + }); + + it('filters arrays of objects', (done) => { + const data = [ + { id: '1', title: 'A', totalValue: 100 }, + { id: '2', title: 'B', ustValue: 50 }, + ]; + interceptor + .intercept(buildContext(Role.PO), buildCallHandler(data)) + .subscribe((result: unknown[]) => { + expect(result).toHaveLength(2); + expect(result[0]).not.toHaveProperty('totalValue'); + expect(result[1]).not.toHaveProperty('ustValue'); + done(); + }); + }); + + it('/auth routes pass through unfiltered regardless of role', (done) => { + const data = { token: 'abc123', role: Role.PO, totalValue: 99 }; + interceptor + .intercept(buildContext(Role.PO, '/auth/login'), buildCallHandler(data)) + .subscribe((result) => { + expect(result).toEqual(data); + done(); + }); + }); + + it.each([Role.FISCAL_CONTRATO, Role.GESTOR_CONTRATO])( + '%s does not receive FINANCIAL_FIELDS', + (role, done: jest.DoneCallback) => { + const data: Record = { id: '1' }; + for (const field of FINANCIAL_FIELDS) { + data[field] = 999; + } + interceptor + .intercept(buildContext(role), buildCallHandler(data)) + .subscribe((result) => { + for (const field of FINANCIAL_FIELDS) { + expect(result).not.toHaveProperty(field); + } + done(); + }); + }, + ); +}); diff --git a/src/common/interceptors/profile-visibility.interceptor.ts b/src/common/interceptors/profile-visibility.interceptor.ts new file mode 100644 index 0000000..de814bb --- /dev/null +++ b/src/common/interceptors/profile-visibility.interceptor.ts @@ -0,0 +1,48 @@ +import { CallHandler, ExecutionContext, Injectable, Logger, NestInterceptor } from '@nestjs/common'; +import { Observable } from 'rxjs'; +import { map } from 'rxjs/operators'; +import { Role } from '../enums/role.enum'; +import { VISIBILITY_MAP } from '../constants/visibility-map'; + +@Injectable() +export class ProfileVisibilityInterceptor implements NestInterceptor { + private readonly logger = new Logger(ProfileVisibilityInterceptor.name); + + intercept(context: ExecutionContext, next: CallHandler): Observable { + const request = context.switchToHttp().getRequest<{ user?: { role?: Role }; url?: string }>(); + const role = request?.user?.role; + const url = request?.url ?? ''; + + // Auth routes and no-user requests pass through unfiltered + if (!role || role === Role.ADMIN || url.startsWith('/auth')) { + return next.handle(); + } + + return next.handle().pipe(map((data) => this.filterFields(data, role))); + } + + private filterFields(obj: unknown, role: Role): unknown { + if (obj === null || obj === undefined) return obj; + + if (Array.isArray(obj)) { + return obj.map((item) => this.filterFields(item, role)); + } + + if (typeof obj === 'object') { + const allowed = VISIBILITY_MAP[role]; + if (!allowed) return obj; + + const result: Record = {}; + for (const [key, value] of Object.entries(obj as Record)) { + if (allowed.has(key)) { + result[key] = this.filterFields(value, role); + } else { + this.logger.debug(`Blocked field "${key}" for role ${role}`); + } + } + return result; + } + + return obj; + } +} diff --git a/src/common/pipes/.gitkeep b/src/common/pipes/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/src/common/pipes/validation.pipe.ts b/src/common/pipes/validation.pipe.ts new file mode 100644 index 0000000..a907d03 --- /dev/null +++ b/src/common/pipes/validation.pipe.ts @@ -0,0 +1,7 @@ +import { ValidationPipe } from '@nestjs/common'; + +export const validationPipe = new ValidationPipe({ + whitelist: true, + forbidNonWhitelisted: true, + transform: true, +}); diff --git a/src/common/utils/.gitkeep b/src/common/utils/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/src/common/utils/password-generator.spec.ts b/src/common/utils/password-generator.spec.ts new file mode 100644 index 0000000..851d340 --- /dev/null +++ b/src/common/utils/password-generator.spec.ts @@ -0,0 +1,38 @@ +import { generatePassword } from './password-generator'; + +describe('generatePassword', () => { + it('should generate a password with at least 12 characters by default', () => { + const password = generatePassword(); + expect(password.length).toBe(12); + }); + + it('should generate a password with the specified length', () => { + const password = generatePassword(16); + expect(password.length).toBe(16); + }); + + it('should contain at least one uppercase letter', () => { + const password = generatePassword(); + expect(password).toMatch(/[A-Z]/); + }); + + it('should contain at least one lowercase letter', () => { + const password = generatePassword(); + expect(password).toMatch(/[a-z]/); + }); + + it('should contain at least one digit', () => { + const password = generatePassword(); + expect(password).toMatch(/[0-9]/); + }); + + it('should contain at least one special character', () => { + const password = generatePassword(); + expect(password).toMatch(/[!@#$%&*]/); + }); + + it('should generate different passwords on each call', () => { + const passwords = new Set(Array.from({ length: 10 }, () => generatePassword())); + expect(passwords.size).toBe(10); + }); +}); diff --git a/src/common/utils/password-generator.ts b/src/common/utils/password-generator.ts new file mode 100644 index 0000000..a83cd62 --- /dev/null +++ b/src/common/utils/password-generator.ts @@ -0,0 +1,30 @@ +import { randomInt } from 'crypto'; + +const UPPERCASE = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'; +const LOWERCASE = 'abcdefghijklmnopqrstuvwxyz'; +const DIGITS = '0123456789'; +const SPECIALS = '!@#$%&*'; +const ALL_CHARS = UPPERCASE + LOWERCASE + DIGITS + SPECIALS; + +export function generatePassword(length = 12): string { + const mandatory = [ + UPPERCASE[randomInt(UPPERCASE.length)], + LOWERCASE[randomInt(LOWERCASE.length)], + DIGITS[randomInt(DIGITS.length)], + SPECIALS[randomInt(SPECIALS.length)], + ]; + + const remaining = Array.from( + { length: length - mandatory.length }, + () => ALL_CHARS[randomInt(ALL_CHARS.length)], + ); + + const chars = [...mandatory, ...remaining]; + + for (let i = chars.length - 1; i > 0; i--) { + const j = randomInt(i + 1); + [chars[i], chars[j]] = [chars[j], chars[i]]; + } + + return chars.join(''); +} diff --git a/src/config/auth/.gitkeep b/src/config/auth/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/src/config/auth/auth-config.module.ts b/src/config/auth/auth-config.module.ts new file mode 100644 index 0000000..f40acdc --- /dev/null +++ b/src/config/auth/auth-config.module.ts @@ -0,0 +1,25 @@ +import { Module } from '@nestjs/common'; +import { JwtModule } from '@nestjs/jwt'; +import { PassportModule } from '@nestjs/passport'; +import { EnvModule } from '../env/env.module'; +import { EnvService } from '../env/env.service'; +import { JwtAuthGuard } from './jwt-auth.guard'; +import { JwtStrategy } from './jwt.strategy'; + +@Module({ + imports: [ + EnvModule, + PassportModule.register({ defaultStrategy: 'jwt' }), + JwtModule.registerAsync({ + imports: [EnvModule], + inject: [EnvService], + useFactory: (envService: EnvService) => ({ + secret: envService.jwtSecret, + signOptions: { expiresIn: '8h' }, + }), + }), + ], + providers: [JwtStrategy, JwtAuthGuard], + exports: [JwtModule, JwtStrategy, JwtAuthGuard], +}) +export class AuthConfigModule {} diff --git a/src/config/auth/jwt-auth.guard.ts b/src/config/auth/jwt-auth.guard.ts new file mode 100644 index 0000000..2155290 --- /dev/null +++ b/src/config/auth/jwt-auth.guard.ts @@ -0,0 +1,5 @@ +import { Injectable } from '@nestjs/common'; +import { AuthGuard } from '@nestjs/passport'; + +@Injectable() +export class JwtAuthGuard extends AuthGuard('jwt') {} diff --git a/src/config/auth/jwt.strategy.spec.ts b/src/config/auth/jwt.strategy.spec.ts new file mode 100644 index 0000000..932de9a --- /dev/null +++ b/src/config/auth/jwt.strategy.spec.ts @@ -0,0 +1,38 @@ +import { JwtStrategy } from './jwt.strategy'; + +const mockEnvService = { jwtSecret: 'test-secret' }; + +describe('JwtStrategy', () => { + let strategy: JwtStrategy; + + beforeEach(() => { + strategy = new JwtStrategy(mockEnvService as never); + }); + + it('validate() returns id, email, role and clientId from payload', () => { + const payload = { sub: 'user-123', email: 'user@example.com', role: 'ADMIN', clientId: null }; + const result = strategy.validate(payload); + expect(result).toEqual({ + id: 'user-123', + email: 'user@example.com', + role: 'ADMIN', + clientId: null, + }); + }); + + it('validate() returns clientId for CLIENT payload', () => { + const payload = { + sub: 'user-456', + email: 'client@empresa.com', + role: 'CLIENT', + clientId: 'client-id-1', + }; + const result = strategy.validate(payload); + expect(result).toEqual({ + id: 'user-456', + email: 'client@empresa.com', + role: 'CLIENT', + clientId: 'client-id-1', + }); + }); +}); diff --git a/src/config/auth/jwt.strategy.ts b/src/config/auth/jwt.strategy.ts new file mode 100644 index 0000000..acfd0f8 --- /dev/null +++ b/src/config/auth/jwt.strategy.ts @@ -0,0 +1,36 @@ +import { Injectable } from '@nestjs/common'; +import { PassportStrategy } from '@nestjs/passport'; +import { ExtractJwt, Strategy } from 'passport-jwt'; +import { EnvService } from '../env/env.service'; + +interface JwtPayload { + sub: string; + email: string; + role: string; + clientId: string | null; +} + +@Injectable() +export class JwtStrategy extends PassportStrategy(Strategy) { + constructor(envService: EnvService) { + super({ + jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), + ignoreExpiration: false, + secretOrKey: envService.jwtSecret, + }); + } + + validate(payload: JwtPayload): { + id: string; + email: string; + role: string; + clientId: string | null; + } { + return { + id: payload.sub, + email: payload.email, + role: payload.role, + clientId: payload.clientId ?? null, + }; + } +} diff --git a/src/config/database/.gitkeep b/src/config/database/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/src/config/database/prisma.module.ts b/src/config/database/prisma.module.ts new file mode 100644 index 0000000..7207426 --- /dev/null +++ b/src/config/database/prisma.module.ts @@ -0,0 +1,9 @@ +import { Global, Module } from '@nestjs/common'; +import { PrismaService } from './prisma.service'; + +@Global() +@Module({ + providers: [PrismaService], + exports: [PrismaService], +}) +export class PrismaModule {} diff --git a/src/config/database/prisma.service.spec.ts b/src/config/database/prisma.service.spec.ts new file mode 100644 index 0000000..d882a8a --- /dev/null +++ b/src/config/database/prisma.service.spec.ts @@ -0,0 +1,25 @@ +import { PrismaService } from './prisma.service'; + +describe('PrismaService', () => { + let service: PrismaService; + + beforeEach(() => { + service = new PrismaService(); + jest.spyOn(service, '$connect').mockResolvedValue(); + jest.spyOn(service, '$disconnect').mockResolvedValue(); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('should call $connect on module init', async () => { + await service.onModuleInit(); + expect(service.$connect).toHaveBeenCalledTimes(1); + }); + + it('should call $disconnect on module destroy', async () => { + await service.onModuleDestroy(); + expect(service.$disconnect).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/config/database/prisma.service.ts b/src/config/database/prisma.service.ts new file mode 100644 index 0000000..5064444 --- /dev/null +++ b/src/config/database/prisma.service.ts @@ -0,0 +1,13 @@ +import { Injectable, OnModuleInit, OnModuleDestroy } from '@nestjs/common'; +import { PrismaClient } from '@prisma/client'; + +@Injectable() +export class PrismaService extends PrismaClient implements OnModuleInit, OnModuleDestroy { + async onModuleInit(): Promise { + await this.$connect(); + } + + async onModuleDestroy(): Promise { + await this.$disconnect(); + } +} diff --git a/src/config/env/.gitkeep b/src/config/env/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/src/config/env/env.module.spec.ts b/src/config/env/env.module.spec.ts new file mode 100644 index 0000000..9621da8 --- /dev/null +++ b/src/config/env/env.module.spec.ts @@ -0,0 +1,61 @@ +import { Test } from '@nestjs/testing'; +import { ConfigModule } from '@nestjs/config'; +import * as Joi from 'joi'; +import { EnvService } from './env.service'; + +const validationSchema = Joi.object({ + DATABASE_URL: Joi.string().required(), + JWT_SECRET: Joi.string().required(), + PORT: Joi.number().default(3000), +}); + +async function buildTestModule() { + return Test.createTestingModule({ + imports: [ + ConfigModule.forRoot({ + isGlobal: true, + ignoreEnvFile: true, + validationSchema, + validationOptions: { abortEarly: true }, + }), + ], + providers: [EnvService], + }).compile(); +} + +describe('EnvModule', () => { + const originalEnv = process.env; + + beforeEach(() => { + process.env = { ...originalEnv }; + delete process.env.DATABASE_URL; + delete process.env.JWT_SECRET; + delete process.env.PORT; + }); + + afterEach(() => { + process.env = originalEnv; + }); + + it('should fail to initialize without JWT_SECRET', async () => { + process.env.DATABASE_URL = 'postgresql://localhost:5432/iasis_gestao'; + await expect(buildTestModule()).rejects.toThrow(/JWT_SECRET/); + }); + + it('should fail to initialize without DATABASE_URL', async () => { + process.env.JWT_SECRET = 'super-secret'; + await expect(buildTestModule()).rejects.toThrow(/DATABASE_URL/); + }); + + it('should initialize normally with valid env vars', async () => { + process.env.DATABASE_URL = 'postgresql://localhost:5432/iasis_gestao'; + process.env.JWT_SECRET = 'super-secret'; + + const module = await buildTestModule(); + const envService = module.get(EnvService); + + expect(envService.databaseUrl).toBe('postgresql://localhost:5432/iasis_gestao'); + expect(envService.jwtSecret).toBe('super-secret'); + expect(envService.port).toBe(3000); + }); +}); diff --git a/src/config/env/env.module.ts b/src/config/env/env.module.ts new file mode 100644 index 0000000..f8eb169 --- /dev/null +++ b/src/config/env/env.module.ts @@ -0,0 +1,17 @@ +import { Module } from '@nestjs/common'; +import { ConfigModule } from '@nestjs/config'; +import { EnvService } from './env.service'; +import { envValidationSchema } from './env.validation'; + +@Module({ + imports: [ + ConfigModule.forRoot({ + isGlobal: true, + validationSchema: envValidationSchema, + validationOptions: { abortEarly: true }, + }), + ], + providers: [EnvService], + exports: [EnvService], +}) +export class EnvModule {} diff --git a/src/config/env/env.service.spec.ts b/src/config/env/env.service.spec.ts new file mode 100644 index 0000000..d78c808 --- /dev/null +++ b/src/config/env/env.service.spec.ts @@ -0,0 +1,184 @@ +import { ConfigService } from '@nestjs/config'; +import { Test } from '@nestjs/testing'; +import { EnvModule } from './env.module'; +import { EnvService } from './env.service'; +import { envValidationSchema } from './env.validation'; + +// ─── Helpers para testar o schema Joi diretamente ──────────────────────────── + +const BASE_VALID = { + DATABASE_URL: 'postgresql://user:pass@localhost:5432/testdb', + JWT_SECRET: 'test-secret', +}; + +function validateEnv(values: Record) { + return envValidationSchema.validate({ ...BASE_VALID, ...values }, { abortEarly: false }); +} + +// ─── Joi schema — validação condicional SMTP ────────────────────────────────── + +describe('envValidationSchema — Joi validation', () => { + it('aceita ausência de variáveis SMTP quando MAILER_PROVIDER não é smtp', () => { + const { error } = validateEnv({ MAILER_PROVIDER: 'log' }); + expect(error).toBeUndefined(); + }); + + it('aceita ausência de MAILER_PROVIDER (usa default log)', () => { + const { error } = validateEnv({}); + expect(error).toBeUndefined(); + }); + + it('aceita MAILER_PROVIDER=smtp com todas as vars SMTP presentes', () => { + const { error } = validateEnv({ + MAILER_PROVIDER: 'smtp', + SMTP_HOST: 'smtp.example.com', + SMTP_PORT: 465, + SMTP_USER: 'user@example.com', + SMTP_PASS: 'secret', + SMTP_FROM: 'noreply@example.com', + }); + expect(error).toBeUndefined(); + }); + + it('rejeita MAILER_PROVIDER=smtp sem SMTP_HOST', () => { + const { error } = validateEnv({ + MAILER_PROVIDER: 'smtp', + SMTP_USER: 'user@example.com', + SMTP_PASS: 'secret', + SMTP_FROM: 'noreply@example.com', + }); + expect(error).toBeDefined(); + expect(error!.message).toContain('SMTP_HOST'); + }); + + it('rejeita MAILER_PROVIDER=smtp sem SMTP_USER', () => { + const { error } = validateEnv({ + MAILER_PROVIDER: 'smtp', + SMTP_HOST: 'smtp.example.com', + SMTP_PASS: 'secret', + SMTP_FROM: 'noreply@example.com', + }); + expect(error).toBeDefined(); + expect(error!.message).toContain('SMTP_USER'); + }); + + it('rejeita MAILER_PROVIDER=smtp sem SMTP_PASS', () => { + const { error } = validateEnv({ + MAILER_PROVIDER: 'smtp', + SMTP_HOST: 'smtp.example.com', + SMTP_USER: 'user@example.com', + SMTP_FROM: 'noreply@example.com', + }); + expect(error).toBeDefined(); + expect(error!.message).toContain('SMTP_PASS'); + }); + + it('rejeita MAILER_PROVIDER=smtp sem SMTP_FROM', () => { + const { error } = validateEnv({ + MAILER_PROVIDER: 'smtp', + SMTP_HOST: 'smtp.example.com', + SMTP_USER: 'user@example.com', + SMTP_PASS: 'secret', + }); + expect(error).toBeDefined(); + expect(error!.message).toContain('SMTP_FROM'); + }); + + it('aplica defaults: MAILER_PROVIDER=log, SMTP_PORT=587, PASSWORD_RESET_TOKEN_TTL_MINUTES=60', () => { + const { value } = validateEnv({}); + expect(value.MAILER_PROVIDER).toBe('log'); + expect(value.SMTP_PORT).toBe(587); + expect(value.FRONTEND_URL).toBe('http://localhost:5173'); + expect(value.PASSWORD_RESET_TOKEN_TTL_MINUTES).toBe(60); + }); + + it('rejeita MAILER_PROVIDER inválido', () => { + const { error } = validateEnv({ MAILER_PROVIDER: 'sendgrid' }); + expect(error).toBeDefined(); + }); + + it('rejeita FRONTEND_URL que não é URI válida', () => { + const { error } = validateEnv({ FRONTEND_URL: 'not-a-url' }); + expect(error).toBeDefined(); + }); + + it('rejeita PASSWORD_RESET_TOKEN_TTL_MINUTES=0', () => { + const { error } = validateEnv({ PASSWORD_RESET_TOKEN_TTL_MINUTES: 0 }); + expect(error).toBeDefined(); + }); +}); + +// ─── EnvService getters — ConfigService mockado ────────────────────────────── + +function makeService(values: Record): EnvService { + const configService = { + get: (key: string, defaultValue?: unknown) => values[key] ?? defaultValue, + getOrThrow: (key: string) => { + if (values[key] === undefined) throw new Error(`Missing: ${key}`); + return values[key]; + }, + } as unknown as ConfigService; + return new EnvService(configService); +} + +describe('EnvService — getters com valores explícitos', () => { + let svc: EnvService; + + beforeAll(() => { + svc = makeService({ + DATABASE_URL: 'postgresql://test', + JWT_SECRET: 'secret', + PORT: 4000, + MAILER_PROVIDER: 'smtp', + SMTP_HOST: 'smtp.host.com', + SMTP_PORT: 2525, + SMTP_USER: 'usr', + SMTP_PASS: 'pwd', + SMTP_FROM: 'from@host.com', + FRONTEND_URL: 'https://app.example.com', + PASSWORD_RESET_TOKEN_TTL_MINUTES: 30, + }); + }); + + it('mailerProvider', () => expect(svc.mailerProvider).toBe('smtp')); + it('smtpHost', () => expect(svc.smtpHost).toBe('smtp.host.com')); + it('smtpPort como número', () => expect(svc.smtpPort).toBe(2525)); + it('smtpUser', () => expect(svc.smtpUser).toBe('usr')); + it('smtpPass', () => expect(svc.smtpPass).toBe('pwd')); + it('smtpFrom', () => expect(svc.smtpFrom).toBe('from@host.com')); + it('frontendUrl', () => expect(svc.frontendUrl).toBe('https://app.example.com')); + it('passwordResetTtlMinutes como número', () => expect(svc.passwordResetTtlMinutes).toBe(30)); +}); + +describe('EnvService — defaults quando vars ausentes', () => { + let svc: EnvService; + + beforeAll(() => { + svc = makeService({ + DATABASE_URL: 'postgresql://test', + JWT_SECRET: 'secret', + }); + }); + + it('mailerProvider default = log', () => expect(svc.mailerProvider).toBe('log')); + it('smtpPort default = 587', () => expect(svc.smtpPort).toBe(587)); + it('frontendUrl default = http://localhost:5173', () => + expect(svc.frontendUrl).toBe('http://localhost:5173')); + it('passwordResetTtlMinutes default = 60', () => expect(svc.passwordResetTtlMinutes).toBe(60)); + it('smtpHost undefined quando não definido', () => expect(svc.smtpHost).toBeUndefined()); + it('smtpUser undefined quando não definido', () => expect(svc.smtpUser).toBeUndefined()); + it('smtpPass undefined quando não definido', () => expect(svc.smtpPass).toBeUndefined()); + it('smtpFrom undefined quando não definido', () => expect(svc.smtpFrom).toBeUndefined()); +}); + +// ─── Boot real do EnvModule (smoke test) ───────────────────────────────────── + +describe('EnvModule — boot de integração', () => { + it('compila o módulo com as vars obrigatórias do .env', async () => { + const module = await Test.createTestingModule({ imports: [EnvModule] }).compile(); + const svc = module.get(EnvService); + expect(svc).toBeInstanceOf(EnvService); + expect(svc.databaseUrl).toBeTruthy(); + expect(svc.jwtSecret).toBeTruthy(); + }); +}); diff --git a/src/config/env/env.service.ts b/src/config/env/env.service.ts new file mode 100644 index 0000000..be864c3 --- /dev/null +++ b/src/config/env/env.service.ts @@ -0,0 +1,51 @@ +import { Injectable } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; + +@Injectable() +export class EnvService { + constructor(private readonly config: ConfigService) {} + + get databaseUrl(): string { + return this.config.getOrThrow('DATABASE_URL'); + } + + get jwtSecret(): string { + return this.config.getOrThrow('JWT_SECRET'); + } + + get port(): number { + return this.config.get('PORT', 3000); + } + + get mailerProvider(): string { + return this.config.get('MAILER_PROVIDER', 'log'); + } + + get smtpHost(): string | undefined { + return this.config.get('SMTP_HOST'); + } + + get smtpPort(): number { + return this.config.get('SMTP_PORT', 587); + } + + get smtpUser(): string | undefined { + return this.config.get('SMTP_USER'); + } + + get smtpPass(): string | undefined { + return this.config.get('SMTP_PASS'); + } + + get smtpFrom(): string | undefined { + return this.config.get('SMTP_FROM'); + } + + get frontendUrl(): string { + return this.config.get('FRONTEND_URL', 'http://localhost:5173'); + } + + get passwordResetTtlMinutes(): number { + return this.config.get('PASSWORD_RESET_TOKEN_TTL_MINUTES', 60); + } +} diff --git a/src/config/env/env.validation.ts b/src/config/env/env.validation.ts new file mode 100644 index 0000000..830bb15 --- /dev/null +++ b/src/config/env/env.validation.ts @@ -0,0 +1,31 @@ +import * as Joi from 'joi'; + +export const envValidationSchema = Joi.object({ + DATABASE_URL: Joi.string().required(), + JWT_SECRET: Joi.string().required(), + PORT: Joi.number().default(3000), + MAILER_PROVIDER: Joi.string().valid('smtp', 'log').default('log'), + SMTP_HOST: Joi.string().when('MAILER_PROVIDER', { + is: 'smtp', + then: Joi.required(), + otherwise: Joi.optional(), + }), + SMTP_PORT: Joi.number().default(587), + SMTP_USER: Joi.string().when('MAILER_PROVIDER', { + is: 'smtp', + then: Joi.required(), + otherwise: Joi.optional(), + }), + SMTP_PASS: Joi.string().when('MAILER_PROVIDER', { + is: 'smtp', + then: Joi.required(), + otherwise: Joi.optional(), + }), + SMTP_FROM: Joi.string().when('MAILER_PROVIDER', { + is: 'smtp', + then: Joi.required(), + otherwise: Joi.optional(), + }), + FRONTEND_URL: Joi.string().uri().default('http://localhost:5173'), + PASSWORD_RESET_TOKEN_TTL_MINUTES: Joi.number().integer().min(1).default(60), +}); diff --git a/src/main.ts b/src/main.ts new file mode 100644 index 0000000..0a15929 --- /dev/null +++ b/src/main.ts @@ -0,0 +1,31 @@ +import { NestFactory } from '@nestjs/core'; +import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger'; +import { AppModule } from './app.module'; +import { validationPipe } from './common/pipes/validation.pipe'; +import { HttpExceptionFilter } from './common/filters/http-exception.filter'; +import { LoggingInterceptor } from './common/interceptors/logging.interceptor'; +import { IntegrationsModule } from './modules/integrations/integrations.module'; + +async function bootstrap(): Promise { + const app = await NestFactory.create(AppModule); + app.enableCors({ origin: process.env.CORS_ORIGIN ?? 'http://localhost:5173' }); + app.useGlobalPipes(validationPipe); + app.useGlobalFilters(new HttpExceptionFilter()); + app.useGlobalInterceptors(new LoggingInterceptor()); + + if (process.env.NODE_ENV !== 'production') { + const integrationConfig = new DocumentBuilder() + .setTitle('Iasis Gestão — API de Integração') + .setVersion('1.0') + .addApiKey({ type: 'apiKey', name: 'X-API-Key', in: 'header' }, 'api-key') + .build(); + + const integrationDoc = SwaggerModule.createDocument(app, integrationConfig, { + include: [IntegrationsModule], + }); + SwaggerModule.setup('api/v1/integrations/docs', app, integrationDoc); + } + + await app.listen(process.env.PORT ?? 3000); +} +void bootstrap(); diff --git a/src/modules/.gitkeep b/src/modules/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/src/modules/allocation-templates/allocation-templates.controller.ts b/src/modules/allocation-templates/allocation-templates.controller.ts new file mode 100644 index 0000000..bbd03cd --- /dev/null +++ b/src/modules/allocation-templates/allocation-templates.controller.ts @@ -0,0 +1,79 @@ +import { + Body, + Controller, + Delete, + Get, + Param, + Patch, + Post, + Query, + UseGuards, +} from '@nestjs/common'; +import { SprintType } from '@prisma/client'; +import { JwtAuthGuard } from '../../config/auth/jwt-auth.guard'; +import { ReadOnlyClientGuard } from '../../common/guards/read-only-client.guard'; +import { RolesGuard } from '../../common/guards/roles.guard'; +import { Roles } from '../../common/decorators/roles.decorator'; +import { Role } from '../../common/enums/role.enum'; +import { AllocationTemplatesService } from './allocation-templates.service'; +import { CreateAllocationTemplateDto } from './dto/create-allocation-template.dto'; +import { UpdateAllocationTemplateDto } from './dto/update-allocation-template.dto'; +import { ListAllocationTemplatesQueryDto } from './dto/list-allocation-templates-query.dto'; + +@Controller('clients/:clientId/allocation-templates') +export class AllocationTemplatesController { + constructor(private readonly service: AllocationTemplatesService) {} + + @Get() + @UseGuards(JwtAuthGuard, ReadOnlyClientGuard, RolesGuard) + @Roles(Role.ADMIN, Role.OPERATOR, Role.CLIENT) + async findAll( + @Param('clientId') clientId: string, + @Query() query: ListAllocationTemplatesQueryDto, + ) { + return this.service.findByClientId(clientId, query); + } + + @Get('match') + @UseGuards(JwtAuthGuard, ReadOnlyClientGuard, RolesGuard) + @Roles(Role.ADMIN, Role.OPERATOR, Role.CLIENT) + async findMatch( + @Param('clientId') clientId: string, + @Query('sprintType') sprintType: SprintType, + @Query('contractItemId') contractItemId: string, + ) { + return this.service.findMatch(clientId, sprintType, contractItemId); + } + + @Post() + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles(Role.ADMIN, Role.OPERATOR) + async create(@Param('clientId') clientId: string, @Body() dto: CreateAllocationTemplateDto) { + return this.service.create(clientId, dto); + } + + @Get(':id') + @UseGuards(JwtAuthGuard, ReadOnlyClientGuard, RolesGuard) + @Roles(Role.ADMIN, Role.OPERATOR, Role.CLIENT) + async findOne(@Param('clientId') clientId: string, @Param('id') id: string) { + return this.service.findOne(clientId, id); + } + + @Patch(':id') + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles(Role.ADMIN, Role.OPERATOR) + async update( + @Param('clientId') clientId: string, + @Param('id') id: string, + @Body() dto: UpdateAllocationTemplateDto, + ) { + return this.service.update(clientId, id, dto); + } + + @Delete(':id') + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles(Role.ADMIN, Role.OPERATOR) + async remove(@Param('clientId') clientId: string, @Param('id') id: string) { + return this.service.remove(clientId, id); + } +} diff --git a/src/modules/allocation-templates/allocation-templates.module.ts b/src/modules/allocation-templates/allocation-templates.module.ts new file mode 100644 index 0000000..59bd983 --- /dev/null +++ b/src/modules/allocation-templates/allocation-templates.module.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common'; +import { AllocationTemplatesController } from './allocation-templates.controller'; +import { AllocationTemplatesService } from './allocation-templates.service'; +import { AllocationTemplatesRepository } from './allocation-templates.repository'; + +@Module({ + controllers: [AllocationTemplatesController], + providers: [AllocationTemplatesService, AllocationTemplatesRepository], + exports: [AllocationTemplatesService], +}) +export class AllocationTemplatesModule {} diff --git a/src/modules/allocation-templates/allocation-templates.repository.ts b/src/modules/allocation-templates/allocation-templates.repository.ts new file mode 100644 index 0000000..28696b0 --- /dev/null +++ b/src/modules/allocation-templates/allocation-templates.repository.ts @@ -0,0 +1,126 @@ +import { Injectable } from '@nestjs/common'; +import { Prisma, SprintType } from '@prisma/client'; +import { PrismaService } from '../../config/database/prisma.service'; + +const templateSelect = { + id: true, + sprintType: true, + isActive: true, + createdAt: true, + updatedAt: true, + contractItem: { select: { id: true, code: true, name: true, itemType: true } }, + items: { + select: { + id: true, + quantity: true, + allocationPercentage: true, + profile: { select: { id: true, name: true } }, + }, + }, +} as const; + +const templateListSelect = { + id: true, + sprintType: true, + isActive: true, + createdAt: true, + updatedAt: true, + contractItem: { select: { id: true, code: true, name: true, itemType: true } }, + _count: { select: { items: true } }, +} as const; + +@Injectable() +export class AllocationTemplatesRepository { + constructor(private readonly prisma: PrismaService) {} + + async findByClientId(params: { + clientId: string; + sprintType?: SprintType; + contractItemId?: string; + page: number; + limit: number; + }) { + const where: Prisma.AllocationTemplateWhereInput = { + clientId: params.clientId, + isActive: true, + }; + + if (params.sprintType) where.sprintType = params.sprintType; + if (params.contractItemId) where.contractItemId = params.contractItemId; + + const [data, total] = await Promise.all([ + this.prisma.allocationTemplate.findMany({ + where, + select: templateListSelect, + orderBy: { createdAt: 'desc' }, + skip: (params.page - 1) * params.limit, + take: params.limit, + }), + this.prisma.allocationTemplate.count({ where }), + ]); + + return { data, total }; + } + + async findById(id: string) { + return this.prisma.allocationTemplate.findUnique({ + where: { id }, + select: templateSelect, + }); + } + + async findMatch(clientId: string, sprintType: SprintType, contractItemId: string) { + return this.prisma.allocationTemplate.findUnique({ + where: { + clientId_sprintType_contractItemId: { clientId, sprintType, contractItemId }, + }, + select: templateSelect, + }); + } + + async create( + data: { + clientId: string; + sprintType: SprintType; + contractItemId: string; + }, + items: { profileId: string; quantity: number; allocationPercentage: number }[], + ) { + return this.prisma.allocationTemplate.create({ + data: { + clientId: data.clientId, + sprintType: data.sprintType, + contractItemId: data.contractItemId, + items: { + create: items, + }, + }, + select: templateSelect, + }); + } + + async update( + id: string, + items: { profileId: string; quantity: number; allocationPercentage: number }[], + ) { + return this.prisma.$transaction(async (tx) => { + await tx.allocationTemplateItem.deleteMany({ where: { templateId: id } }); + + return tx.allocationTemplate.update({ + where: { id }, + data: { + items: { create: items }, + }, + select: templateSelect, + }); + }); + } + + async softDelete(id: string) { + return this.prisma.allocationTemplate.update({ + where: { id }, + data: { isActive: false }, + select: templateSelect, + }); + } +} diff --git a/src/modules/allocation-templates/allocation-templates.service.ts b/src/modules/allocation-templates/allocation-templates.service.ts new file mode 100644 index 0000000..9e56ba7 --- /dev/null +++ b/src/modules/allocation-templates/allocation-templates.service.ts @@ -0,0 +1,118 @@ +import { ConflictException, Injectable, NotFoundException } from '@nestjs/common'; +import { SprintType } from '@prisma/client'; +import { AllocationTemplatesRepository } from './allocation-templates.repository'; +import { PrismaService } from '../../config/database/prisma.service'; +import { CreateAllocationTemplateDto } from './dto/create-allocation-template.dto'; +import { UpdateAllocationTemplateDto } from './dto/update-allocation-template.dto'; +import { ListAllocationTemplatesQueryDto } from './dto/list-allocation-templates-query.dto'; + +@Injectable() +export class AllocationTemplatesService { + constructor( + private readonly repository: AllocationTemplatesRepository, + private readonly prisma: PrismaService, + ) {} + + async findByClientId(clientId: string, query: ListAllocationTemplatesQueryDto) { + await this.validateClientExists(clientId); + const { page, limit, ...filters } = query; + const { data, total } = await this.repository.findByClientId({ + clientId, + ...filters, + page, + limit, + }); + return { data, total, page, limit }; + } + + async findOne(clientId: string, id: string) { + await this.validateClientExists(clientId); + const template = await this.repository.findById(id); + + if (!template) { + throw new NotFoundException('Template de alocação não encontrado'); + } + + return template; + } + + async findMatch(clientId: string, sprintType: SprintType, contractItemId: string) { + await this.validateClientExists(clientId); + return this.repository.findMatch(clientId, sprintType, contractItemId); + } + + async create(clientId: string, dto: CreateAllocationTemplateDto) { + await this.validateClientExists(clientId); + + const existing = await this.repository.findMatch(clientId, dto.sprintType, dto.contractItemId); + if (existing) { + throw new ConflictException( + 'Já existe um template para esta combinação de tipo de sprint e item de contrato', + ); + } + + await this.validateContractItemBelongsToClient(dto.contractItemId, clientId); + await this.validateProfilesBelongToClient( + dto.items.map((i) => i.profileId), + clientId, + ); + + return this.repository.create( + { clientId, sprintType: dto.sprintType, contractItemId: dto.contractItemId }, + dto.items, + ); + } + + async update(clientId: string, id: string, dto: UpdateAllocationTemplateDto) { + const template = await this.findOne(clientId, id); + + if (!template) { + throw new NotFoundException('Template de alocação não encontrado'); + } + + await this.validateProfilesBelongToClient( + dto.items.map((i) => i.profileId), + clientId, + ); + + return this.repository.update(id, dto.items); + } + + async remove(clientId: string, id: string) { + await this.findOne(clientId, id); + return this.repository.softDelete(id); + } + + private async validateClientExists(clientId: string) { + const client = await this.prisma.client.findUnique({ + where: { id: clientId }, + select: { id: true }, + }); + + if (!client) { + throw new NotFoundException('Cliente não encontrado'); + } + } + + private async validateContractItemBelongsToClient(contractItemId: string, clientId: string) { + const item = await this.prisma.contractItem.findUnique({ + where: { id: contractItemId }, + select: { clientId: true }, + }); + + if (!item || item.clientId !== clientId) { + throw new NotFoundException('Item de contrato não encontrado para este cliente'); + } + } + + private async validateProfilesBelongToClient(profileIds: string[], clientId: string) { + const profiles = await this.prisma.clientProfile.findMany({ + where: { id: { in: profileIds }, clientId }, + select: { id: true }, + }); + + if (profiles.length !== profileIds.length) { + throw new NotFoundException('Um ou mais perfis não pertencem a este cliente'); + } + } +} diff --git a/src/modules/allocation-templates/dto/create-allocation-template.dto.ts b/src/modules/allocation-templates/dto/create-allocation-template.dto.ts new file mode 100644 index 0000000..e110491 --- /dev/null +++ b/src/modules/allocation-templates/dto/create-allocation-template.dto.ts @@ -0,0 +1,30 @@ +import { IsEnum, IsInt, IsNumber, IsUUID, Max, Min, ValidateNested } from 'class-validator'; +import { Type } from 'class-transformer'; +import { SprintType } from '@prisma/client'; + +export class CreateAllocationTemplateItemDto { + @IsUUID() + profileId!: string; + + @IsInt() + @Min(1) + quantity!: number; + + @Type(() => Number) + @IsNumber() + @Min(0) + @Max(100) + allocationPercentage!: number; +} + +export class CreateAllocationTemplateDto { + @IsEnum(SprintType) + sprintType!: SprintType; + + @IsUUID() + contractItemId!: string; + + @ValidateNested({ each: true }) + @Type(() => CreateAllocationTemplateItemDto) + items!: CreateAllocationTemplateItemDto[]; +} diff --git a/src/modules/allocation-templates/dto/list-allocation-templates-query.dto.ts b/src/modules/allocation-templates/dto/list-allocation-templates-query.dto.ts new file mode 100644 index 0000000..553a01a --- /dev/null +++ b/src/modules/allocation-templates/dto/list-allocation-templates-query.dto.ts @@ -0,0 +1,26 @@ +import { IsEnum, IsInt, IsOptional, IsUUID, Max, Min } from 'class-validator'; +import { Transform } from 'class-transformer'; +import { SprintType } from '@prisma/client'; + +export class ListAllocationTemplatesQueryDto { + @IsOptional() + @IsEnum(SprintType) + sprintType?: SprintType; + + @IsOptional() + @IsUUID() + contractItemId?: string; + + @IsOptional() + @Transform(({ value }: { value: unknown }) => parseInt(String(value), 10)) + @IsInt() + @Min(1) + page: number = 1; + + @IsOptional() + @Transform(({ value }: { value: unknown }) => parseInt(String(value), 10)) + @IsInt() + @Min(1) + @Max(100) + limit: number = 20; +} diff --git a/src/modules/allocation-templates/dto/update-allocation-template.dto.ts b/src/modules/allocation-templates/dto/update-allocation-template.dto.ts new file mode 100644 index 0000000..793063c --- /dev/null +++ b/src/modules/allocation-templates/dto/update-allocation-template.dto.ts @@ -0,0 +1,23 @@ +import { IsInt, IsNumber, IsUUID, Max, Min, ValidateNested } from 'class-validator'; +import { Type } from 'class-transformer'; + +export class UpdateAllocationTemplateItemDto { + @IsUUID() + profileId!: string; + + @IsInt() + @Min(1) + quantity!: number; + + @Type(() => Number) + @IsNumber() + @Min(0) + @Max(100) + allocationPercentage!: number; +} + +export class UpdateAllocationTemplateDto { + @ValidateNested({ each: true }) + @Type(() => UpdateAllocationTemplateItemDto) + items!: UpdateAllocationTemplateItemDto[]; +} diff --git a/src/modules/auth/auth.controller.spec.ts b/src/modules/auth/auth.controller.spec.ts new file mode 100644 index 0000000..5569f6a --- /dev/null +++ b/src/modules/auth/auth.controller.spec.ts @@ -0,0 +1,72 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import type { CurrentUserPayload } from '../../common/decorators/user.decorator'; +import { Role } from '../../common/enums/role.enum'; +import { AuthController } from './auth.controller'; +import { AuthService } from './auth.service'; + +const mockLoginResult = { + accessToken: 'jwt-token', + user: { + id: 'user-id-1', + name: 'Admin', + email: 'admin@iasis.com.br', + role: 'ADMIN', + }, +}; + +const mockMeResult = { + id: 'user-id-1', + name: 'Admin', + email: 'admin@iasis.com.br', + role: 'ADMIN', +}; + +describe('AuthController', () => { + let controller: AuthController; + let authService: jest.Mocked; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + controllers: [AuthController], + providers: [ + { + provide: AuthService, + useValue: { + login: jest.fn().mockResolvedValue(mockLoginResult), + getMe: jest.fn().mockResolvedValue(mockMeResult), + }, + }, + ], + }).compile(); + + controller = module.get(AuthController); + authService = module.get(AuthService); + }); + + describe('login', () => { + it('should call authService.login and return the result', async () => { + const dto = { email: 'admin@iasis.com.br', password: 'admin123' }; + + const result = await controller.login(dto); + + expect(authService.login).toHaveBeenCalledWith(dto); + expect(result).toEqual(mockLoginResult); + }); + }); + + describe('getMe', () => { + it('should call authService.getMe and return user data', async () => { + const user: CurrentUserPayload = { + id: 'user-id-1', + email: 'admin@iasis.com.br', + role: Role.ADMIN, + clientId: null, + }; + + const result = await controller.getMe(user); + + expect(authService.getMe).toHaveBeenCalledWith('user-id-1'); + expect(result).toEqual(mockMeResult); + }); + }); +}); diff --git a/src/modules/auth/auth.controller.ts b/src/modules/auth/auth.controller.ts new file mode 100644 index 0000000..7f128d9 --- /dev/null +++ b/src/modules/auth/auth.controller.ts @@ -0,0 +1,53 @@ +import { + Body, + Controller, + Get, + HttpCode, + HttpStatus, + Param, + Post, + UseGuards, +} from '@nestjs/common'; +import { JwtAuthGuard } from '../../config/auth/jwt-auth.guard'; +import { CurrentUser, type CurrentUserPayload } from '../../common/decorators/user.decorator'; +import { AuthService } from './auth.service'; +import { ChangePasswordDto } from './dto/change-password.dto'; +import { ForgotPasswordDto } from './dto/forgot-password.dto'; +import { LoginDto } from './dto/login.dto'; +import { ResetPasswordDto } from './dto/reset-password.dto'; + +@Controller('auth') +export class AuthController { + constructor(private readonly authService: AuthService) {} + + @Post('login') + @HttpCode(HttpStatus.OK) + async login(@Body() dto: LoginDto) { + return this.authService.login(dto); + } + + @Post('change-password') + @HttpCode(HttpStatus.OK) + @UseGuards(JwtAuthGuard) + async changePassword(@CurrentUser() user: CurrentUserPayload, @Body() dto: ChangePasswordDto) { + return this.authService.changePassword(user.id, dto); + } + + @Get('me') + @UseGuards(JwtAuthGuard) + async getMe(@CurrentUser() user: CurrentUserPayload) { + return this.authService.getMe(user.id); + } + + @Post('forgot-password') + @HttpCode(HttpStatus.OK) + async forgotPassword(@Body() dto: ForgotPasswordDto) { + return this.authService.forgotPassword(dto.email); + } + + @Post('reset-password/:token') + @HttpCode(HttpStatus.OK) + async resetPassword(@Param('token') token: string, @Body() dto: ResetPasswordDto) { + return this.authService.resetPassword(token, dto); + } +} diff --git a/src/modules/auth/auth.module.ts b/src/modules/auth/auth.module.ts new file mode 100644 index 0000000..b3b55c2 --- /dev/null +++ b/src/modules/auth/auth.module.ts @@ -0,0 +1,16 @@ +import { Module } from '@nestjs/common'; +import { AuthConfigModule } from '../../config/auth/auth-config.module'; +import { EnvModule } from '../../config/env/env.module'; +import { AuthController } from './auth.controller'; +import { AuthService } from './auth.service'; +import { AuthRepository } from './auth.repository'; +import { PasswordPolicyService } from './password-policy.service'; +import { PasswordResetRepository } from './password-reset.repository'; + +@Module({ + imports: [AuthConfigModule, EnvModule], + controllers: [AuthController], + providers: [AuthService, AuthRepository, PasswordPolicyService, PasswordResetRepository], + exports: [PasswordPolicyService], +}) +export class AuthModule {} diff --git a/src/modules/auth/auth.repository.ts b/src/modules/auth/auth.repository.ts new file mode 100644 index 0000000..3a6707f --- /dev/null +++ b/src/modules/auth/auth.repository.ts @@ -0,0 +1,36 @@ +import { Injectable } from '@nestjs/common'; +import { PrismaService } from '../../config/database/prisma.service'; + +@Injectable() +export class AuthRepository { + constructor(private readonly prisma: PrismaService) {} + + async findByEmail(email: string) { + return this.prisma.user.findUnique({ where: { email } }); + } + + async findById(id: string) { + return this.prisma.user.findUnique({ + where: { id }, + select: { + id: true, + name: true, + email: true, + role: true, + mustChangePassword: true, + clientId: true, + }, + }); + } + + async findByIdWithPassword(id: string) { + return this.prisma.user.findUnique({ where: { id } }); + } + + async updatePassword(id: string, hashedPassword: string) { + return this.prisma.user.update({ + where: { id }, + data: { password: hashedPassword, mustChangePassword: false }, + }); + } +} diff --git a/src/modules/auth/auth.service.spec.ts b/src/modules/auth/auth.service.spec.ts new file mode 100644 index 0000000..e76e6e8 --- /dev/null +++ b/src/modules/auth/auth.service.spec.ts @@ -0,0 +1,450 @@ +import { BadRequestException, ForbiddenException, UnauthorizedException } from '@nestjs/common'; +import { JwtService } from '@nestjs/jwt'; +import { Test, TestingModule } from '@nestjs/testing'; +import * as bcrypt from 'bcrypt'; +import { createHash } from 'crypto'; +import { Role } from '../../common/enums/role.enum'; +import { AuthRepository } from './auth.repository'; +import { AuthService } from './auth.service'; +import { PasswordPolicyService } from './password-policy.service'; +import { PasswordResetRepository } from './password-reset.repository'; +import { MailerService } from '../mailer/mailer.service'; +import { EnvService } from '../../config/env/env.service'; + +jest.mock('bcrypt'); + +const mockUser = { + id: 'user-id-1', + name: 'Admin', + email: 'admin@iasis.com.br', + password: 'hashed-password', + role: Role.ADMIN as Role, + isActive: true, + mustChangePassword: false, + clientId: null as string | null, + createdAt: new Date(), + updatedAt: new Date(), +}; + +const mockToken = { + id: 'token-id-1', + userId: 'user-id-1', + tokenHash: 'hash', + expiresAt: new Date(Date.now() + 60 * 60 * 1000), + usedAt: null as Date | null, + createdAt: new Date(), +}; + +describe('AuthService', () => { + let service: AuthService; + let repository: jest.Mocked; + let jwtService: jest.Mocked; + let passwordResetRepository: jest.Mocked; + let mailerService: jest.Mocked; + + beforeEach(async () => { + jest.clearAllMocks(); + const module: TestingModule = await Test.createTestingModule({ + providers: [ + AuthService, + PasswordPolicyService, + { + provide: AuthRepository, + useValue: { + findByEmail: jest.fn(), + findById: jest.fn(), + findByIdWithPassword: jest.fn(), + updatePassword: jest.fn(), + }, + }, + { + provide: JwtService, + useValue: { + sign: jest.fn().mockReturnValue('jwt-token'), + }, + }, + { + provide: PasswordResetRepository, + useValue: { + create: jest.fn(), + findByHash: jest.fn(), + invalidatePrevious: jest.fn(), + markAsUsed: jest.fn(), + }, + }, + { + provide: MailerService, + useValue: { + sendMail: jest.fn().mockResolvedValue(undefined), + }, + }, + { + provide: EnvService, + useValue: { + frontendUrl: 'http://localhost:5173', + passwordResetTtlMinutes: 60, + }, + }, + ], + }).compile(); + + service = module.get(AuthService); + repository = module.get(AuthRepository); + jwtService = module.get(JwtService); + passwordResetRepository = module.get(PasswordResetRepository); + mailerService = module.get(MailerService); + }); + + describe('getMe', () => { + it('should return user data when user exists', async () => { + const expectedUser = { + id: mockUser.id, + name: mockUser.name, + email: mockUser.email, + role: mockUser.role, + mustChangePassword: mockUser.mustChangePassword, + clientId: null, + }; + repository.findById.mockResolvedValue(expectedUser); + + const result = await service.getMe(mockUser.id); + + expect(repository.findById).toHaveBeenCalledWith(mockUser.id); + expect(result).toEqual(expectedUser); + }); + + it('should throw UnauthorizedException when user is not found', async () => { + repository.findById.mockResolvedValue(null); + + await expect(service.getMe('invalid-id')).rejects.toThrow(UnauthorizedException); + await expect(service.getMe('invalid-id')).rejects.toThrow('Usuário não encontrado'); + }); + }); + + describe('login', () => { + it('should return access token and user data with valid credentials', async () => { + repository.findByEmail.mockResolvedValue(mockUser); + (bcrypt.compare as jest.Mock).mockResolvedValue(true); + + const result = await service.login({ + email: 'admin@iasis.com.br', + password: 'admin123', + }); + + expect(result).toEqual({ + accessToken: 'jwt-token', + user: { + id: mockUser.id, + name: mockUser.name, + email: mockUser.email, + role: mockUser.role, + mustChangePassword: mockUser.mustChangePassword, + clientId: null, + }, + }); + expect(jwtService.sign).toHaveBeenCalledWith({ + sub: mockUser.id, + email: mockUser.email, + role: mockUser.role, + clientId: null, + }); + }); + + it('should throw UnauthorizedException when email does not exist', async () => { + repository.findByEmail.mockResolvedValue(null); + + await expect(service.login({ email: 'invalid@test.com', password: 'any' })).rejects.toThrow( + UnauthorizedException, + ); + + await expect(service.login({ email: 'invalid@test.com', password: 'any' })).rejects.toThrow( + 'Credenciais inválidas', + ); + }); + + it('should throw UnauthorizedException when password is incorrect', async () => { + repository.findByEmail.mockResolvedValue(mockUser); + (bcrypt.compare as jest.Mock).mockResolvedValue(false); + + await expect( + service.login({ email: 'admin@iasis.com.br', password: 'wrong' }), + ).rejects.toThrow(UnauthorizedException); + + await expect( + service.login({ email: 'admin@iasis.com.br', password: 'wrong' }), + ).rejects.toThrow('Credenciais inválidas'); + }); + + it('should include clientId in JWT payload and response for CLIENT user', async () => { + const clientUser = { + ...mockUser, + role: Role.CLIENT as Role, + clientId: 'client-id-1', + }; + repository.findByEmail.mockResolvedValue(clientUser); + (bcrypt.compare as jest.Mock).mockResolvedValue(true); + + const result = await service.login({ + email: 'admin@iasis.com.br', + password: 'admin123', + }); + + expect(jwtService.sign).toHaveBeenCalledWith({ + sub: clientUser.id, + email: clientUser.email, + role: clientUser.role, + clientId: 'client-id-1', + }); + expect(result.user.clientId).toBe('client-id-1'); + }); + + it('should throw ForbiddenException when user is inactive', async () => { + repository.findByEmail.mockResolvedValue({ + ...mockUser, + isActive: false, + }); + (bcrypt.compare as jest.Mock).mockResolvedValue(true); + + await expect( + service.login({ email: 'admin@iasis.com.br', password: 'admin123' }), + ).rejects.toThrow(ForbiddenException); + + await expect( + service.login({ email: 'admin@iasis.com.br', password: 'admin123' }), + ).rejects.toThrow('Usuário inativo'); + }); + }); + + describe('changePassword', () => { + it('should change password and set mustChangePassword to false', async () => { + repository.findByIdWithPassword.mockResolvedValue(mockUser); + (bcrypt.compare as jest.Mock).mockResolvedValue(true); + (bcrypt.hash as jest.Mock).mockResolvedValue('new-hashed-password'); + repository.updatePassword.mockResolvedValue(undefined); + + const result = await service.changePassword(mockUser.id, { + currentPassword: 'admin123', + newPassword: 'Newpassword1', + }); + + expect(result).toEqual({ message: 'Senha alterada com sucesso' }); + expect(bcrypt.compare).toHaveBeenCalledWith('admin123', mockUser.password); + expect(bcrypt.hash).toHaveBeenCalledWith('Newpassword1', 10); + expect(repository.updatePassword).toHaveBeenCalledWith(mockUser.id, 'new-hashed-password'); + }); + + it('should throw BadRequestException when new password fails policy', async () => { + repository.findByIdWithPassword.mockResolvedValue(mockUser); + (bcrypt.compare as jest.Mock).mockResolvedValue(true); + + await expect( + service.changePassword(mockUser.id, { + currentPassword: 'admin123', + newPassword: 'weakpassword', + }), + ).rejects.toThrow(BadRequestException); + + await expect( + service.changePassword(mockUser.id, { + currentPassword: 'admin123', + newPassword: 'weakpassword', + }), + ).rejects.toThrow('A senha deve conter ao menos uma letra maiúscula'); + + expect(bcrypt.hash).not.toHaveBeenCalled(); + expect(repository.updatePassword).not.toHaveBeenCalled(); + }); + + it('should throw UnauthorizedException when current password is wrong', async () => { + repository.findByIdWithPassword.mockResolvedValue(mockUser); + (bcrypt.compare as jest.Mock).mockResolvedValue(false); + + await expect( + service.changePassword(mockUser.id, { + currentPassword: 'wrong', + newPassword: 'newpassword123', + }), + ).rejects.toThrow(UnauthorizedException); + + await expect( + service.changePassword(mockUser.id, { + currentPassword: 'wrong', + newPassword: 'newpassword123', + }), + ).rejects.toThrow('Senha atual incorreta'); + }); + + it('should throw UnauthorizedException when user is not found', async () => { + repository.findByIdWithPassword.mockResolvedValue(null); + + await expect( + service.changePassword('invalid-id', { + currentPassword: 'any', + newPassword: 'newpassword123', + }), + ).rejects.toThrow(UnauthorizedException); + }); + }); + + describe('forgotPassword', () => { + const GENERIC_MSG = 'Se o email existir no sistema, um link de redefinição será enviado.'; + + it('should return generic message when email does not exist', async () => { + repository.findByEmail.mockResolvedValue(null); + + const result = await service.forgotPassword('unknown@test.com'); + + expect(result).toEqual({ message: GENERIC_MSG }); + expect(mailerService.sendMail).not.toHaveBeenCalled(); + }); + + it('should return generic message when user is inactive (no email sent)', async () => { + repository.findByEmail.mockResolvedValue({ ...mockUser, isActive: false }); + + const result = await service.forgotPassword(mockUser.email); + + expect(result).toEqual({ message: GENERIC_MSG }); + expect(mailerService.sendMail).not.toHaveBeenCalled(); + }); + + it('should invalidate previous tokens, persist hash and send email for active user', async () => { + repository.findByEmail.mockResolvedValue(mockUser); + passwordResetRepository.invalidatePrevious.mockResolvedValue({ count: 1 } as never); + passwordResetRepository.create.mockResolvedValue(mockToken); + + const result = await service.forgotPassword(mockUser.email); + + expect(passwordResetRepository.invalidatePrevious).toHaveBeenCalledWith(mockUser.id); + expect(passwordResetRepository.create).toHaveBeenCalledWith( + expect.objectContaining({ + userId: mockUser.id, + tokenHash: expect.any(String), + expiresAt: expect.any(Date), + }), + ); + expect(mailerService.sendMail).toHaveBeenCalledWith( + expect.objectContaining({ to: mockUser.email }), + ); + expect(result).toEqual({ message: GENERIC_MSG }); + }); + + it('should still return generic message when mail sending fails', async () => { + repository.findByEmail.mockResolvedValue(mockUser); + passwordResetRepository.invalidatePrevious.mockResolvedValue({ count: 0 } as never); + passwordResetRepository.create.mockResolvedValue(mockToken); + mailerService.sendMail.mockRejectedValue(new Error('SMTP error')); + + const result = await service.forgotPassword(mockUser.email); + + expect(result).toEqual({ message: GENERIC_MSG }); + }); + + it('should store SHA-256 hash, not raw token', async () => { + repository.findByEmail.mockResolvedValue(mockUser); + passwordResetRepository.invalidatePrevious.mockResolvedValue({ count: 0 } as never); + + let capturedHash = ''; + passwordResetRepository.create.mockImplementation(async (data) => { + capturedHash = data.tokenHash; + return mockToken; + }); + + let emailLink = ''; + mailerService.sendMail.mockImplementation(async (payload) => { + emailLink = payload.html; + }); + + await service.forgotPassword(mockUser.email); + + const rawTokenFromLink = emailLink.match(/redefinir-senha\/([a-f0-9]+)/)?.[1] ?? ''; + expect(rawTokenFromLink).toBeTruthy(); + expect(capturedHash).toBe(createHash('sha256').update(rawTokenFromLink).digest('hex')); + expect(capturedHash).not.toBe(rawTokenFromLink); + }); + }); + + describe('resetPassword', () => { + const validDto = { newPassword: 'ValidPass1', confirmPassword: 'ValidPass1' }; + + it('should throw BadRequestException when token does not exist', async () => { + passwordResetRepository.findByHash.mockResolvedValue(null); + + await expect(service.resetPassword('invalid-token', validDto)).rejects.toThrow( + BadRequestException, + ); + await expect(service.resetPassword('invalid-token', validDto)).rejects.toThrow( + 'Token de redefinição inválido', + ); + }); + + it('should throw BadRequestException when token is already used', async () => { + passwordResetRepository.findByHash.mockResolvedValue({ + ...mockToken, + usedAt: new Date(), + }); + + await expect(service.resetPassword('some-token', validDto)).rejects.toThrow( + BadRequestException, + ); + await expect(service.resetPassword('some-token', validDto)).rejects.toThrow( + 'Token de redefinição já utilizado', + ); + }); + + it('should throw BadRequestException when token is expired', async () => { + passwordResetRepository.findByHash.mockResolvedValue({ + ...mockToken, + expiresAt: new Date(Date.now() - 1000), + }); + + await expect(service.resetPassword('some-token', validDto)).rejects.toThrow( + BadRequestException, + ); + await expect(service.resetPassword('some-token', validDto)).rejects.toThrow( + 'Token de redefinição expirado', + ); + }); + + it('should throw BadRequestException when passwords do not match', async () => { + passwordResetRepository.findByHash.mockResolvedValue(mockToken); + + await expect( + service.resetPassword('some-token', { + newPassword: 'ValidPass1', + confirmPassword: 'DifferentPass1', + }), + ).rejects.toThrow(BadRequestException); + await expect( + service.resetPassword('some-token', { + newPassword: 'ValidPass1', + confirmPassword: 'DifferentPass1', + }), + ).rejects.toThrow('As senhas não coincidem'); + }); + + it('should throw BadRequestException when password fails complexity policy', async () => { + passwordResetRepository.findByHash.mockResolvedValue(mockToken); + + await expect( + service.resetPassword('some-token', { + newPassword: 'weakpassword', + confirmPassword: 'weakpassword', + }), + ).rejects.toThrow(BadRequestException); + }); + + it('should reset password, mark token as used and set mustChangePassword=false on happy path', async () => { + passwordResetRepository.findByHash.mockResolvedValue(mockToken); + (bcrypt.hash as jest.Mock).mockResolvedValue('new-hashed-password'); + repository.updatePassword.mockResolvedValue(undefined); + passwordResetRepository.markAsUsed.mockResolvedValue({ ...mockToken, usedAt: new Date() }); + + const result = await service.resetPassword('some-token', validDto); + + expect(bcrypt.hash).toHaveBeenCalledWith(validDto.newPassword, 10); + expect(repository.updatePassword).toHaveBeenCalledWith(mockToken.userId, 'new-hashed-password'); + expect(passwordResetRepository.markAsUsed).toHaveBeenCalledWith(mockToken.id); + expect(result).toEqual({ message: 'Senha redefinida com sucesso. Você já pode fazer login.' }); + }); + }); +}); diff --git a/src/modules/auth/auth.service.ts b/src/modules/auth/auth.service.ts new file mode 100644 index 0000000..6241954 --- /dev/null +++ b/src/modules/auth/auth.service.ts @@ -0,0 +1,172 @@ +import { + BadRequestException, + ForbiddenException, + Injectable, + Logger, + UnauthorizedException, +} from '@nestjs/common'; +import { JwtService } from '@nestjs/jwt'; +import * as bcrypt from 'bcrypt'; +import { createHash, randomBytes } from 'crypto'; +import { AuthRepository } from './auth.repository'; +import { PasswordPolicyService } from './password-policy.service'; +import { PasswordResetRepository } from './password-reset.repository'; +import { MailerService } from '../mailer/mailer.service'; +import { EnvService } from '../../config/env/env.service'; +import { ChangePasswordDto } from './dto/change-password.dto'; +import { LoginDto } from './dto/login.dto'; +import { ResetPasswordDto } from './dto/reset-password.dto'; + +@Injectable() +export class AuthService { + private readonly logger = new Logger(AuthService.name); + + constructor( + private readonly repository: AuthRepository, + private readonly jwtService: JwtService, + private readonly passwordPolicy: PasswordPolicyService, + private readonly passwordResetRepository: PasswordResetRepository, + private readonly mailerService: MailerService, + private readonly env: EnvService, + ) {} + + private async validateUser(email: string, plainPassword: string) { + const user = await this.repository.findByEmail(email); + + if (!user) { + throw new UnauthorizedException('Credenciais inválidas'); + } + + const isPasswordValid = await bcrypt.compare(plainPassword, user.password); + + if (!isPasswordValid) { + throw new UnauthorizedException('Credenciais inválidas'); + } + + if (!user.isActive) { + throw new ForbiddenException('Usuário inativo'); + } + + const { password, ...userWithoutPassword } = user; + void password; + return userWithoutPassword; + } + + async getMe(userId: string) { + const user = await this.repository.findById(userId); + + if (!user) { + throw new UnauthorizedException('Usuário não encontrado'); + } + + return user; + } + + async changePassword(userId: string, dto: ChangePasswordDto) { + const user = await this.repository.findByIdWithPassword(userId); + + if (!user) { + throw new UnauthorizedException('Usuário não encontrado'); + } + + const isCurrentPasswordValid = await bcrypt.compare(dto.currentPassword, user.password); + + if (!isCurrentPasswordValid) { + throw new UnauthorizedException('Senha atual incorreta'); + } + + this.passwordPolicy.validate(dto.newPassword); + + const hashedPassword = await bcrypt.hash(dto.newPassword, 10); + await this.repository.updatePassword(userId, hashedPassword); + + return { message: 'Senha alterada com sucesso' }; + } + + async login(dto: LoginDto) { + const user = await this.validateUser(dto.email, dto.password); + + const payload = { + sub: user.id, + email: user.email, + role: user.role, + clientId: user.clientId ?? null, + }; + const accessToken = this.jwtService.sign(payload); + + return { + accessToken, + user: { + id: user.id, + name: user.name, + email: user.email, + role: user.role, + mustChangePassword: user.mustChangePassword, + clientId: user.clientId ?? null, + }, + }; + } + + async forgotPassword(email: string): Promise<{ message: string }> { + const GENERIC_MSG = 'Se o email existir no sistema, um link de redefinição será enviado.'; + + const user = await this.repository.findByEmail(email); + + if (!user || !user.isActive) { + return { message: GENERIC_MSG }; + } + + const tokenBruto = randomBytes(32).toString('hex'); + const tokenHash = createHash('sha256').update(tokenBruto).digest('hex'); + const expiresAt = new Date(Date.now() + this.env.passwordResetTtlMinutes * 60 * 1000); + + await this.passwordResetRepository.invalidatePrevious(user.id); + await this.passwordResetRepository.create({ userId: user.id, tokenHash, expiresAt }); + + const link = `${this.env.frontendUrl}/redefinir-senha/${tokenBruto}`; + + try { + await this.mailerService.sendMail({ + to: user.email, + subject: 'Redefinição de senha — ISIS Gestão', + html: `

Olá, ${user.name}!

Clique no link abaixo para redefinir sua senha:

${link}

O link expira em ${this.env.passwordResetTtlMinutes} minutos e é de uso único.

`, + }); + this.logger.log('Email de reset enviado', { userId: user.id }); + } catch (e) { + this.logger.error('Falha ao enviar email', { error: (e as Error).message }); + } + + return { message: GENERIC_MSG }; + } + + async resetPassword(token: string, dto: ResetPasswordDto): Promise<{ message: string }> { + const tokenHash = createHash('sha256').update(token).digest('hex'); + const tokenRecord = await this.passwordResetRepository.findByHash(tokenHash); + + if (!tokenRecord) { + throw new BadRequestException('Token de redefinição inválido'); + } + + if (tokenRecord.usedAt) { + throw new BadRequestException('Token de redefinição já utilizado'); + } + + if (tokenRecord.expiresAt < new Date()) { + throw new BadRequestException('Token de redefinição expirado'); + } + + if (dto.newPassword !== dto.confirmPassword) { + throw new BadRequestException('As senhas não coincidem'); + } + + this.passwordPolicy.validate(dto.newPassword); + + const hashedPassword = await bcrypt.hash(dto.newPassword, 10); + await this.repository.updatePassword(tokenRecord.userId, hashedPassword); + await this.passwordResetRepository.markAsUsed(tokenRecord.id); + + this.logger.log('Token de reset consumido', { userId: tokenRecord.userId }); + + return { message: 'Senha redefinida com sucesso. Você já pode fazer login.' }; + } +} diff --git a/src/modules/auth/dto/.gitkeep b/src/modules/auth/dto/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/src/modules/auth/dto/change-password.dto.ts b/src/modules/auth/dto/change-password.dto.ts new file mode 100644 index 0000000..0c4cecd --- /dev/null +++ b/src/modules/auth/dto/change-password.dto.ts @@ -0,0 +1,11 @@ +import { IsNotEmpty, IsString, MinLength } from 'class-validator'; + +export class ChangePasswordDto { + @IsString() + @IsNotEmpty() + currentPassword!: string; + + @IsString() + @MinLength(8) + newPassword!: string; +} diff --git a/src/modules/auth/dto/forgot-password.dto.ts b/src/modules/auth/dto/forgot-password.dto.ts new file mode 100644 index 0000000..862ba57 --- /dev/null +++ b/src/modules/auth/dto/forgot-password.dto.ts @@ -0,0 +1,6 @@ +import { IsEmail } from 'class-validator'; + +export class ForgotPasswordDto { + @IsEmail({}, { message: 'Email inválido' }) + email!: string; +} diff --git a/src/modules/auth/dto/login.dto.ts b/src/modules/auth/dto/login.dto.ts new file mode 100644 index 0000000..412c634 --- /dev/null +++ b/src/modules/auth/dto/login.dto.ts @@ -0,0 +1,10 @@ +import { IsEmail, IsNotEmpty, IsString } from 'class-validator'; + +export class LoginDto { + @IsEmail() + email!: string; + + @IsString() + @IsNotEmpty() + password!: string; +} diff --git a/src/modules/auth/dto/reset-password.dto.ts b/src/modules/auth/dto/reset-password.dto.ts new file mode 100644 index 0000000..a8b8633 --- /dev/null +++ b/src/modules/auth/dto/reset-password.dto.ts @@ -0,0 +1,11 @@ +import { IsNotEmpty, IsString } from 'class-validator'; + +export class ResetPasswordDto { + @IsString() + @IsNotEmpty({ message: 'Nova senha é obrigatória' }) + newPassword!: string; + + @IsString() + @IsNotEmpty({ message: 'Confirmação de senha é obrigatória' }) + confirmPassword!: string; +} diff --git a/src/modules/auth/password-policy.service.spec.ts b/src/modules/auth/password-policy.service.spec.ts new file mode 100644 index 0000000..d973397 --- /dev/null +++ b/src/modules/auth/password-policy.service.spec.ts @@ -0,0 +1,34 @@ +import { BadRequestException } from '@nestjs/common'; +import { PasswordPolicyService } from './password-policy.service'; + +describe('PasswordPolicyService', () => { + let service: PasswordPolicyService; + + beforeEach(() => { + service = new PasswordPolicyService(); + }); + + it('does not throw for a valid password', () => { + expect(() => service.validate('ValidPass1')).not.toThrow(); + }); + + it('throws when password is shorter than 8 characters', () => { + expect(() => service.validate('Val1A')).toThrow(BadRequestException); + expect(() => service.validate('Val1A')).toThrow('A senha deve ter no mínimo 8 caracteres'); + }); + + it('throws when password has no uppercase letter', () => { + expect(() => service.validate('validpass1')).toThrow(BadRequestException); + expect(() => service.validate('validpass1')).toThrow('A senha deve conter ao menos uma letra maiúscula'); + }); + + it('throws when password has no lowercase letter', () => { + expect(() => service.validate('VALIDPASS1')).toThrow(BadRequestException); + expect(() => service.validate('VALIDPASS1')).toThrow('A senha deve conter ao menos uma letra minúscula'); + }); + + it('throws when password has no digit', () => { + expect(() => service.validate('ValidPass')).toThrow(BadRequestException); + expect(() => service.validate('ValidPass')).toThrow('A senha deve conter ao menos um número'); + }); +}); diff --git a/src/modules/auth/password-policy.service.ts b/src/modules/auth/password-policy.service.ts new file mode 100644 index 0000000..60e48d9 --- /dev/null +++ b/src/modules/auth/password-policy.service.ts @@ -0,0 +1,21 @@ +import { BadRequestException, Injectable } from '@nestjs/common'; + +@Injectable() +export class PasswordPolicyService { + private static readonly MIN_PASSWORD_LENGTH = 8; + + validate(password: string): void { + if (password.length < PasswordPolicyService.MIN_PASSWORD_LENGTH) { + throw new BadRequestException('A senha deve ter no mínimo 8 caracteres'); + } + if (!/[A-Z]/.test(password)) { + throw new BadRequestException('A senha deve conter ao menos uma letra maiúscula'); + } + if (!/[a-z]/.test(password)) { + throw new BadRequestException('A senha deve conter ao menos uma letra minúscula'); + } + if (!/[0-9]/.test(password)) { + throw new BadRequestException('A senha deve conter ao menos um número'); + } + } +} diff --git a/src/modules/auth/password-reset.repository.spec.ts b/src/modules/auth/password-reset.repository.spec.ts new file mode 100644 index 0000000..c0c9f9e --- /dev/null +++ b/src/modules/auth/password-reset.repository.spec.ts @@ -0,0 +1,102 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { PrismaService } from '../../config/database/prisma.service'; +import { PasswordResetRepository } from './password-reset.repository'; + +const mockPrisma = { + passwordResetToken: { + create: jest.fn(), + findUnique: jest.fn(), + updateMany: jest.fn(), + update: jest.fn(), + }, +}; + +describe('PasswordResetRepository', () => { + let repository: PasswordResetRepository; + + beforeEach(async () => { + jest.clearAllMocks(); + const module: TestingModule = await Test.createTestingModule({ + providers: [ + PasswordResetRepository, + { provide: PrismaService, useValue: mockPrisma }, + ], + }).compile(); + + repository = module.get(PasswordResetRepository); + }); + + describe('create', () => { + it('should create a password reset token', async () => { + const data = { + userId: 'user-id-1', + tokenHash: 'hash-abc', + expiresAt: new Date('2026-01-01T01:00:00Z'), + }; + const created = { id: 'token-id-1', ...data, usedAt: null, createdAt: new Date() }; + mockPrisma.passwordResetToken.create.mockResolvedValue(created); + + const result = await repository.create(data); + + expect(mockPrisma.passwordResetToken.create).toHaveBeenCalledWith({ data }); + expect(result).toEqual(created); + }); + }); + + describe('findByHash', () => { + it('should return token when hash exists', async () => { + const token = { + id: 'token-id-1', + userId: 'user-id-1', + tokenHash: 'hash-abc', + expiresAt: new Date(), + usedAt: null, + createdAt: new Date(), + }; + mockPrisma.passwordResetToken.findUnique.mockResolvedValue(token); + + const result = await repository.findByHash('hash-abc'); + + expect(mockPrisma.passwordResetToken.findUnique).toHaveBeenCalledWith({ + where: { tokenHash: 'hash-abc' }, + }); + expect(result).toEqual(token); + }); + + it('should return null when hash does not exist', async () => { + mockPrisma.passwordResetToken.findUnique.mockResolvedValue(null); + + const result = await repository.findByHash('nonexistent'); + + expect(result).toBeNull(); + }); + }); + + describe('invalidatePrevious', () => { + it('should set usedAt on all unused tokens for a user', async () => { + mockPrisma.passwordResetToken.updateMany.mockResolvedValue({ count: 2 }); + + await repository.invalidatePrevious('user-id-1'); + + expect(mockPrisma.passwordResetToken.updateMany).toHaveBeenCalledWith({ + where: { userId: 'user-id-1', usedAt: null }, + data: { usedAt: expect.any(Date) }, + }); + }); + }); + + describe('markAsUsed', () => { + it('should set usedAt on the token', async () => { + const updated = { id: 'token-id-1', usedAt: new Date() }; + mockPrisma.passwordResetToken.update.mockResolvedValue(updated); + + const result = await repository.markAsUsed('token-id-1'); + + expect(mockPrisma.passwordResetToken.update).toHaveBeenCalledWith({ + where: { id: 'token-id-1' }, + data: { usedAt: expect.any(Date) }, + }); + expect(result).toEqual(updated); + }); + }); +}); diff --git a/src/modules/auth/password-reset.repository.ts b/src/modules/auth/password-reset.repository.ts new file mode 100644 index 0000000..754cc51 --- /dev/null +++ b/src/modules/auth/password-reset.repository.ts @@ -0,0 +1,29 @@ +import { Injectable } from '@nestjs/common'; +import { PrismaService } from '../../config/database/prisma.service'; + +@Injectable() +export class PasswordResetRepository { + constructor(private readonly prisma: PrismaService) {} + + async create(data: { userId: string; tokenHash: string; expiresAt: Date }) { + return this.prisma.passwordResetToken.create({ data }); + } + + async findByHash(tokenHash: string) { + return this.prisma.passwordResetToken.findUnique({ where: { tokenHash } }); + } + + async invalidatePrevious(userId: string) { + return this.prisma.passwordResetToken.updateMany({ + where: { userId, usedAt: null }, + data: { usedAt: new Date() }, + }); + } + + async markAsUsed(id: string) { + return this.prisma.passwordResetToken.update({ + where: { id }, + data: { usedAt: new Date() }, + }); + } +} diff --git a/src/modules/client-profiles/client-profiles.controller.ts b/src/modules/client-profiles/client-profiles.controller.ts new file mode 100644 index 0000000..8421def --- /dev/null +++ b/src/modules/client-profiles/client-profiles.controller.ts @@ -0,0 +1,61 @@ +import { Body, Controller, Get, Param, Patch, Post, Query, UseGuards } from '@nestjs/common'; +import { JwtAuthGuard } from '../../config/auth/jwt-auth.guard'; +import { ReadOnlyClientGuard } from '../../common/guards/read-only-client.guard'; +import { RolesGuard } from '../../common/guards/roles.guard'; +import { Roles } from '../../common/decorators/roles.decorator'; +import { Role } from '../../common/enums/role.enum'; +import { ClientProfilesService } from './client-profiles.service'; +import { CreateClientProfileDto } from './dto/create-client-profile.dto'; +import { UpdateClientProfileDto } from './dto/update-client-profile.dto'; +import { ListClientProfilesQueryDto } from './dto/list-client-profiles-query.dto'; + +@Controller('clients/:clientId/profiles') +export class ClientProfilesController { + constructor(private readonly clientProfilesService: ClientProfilesService) {} + + @Get() + @UseGuards(JwtAuthGuard, ReadOnlyClientGuard, RolesGuard) + @Roles(Role.ADMIN, Role.OPERATOR, Role.CLIENT) + async findAll(@Param('clientId') clientId: string, @Query() query: ListClientProfilesQueryDto) { + return this.clientProfilesService.findByClientId(clientId, query); + } + + @Get('active') + @UseGuards(JwtAuthGuard, ReadOnlyClientGuard, RolesGuard) + @Roles(Role.ADMIN, Role.OPERATOR, Role.CLIENT) + async findActive(@Param('clientId') clientId: string) { + return this.clientProfilesService.findActiveByClientId(clientId); + } + + @Post() + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles(Role.ADMIN, Role.OPERATOR) + async create(@Param('clientId') clientId: string, @Body() dto: CreateClientProfileDto) { + return this.clientProfilesService.create(clientId, dto); + } + + @Get(':id') + @UseGuards(JwtAuthGuard, ReadOnlyClientGuard, RolesGuard) + @Roles(Role.ADMIN, Role.OPERATOR, Role.CLIENT) + async findOne(@Param('clientId') clientId: string, @Param('id') id: string) { + return this.clientProfilesService.findOne(clientId, id); + } + + @Patch(':id') + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles(Role.ADMIN, Role.OPERATOR) + async update( + @Param('clientId') clientId: string, + @Param('id') id: string, + @Body() dto: UpdateClientProfileDto, + ) { + return this.clientProfilesService.update(clientId, id, dto); + } + + @Patch(':id/toggle-status') + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles(Role.ADMIN, Role.OPERATOR) + async toggleStatus(@Param('clientId') clientId: string, @Param('id') id: string) { + return this.clientProfilesService.toggleStatus(clientId, id); + } +} diff --git a/src/modules/client-profiles/client-profiles.module.ts b/src/modules/client-profiles/client-profiles.module.ts new file mode 100644 index 0000000..96fcf10 --- /dev/null +++ b/src/modules/client-profiles/client-profiles.module.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common'; +import { ClientProfilesController } from './client-profiles.controller'; +import { ClientProfilesService } from './client-profiles.service'; +import { ClientProfilesRepository } from './client-profiles.repository'; + +@Module({ + controllers: [ClientProfilesController], + providers: [ClientProfilesService, ClientProfilesRepository], + exports: [ClientProfilesService], +}) +export class ClientProfilesModule {} diff --git a/src/modules/client-profiles/client-profiles.repository.ts b/src/modules/client-profiles/client-profiles.repository.ts new file mode 100644 index 0000000..150ff4f --- /dev/null +++ b/src/modules/client-profiles/client-profiles.repository.ts @@ -0,0 +1,75 @@ +import { Injectable } from '@nestjs/common'; +import { Prisma } from '@prisma/client'; +import { PrismaService } from '../../config/database/prisma.service'; + +const clientProfileSelect = { + id: true, + name: true, + isActive: true, + createdAt: true, + updatedAt: true, + client: { select: { id: true, name: true } }, +} as const; + +@Injectable() +export class ClientProfilesRepository { + constructor(private readonly prisma: PrismaService) {} + + async findByClientId(params: { + clientId: string; + search?: string; + isActive?: boolean; + page: number; + limit: number; + }) { + const where: Prisma.ClientProfileWhereInput = { clientId: params.clientId }; + + if (params.search) { + where.name = { contains: params.search, mode: 'insensitive' }; + } + if (params.isActive !== undefined) where.isActive = params.isActive; + + const [data, total] = await Promise.all([ + this.prisma.clientProfile.findMany({ + where, + select: clientProfileSelect, + orderBy: { name: 'asc' }, + skip: (params.page - 1) * params.limit, + take: params.limit, + }), + this.prisma.clientProfile.count({ where }), + ]); + + return { data, total }; + } + + async findActiveByClientId(clientId: string) { + return this.prisma.clientProfile.findMany({ + where: { clientId, isActive: true }, + select: { id: true, name: true }, + orderBy: { name: 'asc' }, + }); + } + + async findById(id: string) { + return this.prisma.clientProfile.findUnique({ + where: { id }, + select: clientProfileSelect, + }); + } + + async create(data: { name: string; clientId: string }) { + return this.prisma.clientProfile.create({ + data, + select: clientProfileSelect, + }); + } + + async update(id: string, data: { name?: string; isActive?: boolean }) { + return this.prisma.clientProfile.update({ + where: { id }, + data, + select: clientProfileSelect, + }); + } +} diff --git a/src/modules/client-profiles/client-profiles.service.ts b/src/modules/client-profiles/client-profiles.service.ts new file mode 100644 index 0000000..609ab4c --- /dev/null +++ b/src/modules/client-profiles/client-profiles.service.ts @@ -0,0 +1,72 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { ClientProfilesRepository } from './client-profiles.repository'; +import { PrismaService } from '../../config/database/prisma.service'; +import { CreateClientProfileDto } from './dto/create-client-profile.dto'; +import { UpdateClientProfileDto } from './dto/update-client-profile.dto'; +import { ListClientProfilesQueryDto } from './dto/list-client-profiles-query.dto'; + +@Injectable() +export class ClientProfilesService { + constructor( + private readonly repository: ClientProfilesRepository, + private readonly prisma: PrismaService, + ) {} + + async findByClientId(clientId: string, query: ListClientProfilesQueryDto) { + await this.validateClientExists(clientId); + const { page, limit, ...filters } = query; + const { data, total } = await this.repository.findByClientId({ + clientId, + ...filters, + page, + limit, + }); + return { data, total, page, limit }; + } + + async findActiveByClientId(clientId: string) { + await this.validateClientExists(clientId); + return this.repository.findActiveByClientId(clientId); + } + + async findOne(clientId: string, id: string) { + await this.validateClientExists(clientId); + const profile = await this.repository.findById(id); + + if (!profile || profile.client.id !== clientId) { + throw new NotFoundException('Perfil não encontrado'); + } + + return profile; + } + + async create(clientId: string, dto: CreateClientProfileDto) { + await this.validateClientExists(clientId); + + return this.repository.create({ + ...dto, + clientId, + }); + } + + async update(clientId: string, id: string, dto: UpdateClientProfileDto) { + await this.findOne(clientId, id); + return this.repository.update(id, dto); + } + + async toggleStatus(clientId: string, id: string) { + const profile = await this.findOne(clientId, id); + return this.repository.update(id, { isActive: !profile.isActive }); + } + + private async validateClientExists(clientId: string) { + const client = await this.prisma.client.findUnique({ + where: { id: clientId }, + select: { id: true }, + }); + + if (!client) { + throw new NotFoundException('Cliente não encontrado'); + } + } +} diff --git a/src/modules/client-profiles/dto/create-client-profile.dto.ts b/src/modules/client-profiles/dto/create-client-profile.dto.ts new file mode 100644 index 0000000..37c5dc7 --- /dev/null +++ b/src/modules/client-profiles/dto/create-client-profile.dto.ts @@ -0,0 +1,7 @@ +import { IsNotEmpty, IsString } from 'class-validator'; + +export class CreateClientProfileDto { + @IsString() + @IsNotEmpty() + name!: string; +} diff --git a/src/modules/client-profiles/dto/list-client-profiles-query.dto.ts b/src/modules/client-profiles/dto/list-client-profiles-query.dto.ts new file mode 100644 index 0000000..9b26d27 --- /dev/null +++ b/src/modules/client-profiles/dto/list-client-profiles-query.dto.ts @@ -0,0 +1,25 @@ +import { IsInt, IsOptional, IsString, Max, Min } from 'class-validator'; +import { Transform } from 'class-transformer'; + +export class ListClientProfilesQueryDto { + @IsOptional() + @IsString() + search?: string; + + @IsOptional() + @Transform(({ value }) => value === 'true') + isActive?: boolean; + + @IsOptional() + @Transform(({ value }: { value: unknown }) => parseInt(String(value), 10)) + @IsInt() + @Min(1) + page: number = 1; + + @IsOptional() + @Transform(({ value }: { value: unknown }) => parseInt(String(value), 10)) + @IsInt() + @Min(1) + @Max(100) + limit: number = 20; +} diff --git a/src/modules/client-profiles/dto/update-client-profile.dto.ts b/src/modules/client-profiles/dto/update-client-profile.dto.ts new file mode 100644 index 0000000..af17091 --- /dev/null +++ b/src/modules/client-profiles/dto/update-client-profile.dto.ts @@ -0,0 +1,8 @@ +import { IsNotEmpty, IsOptional, IsString } from 'class-validator'; + +export class UpdateClientProfileDto { + @IsOptional() + @IsString() + @IsNotEmpty() + name?: string; +} diff --git a/src/modules/clients/clients.controller.ts b/src/modules/clients/clients.controller.ts new file mode 100644 index 0000000..a6a740a --- /dev/null +++ b/src/modules/clients/clients.controller.ts @@ -0,0 +1,109 @@ +import { + Body, + Controller, + Get, + NotFoundException, + Param, + Patch, + Post, + Query, + UseGuards, +} from '@nestjs/common'; +import { JwtAuthGuard } from '../../config/auth/jwt-auth.guard'; +import { ReadOnlyClientGuard } from '../../common/guards/read-only-client.guard'; +import { RolesGuard } from '../../common/guards/roles.guard'; +import { Roles } from '../../common/decorators/roles.decorator'; +import { Role } from '../../common/enums/role.enum'; +import { CurrentUser } from '../../common/decorators/user.decorator'; +import type { CurrentUserPayload } from '../../common/decorators/user.decorator'; +import { ClientsService } from './clients.service'; +import { ContractsService } from '../contracts/contracts.service'; +import { ProjectsService } from '../projects/projects.service'; +import { CreateClientDto } from './dto/create-client.dto'; +import { UpdateClientDto } from './dto/update-client.dto'; +import { ListClientsQueryDto } from './dto/list-clients-query.dto'; +import { ListContractsQueryDto } from '../contracts/dto/list-contracts-query.dto'; +import { ListProjectsQueryDto } from '../projects/dto/list-projects-query.dto'; + +@Controller('clients') +export class ClientsController { + constructor( + private readonly clientsService: ClientsService, + private readonly contractsService: ContractsService, + private readonly projectsService: ProjectsService, + ) {} + + private validateClientOwnership(user: CurrentUserPayload, clientId: string): void { + if (user.role === Role.CLIENT && user.clientId !== clientId) { + throw new NotFoundException('Cliente não encontrado'); + } + } + + @Get() + @UseGuards(JwtAuthGuard, ReadOnlyClientGuard, RolesGuard) + @Roles(Role.ADMIN, Role.OPERATOR, Role.CLIENT) + async findAll(@Query() query: ListClientsQueryDto, @CurrentUser() user: CurrentUserPayload) { + if (user.role === Role.CLIENT && user.clientId) { + const client = await this.clientsService.findOne(user.clientId); + return { data: [client], total: 1, page: 1, limit: 20 }; + } + return this.clientsService.findAll(query); + } + + @Post() + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles(Role.ADMIN, Role.OPERATOR) + async create(@Body() dto: CreateClientDto) { + return this.clientsService.create(dto); + } + + @Get(':id/contracts/active') + @UseGuards(JwtAuthGuard, ReadOnlyClientGuard, RolesGuard) + @Roles(Role.ADMIN, Role.OPERATOR, Role.CLIENT) + async findActiveContracts(@Param('id') id: string, @CurrentUser() user: CurrentUserPayload) { + this.validateClientOwnership(user, id); + await this.clientsService.findOne(id); + return this.contractsService.findActiveByClientId(id); + } + + @Get(':id/contracts') + @UseGuards(JwtAuthGuard, ReadOnlyClientGuard, RolesGuard) + @Roles(Role.ADMIN, Role.OPERATOR, Role.CLIENT) + async findContracts( + @Param('id') id: string, + @Query() query: ListContractsQueryDto, + @CurrentUser() user: CurrentUserPayload, + ) { + this.validateClientOwnership(user, id); + await this.clientsService.findOne(id); + return this.contractsService.findByClientId(id, query); + } + + @Get(':id/projects') + @UseGuards(JwtAuthGuard, ReadOnlyClientGuard, RolesGuard) + @Roles(Role.ADMIN, Role.OPERATOR, Role.CLIENT) + async findProjects( + @Param('id') id: string, + @Query() query: ListProjectsQueryDto, + @CurrentUser() user: CurrentUserPayload, + ) { + this.validateClientOwnership(user, id); + await this.clientsService.findOne(id); + return this.projectsService.findByClientId(id, query); + } + + @Get(':id') + @UseGuards(JwtAuthGuard, ReadOnlyClientGuard, RolesGuard) + @Roles(Role.ADMIN, Role.OPERATOR, Role.CLIENT) + async findOne(@Param('id') id: string, @CurrentUser() user: CurrentUserPayload) { + this.validateClientOwnership(user, id); + return this.clientsService.findOne(id); + } + + @Patch(':id') + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles(Role.ADMIN, Role.OPERATOR) + async update(@Param('id') id: string, @Body() dto: UpdateClientDto) { + return this.clientsService.update(id, dto); + } +} diff --git a/src/modules/clients/clients.module.ts b/src/modules/clients/clients.module.ts new file mode 100644 index 0000000..36d37ea --- /dev/null +++ b/src/modules/clients/clients.module.ts @@ -0,0 +1,13 @@ +import { Module } from '@nestjs/common'; +import { ClientsController } from './clients.controller'; +import { ClientsService } from './clients.service'; +import { ClientsRepository } from './clients.repository'; +import { ContractsModule } from '../contracts/contracts.module'; +import { ProjectsModule } from '../projects/projects.module'; + +@Module({ + imports: [ContractsModule, ProjectsModule], + controllers: [ClientsController], + providers: [ClientsService, ClientsRepository], +}) +export class ClientsModule {} diff --git a/src/modules/clients/clients.repository.ts b/src/modules/clients/clients.repository.ts new file mode 100644 index 0000000..5ca6d8e --- /dev/null +++ b/src/modules/clients/clients.repository.ts @@ -0,0 +1,81 @@ +import { Injectable } from '@nestjs/common'; +import { Prisma } from '@prisma/client'; +import { PrismaService } from '../../config/database/prisma.service'; + +const clientSelect = { + id: true, + name: true, + document: true, + email: true, + phone: true, + contactName: true, + description: true, + isActive: true, + createdAt: true, + updatedAt: true, +} as const; + +@Injectable() +export class ClientsRepository { + constructor(private readonly prisma: PrismaService) {} + + async findAll(params: { name?: string; isActive?: boolean; page: number; limit: number }) { + const where: Prisma.ClientWhereInput = {}; + + if (params.name) where.name = { contains: params.name, mode: 'insensitive' }; + if (params.isActive !== undefined) where.isActive = params.isActive; + + const [data, total] = await Promise.all([ + this.prisma.client.findMany({ + where, + select: clientSelect, + orderBy: { name: 'asc' }, + skip: (params.page - 1) * params.limit, + take: params.limit, + }), + this.prisma.client.count({ where }), + ]); + + return { data, total }; + } + + async findById(id: string) { + return this.prisma.client.findUnique({ + where: { id }, + select: clientSelect, + }); + } + + async create(data: { + name: string; + document?: string; + email?: string; + phone?: string; + contactName?: string; + description?: string; + }) { + return this.prisma.client.create({ + data, + select: clientSelect, + }); + } + + async update( + id: string, + data: { + name?: string; + document?: string; + email?: string; + phone?: string; + contactName?: string; + description?: string; + isActive?: boolean; + }, + ) { + return this.prisma.client.update({ + where: { id }, + data, + select: clientSelect, + }); + } +} diff --git a/src/modules/clients/clients.service.ts b/src/modules/clients/clients.service.ts new file mode 100644 index 0000000..94a764c --- /dev/null +++ b/src/modules/clients/clients.service.ts @@ -0,0 +1,39 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { ClientsRepository } from './clients.repository'; +import { CreateClientDto } from './dto/create-client.dto'; +import { UpdateClientDto } from './dto/update-client.dto'; +import { ListClientsQueryDto } from './dto/list-clients-query.dto'; + +@Injectable() +export class ClientsService { + constructor(private readonly repository: ClientsRepository) {} + + async findAll(query: ListClientsQueryDto) { + const { page, limit, ...filters } = query; + const { data, total } = await this.repository.findAll({ + ...filters, + page, + limit, + }); + return { data, total, page, limit }; + } + + async findOne(id: string) { + const client = await this.repository.findById(id); + + if (!client) { + throw new NotFoundException('Cliente não encontrado'); + } + + return client; + } + + async create(dto: CreateClientDto) { + return this.repository.create(dto); + } + + async update(id: string, dto: UpdateClientDto) { + await this.findOne(id); + return this.repository.update(id, dto); + } +} diff --git a/src/modules/clients/dto/.gitkeep b/src/modules/clients/dto/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/src/modules/clients/dto/create-client.dto.ts b/src/modules/clients/dto/create-client.dto.ts new file mode 100644 index 0000000..acc0306 --- /dev/null +++ b/src/modules/clients/dto/create-client.dto.ts @@ -0,0 +1,27 @@ +import { IsEmail, IsNotEmpty, IsOptional, IsString } from 'class-validator'; + +export class CreateClientDto { + @IsString() + @IsNotEmpty() + name!: string; + + @IsOptional() + @IsString() + document?: string; + + @IsOptional() + @IsEmail() + email?: string; + + @IsOptional() + @IsString() + phone?: string; + + @IsOptional() + @IsString() + contactName?: string; + + @IsOptional() + @IsString() + description?: string; +} diff --git a/src/modules/clients/dto/list-clients-query.dto.ts b/src/modules/clients/dto/list-clients-query.dto.ts new file mode 100644 index 0000000..a4bfb32 --- /dev/null +++ b/src/modules/clients/dto/list-clients-query.dto.ts @@ -0,0 +1,25 @@ +import { IsInt, IsOptional, IsString, Max, Min } from 'class-validator'; +import { Transform } from 'class-transformer'; + +export class ListClientsQueryDto { + @IsOptional() + @IsString() + name?: string; + + @IsOptional() + @Transform(({ value }) => value === 'true') + isActive?: boolean; + + @IsOptional() + @Transform(({ value }: { value: unknown }) => parseInt(String(value), 10)) + @IsInt() + @Min(1) + page: number = 1; + + @IsOptional() + @Transform(({ value }: { value: unknown }) => parseInt(String(value), 10)) + @IsInt() + @Min(1) + @Max(100) + limit: number = 20; +} diff --git a/src/modules/clients/dto/update-client.dto.ts b/src/modules/clients/dto/update-client.dto.ts new file mode 100644 index 0000000..383859d --- /dev/null +++ b/src/modules/clients/dto/update-client.dto.ts @@ -0,0 +1,31 @@ +import { IsString, IsEmail, IsBoolean, IsOptional } from 'class-validator'; + +export class UpdateClientDto { + @IsOptional() + @IsString() + name?: string; + + @IsOptional() + @IsString() + document?: string; + + @IsOptional() + @IsEmail() + email?: string; + + @IsOptional() + @IsString() + phone?: string; + + @IsOptional() + @IsString() + contactName?: string; + + @IsOptional() + @IsString() + description?: string; + + @IsOptional() + @IsBoolean() + isActive?: boolean; +} diff --git a/src/modules/contract-items/contract-items.controller.ts b/src/modules/contract-items/contract-items.controller.ts new file mode 100644 index 0000000..ec6b9c3 --- /dev/null +++ b/src/modules/contract-items/contract-items.controller.ts @@ -0,0 +1,64 @@ +import { Body, Controller, Get, Param, Patch, Post, Query, UseGuards } from '@nestjs/common'; +import { JwtAuthGuard } from '../../config/auth/jwt-auth.guard'; +import { ReadOnlyClientGuard } from '../../common/guards/read-only-client.guard'; +import { RolesGuard } from '../../common/guards/roles.guard'; +import { Roles } from '../../common/decorators/roles.decorator'; +import { Role } from '../../common/enums/role.enum'; +import { CurrentUser } from '../../common/decorators/user.decorator'; +import type { CurrentUserPayload } from '../../common/decorators/user.decorator'; +import { ContractItemsService } from './contract-items.service'; +import { CreateContractItemDto } from './dto/create-contract-item.dto'; +import { UpdateContractItemDto } from './dto/update-contract-item.dto'; +import { ListContractItemsQueryDto } from './dto/list-contract-items-query.dto'; + +@Controller('clients/:clientId/contract-items') +export class ContractItemsController { + constructor(private readonly contractItemsService: ContractItemsService) {} + + @Get() + @UseGuards(JwtAuthGuard, ReadOnlyClientGuard, RolesGuard) + @Roles(Role.ADMIN, Role.OPERATOR, Role.CLIENT) + async findAll(@Param('clientId') clientId: string, @Query() query: ListContractItemsQueryDto) { + return this.contractItemsService.findByClientId(clientId, query); + } + + @Get('active') + @UseGuards(JwtAuthGuard, ReadOnlyClientGuard, RolesGuard) + @Roles(Role.ADMIN, Role.OPERATOR, Role.CLIENT) + async findActive(@Param('clientId') clientId: string) { + return this.contractItemsService.findActiveByClientId(clientId); + } + + @Post() + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles(Role.ADMIN, Role.OPERATOR) + async create(@Param('clientId') clientId: string, @Body() dto: CreateContractItemDto) { + return this.contractItemsService.create(clientId, dto); + } + + @Get(':id') + @UseGuards(JwtAuthGuard, ReadOnlyClientGuard, RolesGuard) + @Roles(Role.ADMIN, Role.OPERATOR, Role.CLIENT) + async findOne(@Param('clientId') clientId: string, @Param('id') id: string) { + return this.contractItemsService.findOne(clientId, id); + } + + @Patch(':id') + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles(Role.ADMIN, Role.OPERATOR) + async update( + @Param('clientId') clientId: string, + @Param('id') id: string, + @Body() dto: UpdateContractItemDto, + @CurrentUser() user: CurrentUserPayload, + ) { + return this.contractItemsService.update(clientId, id, dto, user.id); + } + + @Patch(':id/toggle-status') + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles(Role.ADMIN, Role.OPERATOR) + async toggleStatus(@Param('clientId') clientId: string, @Param('id') id: string) { + return this.contractItemsService.toggleStatus(clientId, id); + } +} diff --git a/src/modules/contract-items/contract-items.module.ts b/src/modules/contract-items/contract-items.module.ts new file mode 100644 index 0000000..7eb19af --- /dev/null +++ b/src/modules/contract-items/contract-items.module.ts @@ -0,0 +1,13 @@ +import { Module } from '@nestjs/common'; +import { ContractItemsController } from './contract-items.controller'; +import { ContractItemsService } from './contract-items.service'; +import { ContractItemsRepository } from './contract-items.repository'; +import { DeliverablesModule } from '../deliverables/deliverables.module'; + +@Module({ + imports: [DeliverablesModule], + controllers: [ContractItemsController], + providers: [ContractItemsService, ContractItemsRepository], + exports: [ContractItemsService], +}) +export class ContractItemsModule {} diff --git a/src/modules/contract-items/contract-items.repository.ts b/src/modules/contract-items/contract-items.repository.ts new file mode 100644 index 0000000..0c98394 --- /dev/null +++ b/src/modules/contract-items/contract-items.repository.ts @@ -0,0 +1,135 @@ +import { Injectable } from '@nestjs/common'; +import { ContractItemType, Prisma } from '@prisma/client'; +import { PrismaService } from '../../config/database/prisma.service'; + +const contractItemSelect = { + id: true, + code: true, + name: true, + description: true, + itemType: true, + totalUst: true, + ustValue: true, + timeboxDescoberta: true, + timeboxDesign: true, + timeboxArquitetura: true, + timeboxConstrucao: true, + isActive: true, + createdAt: true, + updatedAt: true, + client: { select: { id: true, name: true } }, +} as const; + +@Injectable() +export class ContractItemsRepository { + constructor(private readonly prisma: PrismaService) {} + + async findByClientId(params: { + clientId: string; + search?: string; + isActive?: boolean; + itemType?: ContractItemType; + page: number; + limit: number; + }) { + const where: Prisma.ContractItemWhereInput = { clientId: params.clientId }; + + if (params.search) { + where.OR = [ + { name: { contains: params.search, mode: 'insensitive' } }, + { code: { contains: params.search, mode: 'insensitive' } }, + ]; + } + if (params.isActive !== undefined) where.isActive = params.isActive; + if (params.itemType !== undefined) where.itemType = params.itemType; + + const [data, total] = await Promise.all([ + this.prisma.contractItem.findMany({ + where, + select: contractItemSelect, + orderBy: { name: 'asc' }, + skip: (params.page - 1) * params.limit, + take: params.limit, + }), + this.prisma.contractItem.count({ where }), + ]); + + return { data, total }; + } + + async findActiveByClientId(clientId: string) { + return this.prisma.contractItem.findMany({ + where: { clientId, isActive: true }, + select: { + id: true, + code: true, + name: true, + itemType: true, + totalUst: true, + ustValue: true, + timeboxDescoberta: true, + timeboxDesign: true, + timeboxArquitetura: true, + timeboxConstrucao: true, + }, + orderBy: { name: 'asc' }, + }); + } + + async findById(id: string) { + return this.prisma.contractItem.findUnique({ + where: { id }, + select: contractItemSelect, + }); + } + + async findByCodeAndClientId(code: string, clientId: string) { + return this.prisma.contractItem.findUnique({ + where: { code_clientId: { code, clientId } }, + select: { id: true }, + }); + } + + async create(data: { + code: string; + name: string; + description?: string; + itemType: ContractItemType; + totalUst: number; + ustValue?: number; + timeboxDescoberta?: number; + timeboxDesign?: number; + timeboxArquitetura?: number; + timeboxConstrucao?: number; + clientId: string; + }) { + return this.prisma.contractItem.create({ + data, + select: contractItemSelect, + }); + } + + async update( + id: string, + data: { + code?: string; + name?: string; + description?: string; + itemType?: ContractItemType; + totalUst?: number; + ustValue?: number; + timeboxDescoberta?: number; + timeboxDesign?: number; + timeboxArquitetura?: number; + timeboxConstrucao?: number; + isActive?: boolean; + }, + tx?: Prisma.TransactionClient, + ) { + return (tx ?? this.prisma).contractItem.update({ + where: { id }, + data, + select: contractItemSelect, + }); + } +} diff --git a/src/modules/contract-items/contract-items.service.spec.ts b/src/modules/contract-items/contract-items.service.spec.ts new file mode 100644 index 0000000..8452022 --- /dev/null +++ b/src/modules/contract-items/contract-items.service.spec.ts @@ -0,0 +1,310 @@ +import { BadRequestException } from '@nestjs/common'; +import { Test, TestingModule } from '@nestjs/testing'; +import { ContractItemType, Prisma } from '@prisma/client'; +import { ContractItemsService } from './contract-items.service'; +import { ContractItemsRepository } from './contract-items.repository'; +import { PrismaService } from '../../config/database/prisma.service'; +import { DeliverablesService } from '../deliverables/deliverables.service'; +import { CreateContractItemDto } from './dto/create-contract-item.dto'; + +const mockItem = (overrides: Record = {}) => ({ + id: 'item-1', + code: 'IC-1', + name: 'Item', + description: null, + itemType: ContractItemType.UST, + totalUst: new Prisma.Decimal(1000), + ustValue: new Prisma.Decimal(100), + timeboxDescoberta: null, + timeboxDesign: null, + timeboxArquitetura: null, + timeboxConstrucao: null, + isActive: true, + createdAt: new Date(), + updatedAt: new Date(), + client: { id: 'client-1', name: 'Cliente' }, + ...overrides, +}); + +describe('ContractItemsService', () => { + let service: ContractItemsService; + let repository: jest.Mocked; + let deliverablesService: jest.Mocked>; + let prisma: { client: { findUnique: jest.Mock }; $transaction: jest.Mock }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + ContractItemsService, + { + provide: ContractItemsRepository, + useValue: { + findByClientId: jest.fn(), + findActiveByClientId: jest.fn(), + findById: jest.fn(), + findByCodeAndClientId: jest.fn().mockResolvedValue(null), + create: jest.fn(), + update: jest.fn(), + }, + }, + { + provide: PrismaService, + useValue: { + client: { + findUnique: jest.fn().mockResolvedValue({ id: 'client-1' }), + }, + $transaction: jest.fn((cb: (tx: unknown) => unknown) => Promise.resolve(cb({}))), + }, + }, + { + provide: DeliverablesService, + useValue: { + recalculateForContractItem: jest + .fn() + .mockResolvedValue({ recalculatedCount: 0, recalculatedWorkOrdersCount: 0 }), + }, + }, + ], + }).compile(); + + service = module.get(ContractItemsService); + repository = module.get(ContractItemsRepository); + deliverablesService = module.get(DeliverablesService); + prisma = module.get(PrismaService); + }); + + describe('create', () => { + const baseDto: CreateContractItemDto = { + code: 'IC-1', + name: 'Item', + itemType: ContractItemType.UST, + totalUst: 1000, + }; + + it('cria item UST com payload atual', async () => { + repository.create.mockResolvedValue(mockItem() as never); + + const result = await service.create('client-1', baseDto); + + expect(result.itemType).toBe(ContractItemType.UST); + expect(repository.create).toHaveBeenCalledWith( + expect.objectContaining({ itemType: ContractItemType.UST, clientId: 'client-1' }), + ); + }); + + it('cria item SAAS_LICENSE válido (ustValue > 0, sem timeboxes)', async () => { + repository.create.mockResolvedValue( + mockItem({ itemType: ContractItemType.SAAS_LICENSE }) as never, + ); + + const dto: CreateContractItemDto = { + ...baseDto, + itemType: ContractItemType.SAAS_LICENSE, + ustValue: 200, + }; + + const result = await service.create('client-1', dto); + + expect(result.itemType).toBe(ContractItemType.SAAS_LICENSE); + expect(repository.create).toHaveBeenCalled(); + }); + + it('falha com 400 ao criar SAAS_LICENSE sem ustValue', async () => { + const dto: CreateContractItemDto = { + ...baseDto, + itemType: ContractItemType.SAAS_LICENSE, + }; + + await expect(service.create('client-1', dto)).rejects.toThrow( + /Itens do tipo Licença SaaS exigem preço unitário/, + ); + }); + + it('falha com 400 ao criar SAAS_LICENSE com timeboxDescoberta definido', async () => { + const dto: CreateContractItemDto = { + ...baseDto, + itemType: ContractItemType.SAAS_LICENSE, + ustValue: 200, + timeboxDescoberta: 5, + }; + + await expect(service.create('client-1', dto)).rejects.toThrow( + /Itens do tipo Licença SaaS não aceitam timeboxes/, + ); + }); + }); + + describe('update', () => { + it('falha ao alterar itemType após criação', async () => { + repository.findById.mockResolvedValue(mockItem() as never); + + await expect( + service.update('client-1', 'item-1', { itemType: ContractItemType.SAAS_LICENSE }), + ).rejects.toThrow(/Tipo do Item de Contrato não pode ser alterado/); + }); + + it('mantém itemType inalterado em update sem itemType', async () => { + repository.findById.mockResolvedValue(mockItem() as never); + repository.update.mockResolvedValue(mockItem({ name: 'Novo' }) as never); + + const result = await service.update('client-1', 'item-1', { name: 'Novo' }); + + expect(result.name).toBe('Novo'); + expect(repository.update).toHaveBeenCalledWith( + 'item-1', + expect.objectContaining({ name: 'Novo' }), + ); + }); + + it('aceita itemType igual ao persistido', async () => { + repository.findById.mockResolvedValue(mockItem() as never); + repository.update.mockResolvedValue(mockItem() as never); + + await expect( + service.update('client-1', 'item-1', { itemType: ContractItemType.UST }), + ).resolves.toBeDefined(); + }); + + it('falha ao remover ustValue de item SAAS_LICENSE persistido', async () => { + repository.findById.mockResolvedValue( + mockItem({ + itemType: ContractItemType.SAAS_LICENSE, + ustValue: new Prisma.Decimal(200), + }) as never, + ); + + await expect(service.update('client-1', 'item-1', { timeboxDesign: 3 })).rejects.toThrow( + BadRequestException, + ); + }); + }); + + describe('update — propagação de recálculo (NBC-006)', () => { + it('alterar ustValue dispara recalculateForContractItem em transação', async () => { + repository.findById.mockResolvedValue(mockItem() as never); + repository.update.mockResolvedValue(mockItem({ ustValue: new Prisma.Decimal(150) }) as never); + deliverablesService.recalculateForContractItem.mockResolvedValue({ + recalculatedCount: 3, + recalculatedWorkOrdersCount: 2, + }); + + const result = await service.update('client-1', 'item-1', { ustValue: 150 }, 'user-1'); + + expect(prisma.$transaction).toHaveBeenCalledTimes(1); + expect(deliverablesService.recalculateForContractItem).toHaveBeenCalledWith( + 'item-1', + 'user-1', + expect.anything(), + ); + expect(result.recalculatedDeliverablesCount).toBe(3); + }); + + it('alterar timeboxConstrucao dispara propagação', async () => { + repository.findById.mockResolvedValue(mockItem() as never); + repository.update.mockResolvedValue( + mockItem({ timeboxConstrucao: new Prisma.Decimal(80) }) as never, + ); + deliverablesService.recalculateForContractItem.mockResolvedValue({ + recalculatedCount: 2, + recalculatedWorkOrdersCount: 1, + }); + + const result = await service.update( + 'client-1', + 'item-1', + { timeboxConstrucao: 80 }, + 'user-1', + ); + + expect(deliverablesService.recalculateForContractItem).toHaveBeenCalled(); + expect(result.recalculatedDeliverablesCount).toBe(2); + }); + + it('não dispara propagação quando ustValue não muda (mesmo valor)', async () => { + repository.findById.mockResolvedValue(mockItem() as never); + repository.update.mockResolvedValue(mockItem() as never); + + const result = await service.update('client-1', 'item-1', { ustValue: 100 }, 'user-1'); + + expect(prisma.$transaction).not.toHaveBeenCalled(); + expect(deliverablesService.recalculateForContractItem).not.toHaveBeenCalled(); + expect(result.recalculatedDeliverablesCount).toBe(0); + expect(result.recalculatedWorkOrdersCount).toBe(0); + }); + + it('não dispara propagação ao alterar campos não-financeiros (name, description)', async () => { + repository.findById.mockResolvedValue(mockItem() as never); + repository.update.mockResolvedValue(mockItem({ name: 'Novo' }) as never); + + const result = await service.update( + 'client-1', + 'item-1', + { name: 'Novo', description: 'Nova descrição' }, + 'user-1', + ); + + expect(prisma.$transaction).not.toHaveBeenCalled(); + expect(deliverablesService.recalculateForContractItem).not.toHaveBeenCalled(); + expect(result.recalculatedDeliverablesCount).toBe(0); + }); + + it('expõe recalculatedDeliverablesCount + recalculatedWorkOrdersCount no payload', async () => { + repository.findById.mockResolvedValue(mockItem() as never); + repository.update.mockResolvedValue(mockItem({ ustValue: new Prisma.Decimal(150) }) as never); + deliverablesService.recalculateForContractItem.mockResolvedValue({ + recalculatedCount: 5, + recalculatedWorkOrdersCount: 4, + }); + + const result = await service.update('client-1', 'item-1', { ustValue: 150 }, 'user-1'); + + expect(result).toHaveProperty('recalculatedDeliverablesCount', 5); + expect(result).toHaveProperty('recalculatedWorkOrdersCount', 4); + }); + + it('alterar timebox de null para valor dispara propagação', async () => { + repository.findById.mockResolvedValue(mockItem({ timeboxDesign: null }) as never); + repository.update.mockResolvedValue( + mockItem({ timeboxDesign: new Prisma.Decimal(40) }) as never, + ); + deliverablesService.recalculateForContractItem.mockResolvedValue({ + recalculatedCount: 1, + recalculatedWorkOrdersCount: 0, + }); + + await service.update('client-1', 'item-1', { timeboxDesign: 40 }, 'user-1'); + + expect(deliverablesService.recalculateForContractItem).toHaveBeenCalled(); + }); + }); + + describe('findByClientId', () => { + it('repassa filtro itemType para o repository', async () => { + repository.findByClientId.mockResolvedValue({ data: [], total: 0 }); + + await service.findByClientId('client-1', { + page: 1, + limit: 20, + itemType: ContractItemType.SAAS_LICENSE, + }); + + expect(repository.findByClientId).toHaveBeenCalledWith( + expect.objectContaining({ itemType: ContractItemType.SAAS_LICENSE }), + ); + }); + + it('repassa itemType=UST', async () => { + repository.findByClientId.mockResolvedValue({ data: [], total: 0 }); + + await service.findByClientId('client-1', { + page: 1, + limit: 20, + itemType: ContractItemType.UST, + }); + + expect(repository.findByClientId).toHaveBeenCalledWith( + expect.objectContaining({ itemType: ContractItemType.UST }), + ); + }); + }); +}); diff --git a/src/modules/contract-items/contract-items.service.ts b/src/modules/contract-items/contract-items.service.ts new file mode 100644 index 0000000..bc2f093 --- /dev/null +++ b/src/modules/contract-items/contract-items.service.ts @@ -0,0 +1,173 @@ +import { + BadRequestException, + ConflictException, + Injectable, + NotFoundException, +} from '@nestjs/common'; +import { Prisma } from '@prisma/client'; +import { ContractItemsRepository } from './contract-items.repository'; +import { PrismaService } from '../../config/database/prisma.service'; +import { CreateContractItemDto } from './dto/create-contract-item.dto'; +import { UpdateContractItemDto } from './dto/update-contract-item.dto'; +import { ListContractItemsQueryDto } from './dto/list-contract-items-query.dto'; +import { validateContractItemByType } from './helpers/validate-contract-item-by-type'; +import { DeliverablesService } from '../deliverables/deliverables.service'; + +const FINANCIAL_FIELDS = [ + 'ustValue', + 'timeboxDescoberta', + 'timeboxDesign', + 'timeboxArquitetura', + 'timeboxConstrucao', + 'itemType', +] as const; + +type FinancialField = (typeof FINANCIAL_FIELDS)[number]; + +@Injectable() +export class ContractItemsService { + constructor( + private readonly repository: ContractItemsRepository, + private readonly prisma: PrismaService, + private readonly deliverablesService: DeliverablesService, + ) {} + + async findByClientId(clientId: string, query: ListContractItemsQueryDto) { + await this.validateClientExists(clientId); + const { page, limit, ...filters } = query; + const { data, total } = await this.repository.findByClientId({ + clientId, + ...filters, + page, + limit, + }); + return { data, total, page, limit }; + } + + async findActiveByClientId(clientId: string) { + await this.validateClientExists(clientId); + return this.repository.findActiveByClientId(clientId); + } + + async findOne(clientId: string, id: string) { + await this.validateClientExists(clientId); + const item = await this.repository.findById(id); + + if (!item || item.client.id !== clientId) { + throw new NotFoundException('Item de contrato não encontrado'); + } + + return item; + } + + async create(clientId: string, dto: CreateContractItemDto) { + await this.validateClientExists(clientId); + await this.validateUniqueCode(dto.code, clientId); + + validateContractItemByType(dto, dto.itemType); + + return this.repository.create({ + ...dto, + clientId, + }); + } + + async update(clientId: string, id: string, dto: UpdateContractItemDto, userId?: string) { + const item = await this.findOne(clientId, id); + + if (dto.itemType && dto.itemType !== item.itemType) { + throw new BadRequestException('Tipo do Item de Contrato não pode ser alterado após criação'); + } + + if (dto.code && dto.code !== item.code) { + await this.validateUniqueCode(dto.code, clientId); + } + + const toNumber = (value: unknown): number | null => (value == null ? null : Number(value)); + + const merged = { + ustValue: dto.ustValue ?? toNumber(item.ustValue), + timeboxDescoberta: dto.timeboxDescoberta ?? toNumber(item.timeboxDescoberta), + timeboxDesign: dto.timeboxDesign ?? toNumber(item.timeboxDesign), + timeboxArquitetura: dto.timeboxArquitetura ?? toNumber(item.timeboxArquitetura), + timeboxConstrucao: dto.timeboxConstrucao ?? toNumber(item.timeboxConstrucao), + }; + + validateContractItemByType(merged, item.itemType); + + const hasFinancialDiff = this.hasFinancialDiff(item, dto); + + if (!hasFinancialDiff) { + const updated = await this.repository.update(id, dto); + return { + ...updated, + recalculatedDeliverablesCount: 0, + recalculatedWorkOrdersCount: 0, + }; + } + + const result = await this.prisma.$transaction(async (tx) => { + const updated = await this.repository.update(id, dto, tx); + const recalc = await this.deliverablesService.recalculateForContractItem(id, userId, tx); + return { updated, recalc }; + }); + + return { + ...result.updated, + recalculatedDeliverablesCount: result.recalc.recalculatedCount, + recalculatedWorkOrdersCount: result.recalc.recalculatedWorkOrdersCount, + }; + } + + async toggleStatus(clientId: string, id: string) { + const item = await this.findOne(clientId, id); + return this.repository.update(id, { isActive: !item.isActive }); + } + + private hasFinancialDiff( + current: { + itemType: UpdateContractItemDto['itemType']; + ustValue: Prisma.Decimal | number | null; + timeboxDescoberta: Prisma.Decimal | number | null; + timeboxDesign: Prisma.Decimal | number | null; + timeboxArquitetura: Prisma.Decimal | number | null; + timeboxConstrucao: Prisma.Decimal | number | null; + }, + dto: UpdateContractItemDto, + ): boolean { + const decimalEquals = (a: Prisma.Decimal | number | null, b: number | null): boolean => { + if (a == null && b == null) return true; + if (a == null || b == null) return false; + return new Prisma.Decimal(a).equals(new Prisma.Decimal(b)); + }; + + return FINANCIAL_FIELDS.some((field: FinancialField) => { + if (dto[field] === undefined) return false; + if (field === 'itemType') { + return dto.itemType !== current.itemType; + } + return !decimalEquals(current[field], dto[field] as number | null); + }); + } + + private async validateClientExists(clientId: string) { + const client = await this.prisma.client.findUnique({ + where: { id: clientId }, + select: { id: true }, + }); + + if (!client) { + throw new NotFoundException('Cliente não encontrado'); + } + } + + private async validateUniqueCode(code: string, clientId: string, excludeId?: string) { + const existing = await this.repository.findByCodeAndClientId(code, clientId); + + if (existing && existing.id !== excludeId) { + throw new ConflictException( + 'Já existe um item de contrato com este código para este cliente', + ); + } + } +} diff --git a/src/modules/contract-items/dto/create-contract-item.dto.ts b/src/modules/contract-items/dto/create-contract-item.dto.ts new file mode 100644 index 0000000..bfea9b9 --- /dev/null +++ b/src/modules/contract-items/dto/create-contract-item.dto.ts @@ -0,0 +1,55 @@ +import { IsEnum, IsNotEmpty, IsNumber, IsOptional, IsPositive, IsString } from 'class-validator'; +import { Type } from 'class-transformer'; +import { ContractItemType } from '@prisma/client'; + +export class CreateContractItemDto { + @IsString() + @IsNotEmpty() + code!: string; + + @IsString() + @IsNotEmpty() + name!: string; + + @IsOptional() + @IsString() + description?: string; + + @IsEnum(ContractItemType, { message: 'Tipo do item de contrato inválido' }) + itemType!: ContractItemType; + + @Type(() => Number) + @IsNumber() + @IsPositive() + totalUst!: number; + + @IsOptional() + @Type(() => Number) + @IsNumber() + @IsPositive() + ustValue?: number; + + @IsOptional() + @Type(() => Number) + @IsNumber() + @IsPositive() + timeboxDescoberta?: number; + + @IsOptional() + @Type(() => Number) + @IsNumber() + @IsPositive() + timeboxDesign?: number; + + @IsOptional() + @Type(() => Number) + @IsNumber() + @IsPositive() + timeboxArquitetura?: number; + + @IsOptional() + @Type(() => Number) + @IsNumber() + @IsPositive() + timeboxConstrucao?: number; +} diff --git a/src/modules/contract-items/dto/list-contract-items-query.dto.ts b/src/modules/contract-items/dto/list-contract-items-query.dto.ts new file mode 100644 index 0000000..b312e23 --- /dev/null +++ b/src/modules/contract-items/dto/list-contract-items-query.dto.ts @@ -0,0 +1,30 @@ +import { IsEnum, IsInt, IsOptional, IsString, Max, Min } from 'class-validator'; +import { Transform } from 'class-transformer'; +import { ContractItemType } from '@prisma/client'; + +export class ListContractItemsQueryDto { + @IsOptional() + @IsString() + search?: string; + + @IsOptional() + @IsEnum(ContractItemType, { message: 'Tipo do item de contrato inválido' }) + itemType?: ContractItemType; + + @IsOptional() + @Transform(({ value }) => value === 'true') + isActive?: boolean; + + @IsOptional() + @Transform(({ value }: { value: unknown }) => parseInt(String(value), 10)) + @IsInt() + @Min(1) + page: number = 1; + + @IsOptional() + @Transform(({ value }: { value: unknown }) => parseInt(String(value), 10)) + @IsInt() + @Min(1) + @Max(100) + limit: number = 20; +} diff --git a/src/modules/contract-items/dto/update-contract-item.dto.ts b/src/modules/contract-items/dto/update-contract-item.dto.ts new file mode 100644 index 0000000..baf1586 --- /dev/null +++ b/src/modules/contract-items/dto/update-contract-item.dto.ts @@ -0,0 +1,61 @@ +import { IsBoolean, IsEnum, IsNumber, IsOptional, IsPositive, IsString } from 'class-validator'; +import { Type } from 'class-transformer'; +import { ContractItemType } from '@prisma/client'; + +export class UpdateContractItemDto { + @IsOptional() + @IsString() + code?: string; + + @IsOptional() + @IsString() + name?: string; + + @IsOptional() + @IsString() + description?: string; + + @IsOptional() + @IsEnum(ContractItemType, { message: 'Tipo do item de contrato inválido' }) + itemType?: ContractItemType; + + @IsOptional() + @Type(() => Number) + @IsNumber() + @IsPositive() + totalUst?: number; + + @IsOptional() + @Type(() => Number) + @IsNumber() + @IsPositive() + ustValue?: number; + + @IsOptional() + @Type(() => Number) + @IsNumber() + @IsPositive() + timeboxDescoberta?: number; + + @IsOptional() + @Type(() => Number) + @IsNumber() + @IsPositive() + timeboxDesign?: number; + + @IsOptional() + @Type(() => Number) + @IsNumber() + @IsPositive() + timeboxArquitetura?: number; + + @IsOptional() + @Type(() => Number) + @IsNumber() + @IsPositive() + timeboxConstrucao?: number; + + @IsOptional() + @IsBoolean() + isActive?: boolean; +} diff --git a/src/modules/contract-items/helpers/validate-contract-item-by-type.ts b/src/modules/contract-items/helpers/validate-contract-item-by-type.ts new file mode 100644 index 0000000..ca1434d --- /dev/null +++ b/src/modules/contract-items/helpers/validate-contract-item-by-type.ts @@ -0,0 +1,37 @@ +import { BadRequestException } from '@nestjs/common'; +import { ContractItemType } from '@prisma/client'; + +type ContractItemPayload = { + ustValue?: number | null; + timeboxDescoberta?: number | null; + timeboxDesign?: number | null; + timeboxArquitetura?: number | null; + timeboxConstrucao?: number | null; +}; + +export function validateContractItemByType( + payload: ContractItemPayload, + itemType: ContractItemType, +): void { + if (itemType === ContractItemType.UST) { + return; + } + + if (itemType === ContractItemType.SAAS_LICENSE) { + if (payload.ustValue === undefined || payload.ustValue === null || payload.ustValue <= 0) { + throw new BadRequestException( + 'Itens do tipo Licença SaaS exigem preço unitário (ustValue) maior que zero', + ); + } + + const hasTimebox = + payload.timeboxDescoberta != null || + payload.timeboxDesign != null || + payload.timeboxArquitetura != null || + payload.timeboxConstrucao != null; + + if (hasTimebox) { + throw new BadRequestException('Itens do tipo Licença SaaS não aceitam timeboxes'); + } + } +} diff --git a/src/modules/contracts/contracts.controller.ts b/src/modules/contracts/contracts.controller.ts new file mode 100644 index 0000000..042d50d --- /dev/null +++ b/src/modules/contracts/contracts.controller.ts @@ -0,0 +1,48 @@ +import { Body, Controller, Get, Param, Patch, Post, Query, UseGuards } from '@nestjs/common'; +import { JwtAuthGuard } from '../../config/auth/jwt-auth.guard'; +import { ReadOnlyClientGuard } from '../../common/guards/read-only-client.guard'; +import { RolesGuard } from '../../common/guards/roles.guard'; +import { Roles } from '../../common/decorators/roles.decorator'; +import { Role } from '../../common/enums/role.enum'; +import { CurrentUser } from '../../common/decorators/user.decorator'; +import type { CurrentUserPayload } from '../../common/decorators/user.decorator'; +import { ContractsService } from './contracts.service'; +import { CreateContractDto } from './dto/create-contract.dto'; +import { UpdateContractDto } from './dto/update-contract.dto'; +import { ListContractsQueryDto } from './dto/list-contracts-query.dto'; + +@Controller('contracts') +export class ContractsController { + constructor(private readonly contractsService: ContractsService) {} + + @Get() + @UseGuards(JwtAuthGuard, ReadOnlyClientGuard, RolesGuard) + @Roles(Role.ADMIN, Role.OPERATOR, Role.CLIENT) + async findAll(@Query() query: ListContractsQueryDto, @CurrentUser() user: CurrentUserPayload) { + if (user.role === Role.CLIENT && user.clientId) { + return this.contractsService.findByClientId(user.clientId, query); + } + return this.contractsService.findAll(query); + } + + @Post() + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles(Role.ADMIN, Role.OPERATOR) + async create(@Body() dto: CreateContractDto) { + return this.contractsService.create(dto); + } + + @Get(':id') + @UseGuards(JwtAuthGuard, ReadOnlyClientGuard, RolesGuard) + @Roles(Role.ADMIN, Role.OPERATOR, Role.CLIENT) + async findOne(@Param('id') id: string, @CurrentUser() user: CurrentUserPayload) { + return this.contractsService.findOne(id, user); + } + + @Patch(':id') + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles(Role.ADMIN, Role.OPERATOR) + async update(@Param('id') id: string, @Body() dto: UpdateContractDto) { + return this.contractsService.update(id, dto); + } +} diff --git a/src/modules/contracts/contracts.module.ts b/src/modules/contracts/contracts.module.ts new file mode 100644 index 0000000..692c68f --- /dev/null +++ b/src/modules/contracts/contracts.module.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common'; +import { ContractsController } from './contracts.controller'; +import { ContractsService } from './contracts.service'; +import { ContractsRepository } from './contracts.repository'; + +@Module({ + controllers: [ContractsController], + providers: [ContractsService, ContractsRepository], + exports: [ContractsService], +}) +export class ContractsModule {} diff --git a/src/modules/contracts/contracts.repository.ts b/src/modules/contracts/contracts.repository.ts new file mode 100644 index 0000000..87f7280 --- /dev/null +++ b/src/modules/contracts/contracts.repository.ts @@ -0,0 +1,118 @@ +import { Injectable } from '@nestjs/common'; +import { Prisma } from '@prisma/client'; +import { PrismaService } from '../../config/database/prisma.service'; + +const contractSelect = { + id: true, + name: true, + code: true, + description: true, + startDate: true, + endDate: true, + ustValue: true, + isActive: true, + createdAt: true, + updatedAt: true, + client: { select: { id: true, name: true } }, +} as const; + +@Injectable() +export class ContractsRepository { + constructor(private readonly prisma: PrismaService) {} + + async findAll(params: { name?: string; isActive?: boolean; page: number; limit: number }) { + const where: Prisma.ContractWhereInput = {}; + + if (params.name) where.name = { contains: params.name, mode: 'insensitive' }; + if (params.isActive !== undefined) where.isActive = params.isActive; + + const [data, total] = await Promise.all([ + this.prisma.contract.findMany({ + where, + select: contractSelect, + orderBy: { name: 'asc' }, + skip: (params.page - 1) * params.limit, + take: params.limit, + }), + this.prisma.contract.count({ where }), + ]); + + return { data, total }; + } + + async findByClientId(params: { + clientId: string; + name?: string; + isActive?: boolean; + page: number; + limit: number; + }) { + const where: Prisma.ContractWhereInput = { clientId: params.clientId }; + + if (params.name) where.name = { contains: params.name, mode: 'insensitive' }; + if (params.isActive !== undefined) where.isActive = params.isActive; + + const [data, total] = await Promise.all([ + this.prisma.contract.findMany({ + where, + select: contractSelect, + orderBy: { name: 'asc' }, + skip: (params.page - 1) * params.limit, + take: params.limit, + }), + this.prisma.contract.count({ where }), + ]); + + return { data, total }; + } + + async findActiveByClientId(clientId: string) { + return this.prisma.contract.findMany({ + where: { clientId, isActive: true }, + select: { id: true, name: true }, + orderBy: { name: 'asc' }, + }); + } + + async findById(id: string) { + return this.prisma.contract.findUnique({ + where: { id }, + select: contractSelect, + }); + } + + async create(data: { + name: string; + clientId: string; + code?: string; + description?: string; + startDate?: string; + endDate?: string; + ustValue?: number; + }) { + return this.prisma.contract.create({ + data, + select: contractSelect, + }); + } + + async update( + id: string, + data: { + name?: string; + clientId?: string; + code?: string; + description?: string; + startDate?: string; + endDate?: string; + ustValue?: number; + isActive?: boolean; + }, + ) { + return this.prisma.contract.update({ + where: { id }, + data, + select: contractSelect, + }); + } +} diff --git a/src/modules/contracts/contracts.service.ts b/src/modules/contracts/contracts.service.ts new file mode 100644 index 0000000..b4f9695 --- /dev/null +++ b/src/modules/contracts/contracts.service.ts @@ -0,0 +1,90 @@ +import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; +import { Role } from '../../common/enums/role.enum'; +import type { CurrentUserPayload } from '../../common/decorators/user.decorator'; +import { ContractsRepository } from './contracts.repository'; +import { PrismaService } from '../../config/database/prisma.service'; +import { CreateContractDto } from './dto/create-contract.dto'; +import { UpdateContractDto } from './dto/update-contract.dto'; +import { ListContractsQueryDto } from './dto/list-contracts-query.dto'; + +@Injectable() +export class ContractsService { + constructor( + private readonly repository: ContractsRepository, + private readonly prisma: PrismaService, + ) {} + + async findAll(query: ListContractsQueryDto) { + const { page, limit, ...filters } = query; + const { data, total } = await this.repository.findAll({ + ...filters, + page, + limit, + }); + return { data, total, page, limit }; + } + + async findByClientId(clientId: string, query: ListContractsQueryDto) { + const { page, limit, ...filters } = query; + const { data, total } = await this.repository.findByClientId({ + clientId, + ...filters, + page, + limit, + }); + return { data, total, page, limit }; + } + + async findActiveByClientId(clientId: string) { + return this.repository.findActiveByClientId(clientId); + } + + async findOne(id: string, user?: CurrentUserPayload) { + const contract = await this.repository.findById(id); + + if (!contract) { + throw new NotFoundException('Contrato não encontrado'); + } + + if (user?.role === Role.CLIENT && user.clientId && contract.client.id !== user.clientId) { + throw new NotFoundException('Contrato não encontrado'); + } + + return contract; + } + + async create(dto: CreateContractDto) { + const client = await this.prisma.client.findUnique({ + where: { id: dto.clientId }, + select: { id: true, isActive: true }, + }); + + if (!client || !client.isActive) { + throw new BadRequestException('Cliente não encontrado ou inativo'); + } + + this.validateDates(dto.startDate, dto.endDate); + + return this.repository.create({ + ...dto, + startDate: dto.startDate ? new Date(dto.startDate).toISOString() : undefined, + endDate: dto.endDate ? new Date(dto.endDate).toISOString() : undefined, + }); + } + + async update(id: string, dto: UpdateContractDto) { + await this.findOne(id); + this.validateDates(dto.startDate, dto.endDate); + return this.repository.update(id, { + ...dto, + startDate: dto.startDate ? new Date(dto.startDate).toISOString() : undefined, + endDate: dto.endDate ? new Date(dto.endDate).toISOString() : undefined, + }); + } + + private validateDates(startDate?: string, endDate?: string): void { + if (startDate && endDate && new Date(endDate) < new Date(startDate)) { + throw new BadRequestException('Data de término deve ser igual ou posterior à data de início'); + } + } +} diff --git a/src/modules/contracts/dto/.gitkeep b/src/modules/contracts/dto/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/src/modules/contracts/dto/create-contract.dto.ts b/src/modules/contracts/dto/create-contract.dto.ts new file mode 100644 index 0000000..073bc65 --- /dev/null +++ b/src/modules/contracts/dto/create-contract.dto.ts @@ -0,0 +1,43 @@ +import { + IsDateString, + IsNotEmpty, + IsNumber, + IsOptional, + IsPositive, + IsString, + IsUUID, +} from 'class-validator'; +import { Type } from 'class-transformer'; + +export class CreateContractDto { + @IsString() + @IsNotEmpty() + name!: string; + + @IsString() + @IsNotEmpty() + @IsUUID() + clientId!: string; + + @IsOptional() + @IsString() + code?: string; + + @IsOptional() + @IsString() + description?: string; + + @IsOptional() + @IsDateString() + startDate?: string; + + @IsOptional() + @IsDateString() + endDate?: string; + + @IsOptional() + @Type(() => Number) + @IsNumber() + @IsPositive() + ustValue?: number; +} diff --git a/src/modules/contracts/dto/list-contracts-query.dto.ts b/src/modules/contracts/dto/list-contracts-query.dto.ts new file mode 100644 index 0000000..7ce0723 --- /dev/null +++ b/src/modules/contracts/dto/list-contracts-query.dto.ts @@ -0,0 +1,25 @@ +import { IsInt, IsOptional, IsString, Max, Min } from 'class-validator'; +import { Transform } from 'class-transformer'; + +export class ListContractsQueryDto { + @IsOptional() + @IsString() + name?: string; + + @IsOptional() + @Transform(({ value }) => value === 'true') + isActive?: boolean; + + @IsOptional() + @Transform(({ value }: { value: unknown }) => parseInt(String(value), 10)) + @IsInt() + @Min(1) + page: number = 1; + + @IsOptional() + @Transform(({ value }: { value: unknown }) => parseInt(String(value), 10)) + @IsInt() + @Min(1) + @Max(100) + limit: number = 20; +} diff --git a/src/modules/contracts/dto/update-contract.dto.ts b/src/modules/contracts/dto/update-contract.dto.ts new file mode 100644 index 0000000..5f882e6 --- /dev/null +++ b/src/modules/contracts/dto/update-contract.dto.ts @@ -0,0 +1,47 @@ +import { + IsBoolean, + IsDateString, + IsNumber, + IsOptional, + IsPositive, + IsString, + IsUUID, +} from 'class-validator'; +import { Type } from 'class-transformer'; + +export class UpdateContractDto { + @IsOptional() + @IsString() + name?: string; + + @IsOptional() + @IsString() + @IsUUID() + clientId?: string; + + @IsOptional() + @IsString() + code?: string; + + @IsOptional() + @IsString() + description?: string; + + @IsOptional() + @IsDateString() + startDate?: string; + + @IsOptional() + @IsDateString() + endDate?: string; + + @IsOptional() + @IsBoolean() + isActive?: boolean; + + @IsOptional() + @Type(() => Number) + @IsNumber() + @IsPositive() + ustValue?: number; +} diff --git a/src/modules/dashboard/dashboard.controller.ts b/src/modules/dashboard/dashboard.controller.ts new file mode 100644 index 0000000..870c5f9 --- /dev/null +++ b/src/modules/dashboard/dashboard.controller.ts @@ -0,0 +1,50 @@ +import { Controller, Get, Query, UseGuards } from '@nestjs/common'; +import { JwtAuthGuard } from '../../config/auth/jwt-auth.guard'; +import { ReadOnlyClientGuard } from '../../common/guards/read-only-client.guard'; +import { RolesGuard } from '../../common/guards/roles.guard'; +import { Roles } from '../../common/decorators/roles.decorator'; +import { Role } from '../../common/enums/role.enum'; +import { CurrentUser } from '../../common/decorators/user.decorator'; +import type { CurrentUserPayload } from '../../common/decorators/user.decorator'; +import { DashboardService } from './dashboard.service'; +import { DashboardFilterDto } from './dto/dashboard-filter.dto'; + +@Controller('dashboard') +@UseGuards(JwtAuthGuard, ReadOnlyClientGuard, RolesGuard) +@Roles(Role.ADMIN, Role.OPERATOR, Role.CLIENT) +export class DashboardController { + constructor(private readonly dashboardService: DashboardService) {} + + private buildFilter(filter: DashboardFilterDto, user: CurrentUserPayload) { + return { + ...filter, + ...(user.role === Role.CLIENT && user.clientId ? { clientId: user.clientId } : {}), + }; + } + + @Get('summary') + async getSummary(@Query() filter: DashboardFilterDto, @CurrentUser() user: CurrentUserPayload) { + return this.dashboardService.getSummary(this.buildFilter(filter, user)); + } + + @Get('recent-deliverables') + async getRecentDeliverables( + @Query() filter: DashboardFilterDto, + @CurrentUser() user: CurrentUserPayload, + ) { + return this.dashboardService.getRecentDeliverables(this.buildFilter(filter, user)); + } + + @Get('recent-activity') + async getRecentActivity( + @Query() filter: DashboardFilterDto, + @CurrentUser() user: CurrentUserPayload, + ) { + return this.dashboardService.getRecentActivity(this.buildFilter(filter, user)); + } + + @Get('billing') + async getBilling(@Query() filter: DashboardFilterDto, @CurrentUser() user: CurrentUserPayload) { + return this.dashboardService.getBillingSummary(this.buildFilter(filter, user)); + } +} diff --git a/src/modules/dashboard/dashboard.module.ts b/src/modules/dashboard/dashboard.module.ts new file mode 100644 index 0000000..a7c2158 --- /dev/null +++ b/src/modules/dashboard/dashboard.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { DashboardController } from './dashboard.controller'; +import { DashboardService } from './dashboard.service'; +import { DashboardRepository } from './dashboard.repository'; + +@Module({ + controllers: [DashboardController], + providers: [DashboardService, DashboardRepository], +}) +export class DashboardModule {} diff --git a/src/modules/dashboard/dashboard.repository.ts b/src/modules/dashboard/dashboard.repository.ts new file mode 100644 index 0000000..88a2fdc --- /dev/null +++ b/src/modules/dashboard/dashboard.repository.ts @@ -0,0 +1,138 @@ +import { Injectable } from '@nestjs/common'; +import { Prisma } from '@prisma/client'; +import { PrismaService } from '../../config/database/prisma.service'; + +interface DateFilter { + startDate?: string; + endDate?: string; + clientId?: string; +} + +function buildDateCondition(filter: DateFilter): Prisma.DateTimeFilter | undefined { + if (!filter.startDate && !filter.endDate) return undefined; + const condition: Prisma.DateTimeFilter = {}; + if (filter.startDate) condition.gte = new Date(filter.startDate); + if (filter.endDate) condition.lte = new Date(filter.endDate); + return condition; +} + +@Injectable() +export class DashboardRepository { + constructor(private readonly prisma: PrismaService) {} + + async countByStatus(filter: DateFilter = {}): Promise<{ status: string; _count: number }[]> { + const createdAt = buildDateCondition(filter); + const result = await this.prisma.deliverable.groupBy({ + by: ['status'], + where: { + isActive: true, + ...(createdAt && { createdAt }), + ...(filter.clientId && { clientId: filter.clientId }), + }, + _count: { _all: true }, + }); + + return result.map((item) => ({ + status: item.status, + _count: item._count._all, + })); + } + + async countRecentlyCreated(filter: DateFilter = {}): Promise { + const sevenDaysAgo = new Date(); + sevenDaysAgo.setDate(sevenDaysAgo.getDate() - 7); + + const createdAt = buildDateCondition(filter); + + return this.prisma.deliverable.count({ + where: { + isActive: true, + createdAt: createdAt ?? { gte: sevenDaysAgo }, + ...(filter.clientId && { clientId: filter.clientId }), + }, + }); + } + + async findRecentDeliverables(filter: DateFilter = {}) { + const createdAt = buildDateCondition(filter); + return this.prisma.deliverable.findMany({ + where: { + isActive: true, + ...(createdAt && { createdAt }), + ...(filter.clientId && { clientId: filter.clientId }), + }, + orderBy: { updatedAt: 'desc' }, + take: 10, + select: { + id: true, + code: true, + title: true, + status: true, + type: true, + updatedAt: true, + project: { select: { name: true } }, + client: { select: { name: true } }, + workOrder: { select: { id: true, code: true, name: true } }, + }, + }); + } + + async sumTotalValueByStatuses(statuses: string[], filter: DateFilter = {}): Promise { + const createdAt = buildDateCondition(filter); + const result = await this.prisma.deliverable.aggregate({ + _sum: { totalValue: true }, + where: { + status: { in: statuses as never[] }, + isActive: true, + totalValue: { not: null }, + ...(createdAt && { createdAt }), + ...(filter.clientId && { clientId: filter.clientId }), + }, + }); + return result._sum.totalValue?.toNumber() ?? 0; + } + + async findTopValueDeliverables(limit: number, filter: DateFilter = {}) { + const createdAt = buildDateCondition(filter); + return this.prisma.deliverable.findMany({ + where: { + isActive: true, + totalValue: { not: null }, + status: { notIn: ['PAGA', 'ENCERRADA', 'CANCELADA'] as never[] }, + ...(createdAt && { createdAt }), + ...(filter.clientId && { clientId: filter.clientId }), + }, + orderBy: { totalValue: 'desc' }, + take: limit, + select: { + id: true, + code: true, + title: true, + status: true, + totalValue: true, + client: { select: { name: true } }, + }, + }); + } + + async findRecentActivity(filter: DateFilter = {}) { + const createdAt = buildDateCondition(filter); + return this.prisma.timelineEvent.findMany({ + where: { + ...(createdAt && { createdAt }), + ...(filter.clientId && { deliverable: { clientId: filter.clientId } }), + }, + orderBy: { createdAt: 'desc' }, + take: 5, + select: { + id: true, + type: true, + title: true, + description: true, + createdAt: true, + createdBy: true, + deliverable: { select: { id: true, code: true, title: true } }, + }, + }); + } +} diff --git a/src/modules/dashboard/dashboard.service.spec.ts b/src/modules/dashboard/dashboard.service.spec.ts new file mode 100644 index 0000000..c002c49 --- /dev/null +++ b/src/modules/dashboard/dashboard.service.spec.ts @@ -0,0 +1,157 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { DashboardService } from './dashboard.service'; +import { DashboardRepository } from './dashboard.repository'; + +describe('DashboardService', () => { + let service: DashboardService; + let repository: jest.Mocked; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + DashboardService, + { + provide: DashboardRepository, + useValue: { + countByStatus: jest.fn(), + countRecentlyCreated: jest.fn(), + findRecentDeliverables: jest.fn(), + findRecentActivity: jest.fn(), + }, + }, + ], + }).compile(); + + service = module.get(DashboardService); + repository = module.get(DashboardRepository); + }); + + describe('getSummary', () => { + it('deve retornar totalDeliverables correto somando todas as contagens por status', async () => { + repository.countByStatus.mockResolvedValue([ + { status: 'RASCUNHO', _count: 3 }, + { status: 'EM_EXECUCAO', _count: 5 }, + { status: 'AGUARDANDO_VALIDACAO', _count: 2 }, + ]); + repository.countRecentlyCreated.mockResolvedValue(4); + + const result = await service.getSummary(); + + expect(result.totalDeliverables).toBe(10); + }); + + it('deve retornar countByStatus com contagem para cada status', async () => { + repository.countByStatus.mockResolvedValue([ + { status: 'RASCUNHO', _count: 3 }, + { status: 'EMITIDA', _count: 7 }, + { status: 'EM_EXECUCAO', _count: 5 }, + ]); + repository.countRecentlyCreated.mockResolvedValue(2); + + const result = await service.getSummary(); + + expect(result.countByStatus).toEqual({ + RASCUNHO: 3, + EMITIDA: 7, + EM_EXECUCAO: 5, + }); + }); + + it('deve retornar inProgress contando apenas EM_EXECUCAO', async () => { + repository.countByStatus.mockResolvedValue([ + { status: 'RASCUNHO', _count: 3 }, + { status: 'EM_EXECUCAO', _count: 8 }, + { status: 'AGUARDANDO_VALIDACAO', _count: 2 }, + ]); + repository.countRecentlyCreated.mockResolvedValue(1); + + const result = await service.getSummary(); + + expect(result.inProgress).toBe(8); + }); + + it('deve retornar awaitingValidation contando apenas AGUARDANDO_VALIDACAO', async () => { + repository.countByStatus.mockResolvedValue([ + { status: 'EM_EXECUCAO', _count: 5 }, + { status: 'AGUARDANDO_VALIDACAO', _count: 4 }, + ]); + repository.countRecentlyCreated.mockResolvedValue(1); + + const result = await service.getSummary(); + + expect(result.awaitingValidation).toBe(4); + }); + + it('deve retornar recentlyCreated do repository', async () => { + repository.countByStatus.mockResolvedValue([{ status: 'RASCUNHO', _count: 10 }]); + repository.countRecentlyCreated.mockResolvedValue(6); + + const result = await service.getSummary(); + + expect(result.recentlyCreated).toBe(6); + }); + + it('deve retornar 0 para inProgress e awaitingValidation quando status não existe', async () => { + repository.countByStatus.mockResolvedValue([{ status: 'RASCUNHO', _count: 5 }]); + repository.countRecentlyCreated.mockResolvedValue(0); + + const result = await service.getSummary(); + + expect(result.inProgress).toBe(0); + expect(result.awaitingValidation).toBe(0); + }); + }); + + describe('getRecentDeliverables', () => { + it('deve retornar entregáveis recentes do repository', async () => { + const mockDeliverables = [ + { + id: 'ent-1', + code: 'ENT-001', + title: 'Entregável 1', + status: 'EM_EXECUCAO', + updatedAt: new Date('2026-04-04'), + project: { name: 'Projeto A' }, + client: { name: 'Cliente X' }, + }, + { + id: 'ent-2', + code: 'ENT-002', + title: 'Entregável 2', + status: 'RASCUNHO', + updatedAt: new Date('2026-04-03'), + project: { name: 'Projeto B' }, + client: { name: 'Cliente Y' }, + }, + ]; + repository.findRecentDeliverables.mockResolvedValue(mockDeliverables); + + const result = await service.getRecentDeliverables(); + + expect(result).toEqual(mockDeliverables); + expect(repository.findRecentDeliverables).toHaveBeenCalled(); + }); + }); + + describe('getRecentActivity', () => { + it('deve retornar atividades recentes do repository', async () => { + const mockActivity = [ + { + id: 'evt-1', + type: 'CRIACAO', + title: 'Entregável criado', + description: 'ENT-001 criado', + createdAt: new Date('2026-04-04'), + createdBy: 'user-1', + deliverable: { id: 'ent-1', code: 'ENT-001', title: 'Entregável 1' }, + }, + ]; + repository.findRecentActivity.mockResolvedValue(mockActivity); + + const result = await service.getRecentActivity(); + + expect(result).toEqual(mockActivity); + expect(repository.findRecentActivity).toHaveBeenCalled(); + }); + }); +}); diff --git a/src/modules/dashboard/dashboard.service.ts b/src/modules/dashboard/dashboard.service.ts new file mode 100644 index 0000000..64756a0 --- /dev/null +++ b/src/modules/dashboard/dashboard.service.ts @@ -0,0 +1,76 @@ +import { Injectable } from '@nestjs/common'; +import { DashboardRepository } from './dashboard.repository'; +import { DashboardFilterDto } from './dto/dashboard-filter.dto'; + +export interface DashboardSummary { + totalDeliverables: number; + countByStatus: Record; + inProgress: number; + awaitingValidation: number; + recentlyCreated: number; +} + +interface DashboardFilter extends DashboardFilterDto { + clientId?: string; +} + +@Injectable() +export class DashboardService { + constructor(private readonly repository: DashboardRepository) {} + + async getSummary(filter: DashboardFilter = {}): Promise { + const [statusCounts, recentlyCreated] = await Promise.all([ + this.repository.countByStatus(filter), + this.repository.countRecentlyCreated(filter), + ]); + + const countByStatus: Record = {}; + let totalDeliverables = 0; + + for (const item of statusCounts) { + countByStatus[item.status] = item._count; + totalDeliverables += item._count; + } + + return { + totalDeliverables, + countByStatus, + inProgress: countByStatus['EM_EXECUCAO'] ?? 0, + awaitingValidation: countByStatus['AGUARDANDO_VALIDACAO'] ?? 0, + recentlyCreated, + }; + } + + async getRecentDeliverables(filter: DashboardFilter = {}) { + return this.repository.findRecentDeliverables(filter); + } + + async getRecentActivity(filter: DashboardFilter = {}) { + return this.repository.findRecentActivity(filter); + } + + async getBillingSummary(filter: DashboardFilter = {}) { + const [totalBilled, awaitingPayment, inProgress, approved, topValueDeliverables] = + await Promise.all([ + this.repository.sumTotalValueByStatuses(['PAGA'], filter), + this.repository.sumTotalValueByStatuses(['AGUARDANDO_PAGAMENTO'], filter), + this.repository.sumTotalValueByStatuses( + ['EM_EXECUCAO', 'AGUARDANDO_VALIDACAO', 'EM_REVISAO'], + filter, + ), + this.repository.sumTotalValueByStatuses(['APROVADA'], filter), + this.repository.findTopValueDeliverables(5, filter), + ]); + + return { + totalBilled, + awaitingPayment, + inProgress, + approved, + topValueDeliverables: topValueDeliverables.map((d) => ({ + ...d, + totalValue: d.totalValue?.toNumber() ?? 0, + })), + }; + } +} diff --git a/src/modules/dashboard/dto/.gitkeep b/src/modules/dashboard/dto/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/src/modules/dashboard/dto/dashboard-filter.dto.ts b/src/modules/dashboard/dto/dashboard-filter.dto.ts new file mode 100644 index 0000000..836e1dc --- /dev/null +++ b/src/modules/dashboard/dto/dashboard-filter.dto.ts @@ -0,0 +1,11 @@ +import { IsDateString, IsOptional } from 'class-validator'; + +export class DashboardFilterDto { + @IsOptional() + @IsDateString() + startDate?: string; + + @IsOptional() + @IsDateString() + endDate?: string; +} diff --git a/src/modules/deliverable-backlog/deliverable-backlog.controller.ts b/src/modules/deliverable-backlog/deliverable-backlog.controller.ts new file mode 100644 index 0000000..66ff0e0 --- /dev/null +++ b/src/modules/deliverable-backlog/deliverable-backlog.controller.ts @@ -0,0 +1,78 @@ +import { Body, Controller, Delete, Get, Param, Patch, Post, UseGuards } from '@nestjs/common'; +import { JwtAuthGuard } from '../../config/auth/jwt-auth.guard'; +import { ReadOnlyClientGuard } from '../../common/guards/read-only-client.guard'; +import { RolesGuard } from '../../common/guards/roles.guard'; +import { Roles } from '../../common/decorators/roles.decorator'; +import { Role } from '../../common/enums/role.enum'; +import { CurrentUser } from '../../common/decorators/user.decorator'; +import type { CurrentUserPayload } from '../../common/decorators/user.decorator'; +import { DeliverablesService } from '../deliverables/deliverables.service'; +import { DeliverableBacklogService } from './deliverable-backlog.service'; +import { CreateBacklogItemDto } from './dto/create-backlog-item.dto'; +import { UpdateBacklogItemDto } from './dto/update-backlog-item.dto'; +import { ReorderBacklogDto } from './dto/reorder-backlog.dto'; +import { ChangeBacklogStatusDto } from './dto/change-backlog-status.dto'; + +@Controller('deliverables/:deliverableId/backlog-items') +@UseGuards(JwtAuthGuard, ReadOnlyClientGuard, RolesGuard) +@Roles(Role.ADMIN, Role.OPERATOR, Role.CLIENT) +export class DeliverableBacklogController { + constructor( + private readonly backlogService: DeliverableBacklogService, + private readonly deliverablesService: DeliverablesService, + ) {} + + @Post() + async create( + @Param('deliverableId') deliverableId: string, + @Body() dto: CreateBacklogItemDto, + @CurrentUser() user: CurrentUserPayload, + ) { + return this.backlogService.create(deliverableId, dto, user.id); + } + + @Get() + async findAll( + @Param('deliverableId') deliverableId: string, + @CurrentUser() user: CurrentUserPayload, + ) { + if (user.role === Role.CLIENT && user.clientId) { + await this.deliverablesService.validateDeliverableOwnership(deliverableId, user.clientId); + } + return this.backlogService.findAll(deliverableId); + } + + @Patch('reorder') + async reorder(@Param('deliverableId') deliverableId: string, @Body() dto: ReorderBacklogDto) { + return this.backlogService.reorder(deliverableId, dto.items); + } + + @Patch(':id/status') + async changeStatus( + @Param('deliverableId') deliverableId: string, + @Param('id') id: string, + @Body() dto: ChangeBacklogStatusDto, + @CurrentUser() user: CurrentUserPayload, + ) { + return this.backlogService.changeStatus(deliverableId, id, dto, user.id); + } + + @Patch(':id') + async update( + @Param('deliverableId') deliverableId: string, + @Param('id') id: string, + @Body() dto: UpdateBacklogItemDto, + @CurrentUser() user: CurrentUserPayload, + ) { + return this.backlogService.update(deliverableId, id, dto, user.id); + } + + @Delete(':id') + async remove( + @Param('deliverableId') deliverableId: string, + @Param('id') id: string, + @CurrentUser() user: CurrentUserPayload, + ) { + return this.backlogService.remove(deliverableId, id, user.id); + } +} diff --git a/src/modules/deliverable-backlog/deliverable-backlog.module.ts b/src/modules/deliverable-backlog/deliverable-backlog.module.ts new file mode 100644 index 0000000..e16a87b --- /dev/null +++ b/src/modules/deliverable-backlog/deliverable-backlog.module.ts @@ -0,0 +1,13 @@ +import { Module, forwardRef } from '@nestjs/common'; +import { DeliverablesModule } from '../deliverables/deliverables.module'; +import { TimelineModule } from '../timeline/timeline.module'; +import { DeliverableBacklogController } from './deliverable-backlog.controller'; +import { DeliverableBacklogService } from './deliverable-backlog.service'; +import { DeliverableBacklogRepository } from './deliverable-backlog.repository'; + +@Module({ + imports: [TimelineModule, forwardRef(() => DeliverablesModule)], + controllers: [DeliverableBacklogController], + providers: [DeliverableBacklogService, DeliverableBacklogRepository], +}) +export class DeliverableBacklogModule {} diff --git a/src/modules/deliverable-backlog/deliverable-backlog.repository.ts b/src/modules/deliverable-backlog/deliverable-backlog.repository.ts new file mode 100644 index 0000000..ee9345f --- /dev/null +++ b/src/modules/deliverable-backlog/deliverable-backlog.repository.ts @@ -0,0 +1,111 @@ +import { Injectable } from '@nestjs/common'; +import { BacklogItemStatus } from '@prisma/client'; +import { PrismaService } from '../../config/database/prisma.service'; + +const backlogItemSelect = { + id: true, + deliverableId: true, + title: true, + description: true, + acceptanceCriteria: true, + status: true, + rejectionReason: true, + sortOrder: true, + isActive: true, + createdAt: true, + updatedAt: true, + createdBy: true, + updatedBy: true, +} as const; + +@Injectable() +export class DeliverableBacklogRepository { + constructor(private readonly prisma: PrismaService) {} + + async findByDeliverableId(deliverableId: string) { + return this.prisma.deliverableBacklogItem.findMany({ + where: { deliverableId, isActive: true }, + select: backlogItemSelect, + orderBy: { sortOrder: 'asc' }, + }); + } + + async findById(id: string) { + return this.prisma.deliverableBacklogItem.findUnique({ + where: { id }, + select: backlogItemSelect, + }); + } + + async getNextSortOrder(deliverableId: string): Promise { + const result = await this.prisma.deliverableBacklogItem.aggregate({ + where: { deliverableId, isActive: true }, + _max: { sortOrder: true }, + }); + return (result._max.sortOrder ?? 0) + 1; + } + + async create(data: { + deliverableId: string; + title: string; + description?: string; + acceptanceCriteria?: string; + sortOrder: number; + createdBy?: string; + }) { + return this.prisma.deliverableBacklogItem.create({ + data, + select: backlogItemSelect, + }); + } + + async update( + id: string, + data: { + title?: string; + description?: string; + acceptanceCriteria?: string; + updatedBy?: string; + }, + ) { + return this.prisma.deliverableBacklogItem.update({ + where: { id }, + data, + select: backlogItemSelect, + }); + } + + async reorder(items: { id: string; sortOrder: number }[]) { + await this.prisma.$transaction( + items.map((item) => + this.prisma.deliverableBacklogItem.update({ + where: { id: item.id }, + data: { sortOrder: item.sortOrder }, + }), + ), + ); + } + + async updateStatus( + id: string, + data: { + status: BacklogItemStatus; + rejectionReason?: string | null; + updatedBy?: string; + }, + ) { + return this.prisma.deliverableBacklogItem.update({ + where: { id }, + data, + select: backlogItemSelect, + }); + } + + async softDelete(id: string) { + return this.prisma.deliverableBacklogItem.update({ + where: { id }, + data: { isActive: false }, + select: backlogItemSelect, + }); + } +} diff --git a/src/modules/deliverable-backlog/deliverable-backlog.service.spec.ts b/src/modules/deliverable-backlog/deliverable-backlog.service.spec.ts new file mode 100644 index 0000000..0cd13ca --- /dev/null +++ b/src/modules/deliverable-backlog/deliverable-backlog.service.spec.ts @@ -0,0 +1,74 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { DeliverableBacklogService } from './deliverable-backlog.service'; +import { DeliverableBacklogRepository } from './deliverable-backlog.repository'; +import { PrismaService } from '../../config/database/prisma.service'; +import { TimelineService } from '../timeline/timeline.service'; + +const mockBacklogItem = { + id: 'item-1', + deliverableId: 'deliverable-id-1', + title: 'Tarefa de teste', + description: null, + acceptanceCriteria: null, + status: 'PENDENTE', + sortOrder: 1, + isActive: true, + createdAt: new Date(), + createdBy: 'user-id-1', +}; + +describe('DeliverableBacklogService - timeline events', () => { + let service: DeliverableBacklogService; + let timelineService: jest.Mocked; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + DeliverableBacklogService, + { + provide: DeliverableBacklogRepository, + useValue: { + findByDeliverableId: jest.fn(), + findById: jest.fn(), + create: jest.fn().mockResolvedValue(mockBacklogItem), + update: jest.fn().mockResolvedValue(mockBacklogItem), + softDelete: jest.fn().mockResolvedValue(mockBacklogItem), + getNextSortOrder: jest.fn().mockResolvedValue(1), + reorder: jest.fn(), + updateStatus: jest.fn(), + }, + }, + { + provide: PrismaService, + useValue: { + deliverable: { + findUnique: jest.fn().mockResolvedValue({ id: 'deliverable-id-1', isActive: true }), + }, + }, + }, + { + provide: TimelineService, + useValue: { + createEvent: jest.fn(), + }, + }, + ], + }).compile(); + + service = module.get(DeliverableBacklogService); + timelineService = module.get(TimelineService); + }); + + it('deve emitir evento BACKLOG ao criar item de backlog', async () => { + await service.create('deliverable-id-1', { title: 'Tarefa de teste' }, 'user-id-1'); + + expect(timelineService.createEvent).toHaveBeenCalledWith( + expect.objectContaining({ + deliverableId: 'deliverable-id-1', + type: 'BACKLOG', + title: 'Item de backlog criado', + createdBy: 'user-id-1', + }), + ); + }); +}); diff --git a/src/modules/deliverable-backlog/deliverable-backlog.service.ts b/src/modules/deliverable-backlog/deliverable-backlog.service.ts new file mode 100644 index 0000000..a43d205 --- /dev/null +++ b/src/modules/deliverable-backlog/deliverable-backlog.service.ts @@ -0,0 +1,197 @@ +import { BadRequestException, Injectable, Logger, NotFoundException } from '@nestjs/common'; +import { PrismaService } from '../../config/database/prisma.service'; +import { TimelineService } from '../timeline/timeline.service'; +import { DeliverableBacklogRepository } from './deliverable-backlog.repository'; +import { BacklogItemStatus } from '@prisma/client'; +import { CreateBacklogItemDto } from './dto/create-backlog-item.dto'; +import { UpdateBacklogItemDto } from './dto/update-backlog-item.dto'; +import { ChangeBacklogStatusDto } from './dto/change-backlog-status.dto'; + +@Injectable() +export class DeliverableBacklogService { + private readonly logger = new Logger(DeliverableBacklogService.name); + + constructor( + private readonly repository: DeliverableBacklogRepository, + private readonly prisma: PrismaService, + private readonly timelineService: TimelineService, + ) {} + + async findAll(deliverableId: string) { + return this.repository.findByDeliverableId(deliverableId); + } + + async create(deliverableId: string, dto: CreateBacklogItemDto, userId: string) { + await this.validateDeliverableExists(deliverableId); + + const sortOrder = await this.repository.getNextSortOrder(deliverableId); + + const item = await this.repository.create({ + deliverableId, + title: dto.title, + description: dto.description, + acceptanceCriteria: dto.acceptanceCriteria, + sortOrder, + createdBy: userId, + }); + + try { + await this.timelineService.createEvent({ + deliverableId, + type: 'BACKLOG', + title: 'Item de backlog criado', + description: `Item "${dto.title}" adicionado ao backlog`, + metadata: { backlogItemId: item.id, title: dto.title }, + createdBy: userId, + }); + } catch (error) { + this.logger.error('Falha ao criar evento de timeline para criação de backlog', error); + } + + return item; + } + + async update(deliverableId: string, itemId: string, dto: UpdateBacklogItemDto, userId: string) { + const existing = await this.findItemOrFail(itemId, deliverableId); + + const item = await this.repository.update(itemId, { + title: dto.title, + description: dto.description, + acceptanceCriteria: dto.acceptanceCriteria, + updatedBy: userId, + }); + + try { + const changes = Object.keys(dto).filter( + (key) => dto[key as keyof UpdateBacklogItemDto] !== undefined, + ); + await this.timelineService.createEvent({ + deliverableId, + type: 'BACKLOG', + title: 'Item de backlog atualizado', + description: `Item "${existing.title}" atualizado (${changes.join(', ')})`, + metadata: { backlogItemId: itemId, changes }, + createdBy: userId, + }); + } catch (error) { + this.logger.error('Falha ao criar evento de timeline para atualização de backlog', error); + } + + return item; + } + + async remove(deliverableId: string, itemId: string, userId: string) { + const existing = await this.findItemOrFail(itemId, deliverableId); + const result = await this.repository.softDelete(itemId); + + try { + await this.timelineService.createEvent({ + deliverableId, + type: 'BACKLOG', + title: 'Item de backlog removido', + description: `Item "${existing.title}" removido do backlog`, + metadata: { backlogItemId: itemId, title: existing.title }, + createdBy: userId, + }); + } catch (error) { + this.logger.error('Falha ao criar evento de timeline para remoção de backlog', error); + } + + return result; + } + + async changeStatus( + deliverableId: string, + itemId: string, + dto: ChangeBacklogStatusDto, + userId: string, + ) { + const existing = await this.findItemOrFail(itemId, deliverableId); + + this.validateStatusTransition(existing.status, dto.status); + + if (dto.status === BacklogItemStatus.REJEITADO && !dto.rejectionReason) { + throw new BadRequestException('O motivo da rejeição é obrigatório'); + } + + const rejectionReason = dto.status === BacklogItemStatus.REJEITADO ? dto.rejectionReason : null; + + const item = await this.repository.updateStatus(itemId, { + status: dto.status, + rejectionReason, + updatedBy: userId, + }); + + try { + await this.timelineService.createEvent({ + deliverableId, + type: 'BACKLOG', + title: 'Status de item de backlog alterado', + description: `Item "${existing.title}" alterado de ${existing.status} para ${dto.status}`, + metadata: { + backlogItemId: itemId, + previousStatus: existing.status, + newStatus: dto.status, + rejectionReason: rejectionReason ?? undefined, + }, + createdBy: userId, + }); + } catch (error) { + this.logger.error('Falha ao criar evento de timeline para mudança de status', error); + } + + return item; + } + + async reorder(deliverableId: string, items: { id: string; sortOrder: number }[]) { + const existingItems = await this.repository.findByDeliverableId(deliverableId); + const existingIds = new Set(existingItems.map((item) => item.id)); + + const invalidIds = items.filter((item) => !existingIds.has(item.id)); + if (invalidIds.length > 0) { + throw new BadRequestException('Um ou mais itens não pertencem a este entregável'); + } + + const sortOrders = items.map((item) => item.sortOrder); + if (new Set(sortOrders).size !== sortOrders.length) { + throw new BadRequestException('Não é permitido sortOrder duplicado'); + } + + await this.repository.reorder(items); + + return this.repository.findByDeliverableId(deliverableId); + } + + private async findItemOrFail(itemId: string, deliverableId: string) { + const item = await this.repository.findById(itemId); + + if (!item || !item.isActive || item.deliverableId !== deliverableId) { + throw new NotFoundException('Item de backlog não encontrado'); + } + + return item; + } + + private validateStatusTransition(current: BacklogItemStatus, next: BacklogItemStatus) { + const allowed: Record = { + [BacklogItemStatus.PENDENTE]: [BacklogItemStatus.ACEITO, BacklogItemStatus.REJEITADO], + [BacklogItemStatus.REJEITADO]: [BacklogItemStatus.PENDENTE], + [BacklogItemStatus.ACEITO]: [BacklogItemStatus.PENDENTE], + }; + + if (!allowed[current].includes(next)) { + throw new BadRequestException(`Transição de status não permitida: ${current} → ${next}`); + } + } + + private async validateDeliverableExists(deliverableId: string) { + const deliverable = await this.prisma.deliverable.findUnique({ + where: { id: deliverableId }, + select: { id: true, isActive: true }, + }); + + if (!deliverable || !deliverable.isActive) { + throw new NotFoundException('Entregável não encontrado'); + } + } +} diff --git a/src/modules/deliverable-backlog/dto/.gitkeep b/src/modules/deliverable-backlog/dto/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/src/modules/deliverable-backlog/dto/change-backlog-status.dto.ts b/src/modules/deliverable-backlog/dto/change-backlog-status.dto.ts new file mode 100644 index 0000000..2952828 --- /dev/null +++ b/src/modules/deliverable-backlog/dto/change-backlog-status.dto.ts @@ -0,0 +1,11 @@ +import { IsEnum, IsOptional, IsString } from 'class-validator'; +import { BacklogItemStatus } from '@prisma/client'; + +export class ChangeBacklogStatusDto { + @IsEnum(BacklogItemStatus, { message: 'O status deve ser PENDENTE, ACEITO ou REJEITADO' }) + status!: BacklogItemStatus; + + @IsOptional() + @IsString({ message: 'O motivo da rejeição deve ser uma string' }) + rejectionReason?: string; +} diff --git a/src/modules/deliverable-backlog/dto/create-backlog-item.dto.ts b/src/modules/deliverable-backlog/dto/create-backlog-item.dto.ts new file mode 100644 index 0000000..b24d8e3 --- /dev/null +++ b/src/modules/deliverable-backlog/dto/create-backlog-item.dto.ts @@ -0,0 +1,15 @@ +import { IsNotEmpty, IsOptional, IsString } from 'class-validator'; + +export class CreateBacklogItemDto { + @IsString({ message: 'O título deve ser uma string' }) + @IsNotEmpty({ message: 'O título é obrigatório' }) + title!: string; + + @IsOptional() + @IsString({ message: 'A descrição deve ser uma string' }) + description?: string; + + @IsOptional() + @IsString({ message: 'O critério de aceite deve ser uma string' }) + acceptanceCriteria?: string; +} diff --git a/src/modules/deliverable-backlog/dto/reorder-backlog.dto.ts b/src/modules/deliverable-backlog/dto/reorder-backlog.dto.ts new file mode 100644 index 0000000..44a92fa --- /dev/null +++ b/src/modules/deliverable-backlog/dto/reorder-backlog.dto.ts @@ -0,0 +1,17 @@ +import { Type } from 'class-transformer'; +import { IsArray, IsInt, IsUUID, ValidateNested } from 'class-validator'; + +export class ReorderItemDto { + @IsUUID('4', { message: 'O ID do item deve ser um UUID válido' }) + id!: string; + + @IsInt({ message: 'A ordem deve ser um número inteiro' }) + sortOrder!: number; +} + +export class ReorderBacklogDto { + @IsArray({ message: 'Os itens devem ser um array' }) + @ValidateNested({ each: true }) + @Type(() => ReorderItemDto) + items!: ReorderItemDto[]; +} diff --git a/src/modules/deliverable-backlog/dto/update-backlog-item.dto.ts b/src/modules/deliverable-backlog/dto/update-backlog-item.dto.ts new file mode 100644 index 0000000..d19c288 --- /dev/null +++ b/src/modules/deliverable-backlog/dto/update-backlog-item.dto.ts @@ -0,0 +1,15 @@ +import { IsOptional, IsString } from 'class-validator'; + +export class UpdateBacklogItemDto { + @IsOptional() + @IsString({ message: 'O título deve ser uma string' }) + title?: string; + + @IsOptional() + @IsString({ message: 'A descrição deve ser uma string' }) + description?: string; + + @IsOptional() + @IsString({ message: 'O critério de aceite deve ser uma string' }) + acceptanceCriteria?: string; +} diff --git a/src/modules/deliverables/allocations.controller.ts b/src/modules/deliverables/allocations.controller.ts new file mode 100644 index 0000000..b0c0675 --- /dev/null +++ b/src/modules/deliverables/allocations.controller.ts @@ -0,0 +1,79 @@ +import { Body, Controller, Delete, Get, Param, Patch, Post, Put, UseGuards } from '@nestjs/common'; +import { JwtAuthGuard } from '../../config/auth/jwt-auth.guard'; +import { ReadOnlyClientGuard } from '../../common/guards/read-only-client.guard'; +import { RolesGuard } from '../../common/guards/roles.guard'; +import { Roles } from '../../common/decorators/roles.decorator'; +import { CurrentUser } from '../../common/decorators/user.decorator'; +import type { CurrentUserPayload } from '../../common/decorators/user.decorator'; +import { Role } from '../../common/enums/role.enum'; +import { AllocationsService } from './allocations.service'; +import { CreateDeliverableAllocationDto } from './dto/create-deliverable-allocation.dto'; +import { UpdateDeliverableAllocationDto } from './dto/update-deliverable-allocation.dto'; +import { BulkSetDeliverableAllocationsDto } from './dto/bulk-set-deliverable-allocations.dto'; + +@Controller('deliverables/:deliverableId/allocations') +export class AllocationsController { + constructor(private readonly allocationsService: AllocationsService) {} + + @Get() + @UseGuards(JwtAuthGuard, ReadOnlyClientGuard, RolesGuard) + @Roles(Role.ADMIN, Role.OPERATOR, Role.CLIENT) + async findAll(@Param('deliverableId') deliverableId: string) { + return this.allocationsService.findAll(deliverableId); + } + + @Post() + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles(Role.ADMIN, Role.OPERATOR) + async create( + @Param('deliverableId') deliverableId: string, + @Body() dto: CreateDeliverableAllocationDto, + @CurrentUser() user: CurrentUserPayload, + ) { + return this.allocationsService.create(deliverableId, dto, user.id); + } + + @Patch(':id') + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles(Role.ADMIN, Role.OPERATOR) + async update( + @Param('deliverableId') deliverableId: string, + @Param('id') id: string, + @Body() dto: UpdateDeliverableAllocationDto, + @CurrentUser() user: CurrentUserPayload, + ) { + return this.allocationsService.update(deliverableId, id, dto, user.id); + } + + @Delete(':id') + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles(Role.ADMIN, Role.OPERATOR) + async remove( + @Param('deliverableId') deliverableId: string, + @Param('id') id: string, + @CurrentUser() user: CurrentUserPayload, + ) { + return this.allocationsService.remove(deliverableId, id, user.id); + } + + @Put('bulk') + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles(Role.ADMIN, Role.OPERATOR) + async bulkSet( + @Param('deliverableId') deliverableId: string, + @Body() dto: BulkSetDeliverableAllocationsDto, + @CurrentUser() user: CurrentUserPayload, + ) { + return this.allocationsService.bulkSet(deliverableId, dto, user.id); + } + + @Post('apply-template') + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles(Role.ADMIN, Role.OPERATOR) + async applyTemplate( + @Param('deliverableId') deliverableId: string, + @CurrentUser() user: CurrentUserPayload, + ) { + return this.allocationsService.applyTemplate(deliverableId, user.id); + } +} diff --git a/src/modules/deliverables/allocations.repository.ts b/src/modules/deliverables/allocations.repository.ts new file mode 100644 index 0000000..df16be2 --- /dev/null +++ b/src/modules/deliverables/allocations.repository.ts @@ -0,0 +1,108 @@ +import { Injectable } from '@nestjs/common'; +import { PrismaService } from '../../config/database/prisma.service'; + +const allocationSelect = { + id: true, + quantity: true, + allocationPercentage: true, + calculatedValue: true, + isActive: true, + createdAt: true, + updatedAt: true, + profile: { select: { id: true, name: true } }, +} as const; + +@Injectable() +export class AllocationsRepository { + constructor(private readonly prisma: PrismaService) {} + + async findByDeliverableId(deliverableId: string) { + return this.prisma.deliverableAllocation.findMany({ + where: { deliverableId, isActive: true }, + select: allocationSelect, + orderBy: { createdAt: 'asc' }, + }); + } + + async findById(id: string) { + return this.prisma.deliverableAllocation.findUnique({ + where: { id }, + select: { + ...allocationSelect, + deliverableId: true, + }, + }); + } + + async create(data: { + deliverableId: string; + profileId: string; + quantity: number; + allocationPercentage: number; + calculatedValue: number; + createdBy?: string; + }) { + return this.prisma.deliverableAllocation.create({ + data, + select: allocationSelect, + }); + } + + async update( + id: string, + data: { + profileId?: string; + quantity?: number; + allocationPercentage?: number; + calculatedValue?: number; + }, + ) { + return this.prisma.deliverableAllocation.update({ + where: { id }, + data, + select: allocationSelect, + }); + } + + async softDelete(id: string) { + return this.prisma.deliverableAllocation.update({ + where: { id }, + data: { isActive: false }, + select: allocationSelect, + }); + } + + async bulkReplace( + deliverableId: string, + items: { + profileId: string; + quantity: number; + allocationPercentage: number; + calculatedValue: number; + }[], + createdBy?: string, + ) { + return this.prisma.$transaction(async (tx) => { + await tx.deliverableAllocation.updateMany({ + where: { deliverableId, isActive: true }, + data: { isActive: false }, + }); + + if (items.length > 0) { + await tx.deliverableAllocation.createMany({ + data: items.map((item) => ({ + deliverableId, + ...item, + createdBy, + })), + }); + } + + return tx.deliverableAllocation.findMany({ + where: { deliverableId, isActive: true }, + select: allocationSelect, + orderBy: { createdAt: 'asc' }, + }); + }); + } +} diff --git a/src/modules/deliverables/allocations.service.spec.ts b/src/modules/deliverables/allocations.service.spec.ts new file mode 100644 index 0000000..c8fa479 --- /dev/null +++ b/src/modules/deliverables/allocations.service.spec.ts @@ -0,0 +1,153 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { DeliverableType, Prisma, SprintType } from '@prisma/client'; +import { AllocationsService } from './allocations.service'; +import { AllocationsRepository } from './allocations.repository'; +import { PrismaService } from '../../config/database/prisma.service'; +import { TimelineService } from '../timeline/timeline.service'; + +describe('AllocationsService — SAAS_LICENSE (NBC-010)', () => { + let service: AllocationsService; + let repository: jest.Mocked; + let prisma: { + deliverable: { findUnique: jest.Mock }; + clientProfile: { findUnique: jest.Mock }; + allocationTemplate: { findUnique: jest.Mock }; + }; + + const buildDeliverableContext = (overrides: Record = {}) => ({ + type: DeliverableType.DESCOBERTA, + contractItem: { + ustValue: new Prisma.Decimal(100), + timeboxDescoberta: new Prisma.Decimal(40), + timeboxDesign: new Prisma.Decimal(40), + timeboxArquitetura: new Prisma.Decimal(40), + timeboxConstrucao: new Prisma.Decimal(80), + }, + sprint: { + startDate: new Date('2026-04-01'), + endDate: new Date('2026-04-08'), + }, + ...overrides, + }); + + const buildSaasDeliverable = (overrides: Record = {}) => ({ + type: DeliverableType.DESCOBERTA, + contractItem: { + ustValue: new Prisma.Decimal(100), + timeboxDescoberta: null, + timeboxDesign: null, + timeboxArquitetura: null, + timeboxConstrucao: null, + }, + sprint: { + startDate: new Date('2026-04-01'), + endDate: new Date('2026-04-08'), + }, + ...overrides, + }); + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + AllocationsService, + { + provide: AllocationsRepository, + useValue: { + findByDeliverableId: jest.fn().mockResolvedValue([]), + findById: jest.fn(), + create: jest.fn(), + update: jest.fn(), + softDelete: jest.fn(), + replaceForDeliverable: jest.fn(), + bulkReplace: jest.fn().mockResolvedValue([]), + }, + }, + { + provide: PrismaService, + useValue: { + deliverable: { findUnique: jest.fn() }, + clientProfile: { + findUnique: jest.fn().mockResolvedValue({ + id: 'profile-1', + clientId: 'client-1', + isActive: true, + }), + }, + allocationTemplate: { findUnique: jest.fn() }, + }, + }, + { + provide: TimelineService, + useValue: { createEvent: jest.fn() }, + }, + ], + }).compile(); + + service = module.get(AllocationsService); + repository = module.get(AllocationsRepository); + prisma = module.get(PrismaService); + }); + + it('aceita criar alocação em Entregável SaaS sem bloqueio de itemType', async () => { + prisma.deliverable.findUnique.mockResolvedValueOnce({ id: 'd-1', clientId: 'client-1' }); + prisma.deliverable.findUnique.mockResolvedValueOnce(buildSaasDeliverable()); + prisma.deliverable.findUnique.mockResolvedValueOnce({ id: 'd-1', clientId: 'client-1' }); + repository.create.mockResolvedValue({ id: 'a-1' } as never); + + const result = await service.create( + 'd-1', + { profileId: 'profile-1', quantity: 1, allocationPercentage: 100 }, + 'user-1', + ); + + expect(result).toBeDefined(); + expect(repository.create).toHaveBeenCalled(); + }); + + it('não lê nem grava Deliverable.totalValue ao criar alocação (decoupled em NBC-004)', async () => { + prisma.deliverable.findUnique.mockResolvedValueOnce({ id: 'd-1', clientId: 'client-1' }); + prisma.deliverable.findUnique.mockResolvedValueOnce(buildDeliverableContext()); + prisma.deliverable.findUnique.mockResolvedValueOnce({ id: 'd-1', clientId: 'client-1' }); + repository.create.mockResolvedValue({ id: 'a-1' } as never); + + await service.create( + 'd-1', + { profileId: 'profile-1', quantity: 1, allocationPercentage: 100 }, + 'user-1', + ); + + const createCall = repository.create.mock.calls[0][0] as Record; + expect(createCall.deliverableId).toBe('d-1'); + expect(createCall).not.toHaveProperty('totalValue'); + }); + + it('applyTemplate funciona em Entregável SaaS (template carregado pelo tipo de entrega)', async () => { + prisma.deliverable.findUnique.mockResolvedValueOnce({ + clientId: 'client-1', + contractItemId: 'item-saas', + type: DeliverableType.DESCOBERTA, + }); + prisma.allocationTemplate.findUnique.mockResolvedValue({ + items: [ + { profileId: 'profile-1', quantity: 1, allocationPercentage: new Prisma.Decimal(100) }, + ], + }); + prisma.deliverable.findUnique.mockResolvedValueOnce({ id: 'd-1', clientId: 'client-1' }); + prisma.deliverable.findUnique.mockResolvedValueOnce(buildSaasDeliverable()); + prisma.deliverable.findUnique.mockResolvedValueOnce({ id: 'd-1', clientId: 'client-1' }); + await service.applyTemplate('d-1', 'user-1'); + + expect(prisma.allocationTemplate.findUnique).toHaveBeenCalledWith( + expect.objectContaining({ + where: { + clientId_sprintType_contractItemId: { + clientId: 'client-1', + sprintType: SprintType.DESCOBERTA, + contractItemId: 'item-saas', + }, + }, + }), + ); + expect(repository.bulkReplace).toHaveBeenCalled(); + }); +}); diff --git a/src/modules/deliverables/allocations.service.ts b/src/modules/deliverables/allocations.service.ts new file mode 100644 index 0000000..ce24791 --- /dev/null +++ b/src/modules/deliverables/allocations.service.ts @@ -0,0 +1,319 @@ +import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; +import { DeliverableType, Prisma, SprintType, TimelineEventType } from '@prisma/client'; +import { AllocationsRepository } from './allocations.repository'; +import { PrismaService } from '../../config/database/prisma.service'; +import { TimelineService } from '../timeline/timeline.service'; +import { CreateDeliverableAllocationDto } from './dto/create-deliverable-allocation.dto'; +import { UpdateDeliverableAllocationDto } from './dto/update-deliverable-allocation.dto'; +import { BulkSetDeliverableAllocationsDto } from './dto/bulk-set-deliverable-allocations.dto'; + +interface DeliverableCalculationContext { + ustValue: number; + weeklyHours: number; + weeks: number; +} + +@Injectable() +export class AllocationsService { + constructor( + private readonly repository: AllocationsRepository, + private readonly prisma: PrismaService, + private readonly timelineService: TimelineService, + ) {} + + async findAll(deliverableId: string) { + await this.validateDeliverableExists(deliverableId); + const allocations = await this.repository.findByDeliverableId(deliverableId); + + const ctx = await this.getCalculationContext(deliverableId); + + return allocations.map((a) => ({ + ...a, + allocationPercentage: Number(a.allocationPercentage), + calculatedValue: a.calculatedValue != null ? Number(a.calculatedValue) : null, + ustValue: ctx.ustValue, + weeklyHours: ctx.weeklyHours, + weeks: ctx.weeks, + })); + } + + async create(deliverableId: string, dto: CreateDeliverableAllocationDto, userId?: string) { + await this.validateDeliverableExists(deliverableId); + const ctx = await this.getCalculationContext(deliverableId); + await this.validateProfileBelongsToClient(deliverableId, dto.profileId); + + const calculatedValue = this.calculateLineValue(dto.quantity, dto.allocationPercentage, ctx); + + const allocation = await this.repository.create({ + deliverableId, + ...dto, + calculatedValue, + createdBy: userId, + }); + + await this.createTimelineEvent(deliverableId, 'Alocação adicionada', userId); + + return allocation; + } + + async update( + deliverableId: string, + allocationId: string, + dto: UpdateDeliverableAllocationDto, + userId?: string, + ) { + const existing = await this.repository.findById(allocationId); + if (!existing || existing.deliverableId !== deliverableId) { + throw new NotFoundException('Alocação não encontrada'); + } + + if (dto.profileId) { + await this.validateProfileBelongsToClient(deliverableId, dto.profileId); + } + + const quantity = dto.quantity ?? existing.quantity; + const allocationPercentage = dto.allocationPercentage ?? Number(existing.allocationPercentage); + + const ctx = await this.getCalculationContext(deliverableId); + const calculatedValue = this.calculateLineValue(quantity, allocationPercentage, ctx); + + const allocation = await this.repository.update(allocationId, { + ...dto, + calculatedValue, + }); + + await this.createTimelineEvent(deliverableId, 'Alocação atualizada', userId); + + return allocation; + } + + async remove(deliverableId: string, allocationId: string, userId?: string) { + const existing = await this.repository.findById(allocationId); + if (!existing || existing.deliverableId !== deliverableId) { + throw new NotFoundException('Alocação não encontrada'); + } + + const result = await this.repository.softDelete(allocationId); + await this.createTimelineEvent( + deliverableId, + `Alocação removida: ${existing.profile.name}`, + userId, + ); + + return result; + } + + async bulkSet(deliverableId: string, dto: BulkSetDeliverableAllocationsDto, userId?: string) { + await this.validateDeliverableExists(deliverableId); + const ctx = await this.getCalculationContext(deliverableId); + + for (const item of dto.items) { + await this.validateProfileBelongsToClient(deliverableId, item.profileId); + } + + const itemsWithValues = dto.items.map((item) => ({ + ...item, + calculatedValue: this.calculateLineValue(item.quantity, item.allocationPercentage, ctx), + })); + + const allocations = await this.repository.bulkReplace(deliverableId, itemsWithValues, userId); + + await this.createTimelineEvent(deliverableId, 'Alocações substituídas em lote', userId); + + return allocations; + } + + async applyTemplate(deliverableId: string, userId?: string) { + const deliverable = await this.prisma.deliverable.findUnique({ + where: { id: deliverableId }, + select: { + clientId: true, + contractItemId: true, + type: true, + }, + }); + + if (!deliverable) { + throw new NotFoundException('Entregável não encontrado'); + } + + const sprintTypeFromDeliverable = this.mapDeliverableTypeToSprintType(deliverable.type); + if (!sprintTypeFromDeliverable) { + throw new BadRequestException( + 'Tipo de entrega do entregável não suporta template de alocação', + ); + } + + const template = await this.prisma.allocationTemplate.findUnique({ + where: { + clientId_sprintType_contractItemId: { + clientId: deliverable.clientId, + sprintType: sprintTypeFromDeliverable, + contractItemId: deliverable.contractItemId, + }, + }, + select: { + items: { + select: { + profileId: true, + quantity: true, + allocationPercentage: true, + }, + }, + }, + }); + + if (!template) { + throw new NotFoundException( + 'Nenhum template de alocação encontrado para esta combinação de sprint e item de contrato', + ); + } + + return this.bulkSet( + deliverableId, + { + items: template.items.map((i) => ({ + ...i, + allocationPercentage: Number(i.allocationPercentage), + })), + }, + userId, + ); + } + + private calculateLineValue( + quantity: number, + allocationPercentage: number, + ctx: DeliverableCalculationContext, + ): number { + // Qtd × (% / 100) × (horas_semanais × semanas) × valor_ust + const totalHours = new Prisma.Decimal(ctx.weeklyHours).mul(new Prisma.Decimal(ctx.weeks)); + return new Prisma.Decimal(quantity) + .mul(new Prisma.Decimal(allocationPercentage).div(100)) + .mul(totalHours) + .mul(new Prisma.Decimal(ctx.ustValue)) + .toNumber(); + } + + private async getCalculationContext( + deliverableId: string, + ): Promise { + const deliverable = await this.prisma.deliverable.findUnique({ + where: { id: deliverableId }, + select: { + type: true, + contractItem: { + select: { + ustValue: true, + timeboxDescoberta: true, + timeboxDesign: true, + timeboxArquitetura: true, + timeboxConstrucao: true, + }, + }, + sprint: { select: { startDate: true, endDate: true } }, + }, + }); + + if (!deliverable?.contractItem || !deliverable.sprint) { + throw new NotFoundException('Entregável sem item de contrato ou sprint definido'); + } + + const ustValue = deliverable.contractItem.ustValue?.toNumber() ?? 0; + const weeklyHours = this.getWeeklyHoursForDeliverableType( + deliverable.contractItem, + deliverable.type, + ); + const weeks = this.calculateWeeks(deliverable.sprint.startDate, deliverable.sprint.endDate); + + return { ustValue, weeklyHours, weeks }; + } + + private getWeeklyHoursForDeliverableType( + contractItem: { + timeboxDescoberta: Prisma.Decimal | null; + timeboxDesign: Prisma.Decimal | null; + timeboxArquitetura: Prisma.Decimal | null; + timeboxConstrucao: Prisma.Decimal | null; + } | null, + type: DeliverableType, + ): number { + if (!contractItem) return 0; + const map: Record = { + DESCOBERTA: contractItem.timeboxDescoberta, + DESIGN: contractItem.timeboxDesign, + ARQUITETURA: contractItem.timeboxArquitetura, + CONSTRUCAO: contractItem.timeboxConstrucao, + MANUTENCAO: null, + LICENCA: null, + }; + return map[type]?.toNumber() ?? 0; + } + + private mapDeliverableTypeToSprintType(type: DeliverableType): SprintType | null { + switch (type) { + case DeliverableType.DESCOBERTA: + return SprintType.DESCOBERTA; + case DeliverableType.DESIGN: + return SprintType.DESIGN; + case DeliverableType.ARQUITETURA: + return SprintType.ARQUITETURA; + case DeliverableType.CONSTRUCAO: + return SprintType.CONSTRUCAO; + default: + return null; + } + } + + private calculateWeeks(startDate: Date, endDate: Date): number { + const diffMs = endDate.getTime() - startDate.getTime(); + const diffDays = diffMs / (1000 * 60 * 60 * 24); + // Arredonda para 2 casas decimais para precisão + return Math.round((diffDays / 7) * 100) / 100; + } + + private async validateDeliverableExists(deliverableId: string) { + const deliverable = await this.prisma.deliverable.findUnique({ + where: { id: deliverableId }, + select: { id: true }, + }); + + if (!deliverable) { + throw new NotFoundException('Entregável não encontrado'); + } + } + + private async validateProfileBelongsToClient(deliverableId: string, profileId: string) { + const deliverable = await this.prisma.deliverable.findUnique({ + where: { id: deliverableId }, + select: { clientId: true }, + }); + + if (!deliverable) { + throw new NotFoundException('Entregável não encontrado'); + } + + const profile = await this.prisma.clientProfile.findUnique({ + where: { id: profileId }, + select: { clientId: true }, + }); + + if (!profile || profile.clientId !== deliverable.clientId) { + throw new NotFoundException('Perfil não pertence ao cliente deste entregável'); + } + } + + private async createTimelineEvent(deliverableId: string, description: string, userId?: string) { + try { + await this.timelineService.createEvent({ + deliverableId, + type: TimelineEventType.ALOCACAO, + title: 'Alocação atualizada', + description, + createdBy: userId, + }); + } catch { + // Timeline event failure should not break the main operation + } + } +} diff --git a/src/modules/deliverables/assignments.controller.ts b/src/modules/deliverables/assignments.controller.ts new file mode 100644 index 0000000..cfd246d --- /dev/null +++ b/src/modules/deliverables/assignments.controller.ts @@ -0,0 +1,59 @@ +import { Body, Controller, Delete, Get, Param, Patch, Post, UseGuards } from '@nestjs/common'; +import { JwtAuthGuard } from '../../config/auth/jwt-auth.guard'; +import { ReadOnlyClientGuard } from '../../common/guards/read-only-client.guard'; +import { RolesGuard } from '../../common/guards/roles.guard'; +import { Roles } from '../../common/decorators/roles.decorator'; +import { Role } from '../../common/enums/role.enum'; +import { CurrentUser } from '../../common/decorators/user.decorator'; +import type { CurrentUserPayload } from '../../common/decorators/user.decorator'; +import { AssignmentsService } from './assignments.service'; +import { DeliverablesService } from './deliverables.service'; +import { CreateAssignmentDto } from './dto/create-assignment.dto'; +import { UpdateAssignmentDto } from './dto/update-assignment.dto'; + +@Controller('deliverables/:deliverableId/assignments') +@UseGuards(JwtAuthGuard, ReadOnlyClientGuard, RolesGuard) +@Roles(Role.ADMIN, Role.OPERATOR, Role.CLIENT) +export class AssignmentsController { + constructor( + private readonly assignmentsService: AssignmentsService, + private readonly deliverablesService: DeliverablesService, + ) {} + + @Post() + async create( + @Param('deliverableId') deliverableId: string, + @Body() dto: CreateAssignmentDto, + @CurrentUser() user: CurrentUserPayload, + ) { + return this.assignmentsService.create(deliverableId, dto, user.id); + } + + @Get() + async findAll( + @Param('deliverableId') deliverableId: string, + @CurrentUser() user: CurrentUserPayload, + ) { + if (user.role === Role.CLIENT && user.clientId) { + await this.deliverablesService.validateDeliverableOwnership(deliverableId, user.clientId); + } + return this.assignmentsService.findAll(deliverableId); + } + + @Patch(':assignmentId') + async update( + @Param('assignmentId') assignmentId: string, + @Body() dto: UpdateAssignmentDto, + @CurrentUser() user: CurrentUserPayload, + ) { + return this.assignmentsService.update(assignmentId, dto, user.id); + } + + @Delete(':assignmentId') + async remove( + @Param('assignmentId') assignmentId: string, + @CurrentUser() user: CurrentUserPayload, + ) { + return this.assignmentsService.remove(assignmentId, user.id); + } +} diff --git a/src/modules/deliverables/assignments.service.spec.ts b/src/modules/deliverables/assignments.service.spec.ts new file mode 100644 index 0000000..73e9632 --- /dev/null +++ b/src/modules/deliverables/assignments.service.spec.ts @@ -0,0 +1,80 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { AssignmentsService } from './assignments.service'; +import { AssignmentsRepository } from './repositories/assignments.repository'; +import { PrismaService } from '../../config/database/prisma.service'; +import { TimelineService } from '../timeline/timeline.service'; + +const mockAssignment = { + id: 'assignment-1', + deliverableId: 'deliverable-id-1', + professionalId: 'prof-1', + role: 'Desenvolvedor', + startDate: null, + endDate: null, + observation: null, + isActive: true, + createdAt: new Date(), + createdBy: 'user-id-1', + professional: { id: 'prof-1', name: 'João Silva' }, +}; + +describe('AssignmentsService - timeline events', () => { + let service: AssignmentsService; + let timelineService: jest.Mocked; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + AssignmentsService, + { + provide: AssignmentsRepository, + useValue: { + findByDeliverableId: jest.fn(), + findById: jest.fn(), + findDuplicate: jest.fn().mockResolvedValue(null), + create: jest.fn().mockResolvedValue(mockAssignment), + update: jest.fn().mockResolvedValue(mockAssignment), + softDelete: jest.fn().mockResolvedValue(mockAssignment), + }, + }, + { + provide: PrismaService, + useValue: { + deliverable: { + findUnique: jest.fn().mockResolvedValue({ id: 'deliverable-id-1', isActive: true }), + }, + professional: { + findUnique: jest.fn().mockResolvedValue({ id: 'prof-1', isActive: true }), + }, + }, + }, + { + provide: TimelineService, + useValue: { + createEvent: jest.fn(), + }, + }, + ], + }).compile(); + + service = module.get(AssignmentsService); + timelineService = module.get(TimelineService); + }); + + it('deve emitir evento ASSIGNMENT ao alocar profissional', async () => { + await service.create( + 'deliverable-id-1', + { professionalId: 'prof-1', role: 'Desenvolvedor' }, + 'user-id-1', + ); + + expect(timelineService.createEvent).toHaveBeenCalledWith( + expect.objectContaining({ + deliverableId: 'deliverable-id-1', + type: 'ASSIGNMENT', + title: 'Profissional alocado', + createdBy: 'user-id-1', + }), + ); + }); +}); diff --git a/src/modules/deliverables/assignments.service.ts b/src/modules/deliverables/assignments.service.ts new file mode 100644 index 0000000..5501ea6 --- /dev/null +++ b/src/modules/deliverables/assignments.service.ts @@ -0,0 +1,158 @@ +import { + BadRequestException, + ConflictException, + Injectable, + Logger, + NotFoundException, +} from '@nestjs/common'; +import { PrismaService } from '../../config/database/prisma.service'; +import { TimelineService } from '../timeline/timeline.service'; +import { AssignmentsRepository } from './repositories/assignments.repository'; +import { CreateAssignmentDto } from './dto/create-assignment.dto'; +import { UpdateAssignmentDto } from './dto/update-assignment.dto'; + +@Injectable() +export class AssignmentsService { + private readonly logger = new Logger(AssignmentsService.name); + + constructor( + private readonly repository: AssignmentsRepository, + private readonly prisma: PrismaService, + private readonly timelineService: TimelineService, + ) {} + + async findAll(deliverableId: string) { + return this.repository.findByDeliverableId(deliverableId); + } + + async create(deliverableId: string, dto: CreateAssignmentDto, userId: string) { + await this.validateDeliverableExists(deliverableId); + await this.validateProfessionalActive(dto.professionalId); + await this.validateNoDuplicate(deliverableId, dto.professionalId); + + const assignment = await this.repository.create({ + deliverableId, + professionalId: dto.professionalId, + role: dto.role, + startDate: dto.startDate ? new Date(dto.startDate) : undefined, + endDate: dto.endDate ? new Date(dto.endDate) : undefined, + observation: dto.observation, + createdBy: userId, + }); + + try { + await this.timelineService.createEvent({ + deliverableId, + type: 'ASSIGNMENT', + title: 'Profissional alocado', + description: `${assignment.professional.name} alocado como ${dto.role}`, + metadata: { + assignmentId: assignment.id, + professionalId: dto.professionalId, + role: dto.role, + }, + createdBy: userId, + }); + } catch (error) { + this.logger.error('Falha ao criar evento de timeline para alocação', error); + } + + return assignment; + } + + async update(assignmentId: string, dto: UpdateAssignmentDto, userId: string) { + const existing = await this.findAssignmentOrFail(assignmentId); + + const assignment = await this.repository.update(assignmentId, { + role: dto.role, + startDate: dto.startDate ? new Date(dto.startDate) : undefined, + endDate: dto.endDate ? new Date(dto.endDate) : undefined, + observation: dto.observation, + }); + + try { + const changes = Object.keys(dto).filter( + (key) => dto[key as keyof UpdateAssignmentDto] !== undefined, + ); + await this.timelineService.createEvent({ + deliverableId: existing.deliverableId, + type: 'ASSIGNMENT', + title: 'Alocação atualizada', + description: `Alocação de ${assignment.professional.name} atualizada (${changes.join(', ')})`, + metadata: { assignmentId, changes }, + createdBy: userId, + }); + } catch (error) { + this.logger.error('Falha ao criar evento de timeline para atualização de alocação', error); + } + + return assignment; + } + + async remove(assignmentId: string, userId: string) { + const existing = await this.findAssignmentOrFail(assignmentId); + const result = await this.repository.softDelete(assignmentId); + + try { + await this.timelineService.createEvent({ + deliverableId: existing.deliverableId, + type: 'ASSIGNMENT', + title: 'Profissional removido', + description: `${existing.professional.name} removido da equipe`, + metadata: { + assignmentId, + professionalId: existing.professionalId, + }, + createdBy: userId, + }); + } catch (error) { + this.logger.error('Falha ao criar evento de timeline para remoção de alocação', error); + } + + return result; + } + + private async findAssignmentOrFail(assignmentId: string) { + const assignment = await this.repository.findById(assignmentId); + + if (!assignment || !assignment.isActive) { + throw new NotFoundException('Alocação não encontrada'); + } + + return assignment; + } + + private async validateDeliverableExists(deliverableId: string) { + const deliverable = await this.prisma.deliverable.findUnique({ + where: { id: deliverableId }, + select: { id: true, isActive: true }, + }); + + if (!deliverable || !deliverable.isActive) { + throw new NotFoundException('Entregável não encontrado'); + } + } + + private async validateProfessionalActive(professionalId: string) { + const professional = await this.prisma.professional.findUnique({ + where: { id: professionalId }, + select: { id: true, isActive: true }, + }); + + if (!professional) { + throw new BadRequestException('Profissional não encontrado'); + } + + if (!professional.isActive) { + throw new BadRequestException('Profissional está inativo'); + } + } + + private async validateNoDuplicate(deliverableId: string, professionalId: string) { + const existing = await this.repository.findDuplicate(deliverableId, professionalId); + + if (existing) { + throw new ConflictException('Profissional já está alocado neste entregável'); + } + } +} diff --git a/src/modules/deliverables/constants/required-observation-transitions.ts b/src/modules/deliverables/constants/required-observation-transitions.ts new file mode 100644 index 0000000..7a1c527 --- /dev/null +++ b/src/modules/deliverables/constants/required-observation-transitions.ts @@ -0,0 +1,18 @@ +import { DeliverableStatus } from '@prisma/client'; + +interface StatusTransition { + from: DeliverableStatus | '*'; + to: DeliverableStatus; +} + +export const REQUIRED_OBSERVATION_TRANSITIONS: StatusTransition[] = [ + { from: '*', to: DeliverableStatus.CANCELADA }, + { from: '*', to: DeliverableStatus.GLOSADA }, + { from: DeliverableStatus.EM_REVISAO, to: DeliverableStatus.AGUARDANDO_VALIDACAO }, +]; + +export function isObservationRequired(from: DeliverableStatus, to: DeliverableStatus): boolean { + return REQUIRED_OBSERVATION_TRANSITIONS.some( + (t) => (t.from === '*' || t.from === from) && t.to === to, + ); +} diff --git a/src/modules/deliverables/constants/status-transitions.ts b/src/modules/deliverables/constants/status-transitions.ts new file mode 100644 index 0000000..53392c4 --- /dev/null +++ b/src/modules/deliverables/constants/status-transitions.ts @@ -0,0 +1,19 @@ +import { DeliverableStatus } from '@prisma/client'; + +export const STATUS_TRANSITIONS: Record = { + RASCUNHO: [DeliverableStatus.EMITIDA, DeliverableStatus.CANCELADA], + EMITIDA: [DeliverableStatus.EM_EXECUCAO, DeliverableStatus.CANCELADA], + EM_EXECUCAO: [DeliverableStatus.AGUARDANDO_VALIDACAO, DeliverableStatus.CANCELADA], + AGUARDANDO_VALIDACAO: [ + DeliverableStatus.EM_REVISAO, + DeliverableStatus.APROVADA, + DeliverableStatus.GLOSADA, + ], + EM_REVISAO: [DeliverableStatus.AGUARDANDO_VALIDACAO, DeliverableStatus.CANCELADA], + APROVADA: [DeliverableStatus.ENCERRADA, DeliverableStatus.AGUARDANDO_PAGAMENTO], + GLOSADA: [DeliverableStatus.EM_REVISAO, DeliverableStatus.CANCELADA], + ENCERRADA: [], + CANCELADA: [], + AGUARDANDO_PAGAMENTO: [DeliverableStatus.PAGA], + PAGA: [], +}; diff --git a/src/modules/deliverables/deliverable-num-weeks.util.spec.ts b/src/modules/deliverables/deliverable-num-weeks.util.spec.ts new file mode 100644 index 0000000..1bc1415 --- /dev/null +++ b/src/modules/deliverables/deliverable-num-weeks.util.spec.ts @@ -0,0 +1,33 @@ +import { computeNumWeeks } from './deliverable-num-weeks.util'; + +describe('computeNumWeeks', () => { + it('janela de 7 dias → 1 semana', () => { + expect( + computeNumWeeks(new Date('2026-05-01T00:00:00Z'), new Date('2026-05-08T00:00:00Z')), + ).toBe(1); + }); + + it('janela de 21 dias → 3 semanas', () => { + expect( + computeNumWeeks(new Date('2026-05-01T00:00:00Z'), new Date('2026-05-22T00:00:00Z')), + ).toBe(3); + }); + + it('fração de semana arredonda para cima', () => { + expect( + computeNumWeeks(new Date('2026-05-01T00:00:00Z'), new Date('2026-05-10T00:00:00Z')), + ).toBe(2); + }); + + it('mesmo dia → mínimo 1', () => { + expect( + computeNumWeeks(new Date('2026-05-01T00:00:00Z'), new Date('2026-05-01T00:00:00Z')), + ).toBe(1); + }); + + it('janela negativa → 1 (proteção mínima)', () => { + expect( + computeNumWeeks(new Date('2026-05-10T00:00:00Z'), new Date('2026-05-01T00:00:00Z')), + ).toBe(1); + }); +}); diff --git a/src/modules/deliverables/deliverable-num-weeks.util.ts b/src/modules/deliverables/deliverable-num-weeks.util.ts new file mode 100644 index 0000000..8fccf51 --- /dev/null +++ b/src/modules/deliverables/deliverable-num-weeks.util.ts @@ -0,0 +1,7 @@ +const MS_PER_DAY = 1000 * 60 * 60 * 24; + +export function computeNumWeeks(startDate: Date, expectedEndDate: Date): number { + const diffMs = expectedEndDate.getTime() - startDate.getTime(); + const diffDays = diffMs / MS_PER_DAY; + return Math.max(1, Math.ceil(diffDays / 7)); +} diff --git a/src/modules/deliverables/deliverable-value-calculator.service.spec.ts b/src/modules/deliverables/deliverable-value-calculator.service.spec.ts new file mode 100644 index 0000000..2dd434a --- /dev/null +++ b/src/modules/deliverables/deliverable-value-calculator.service.spec.ts @@ -0,0 +1,189 @@ +import { BadRequestException } from '@nestjs/common'; +import { ContractItemType, DeliverableType, Prisma } from '@prisma/client'; +import { + DeliverableValueCalculatorContractItem, + DeliverableValueCalculatorService, +} from './deliverable-value-calculator.service'; + +const buildContractItem = ( + overrides: Partial = {}, +): DeliverableValueCalculatorContractItem => ({ + itemType: ContractItemType.UST, + ustValue: new Prisma.Decimal(533), + timeboxDescoberta: new Prisma.Decimal(40), + timeboxDesign: new Prisma.Decimal(40), + timeboxArquitetura: new Prisma.Decimal(40), + timeboxConstrucao: new Prisma.Decimal(40), + ...overrides, +}); + +describe('DeliverableValueCalculatorService', () => { + let service: DeliverableValueCalculatorService; + + beforeEach(() => { + service = new DeliverableValueCalculatorService(); + }); + + it('Descoberta × 1 semana × Item04 (timeboxDescoberta=40, ustValue=533) → 21320', () => { + const result = service.calculate({ + type: DeliverableType.DESCOBERTA, + numWeeks: 1, + timeboxManutencao: null, + contractItem: buildContractItem({ + timeboxDescoberta: new Prisma.Decimal(40), + ustValue: new Prisma.Decimal(533), + }), + }); + expect(result.equals(new Prisma.Decimal(21320))).toBe(true); + }); + + it('Construção × 2 semanas × Item05 (timeboxConstrucao=80, ustValue=533) → 85280', () => { + const result = service.calculate({ + type: DeliverableType.CONSTRUCAO, + numWeeks: 2, + timeboxManutencao: null, + contractItem: buildContractItem({ + timeboxConstrucao: new Prisma.Decimal(80), + ustValue: new Prisma.Decimal(533), + }), + }); + expect(result.equals(new Prisma.Decimal(85280))).toBe(true); + }); + + it('Construção × 1 semana × Item02 (timeboxConstrucao=40, ustValue=533) → 21320', () => { + const result = service.calculate({ + type: DeliverableType.CONSTRUCAO, + numWeeks: 1, + timeboxManutencao: null, + contractItem: buildContractItem({ + timeboxConstrucao: new Prisma.Decimal(40), + ustValue: new Prisma.Decimal(533), + }), + }); + expect(result.equals(new Prisma.Decimal(21320))).toBe(true); + }); + + it('Manutenção × 4 semanas × timeboxManutencao=20 × ustValue=500 → 40000', () => { + const result = service.calculate({ + type: DeliverableType.MANUTENCAO, + numWeeks: 4, + timeboxManutencao: new Prisma.Decimal(20), + contractItem: buildContractItem({ + ustValue: new Prisma.Decimal(500), + }), + }); + expect(result.equals(new Prisma.Decimal(40000))).toBe(true); + }); + + it('LICENCA + ustValue=500 (item UST) → 500', () => { + const result = service.calculate({ + type: DeliverableType.LICENCA, + numWeeks: 1, + timeboxManutencao: null, + contractItem: buildContractItem({ ustValue: new Prisma.Decimal(500) }), + }); + expect(result.equals(new Prisma.Decimal(500))).toBe(true); + }); + + it('LICENCA + ustValue=500 (item SAAS_LICENSE) → 500 (substitui regra antiga)', () => { + const result = service.calculate({ + type: DeliverableType.LICENCA, + numWeeks: 1, + timeboxManutencao: null, + contractItem: buildContractItem({ + itemType: ContractItemType.SAAS_LICENSE, + ustValue: new Prisma.Decimal(500), + }), + }); + expect(result.equals(new Prisma.Decimal(500))).toBe(true); + }); + + it('LICENCA ignora numWeeks e timebox', () => { + const result = service.calculate({ + type: DeliverableType.LICENCA, + numWeeks: 99, + timeboxManutencao: new Prisma.Decimal(40), + contractItem: buildContractItem({ ustValue: new Prisma.Decimal(123) }), + }); + expect(result.equals(new Prisma.Decimal(123))).toBe(true); + }); + + it('LICENCA + ustValue=null → BadRequestException', () => { + expect(() => + service.calculate({ + type: DeliverableType.LICENCA, + numWeeks: 1, + timeboxManutencao: null, + contractItem: buildContractItem({ ustValue: null }), + }), + ).toThrow('Item de contrato sem valor da UST'); + }); + + it('UST não-manutenção sem timebox correspondente → BadRequestException', () => { + expect(() => + service.calculate({ + type: DeliverableType.DESIGN, + numWeeks: 1, + timeboxManutencao: null, + contractItem: buildContractItem({ timeboxDesign: null }), + }), + ).toThrow(BadRequestException); + expect(() => + service.calculate({ + type: DeliverableType.DESIGN, + numWeeks: 1, + timeboxManutencao: null, + contractItem: buildContractItem({ timeboxDesign: null }), + }), + ).toThrow('Item de contrato sem time-box configurado para o tipo DESIGN'); + }); + + it('UST com type=MANUTENCAO sem timeboxManutencao → BadRequestException', () => { + expect(() => + service.calculate({ + type: DeliverableType.MANUTENCAO, + numWeeks: 2, + timeboxManutencao: null, + contractItem: buildContractItem(), + }), + ).toThrow(BadRequestException); + expect(() => + service.calculate({ + type: DeliverableType.MANUTENCAO, + numWeeks: 2, + timeboxManutencao: null, + contractItem: buildContractItem(), + }), + ).toThrow('Time-box de manutenção é obrigatório no Entregável'); + }); + + it('UST sem ustValue → BadRequestException', () => { + expect(() => + service.calculate({ + type: DeliverableType.CONSTRUCAO, + numWeeks: 1, + timeboxManutencao: null, + contractItem: buildContractItem({ ustValue: null }), + }), + ).toThrow(BadRequestException); + expect(() => + service.calculate({ + type: DeliverableType.CONSTRUCAO, + numWeeks: 1, + timeboxManutencao: null, + contractItem: buildContractItem({ ustValue: null }), + }), + ).toThrow('Item de contrato sem valor da UST'); + }); + + it('UST com type=MANUTENCAO e timeboxManutencao=0 → BadRequestException', () => { + expect(() => + service.calculate({ + type: DeliverableType.MANUTENCAO, + numWeeks: 4, + timeboxManutencao: 0, + contractItem: buildContractItem(), + }), + ).toThrow('Time-box de manutenção é obrigatório no Entregável'); + }); +}); diff --git a/src/modules/deliverables/deliverable-value-calculator.service.ts b/src/modules/deliverables/deliverable-value-calculator.service.ts new file mode 100644 index 0000000..1931b56 --- /dev/null +++ b/src/modules/deliverables/deliverable-value-calculator.service.ts @@ -0,0 +1,84 @@ +import { BadRequestException, Injectable } from '@nestjs/common'; +import { DeliverableType, Prisma } from '@prisma/client'; +import type { ContractItemType } from '@prisma/client'; + +type DecimalLike = Prisma.Decimal | number | string | null | undefined; + +export type DeliverableValueCalculatorContractItem = { + itemType: ContractItemType; + ustValue: Prisma.Decimal | null; + timeboxDescoberta: Prisma.Decimal | null; + timeboxDesign: Prisma.Decimal | null; + timeboxArquitetura: Prisma.Decimal | null; + timeboxConstrucao: Prisma.Decimal | null; +}; + +export type DeliverableValueCalculatorInput = { + type: DeliverableType; + numWeeks: number; + timeboxManutencao: DecimalLike; + contractItem: DeliverableValueCalculatorContractItem; +}; + +const TIMEBOX_FIELD_BY_TYPE: Partial< + Record +> = { + DESCOBERTA: 'timeboxDescoberta', + DESIGN: 'timeboxDesign', + ARQUITETURA: 'timeboxArquitetura', + CONSTRUCAO: 'timeboxConstrucao', +}; + +@Injectable() +export class DeliverableValueCalculatorService { + calculate(input: DeliverableValueCalculatorInput): Prisma.Decimal { + const { type, numWeeks, timeboxManutencao, contractItem } = input; + + if (type === DeliverableType.LICENCA) { + if (contractItem.ustValue === null || contractItem.ustValue === undefined) { + throw new BadRequestException('Item de contrato sem valor da UST'); + } + return new Prisma.Decimal(contractItem.ustValue); + } + + if (contractItem.ustValue === null || contractItem.ustValue === undefined) { + throw new BadRequestException('Item de contrato sem valor da UST'); + } + + const timebox = this.resolveTimebox(type, timeboxManutencao, contractItem); + + return new Prisma.Decimal(timebox).mul(numWeeks).mul(contractItem.ustValue); + } + + private resolveTimebox( + type: DeliverableType, + timeboxManutencao: DecimalLike, + contractItem: DeliverableValueCalculatorContractItem, + ): Prisma.Decimal { + if (type === DeliverableType.MANUTENCAO) { + if (timeboxManutencao === null || timeboxManutencao === undefined) { + throw new BadRequestException('Time-box de manutenção é obrigatório no Entregável'); + } + const value = new Prisma.Decimal(timeboxManutencao); + if (value.lte(0)) { + throw new BadRequestException('Time-box de manutenção é obrigatório no Entregável'); + } + return value; + } + + const field = TIMEBOX_FIELD_BY_TYPE[type]; + if (!field) { + throw new BadRequestException( + `Item de contrato sem time-box configurado para o tipo ${type}`, + ); + } + + const raw = contractItem[field] as Prisma.Decimal | null; + if (raw === null || raw === undefined) { + throw new BadRequestException( + `Item de contrato sem time-box configurado para o tipo ${type}`, + ); + } + return raw; + } +} diff --git a/src/modules/deliverables/deliverables.controller.ts b/src/modules/deliverables/deliverables.controller.ts new file mode 100644 index 0000000..8cbcad6 --- /dev/null +++ b/src/modules/deliverables/deliverables.controller.ts @@ -0,0 +1,78 @@ +import { + Body, + Controller, + Delete, + Get, + Param, + Patch, + Post, + Query, + UseGuards, +} from '@nestjs/common'; +import { JwtAuthGuard } from '../../config/auth/jwt-auth.guard'; +import { ReadOnlyClientGuard } from '../../common/guards/read-only-client.guard'; +import { RolesGuard } from '../../common/guards/roles.guard'; +import { Roles } from '../../common/decorators/roles.decorator'; +import { Role } from '../../common/enums/role.enum'; +import { CurrentUser } from '../../common/decorators/user.decorator'; +import type { CurrentUserPayload } from '../../common/decorators/user.decorator'; +import { DeliverablesService } from './deliverables.service'; +import { CreateDeliverableDto } from './dto/create-deliverable.dto'; +import { UpdateDeliverableDto } from './dto/update-deliverable.dto'; +import { ChangeStatusDto } from './dto/change-status.dto'; +import { ListDeliverablesQueryDto } from './dto/list-deliverables-query.dto'; + +@Controller('deliverables') +@UseGuards(JwtAuthGuard, ReadOnlyClientGuard, RolesGuard) +@Roles(Role.ADMIN, Role.OPERATOR, Role.CLIENT) +export class DeliverablesController { + constructor(private readonly deliverablesService: DeliverablesService) {} + + @Post() + async create(@Body() dto: CreateDeliverableDto, @CurrentUser() user: CurrentUserPayload) { + return this.deliverablesService.create(dto, user.id); + } + + @Get() + async findAll(@Query() query: ListDeliverablesQueryDto, @CurrentUser() user: CurrentUserPayload) { + return this.deliverablesService.findAll(query, user); + } + + @Get(':id') + async findOne(@Param('id') id: string, @CurrentUser() user: CurrentUserPayload) { + return this.deliverablesService.findOne(id, user); + } + + @Get(':id/allowed-transitions') + async getAllowedTransitions(@Param('id') id: string, @CurrentUser() user: CurrentUserPayload) { + return this.deliverablesService.getAllowedTransitions(id, user); + } + + @Get(':id/status-history') + async getStatusHistory(@Param('id') id: string, @CurrentUser() user: CurrentUserPayload) { + return this.deliverablesService.getStatusHistory(id, user); + } + + @Post(':id/status') + async changeStatus( + @Param('id') id: string, + @Body() dto: ChangeStatusDto, + @CurrentUser() user: CurrentUserPayload, + ) { + return this.deliverablesService.changeStatus(id, dto, user.id); + } + + @Patch(':id') + async update( + @Param('id') id: string, + @Body() dto: UpdateDeliverableDto, + @CurrentUser() user: CurrentUserPayload, + ) { + return this.deliverablesService.update(id, dto, user.id); + } + + @Delete(':id') + async remove(@Param('id') id: string, @CurrentUser() user: CurrentUserPayload) { + return this.deliverablesService.remove(id, user.id); + } +} diff --git a/src/modules/deliverables/deliverables.module.ts b/src/modules/deliverables/deliverables.module.ts new file mode 100644 index 0000000..7cef63b --- /dev/null +++ b/src/modules/deliverables/deliverables.module.ts @@ -0,0 +1,31 @@ +import { Module } from '@nestjs/common'; +import { TimelineModule } from '../timeline/timeline.module'; +import { WorkOrdersModule } from '../work-orders/work-orders.module'; +import { DeliverablesController } from './deliverables.controller'; +import { AssignmentsController } from './assignments.controller'; +import { AllocationsController } from './allocations.controller'; +import { DeliverablesService } from './deliverables.service'; +import { DeliverableValueCalculatorService } from './deliverable-value-calculator.service'; +import { AssignmentsService } from './assignments.service'; +import { AllocationsService } from './allocations.service'; +import { DeliverablesRepository } from './deliverables.repository'; +import { StatusHistoryRepository } from './repositories/status-history.repository'; +import { AssignmentsRepository } from './repositories/assignments.repository'; +import { AllocationsRepository } from './allocations.repository'; + +@Module({ + imports: [TimelineModule, WorkOrdersModule], + controllers: [DeliverablesController, AssignmentsController, AllocationsController], + providers: [ + DeliverablesService, + DeliverableValueCalculatorService, + AssignmentsService, + AllocationsService, + DeliverablesRepository, + StatusHistoryRepository, + AssignmentsRepository, + AllocationsRepository, + ], + exports: [DeliverablesService, AllocationsService, DeliverableValueCalculatorService], +}) +export class DeliverablesModule {} diff --git a/src/modules/deliverables/deliverables.repository.spec.ts b/src/modules/deliverables/deliverables.repository.spec.ts new file mode 100644 index 0000000..4f3795a --- /dev/null +++ b/src/modules/deliverables/deliverables.repository.spec.ts @@ -0,0 +1,27 @@ +import { Role } from '../../common/enums/role.enum'; + +describe('DeliverablesRepository — select projection by role', () => { + it('deliverableSelectRestricted excludes financial fields', () => { + // Verify the restricted select is missing financial fields via structural check + const restrictedKeys = [ + 'id', 'code', 'title', 'description', 'status', 'type', + 'startDate', 'expectedEndDate', 'isActive', 'createdAt', 'updatedAt', + 'client', 'contract', 'project', 'contractItem', 'workOrder', 'sprint', + ]; + const financialFields = [ + 'totalValue', 'ustValue', 'ustQuantity', 'numWeeks', 'timeboxManutencao', + ]; + for (const field of financialFields) { + expect(restrictedKeys).not.toContain(field); + } + for (const key of restrictedKeys) { + expect(financialFields).not.toContain(key); + } + }); + + it('ADMIN role enum value is distinct from non-admin', () => { + expect(Role.ADMIN).toBe('ADMIN'); + expect(Role.PO).not.toBe(Role.ADMIN); + expect(Role.GESTOR_PROJETOS).not.toBe(Role.ADMIN); + }); +}); diff --git a/src/modules/deliverables/deliverables.repository.ts b/src/modules/deliverables/deliverables.repository.ts new file mode 100644 index 0000000..92db14e --- /dev/null +++ b/src/modules/deliverables/deliverables.repository.ts @@ -0,0 +1,237 @@ +import { Injectable } from '@nestjs/common'; +import { DeliverableStatus, DeliverableType, Prisma } from '@prisma/client'; +import { PrismaService } from '../../config/database/prisma.service'; +import { Role } from '../../common/enums/role.enum'; + +const deliverableSelect = { + id: true, + code: true, + title: true, + description: true, + status: true, + type: true, + startDate: true, + expectedEndDate: true, + numWeeks: true, + timeboxManutencao: true, + ustValue: true, + ustQuantity: true, + totalValue: true, + isActive: true, + createdAt: true, + updatedAt: true, + createdBy: true, + updatedBy: true, + client: { select: { id: true, name: true } }, + contract: { select: { id: true, name: true } }, + project: { select: { id: true, name: true } }, + contractItem: { + select: { + id: true, + name: true, + code: true, + itemType: true, + ustValue: true, + timeboxDescoberta: true, + timeboxDesign: true, + timeboxArquitetura: true, + timeboxConstrucao: true, + }, + }, + workOrder: { select: { id: true, code: true, name: true, status: true } }, + sprint: { select: { id: true, name: true, startDate: true, endDate: true } }, +} as const; + +const deliverableSelectRestricted = { + id: true, + code: true, + title: true, + description: true, + status: true, + type: true, + startDate: true, + expectedEndDate: true, + isActive: true, + createdAt: true, + updatedAt: true, + client: { select: { id: true, name: true } }, + contract: { select: { id: true, name: true } }, + project: { select: { id: true, name: true } }, + contractItem: { + select: { + id: true, + name: true, + code: true, + itemType: true, + }, + }, + workOrder: { select: { id: true, code: true, name: true, status: true } }, + sprint: { select: { id: true, name: true, startDate: true, endDate: true } }, +} as const; + +const deliverableDetailSelect = { + ...deliverableSelect, + _count: { + select: { + assignments: { where: { isActive: true } }, + backlogItems: { where: { isActive: true } }, + notes: { where: { isActive: true } }, + allocations: { where: { isActive: true } }, + }, + }, + statusHistory: { + orderBy: { createdAt: 'desc' as const }, + take: 1, + select: { + previousStatus: true, + newStatus: true, + createdAt: true, + }, + }, +} as const; + +@Injectable() +export class DeliverablesRepository { + constructor(private readonly prisma: PrismaService) {} + + async findAll(params: { + search?: string; + status?: DeliverableStatus; + type?: DeliverableType; + clientId?: string; + contractId?: string; + workOrderId?: string; + projectId?: string; + sprintId?: string; + page: number; + limit: number; + role?: Role; + }) { + const where: Prisma.DeliverableWhereInput = { isActive: true }; + + if (params.search) { + where.OR = [ + { title: { contains: params.search, mode: 'insensitive' } }, + { code: { contains: params.search, mode: 'insensitive' } }, + ]; + } + if (params.status) where.status = params.status; + if (params.type) where.type = params.type; + if (params.clientId) where.clientId = params.clientId; + if (params.contractId) where.contractId = params.contractId; + if (params.workOrderId) where.workOrderId = params.workOrderId; + if (params.projectId) where.projectId = params.projectId; + if (params.sprintId) where.sprintId = params.sprintId; + + const select = + params.role === undefined || params.role === Role.ADMIN + ? deliverableSelect + : deliverableSelectRestricted; + + const [data, total] = await Promise.all([ + this.prisma.deliverable.findMany({ + where, + select, + orderBy: { createdAt: 'desc' }, + skip: (params.page - 1) * params.limit, + take: params.limit, + }), + this.prisma.deliverable.count({ where }), + ]); + + return { data, total }; + } + + async findById(id: string) { + return this.prisma.deliverable.findUnique({ + where: { id }, + select: deliverableDetailSelect, + }); + } + + async findByIdPublic(id: string, role?: Role) { + if (role === undefined || role === Role.ADMIN) { + return this.prisma.deliverable.findUnique({ where: { id }, select: deliverableDetailSelect }); + } + return this.prisma.deliverable.findUnique({ + where: { id }, + select: { + ...deliverableSelectRestricted, + _count: deliverableDetailSelect._count, + statusHistory: deliverableDetailSelect.statusHistory, + }, + }); + } + + async findByCode(code: string) { + return this.prisma.deliverable.findUnique({ + where: { code }, + select: { id: true }, + }); + } + + async create(data: { + title: string; + code: string; + type: Prisma.DeliverableCreateInput['type']; + clientId: string; + contractId: string; + projectId: string; + contractItemId: string; + workOrderId: string; + sprintId: string; + numWeeks: number; + timeboxManutencao?: Prisma.Decimal | number | null; + totalValue?: Prisma.Decimal | number | null; + description?: string; + startDate: string; + expectedEndDate: string; + createdBy?: string; + updatedBy?: string; + }) { + return this.prisma.deliverable.create({ + data, + select: deliverableSelect, + }); + } + + async update( + id: string, + data: { + title?: string; + code?: string; + type?: Prisma.DeliverableUpdateInput['type']; + projectId?: string; + sprintId?: string; + numWeeks?: number; + timeboxManutencao?: Prisma.Decimal | number | null; + totalValue?: Prisma.Decimal | number | null; + description?: string; + startDate?: string; + expectedEndDate?: string; + updatedBy?: string; + }, + ) { + return this.prisma.deliverable.update({ + where: { id }, + data, + select: deliverableSelect, + }); + } + + async updateStatus(id: string, status: string) { + return this.prisma.deliverable.update({ + where: { id }, + data: { status: status as never }, + select: deliverableSelect, + }); + } + + async softDelete(id: string) { + return this.prisma.deliverable.update({ + where: { id }, + data: { isActive: false }, + select: deliverableSelect, + }); + } +} diff --git a/src/modules/deliverables/deliverables.service.spec.ts b/src/modules/deliverables/deliverables.service.spec.ts new file mode 100644 index 0000000..b7c6b32 --- /dev/null +++ b/src/modules/deliverables/deliverables.service.spec.ts @@ -0,0 +1,1283 @@ +import { BadRequestException, NotFoundException } from '@nestjs/common'; +import { Test, TestingModule } from '@nestjs/testing'; +import { ContractItemType, DeliverableStatus, DeliverableType, Prisma } from '@prisma/client'; +import { DeliverablesService } from './deliverables.service'; +import { DeliverablesRepository } from './deliverables.repository'; +import { StatusHistoryRepository } from './repositories/status-history.repository'; +import { PrismaService } from '../../config/database/prisma.service'; +import { TimelineService } from '../timeline/timeline.service'; +import { WorkOrdersService } from '../work-orders/work-orders.service'; +import { WorkOrderValueCalculatorService } from '../work-orders/work-order-value-calculator.service'; +import { DeliverableValueCalculatorService } from './deliverable-value-calculator.service'; + +const mockDeliverable = ( + status: DeliverableStatus = DeliverableStatus.RASCUNHO, + overrides: Record = {}, +) => ({ + id: 'deliverable-id-1', + code: 'ENT-001', + title: 'Entregável de Teste', + description: null, + status, + type: DeliverableType.DESCOBERTA, + startDate: new Date('2026-05-01T00:00:00Z'), + expectedEndDate: new Date('2026-05-08T00:00:00Z'), + numWeeks: 1, + timeboxManutencao: null, + ustValue: new Prisma.Decimal(100), + ustQuantity: new Prisma.Decimal(50), + totalValue: new Prisma.Decimal(21320), + isActive: true, + createdAt: new Date(), + updatedAt: new Date(), + createdBy: 'user-id-1', + updatedBy: 'user-id-1', + client: { id: 'client-1', name: 'Cliente' }, + contract: { id: 'contract-1', name: 'Contrato' }, + project: { id: 'project-1', name: 'Projeto' }, + contractItem: { + id: 'contract-item-1', + name: 'Item de Contrato', + code: 'IC-001', + itemType: ContractItemType.UST, + }, + workOrder: { id: 'work-order-1', code: 'OS-001', name: 'OS', status: 'EMITIDA' }, + sprint: { id: 'sprint-1', name: 'Sprint 1', startDate: null, endDate: null }, + _count: { assignments: 0, backlogItems: 0, notes: 0, allocations: 0 }, + statusHistory: [], + ...overrides, +}); + +const buildWorkOrderRecord = (overrides: Record = {}) => ({ + id: 'work-order-1', + status: 'EMITIDA', + isActive: true, + contractId: 'contract-1', + contractItemId: 'contract-item-1', + reservedUst: new Prisma.Decimal(1000), + contract: { clientId: 'client-1' }, + projects: [{ projectId: 'project-1' }], + ...overrides, +}); + +const buildContractItemRecord = (overrides: Record = {}) => ({ + itemType: ContractItemType.UST, + ustValue: new Prisma.Decimal(533), + timeboxDescoberta: new Prisma.Decimal(40), + timeboxDesign: new Prisma.Decimal(40), + timeboxArquitetura: new Prisma.Decimal(40), + timeboxConstrucao: new Prisma.Decimal(80), + ...overrides, +}); + +describe('DeliverablesService', () => { + let service: DeliverablesService; + let repository: jest.Mocked; + let statusHistoryRepository: jest.Mocked; + let timelineService: jest.Mocked; + let prisma: { + workOrder: { findUnique: jest.Mock }; + sprint: { findUnique: jest.Mock }; + contractItem: { findUnique: jest.Mock }; + deliverable: { + findUnique: jest.Mock; + aggregate: jest.Mock; + findMany: jest.Mock; + update: jest.Mock; + }; + user: { findMany: jest.Mock }; + }; + let workOrdersService: jest.Mocked< + Pick + >; + let workOrderValueCalculator: jest.Mocked< + Pick + >; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + DeliverablesService, + DeliverableValueCalculatorService, + { + provide: DeliverablesRepository, + useValue: { + findById: jest.fn(), + findByCode: jest.fn(), + findAll: jest.fn(), + create: jest.fn(), + update: jest.fn(), + updateStatus: jest.fn(), + softDelete: jest.fn(), + }, + }, + { + provide: StatusHistoryRepository, + useValue: { + create: jest.fn(), + findByDeliverableId: jest.fn(), + }, + }, + { + provide: PrismaService, + useValue: { + workOrder: { + findUnique: jest.fn().mockResolvedValue(buildWorkOrderRecord()), + }, + sprint: { + findUnique: jest.fn().mockResolvedValue({ id: 'sprint-1', isActive: true }), + }, + contractItem: { + findUnique: jest.fn().mockResolvedValue(buildContractItemRecord()), + }, + deliverable: { + findUnique: jest.fn(), + aggregate: jest + .fn() + .mockResolvedValue({ _sum: { ustQuantity: new Prisma.Decimal(0) } }), + findMany: jest.fn().mockResolvedValue([]), + update: jest.fn(), + }, + user: { + findMany: jest.fn().mockResolvedValue([ + { id: 'user-id-1', name: 'Raphael Soares' }, + { id: 'user-id-2', name: 'Outro Usuário' }, + ]), + }, + }, + }, + { + provide: TimelineService, + useValue: { createEvent: jest.fn() }, + }, + { + provide: WorkOrdersService, + useValue: { updateStatusFromDeliverableLifecycle: jest.fn() }, + }, + { + provide: WorkOrderValueCalculatorService, + useValue: { + recalculateAndPersist: jest + .fn() + .mockResolvedValue({ totalValue: new Prisma.Decimal(0), changed: false }), + recalculateAllForContractItem: jest.fn().mockResolvedValue({ recalculatedCount: 0 }), + }, + }, + ], + }).compile(); + + service = module.get(DeliverablesService); + repository = module.get(DeliverablesRepository); + statusHistoryRepository = module.get(StatusHistoryRepository); + timelineService = module.get(TimelineService); + prisma = module.get(PrismaService); + workOrdersService = module.get(WorkOrdersService); + workOrderValueCalculator = module.get(WorkOrderValueCalculatorService); + }); + + describe('changeStatus', () => { + it('atualiza status com transição válida (RASCUNHO→EMITIDA) e cria histórico', async () => { + const deliverable = mockDeliverable(DeliverableStatus.RASCUNHO); + const updatedDeliverable = mockDeliverable(DeliverableStatus.EMITIDA); + + repository.findById.mockResolvedValue(deliverable); + repository.updateStatus.mockResolvedValue(updatedDeliverable); + prisma.deliverable.findUnique.mockResolvedValue({ + workOrderId: 'work-order-1', + ustQuantity: new Prisma.Decimal(50), + }); + statusHistoryRepository.create.mockResolvedValue({ + id: 'history-1', + deliverableId: 'deliverable-id-1', + previousStatus: DeliverableStatus.RASCUNHO, + newStatus: DeliverableStatus.EMITIDA, + observation: null, + createdAt: new Date(), + createdBy: 'user-id-1', + }); + + const result = await service.changeStatus( + 'deliverable-id-1', + { status: DeliverableStatus.EMITIDA }, + 'user-id-1', + ); + + expect(result.status).toBe(DeliverableStatus.EMITIDA); + expect(repository.updateStatus).toHaveBeenCalledWith( + 'deliverable-id-1', + DeliverableStatus.EMITIDA, + ); + }); + + it('400 com transição inválida (RASCUNHO→APROVADA)', async () => { + repository.findById.mockResolvedValue(mockDeliverable(DeliverableStatus.RASCUNHO)); + + await expect( + service.changeStatus( + 'deliverable-id-1', + { status: DeliverableStatus.APROVADA }, + 'user-id-1', + ), + ).rejects.toThrow(BadRequestException); + }); + + it('400 para CANCELADA sem observação', async () => { + repository.findById.mockResolvedValue(mockDeliverable(DeliverableStatus.RASCUNHO)); + + await expect( + service.changeStatus( + 'deliverable-id-1', + { status: DeliverableStatus.CANCELADA }, + 'user-id-1', + ), + ).rejects.toThrow('Observação é obrigatória para esta transição de status'); + }); + + it('permite CANCELADA com observação', async () => { + const deliverable = mockDeliverable(DeliverableStatus.RASCUNHO); + const updatedDeliverable = mockDeliverable(DeliverableStatus.CANCELADA); + + repository.findById.mockResolvedValue(deliverable); + repository.updateStatus.mockResolvedValue(updatedDeliverable); + statusHistoryRepository.create.mockResolvedValue({ + id: 'history-1', + deliverableId: 'deliverable-id-1', + previousStatus: DeliverableStatus.RASCUNHO, + newStatus: DeliverableStatus.CANCELADA, + observation: 'Motivo do cancelamento', + createdAt: new Date(), + createdBy: 'user-id-1', + }); + + const result = await service.changeStatus( + 'deliverable-id-1', + { status: DeliverableStatus.CANCELADA, observation: 'Motivo do cancelamento' }, + 'user-id-1', + ); + + expect(result.status).toBe(DeliverableStatus.CANCELADA); + }); + + it('400 para GLOSADA sem observação', async () => { + repository.findById.mockResolvedValue( + mockDeliverable(DeliverableStatus.AGUARDANDO_VALIDACAO), + ); + + await expect( + service.changeStatus( + 'deliverable-id-1', + { status: DeliverableStatus.GLOSADA }, + 'user-id-1', + ), + ).rejects.toThrow('Observação é obrigatória para esta transição de status'); + }); + + it('400 de ENCERRADA para qualquer status', async () => { + repository.findById.mockResolvedValue(mockDeliverable(DeliverableStatus.ENCERRADA)); + + await expect( + service.changeStatus( + 'deliverable-id-1', + { status: DeliverableStatus.EMITIDA }, + 'user-id-1', + ), + ).rejects.toThrow(BadRequestException); + }); + + it('400 de CANCELADA para qualquer status', async () => { + repository.findById.mockResolvedValue(mockDeliverable(DeliverableStatus.CANCELADA)); + + await expect( + service.changeStatus( + 'deliverable-id-1', + { status: DeliverableStatus.RASCUNHO }, + 'user-id-1', + ), + ).rejects.toThrow(BadRequestException); + }); + + it('400 de PAGA para qualquer status', async () => { + repository.findById.mockResolvedValue(mockDeliverable(DeliverableStatus.PAGA)); + + await expect( + service.changeStatus( + 'deliverable-id-1', + { status: DeliverableStatus.ENCERRADA }, + 'user-id-1', + ), + ).rejects.toThrow(BadRequestException); + }); + + it('permite APROVADA→AGUARDANDO_PAGAMENTO', async () => { + const deliverable = mockDeliverable(DeliverableStatus.APROVADA); + const updatedDeliverable = mockDeliverable(DeliverableStatus.AGUARDANDO_PAGAMENTO); + + repository.findById.mockResolvedValue(deliverable); + repository.updateStatus.mockResolvedValue(updatedDeliverable); + statusHistoryRepository.create.mockResolvedValue({ + id: 'history-1', + deliverableId: 'deliverable-id-1', + previousStatus: DeliverableStatus.APROVADA, + newStatus: DeliverableStatus.AGUARDANDO_PAGAMENTO, + observation: null, + createdAt: new Date(), + createdBy: 'user-id-1', + }); + + const result = await service.changeStatus( + 'deliverable-id-1', + { status: DeliverableStatus.AGUARDANDO_PAGAMENTO }, + 'user-id-1', + ); + + expect(result.status).toBe(DeliverableStatus.AGUARDANDO_PAGAMENTO); + }); + + it('permite AGUARDANDO_PAGAMENTO→PAGA', async () => { + const deliverable = mockDeliverable(DeliverableStatus.AGUARDANDO_PAGAMENTO); + const updatedDeliverable = mockDeliverable(DeliverableStatus.PAGA); + + repository.findById.mockResolvedValue(deliverable); + repository.updateStatus.mockResolvedValue(updatedDeliverable); + statusHistoryRepository.create.mockResolvedValue({ + id: 'history-1', + deliverableId: 'deliverable-id-1', + previousStatus: DeliverableStatus.AGUARDANDO_PAGAMENTO, + newStatus: DeliverableStatus.PAGA, + observation: null, + createdAt: new Date(), + createdBy: 'user-id-1', + }); + + const result = await service.changeStatus( + 'deliverable-id-1', + { status: DeliverableStatus.PAGA }, + 'user-id-1', + ); + + expect(result.status).toBe(DeliverableStatus.PAGA); + }); + + it('registra dados corretos no histórico', async () => { + const deliverable = mockDeliverable(DeliverableStatus.EM_EXECUCAO); + const updatedDeliverable = mockDeliverable(DeliverableStatus.AGUARDANDO_VALIDACAO); + + repository.findById.mockResolvedValue(deliverable); + repository.updateStatus.mockResolvedValue(updatedDeliverable); + statusHistoryRepository.create.mockResolvedValue({ + id: 'history-1', + deliverableId: 'deliverable-id-1', + previousStatus: DeliverableStatus.EM_EXECUCAO, + newStatus: DeliverableStatus.AGUARDANDO_VALIDACAO, + observation: 'Trabalho concluído', + createdAt: new Date(), + createdBy: 'user-id-2', + }); + + await service.changeStatus( + 'deliverable-id-1', + { status: DeliverableStatus.AGUARDANDO_VALIDACAO, observation: 'Trabalho concluído' }, + 'user-id-2', + ); + + expect(statusHistoryRepository.create).toHaveBeenCalledWith({ + deliverableId: 'deliverable-id-1', + previousStatus: DeliverableStatus.EM_EXECUCAO, + newStatus: DeliverableStatus.AGUARDANDO_VALIDACAO, + observation: 'Trabalho concluído', + createdBy: 'user-id-2', + }); + }); + + it('404 se entregável não existir', async () => { + repository.findById.mockResolvedValue(null); + + await expect( + service.changeStatus( + 'deliverable-inexistente', + { status: DeliverableStatus.EMITIDA }, + 'user-id-1', + ), + ).rejects.toThrow(NotFoundException); + }); + + it('emite chamando syncWorkOrderStatus', async () => { + repository.findById.mockResolvedValue(mockDeliverable(DeliverableStatus.RASCUNHO)); + repository.updateStatus.mockResolvedValue(mockDeliverable(DeliverableStatus.EMITIDA)); + prisma.deliverable.findUnique.mockResolvedValue({ + workOrderId: 'work-order-1', + ustQuantity: new Prisma.Decimal(50), + }); + + await service.changeStatus( + 'deliverable-id-1', + { status: DeliverableStatus.EMITIDA }, + 'user-id-1', + ); + + expect(workOrdersService.updateStatusFromDeliverableLifecycle).toHaveBeenCalledWith( + 'work-order-1', + 'user-id-1', + ); + }); + }); + + describe('getAllowedTransitions', () => { + it('retorna [EMITIDA, CANCELADA] para RASCUNHO', async () => { + repository.findById.mockResolvedValue(mockDeliverable(DeliverableStatus.RASCUNHO)); + const result = await service.getAllowedTransitions('deliverable-id-1'); + expect(result).toEqual([DeliverableStatus.EMITIDA, DeliverableStatus.CANCELADA]); + }); + + it('retorna [ENCERRADA, AGUARDANDO_PAGAMENTO] para APROVADA', async () => { + repository.findById.mockResolvedValue(mockDeliverable(DeliverableStatus.APROVADA)); + const result = await service.getAllowedTransitions('deliverable-id-1'); + expect(result).toEqual([DeliverableStatus.ENCERRADA, DeliverableStatus.AGUARDANDO_PAGAMENTO]); + }); + + it('retorna [PAGA] para AGUARDANDO_PAGAMENTO', async () => { + repository.findById.mockResolvedValue( + mockDeliverable(DeliverableStatus.AGUARDANDO_PAGAMENTO), + ); + const result = await service.getAllowedTransitions('deliverable-id-1'); + expect(result).toEqual([DeliverableStatus.PAGA]); + }); + + it('retorna [] para ENCERRADA', async () => { + repository.findById.mockResolvedValue(mockDeliverable(DeliverableStatus.ENCERRADA)); + const result = await service.getAllowedTransitions('deliverable-id-1'); + expect(result).toEqual([]); + }); + + it('retorna [] para PAGA', async () => { + repository.findById.mockResolvedValue(mockDeliverable(DeliverableStatus.PAGA)); + const result = await service.getAllowedTransitions('deliverable-id-1'); + expect(result).toEqual([]); + }); + }); + + describe('getStatusHistory', () => { + it('retorna lista ordenada por data desc', async () => { + const mockHistory = [ + { + id: 'h-2', + deliverableId: 'deliverable-id-1', + previousStatus: DeliverableStatus.EMITIDA, + newStatus: DeliverableStatus.EM_EXECUCAO, + observation: null, + createdAt: new Date('2026-04-04T15:00:00Z'), + createdBy: 'user-id-1', + }, + { + id: 'h-1', + deliverableId: 'deliverable-id-1', + previousStatus: DeliverableStatus.RASCUNHO, + newStatus: DeliverableStatus.EMITIDA, + observation: null, + createdAt: new Date('2026-04-04T14:00:00Z'), + createdBy: 'user-id-1', + }, + ]; + + repository.findById.mockResolvedValue(mockDeliverable()); + statusHistoryRepository.findByDeliverableId.mockResolvedValue(mockHistory); + + const result = await service.getStatusHistory('deliverable-id-1'); + + expect(result).toHaveLength(2); + expect(result[0].createdBy).toBe('Raphael Soares'); + }); + + it('404 se entregável não existir', async () => { + repository.findById.mockResolvedValue(null); + + await expect(service.getStatusHistory('deliverable-inexistente')).rejects.toThrow( + NotFoundException, + ); + }); + }); + + describe('timeline events', () => { + const baseCreateDto = { + code: 'ENT-001', + title: 'Entregável de Teste', + workOrderId: 'work-order-1', + type: DeliverableType.DESCOBERTA, + projectId: 'project-1', + sprintId: 'sprint-1', + startDate: '2026-05-01', + expectedEndDate: '2026-05-08', + }; + + it('emite evento CRIACAO ao criar entregável', async () => { + repository.findByCode.mockResolvedValue(null); + repository.create.mockResolvedValue(mockDeliverable()); + + await service.create(baseCreateDto, 'user-id-1'); + + expect(timelineService.createEvent).toHaveBeenCalledWith({ + deliverableId: 'deliverable-id-1', + type: 'CRIACAO', + title: 'Entregável criado', + description: 'ENT-001 - Entregável de Teste', + createdBy: 'user-id-1', + }); + }); + + it('emite evento EDICAO ao editar entregável', async () => { + const deliverable = mockDeliverable(); + repository.findById.mockResolvedValue(deliverable); + repository.update.mockResolvedValue({ ...deliverable, title: 'Novo Título' }); + + await service.update('deliverable-id-1', { title: 'Novo Título' }, 'user-id-1'); + + expect(timelineService.createEvent).toHaveBeenCalledWith({ + deliverableId: 'deliverable-id-1', + type: 'EDICAO', + title: 'Dados do entregável atualizados', + description: 'Campos alterados: título', + createdBy: 'user-id-1', + }); + }); + + it('emite evento STATUS_CHANGE ao mudar status', async () => { + repository.findById.mockResolvedValue(mockDeliverable(DeliverableStatus.RASCUNHO)); + repository.updateStatus.mockResolvedValue(mockDeliverable(DeliverableStatus.EMITIDA)); + prisma.deliverable.findUnique.mockResolvedValue({ + workOrderId: 'work-order-1', + ustQuantity: new Prisma.Decimal(50), + }); + statusHistoryRepository.create.mockResolvedValue({ + id: 'history-1', + deliverableId: 'deliverable-id-1', + previousStatus: DeliverableStatus.RASCUNHO, + newStatus: DeliverableStatus.EMITIDA, + observation: null, + createdAt: new Date(), + createdBy: 'user-id-1', + }); + + await service.changeStatus( + 'deliverable-id-1', + { status: DeliverableStatus.EMITIDA }, + 'user-id-1', + ); + + expect(timelineService.createEvent).toHaveBeenCalledWith({ + deliverableId: 'deliverable-id-1', + type: 'STATUS_CHANGE', + title: 'Status alterado', + description: 'De RASCUNHO para EMITIDA', + metadata: { previousStatus: 'RASCUNHO', newStatus: 'EMITIDA' }, + createdBy: 'user-id-1', + }); + }); + }); + + describe('create — vínculo com WorkOrder', () => { + const baseDto = { + code: 'ENT-001', + title: 'Entregável', + workOrderId: 'work-order-1', + type: DeliverableType.DESCOBERTA, + projectId: 'project-1', + sprintId: 'sprint-1', + startDate: '2026-05-01', + expectedEndDate: '2026-05-08', + }; + + it('cria herdando contractId/contractItemId/clientId da WorkOrder', async () => { + repository.findByCode.mockResolvedValue(null); + repository.create.mockResolvedValue(mockDeliverable()); + + await service.create(baseDto, 'user-id-1'); + + expect(repository.create).toHaveBeenCalledWith( + expect.objectContaining({ + workOrderId: 'work-order-1', + clientId: 'client-1', + contractId: 'contract-1', + contractItemId: 'contract-item-1', + projectId: 'project-1', + }), + ); + }); + + it('falha com 404 quando workOrderId inexistente', async () => { + prisma.workOrder.findUnique.mockResolvedValue(null); + + await expect(service.create(baseDto, 'user-id-1')).rejects.toThrow(NotFoundException); + }); + + it('falha com 400 quando WorkOrder está cancelada', async () => { + prisma.workOrder.findUnique.mockResolvedValue(buildWorkOrderRecord({ status: 'CANCELADA' })); + + await expect(service.create(baseDto, 'user-id-1')).rejects.toThrow( + /cancelada não aceita novos entregáveis/, + ); + }); + + it('falha com 400 quando projectId fora da WorkOrder', async () => { + prisma.workOrder.findUnique.mockResolvedValue( + buildWorkOrderRecord({ projects: [{ projectId: 'project-other' }] }), + ); + + await expect(service.create(baseDto, 'user-id-1')).rejects.toThrow( + /Projeto não pertence à ordem de serviço/, + ); + }); + + it('chama syncWorkOrderStatus após criar', async () => { + repository.findByCode.mockResolvedValue(null); + repository.create.mockResolvedValue(mockDeliverable()); + + await service.create(baseDto, 'user-id-1'); + + expect(workOrdersService.updateStatusFromDeliverableLifecycle).toHaveBeenCalledWith( + 'work-order-1', + 'user-id-1', + ); + }); + + it('chama recalculateAndPersist na WorkOrder após criar (NBC-016)', async () => { + repository.findByCode.mockResolvedValue(null); + repository.create.mockResolvedValue(mockDeliverable()); + + await service.create(baseDto, 'user-id-1'); + + expect(workOrderValueCalculator.recalculateAndPersist).toHaveBeenCalledWith( + 'work-order-1', + undefined, + ); + }); + }); + + describe('changeStatus — saldo do pool na emissão', () => { + beforeEach(() => { + repository.findById.mockResolvedValue(mockDeliverable(DeliverableStatus.RASCUNHO)); + repository.updateStatus.mockResolvedValue(mockDeliverable(DeliverableStatus.EMITIDA)); + statusHistoryRepository.create.mockResolvedValue({ + id: 'history-1', + deliverableId: 'deliverable-id-1', + previousStatus: DeliverableStatus.RASCUNHO, + newStatus: DeliverableStatus.EMITIDA, + observation: null, + createdAt: new Date(), + createdBy: 'user-id-1', + }); + }); + + it('emite com saldo suficiente: lowBalanceWarning false', async () => { + prisma.deliverable.findUnique.mockResolvedValue({ + workOrderId: 'work-order-1', + ustQuantity: new Prisma.Decimal(100), + }); + prisma.workOrder.findUnique.mockResolvedValue({ reservedUst: new Prisma.Decimal(1000) }); + prisma.deliverable.aggregate.mockResolvedValue({ + _sum: { ustQuantity: new Prisma.Decimal(200) }, + }); + + const result = await service.changeStatus( + 'deliverable-id-1', + { status: DeliverableStatus.EMITIDA }, + 'user-id-1', + ); + + expect(result.lowBalanceWarning).toBe(false); + expect(result.status).toBe(DeliverableStatus.EMITIDA); + }); + + it('400 com saldo insuficiente: "Saldo insuficiente no pool da OS Mãe"', async () => { + prisma.deliverable.findUnique.mockResolvedValue({ + workOrderId: 'work-order-1', + ustQuantity: new Prisma.Decimal(500), + }); + prisma.workOrder.findUnique.mockResolvedValue({ reservedUst: new Prisma.Decimal(1000) }); + prisma.deliverable.aggregate.mockResolvedValue({ + _sum: { ustQuantity: new Prisma.Decimal(800) }, + }); + + await expect( + service.changeStatus( + 'deliverable-id-1', + { status: DeliverableStatus.EMITIDA }, + 'user-id-1', + ), + ).rejects.toThrow('Saldo insuficiente no pool da OS Mãe'); + }); + + it('emite cruzando threshold de 20%: lowBalanceWarning true', async () => { + prisma.deliverable.findUnique.mockResolvedValue({ + workOrderId: 'work-order-1', + ustQuantity: new Prisma.Decimal(100), + }); + prisma.workOrder.findUnique.mockResolvedValue({ reservedUst: new Prisma.Decimal(1000) }); + prisma.deliverable.aggregate.mockResolvedValue({ + _sum: { ustQuantity: new Prisma.Decimal(850) }, + }); + + const result = await service.changeStatus( + 'deliverable-id-1', + { status: DeliverableStatus.EMITIDA }, + 'user-id-1', + ); + + expect(result.lowBalanceWarning).toBe(true); + }); + }); + + describe('create — cálculo de totalValue (NBC-003)', () => { + const baseDto = { + code: 'ENT-001', + title: 'Entregável', + workOrderId: 'work-order-1', + type: DeliverableType.DESCOBERTA, + projectId: 'project-1', + sprintId: 'sprint-1', + startDate: '2026-05-01', + expectedEndDate: '2026-05-08', + }; + + it('UST não-MANUTENCAO: persiste totalValue calculado', async () => { + repository.findByCode.mockResolvedValue(null); + repository.create.mockResolvedValue(mockDeliverable()); + + await service.create(baseDto, 'user-id-1'); + + const args = repository.create.mock.calls[0][0] as Record; + expect(args.numWeeks).toBe(1); + expect(args.timeboxManutencao).toBeNull(); + expect((args.totalValue as Prisma.Decimal).equals(new Prisma.Decimal(21320))).toBe(true); + }); + + it('UST MANUTENCAO: usa timeboxManutencao do DTO no cálculo', async () => { + repository.findByCode.mockResolvedValue(null); + repository.create.mockResolvedValue(mockDeliverable()); + prisma.contractItem.findUnique.mockResolvedValue( + buildContractItemRecord({ ustValue: new Prisma.Decimal(500) }), + ); + + await service.create( + { + ...baseDto, + type: DeliverableType.MANUTENCAO, + startDate: '2026-05-01', + expectedEndDate: '2026-05-29', + timeboxManutencao: 20, + }, + 'user-id-1', + ); + + const args = repository.create.mock.calls[0][0] as Record; + expect((args.timeboxManutencao as Prisma.Decimal).equals(new Prisma.Decimal(20))).toBe(true); + expect((args.totalValue as Prisma.Decimal).equals(new Prisma.Decimal(40000))).toBe(true); + }); + + it('UST MANUTENCAO sem timeboxManutencao → 400', async () => { + repository.findByCode.mockResolvedValue(null); + + await expect( + service.create({ ...baseDto, type: DeliverableType.MANUTENCAO }, 'user-id-1'), + ).rejects.toThrow('Time-box de manutenção é obrigatório no Entregável'); + }); + + it('LICENCA em item SAAS_LICENSE → totalValue = ustValue (NBC-019)', async () => { + repository.findByCode.mockResolvedValue(null); + repository.create.mockResolvedValue(mockDeliverable()); + prisma.contractItem.findUnique.mockResolvedValue( + buildContractItemRecord({ + itemType: ContractItemType.SAAS_LICENSE, + ustValue: new Prisma.Decimal(750), + }), + ); + + await service.create({ ...baseDto, type: DeliverableType.LICENCA }, 'user-id-1'); + + const args = repository.create.mock.calls[0][0] as Record; + expect((args.totalValue as Prisma.Decimal).equals(new Prisma.Decimal(750))).toBe(true); + }); + + it('LICENCA em item SAAS_LICENSE → totalValue = ustValue', async () => { + repository.findByCode.mockResolvedValue(null); + repository.create.mockResolvedValue(mockDeliverable()); + prisma.contractItem.findUnique.mockResolvedValue( + buildContractItemRecord({ + itemType: ContractItemType.SAAS_LICENSE, + ustValue: new Prisma.Decimal(900), + }), + ); + + await service.create({ ...baseDto, type: DeliverableType.LICENCA }, 'user-id-1'); + + const args = repository.create.mock.calls[0][0] as Record; + expect((args.totalValue as Prisma.Decimal).equals(new Prisma.Decimal(900))).toBe(true); + }); + + it('type≠MANUTENCAO + timeboxManutencao no DTO → forçado para null', async () => { + repository.findByCode.mockResolvedValue(null); + repository.create.mockResolvedValue(mockDeliverable()); + + await service.create({ ...baseDto, timeboxManutencao: 99 }, 'user-id-1'); + + const args = repository.create.mock.calls[0][0] as Record; + expect(args.timeboxManutencao).toBeNull(); + }); + }); + + describe('update — recálculo e VALOR_RECALCULADO (NBC-003)', () => { + it('editar datas recalcula numWeeks/totalValue e emite VALOR_RECALCULADO', async () => { + const deliverable = mockDeliverable(); + repository.findById.mockResolvedValue(deliverable); + repository.update.mockResolvedValue({ + ...deliverable, + numWeeks: 2, + totalValue: new Prisma.Decimal(42640), + }); + + await service.update( + 'deliverable-id-1', + { startDate: '2026-05-01', expectedEndDate: '2026-05-15' }, + 'user-id-1', + ); + + const updateArgs = repository.update.mock.calls[0][1] as Record; + expect(updateArgs.numWeeks).toBe(2); + expect((updateArgs.totalValue as Prisma.Decimal).equals(new Prisma.Decimal(42640))).toBe( + true, + ); + expect(timelineService.createEvent).toHaveBeenCalledWith( + expect.objectContaining({ type: 'VALOR_RECALCULADO' }), + ); + }); + + it('editar timeboxManutencao com type já MANUTENCAO recalcula', async () => { + const deliverable = mockDeliverable(DeliverableStatus.RASCUNHO, { + type: DeliverableType.MANUTENCAO, + timeboxManutencao: new Prisma.Decimal(20), + totalValue: new Prisma.Decimal(40000), + }); + repository.findById.mockResolvedValue(deliverable); + repository.update.mockResolvedValue(deliverable); + prisma.contractItem.findUnique.mockResolvedValue( + buildContractItemRecord({ ustValue: new Prisma.Decimal(500) }), + ); + + await service.update('deliverable-id-1', { timeboxManutencao: 30 }, 'user-id-1'); + + const updateArgs = repository.update.mock.calls[0][1] as Record; + expect((updateArgs.timeboxManutencao as Prisma.Decimal).equals(new Prisma.Decimal(30))).toBe( + true, + ); + expect(updateArgs.totalValue).toBeDefined(); + }); + + it('editar type para fora de MANUTENCAO limpa timeboxManutencao e recalcula', async () => { + const deliverable = mockDeliverable(DeliverableStatus.RASCUNHO, { + type: DeliverableType.MANUTENCAO, + timeboxManutencao: new Prisma.Decimal(20), + totalValue: new Prisma.Decimal(40000), + }); + repository.findById.mockResolvedValue(deliverable); + repository.update.mockResolvedValue(deliverable); + + await service.update('deliverable-id-1', { type: DeliverableType.DESCOBERTA }, 'user-id-1'); + + const updateArgs = repository.update.mock.calls[0][1] as Record; + expect(updateArgs.timeboxManutencao).toBeNull(); + expect(updateArgs.totalValue).toBeDefined(); + }); + + it('editar type para MANUTENCAO sem informar timeboxManutencao → 400', async () => { + const deliverable = mockDeliverable(); + repository.findById.mockResolvedValue(deliverable); + + await expect( + service.update('deliverable-id-1', { type: DeliverableType.MANUTENCAO }, 'user-id-1'), + ).rejects.toThrow('Time-box de manutenção é obrigatório no Entregável'); + }); + + it('editar type para outro tipo UST recalcula via fórmula UST (NBC-019)', async () => { + const deliverable = mockDeliverable(DeliverableStatus.RASCUNHO, { + type: DeliverableType.DESCOBERTA, + totalValue: new Prisma.Decimal(21320), + }); + repository.findById.mockResolvedValue(deliverable); + repository.update.mockResolvedValue({ + ...deliverable, + type: DeliverableType.CONSTRUCAO, + totalValue: new Prisma.Decimal(42640), + }); + + await service.update('deliverable-id-1', { type: DeliverableType.CONSTRUCAO }, 'user-id-1'); + + const updateArgs = repository.update.mock.calls[0][1] as Record; + expect(updateArgs.totalValue).toBeDefined(); + }); + + it('edição sem mudar campos relevantes não recalcula', async () => { + const deliverable = mockDeliverable(); + repository.findById.mockResolvedValue(deliverable); + repository.update.mockResolvedValue({ ...deliverable, title: 'Novo' }); + + await service.update('deliverable-id-1', { title: 'Novo' }, 'user-id-1'); + + const updateArgs = repository.update.mock.calls[0][1] as Record; + expect(updateArgs.totalValue).toBeUndefined(); + expect(timelineService.createEvent).not.toHaveBeenCalledWith( + expect.objectContaining({ type: 'VALOR_RECALCULADO' }), + ); + }); + }); + + describe('update', () => { + it('permite alterar type para MANUTENCAO informando timeboxManutencao', async () => { + const deliverable = mockDeliverable(); + repository.findById.mockResolvedValue(deliverable); + repository.update.mockResolvedValue({ ...deliverable, type: DeliverableType.MANUTENCAO }); + + const result = await service.update( + 'deliverable-id-1', + { type: DeliverableType.MANUTENCAO, timeboxManutencao: 20 }, + 'user-id-1', + ); + + expect(repository.update).toHaveBeenCalledWith( + 'deliverable-id-1', + expect.objectContaining({ type: DeliverableType.MANUTENCAO }), + ); + expect(result.type).toBe(DeliverableType.MANUTENCAO); + }); + + it('não envia workOrderId para o repository (campo não está no DTO)', async () => { + const deliverable = mockDeliverable(); + repository.findById.mockResolvedValue(deliverable); + repository.update.mockResolvedValue(deliverable); + + await service.update('deliverable-id-1', { title: 'novo' }, 'user-id-1'); + + const call = repository.update.mock.calls[0][1] as Record; + expect(call.workOrderId).toBeUndefined(); + }); + }); + + describe('create/update — numWeeks derivado de datas (NBC-018)', () => { + const baseDto = { + code: 'ENT-001', + title: 'Entregável', + workOrderId: 'work-order-1', + type: DeliverableType.DESCOBERTA, + projectId: 'project-1', + sprintId: 'sprint-1', + startDate: '2026-05-01', + expectedEndDate: '2026-05-08', + }; + + it('deriva numWeeks=1 para janela de 7 dias', async () => { + repository.findByCode.mockResolvedValue(null); + repository.create.mockResolvedValue(mockDeliverable()); + + await service.create(baseDto, 'user-id-1'); + + const args = repository.create.mock.calls[0][0] as Record; + expect(args.numWeeks).toBe(1); + }); + + it('deriva numWeeks=3 para janela de 21 dias', async () => { + repository.findByCode.mockResolvedValue(null); + repository.create.mockResolvedValue(mockDeliverable()); + + await service.create( + { ...baseDto, startDate: '2026-05-01', expectedEndDate: '2026-05-22' }, + 'user-id-1', + ); + + const args = repository.create.mock.calls[0][0] as Record; + expect(args.numWeeks).toBe(3); + }); + + it('400 quando expectedEndDate < startDate', async () => { + repository.findByCode.mockResolvedValue(null); + + await expect( + service.create( + { ...baseDto, startDate: '2026-05-10', expectedEndDate: '2026-05-01' }, + 'user-id-1', + ), + ).rejects.toThrow(BadRequestException); + }); + + it('editar apenas datas recalcula numWeeks/totalValue + VALOR_RECALCULADO', async () => { + const deliverable = mockDeliverable(); + repository.findById.mockResolvedValue(deliverable); + repository.update.mockResolvedValue({ + ...deliverable, + numWeeks: 3, + totalValue: new Prisma.Decimal(63960), + }); + + await service.update( + 'deliverable-id-1', + { startDate: '2026-05-01', expectedEndDate: '2026-05-22' }, + 'user-id-1', + ); + + const updateArgs = repository.update.mock.calls[0][1] as Record; + expect(updateArgs.numWeeks).toBe(3); + expect(updateArgs.totalValue).toBeDefined(); + expect(timelineService.createEvent).toHaveBeenCalledWith( + expect.objectContaining({ type: 'VALOR_RECALCULADO' }), + ); + }); + + it('LICENCA também aceita datas obrigatórias (numWeeks derivado, totalValue=ustValue)', async () => { + repository.findByCode.mockResolvedValue(null); + repository.create.mockResolvedValue(mockDeliverable()); + prisma.contractItem.findUnique.mockResolvedValue( + buildContractItemRecord({ + itemType: ContractItemType.SAAS_LICENSE, + ustValue: new Prisma.Decimal(800), + }), + ); + + await service.create( + { + ...baseDto, + type: DeliverableType.LICENCA, + startDate: '2026-05-01', + expectedEndDate: '2026-05-22', + }, + 'user-id-1', + ); + + const args = repository.create.mock.calls[0][0] as Record; + expect(args.numWeeks).toBe(3); + expect((args.totalValue as Prisma.Decimal).equals(new Prisma.Decimal(800))).toBe(true); + }); + }); + + describe('create — restrição itemType × type (NBC-021)', () => { + const baseDto = { + code: 'ENT-021', + title: 'Entregável', + workOrderId: 'work-order-1', + type: DeliverableType.CONSTRUCAO, + projectId: 'project-1', + sprintId: 'sprint-1', + startDate: '2026-05-01', + expectedEndDate: '2026-05-08', + }; + + it('SAAS_LICENSE + LICENCA → cria com totalValue = ustValue', async () => { + repository.findByCode.mockResolvedValue(null); + repository.create.mockResolvedValue(mockDeliverable()); + prisma.contractItem.findUnique.mockResolvedValue( + buildContractItemRecord({ + itemType: ContractItemType.SAAS_LICENSE, + ustValue: new Prisma.Decimal(900), + }), + ); + + await service.create({ ...baseDto, type: DeliverableType.LICENCA }, 'user-id-1'); + + const args = repository.create.mock.calls[0][0] as Record; + expect((args.totalValue as Prisma.Decimal).equals(new Prisma.Decimal(900))).toBe(true); + }); + + it('SAAS_LICENSE + CONSTRUCAO → 400', async () => { + repository.findByCode.mockResolvedValue(null); + prisma.contractItem.findUnique.mockResolvedValue( + buildContractItemRecord({ itemType: ContractItemType.SAAS_LICENSE }), + ); + + await expect( + service.create({ ...baseDto, type: DeliverableType.CONSTRUCAO }, 'user-id-1'), + ).rejects.toThrow('Item Licença SaaS aceita apenas Entregáveis do Tipo Licença'); + }); + + it('UST + CONSTRUCAO → cria normalmente', async () => { + repository.findByCode.mockResolvedValue(null); + repository.create.mockResolvedValue(mockDeliverable()); + prisma.contractItem.findUnique.mockResolvedValue(buildContractItemRecord()); + + await service.create({ ...baseDto, type: DeliverableType.CONSTRUCAO }, 'user-id-1'); + + expect(repository.create).toHaveBeenCalled(); + }); + + it('UST + LICENCA → 400', async () => { + repository.findByCode.mockResolvedValue(null); + prisma.contractItem.findUnique.mockResolvedValue(buildContractItemRecord()); + + await expect( + service.create({ ...baseDto, type: DeliverableType.LICENCA }, 'user-id-1'), + ).rejects.toThrow('Item UST não aceita Entregáveis do Tipo Licença'); + }); + }); + + describe('update — restrição itemType × type (NBC-021)', () => { + it('PATCH type=LICENCA em Entregável de item UST → 400', async () => { + const deliverable = mockDeliverable(); + repository.findById.mockResolvedValue(deliverable); + prisma.contractItem.findUnique.mockResolvedValue(buildContractItemRecord()); + + await expect( + service.update('deliverable-id-1', { type: DeliverableType.LICENCA }, 'user-id-1'), + ).rejects.toThrow('Item UST não aceita Entregáveis do Tipo Licença'); + }); + + it('PATCH type=CONSTRUCAO em Entregável de item SAAS_LICENSE → 400', async () => { + const deliverable = mockDeliverable(DeliverableStatus.RASCUNHO, { + type: DeliverableType.LICENCA, + contractItem: { + id: 'contract-item-1', + name: 'Item de Contrato', + code: 'IC-001', + itemType: ContractItemType.SAAS_LICENSE, + }, + }); + repository.findById.mockResolvedValue(deliverable); + prisma.contractItem.findUnique.mockResolvedValue( + buildContractItemRecord({ itemType: ContractItemType.SAAS_LICENSE }), + ); + + await expect( + service.update('deliverable-id-1', { type: DeliverableType.CONSTRUCAO }, 'user-id-1'), + ).rejects.toThrow('Item Licença SaaS aceita apenas Entregáveis do Tipo Licença'); + }); + }); + + describe('recalculateForContractItem (NBC-006)', () => { + const buildDeliverableRow = (overrides: Record = {}) => ({ + id: 'd-1', + type: DeliverableType.DESCOBERTA, + numWeeks: 1, + timeboxManutencao: null, + totalValue: new Prisma.Decimal(0), + ...overrides, + }); + + it('recalcula entregáveis com novo ustValue e registra timeline VALOR_RECALCULADO', async () => { + prisma.contractItem.findUnique.mockResolvedValue( + buildContractItemRecord({ ustValue: new Prisma.Decimal(700) }), + ); + prisma.deliverable.findMany.mockResolvedValue([ + buildDeliverableRow({ id: 'd-1', totalValue: new Prisma.Decimal(21320) }), + buildDeliverableRow({ + id: 'd-2', + type: DeliverableType.CONSTRUCAO, + numWeeks: 2, + totalValue: new Prisma.Decimal(85280), + }), + ]); + prisma.deliverable.update.mockResolvedValue({}); + + const result = await service.recalculateForContractItem('item-1', 'user-1'); + + expect(result.recalculatedCount).toBe(2); + expect(prisma.deliverable.update).toHaveBeenCalledTimes(2); + expect(timelineService.createEvent).toHaveBeenCalledWith( + expect.objectContaining({ type: 'VALOR_RECALCULADO', deliverableId: 'd-1' }), + ); + expect(timelineService.createEvent).toHaveBeenCalledWith( + expect.objectContaining({ type: 'VALOR_RECALCULADO', deliverableId: 'd-2' }), + ); + }); + + it('Entregáveis LICENCA recebem ustValue mesmo em itemType SAAS_LICENSE (NBC-019)', async () => { + prisma.contractItem.findUnique.mockResolvedValue( + buildContractItemRecord({ + itemType: ContractItemType.SAAS_LICENSE, + ustValue: new Prisma.Decimal(700), + }), + ); + prisma.deliverable.findMany.mockResolvedValue([ + buildDeliverableRow({ + id: 'd-licenca', + type: DeliverableType.LICENCA, + totalValue: new Prisma.Decimal(0), + }), + ]); + prisma.deliverable.update.mockResolvedValue({}); + + const result = await service.recalculateForContractItem('item-1', 'user-1'); + + expect(result.recalculatedCount).toBe(1); + const updateCalls = prisma.deliverable.update.mock.calls as Array< + [{ data: { totalValue: Prisma.Decimal } }] + >; + expect(updateCalls[0][0].data.totalValue.equals(new Prisma.Decimal(700))).toBe(true); + }); + + it('apenas atualiza entregáveis cujo totalValue muda', async () => { + prisma.contractItem.findUnique.mockResolvedValue(buildContractItemRecord()); + prisma.deliverable.findMany.mockResolvedValue([ + buildDeliverableRow({ id: 'd-unchanged', totalValue: new Prisma.Decimal(21320) }), + buildDeliverableRow({ + id: 'd-changed', + type: DeliverableType.CONSTRUCAO, + numWeeks: 2, + totalValue: new Prisma.Decimal(0), + }), + ]); + prisma.deliverable.update.mockResolvedValue({}); + + const result = await service.recalculateForContractItem('item-1', 'user-1'); + + expect(result.recalculatedCount).toBe(1); + expect(prisma.deliverable.update).toHaveBeenCalledTimes(1); + expect(prisma.deliverable.update).toHaveBeenCalledWith( + expect.objectContaining({ where: { id: 'd-changed' } }), + ); + }); + + it('reusa timeboxManutencao do entregável para tipo MANUTENCAO', async () => { + prisma.contractItem.findUnique.mockResolvedValue( + buildContractItemRecord({ ustValue: new Prisma.Decimal(500) }), + ); + prisma.deliverable.findMany.mockResolvedValue([ + buildDeliverableRow({ + id: 'd-mant', + type: DeliverableType.MANUTENCAO, + numWeeks: 4, + timeboxManutencao: new Prisma.Decimal(20), + totalValue: new Prisma.Decimal(0), + }), + ]); + prisma.deliverable.update.mockResolvedValue({}); + + await service.recalculateForContractItem('item-1', 'user-1'); + + const calls = prisma.deliverable.update.mock.calls as Array< + [{ data: { totalValue: Prisma.Decimal } }] + >; + expect(calls[0][0].data.totalValue.equals(new Prisma.Decimal(40000))).toBe(true); + }); + + it('404 quando contractItem inexistente', async () => { + prisma.contractItem.findUnique.mockResolvedValue(null); + + await expect(service.recalculateForContractItem('item-x')).rejects.toThrow(NotFoundException); + }); + + it('usa cliente de transação quando tx é fornecido', async () => { + const tx = { + contractItem: { findUnique: jest.fn().mockResolvedValue(buildContractItemRecord()) }, + deliverable: { + findMany: jest.fn().mockResolvedValue([]), + update: jest.fn(), + }, + }; + + const result = await service.recalculateForContractItem( + 'item-1', + 'user-1', + tx as unknown as Prisma.TransactionClient, + ); + + expect(tx.contractItem.findUnique).toHaveBeenCalled(); + expect(tx.deliverable.findMany).toHaveBeenCalled(); + expect(prisma.contractItem.findUnique).not.toHaveBeenCalled(); + expect(result.recalculatedCount).toBe(0); + }); + }); +}); diff --git a/src/modules/deliverables/deliverables.service.ts b/src/modules/deliverables/deliverables.service.ts new file mode 100644 index 0000000..254bf03 --- /dev/null +++ b/src/modules/deliverables/deliverables.service.ts @@ -0,0 +1,681 @@ +import { BadRequestException, Injectable, Logger, NotFoundException } from '@nestjs/common'; +import { ContractItemType, DeliverableStatus, DeliverableType, Prisma } from '@prisma/client'; +import { Role } from '../../common/enums/role.enum'; +import type { CurrentUserPayload } from '../../common/decorators/user.decorator'; +import { DeliverablesRepository } from './deliverables.repository'; +import { StatusHistoryRepository } from './repositories/status-history.repository'; +import { PrismaService } from '../../config/database/prisma.service'; +import { TimelineService } from '../timeline/timeline.service'; +import { WorkOrdersService } from '../work-orders/work-orders.service'; +import { WorkOrderValueCalculatorService } from '../work-orders/work-order-value-calculator.service'; +import { CreateDeliverableDto } from './dto/create-deliverable.dto'; +import { UpdateDeliverableDto } from './dto/update-deliverable.dto'; +import { ChangeStatusDto } from './dto/change-status.dto'; +import { ListDeliverablesQueryDto } from './dto/list-deliverables-query.dto'; +import { STATUS_TRANSITIONS } from './constants/status-transitions'; +import { isObservationRequired } from './constants/required-observation-transitions'; +import { + DeliverableValueCalculatorContractItem, + DeliverableValueCalculatorService, +} from './deliverable-value-calculator.service'; +import { computeNumWeeks } from './deliverable-num-weeks.util'; + +const COMMITTED_STATUSES: DeliverableStatus[] = [ + DeliverableStatus.EMITIDA, + DeliverableStatus.EM_EXECUCAO, + DeliverableStatus.AGUARDANDO_VALIDACAO, + DeliverableStatus.EM_REVISAO, + DeliverableStatus.APROVADA, + DeliverableStatus.AGUARDANDO_PAGAMENTO, + DeliverableStatus.PAGA, + DeliverableStatus.ENCERRADA, + DeliverableStatus.GLOSADA, +]; + +const LOW_BALANCE_THRESHOLD = new Prisma.Decimal('0.20'); + +@Injectable() +export class DeliverablesService { + private readonly logger = new Logger(DeliverablesService.name); + + constructor( + private readonly repository: DeliverablesRepository, + private readonly statusHistoryRepository: StatusHistoryRepository, + private readonly prisma: PrismaService, + private readonly timelineService: TimelineService, + private readonly workOrdersService: WorkOrdersService, + private readonly valueCalculator: DeliverableValueCalculatorService, + private readonly workOrderValueCalculator: WorkOrderValueCalculatorService, + ) {} + + private async syncWorkOrderTotalValue( + workOrderId: string | null | undefined, + tx?: Prisma.TransactionClient, + ) { + if (!workOrderId) return; + try { + await this.workOrderValueCalculator.recalculateAndPersist(workOrderId, tx); + } catch (error) { + this.logger.warn( + `Falha ao recalcular totalValue da WorkOrder ${workOrderId}: ${(error as Error).message}`, + ); + } + } + + private async loadContractItemForCalculation( + contractItemId: string, + ): Promise { + const item = await this.prisma.contractItem.findUnique({ + where: { id: contractItemId }, + select: { + itemType: true, + ustValue: true, + timeboxDescoberta: true, + timeboxDesign: true, + timeboxArquitetura: true, + timeboxConstrucao: true, + }, + }); + if (!item) { + throw new NotFoundException('Item de contrato não encontrado'); + } + return item; + } + + private assertDeliverableTypeMatchesContractItem( + type: DeliverableType, + itemType: ContractItemType, + ): void { + if (itemType === ContractItemType.SAAS_LICENSE && type !== DeliverableType.LICENCA) { + throw new BadRequestException('Item Licença SaaS aceita apenas Entregáveis do Tipo Licença'); + } + if (itemType === ContractItemType.UST && type === DeliverableType.LICENCA) { + throw new BadRequestException('Item UST não aceita Entregáveis do Tipo Licença'); + } + } + + private normalizeTimeboxManutencao( + type: DeliverableType, + value: number | null | undefined, + ): Prisma.Decimal | null { + if (type !== DeliverableType.MANUTENCAO) { + if (value !== undefined && value !== null) { + this.logger.warn( + 'timeboxManutencao informado para tipo diferente de MANUTENCAO; valor descartado', + ); + } + return null; + } + if (value === null || value === undefined || value <= 0) { + throw new BadRequestException('Time-box de manutenção é obrigatório no Entregável'); + } + return new Prisma.Decimal(value); + } + + private async syncWorkOrderStatus(workOrderId: string | null | undefined, userId?: string) { + if (!workOrderId) return; + try { + await this.workOrdersService.updateStatusFromDeliverableLifecycle(workOrderId, userId); + } catch (error) { + this.logger.warn( + `Falha ao recalcular status da ordem de serviço ${workOrderId} após mutação de entregável`, + error as Error, + ); + } + } + + async findAll(query: ListDeliverablesQueryDto, user?: CurrentUserPayload) { + const { page, limit, ...filters } = query; + const effectiveFilters = { ...filters }; + + if ( + (user?.role === Role.CLIENT || + user?.role === Role.PO || + user?.role === Role.FISCAL_CONTRATO || + user?.role === Role.GESTOR_CONTRATO) && + user.clientId + ) { + effectiveFilters.clientId = user.clientId; + } + + const { data, total } = await this.repository.findAll({ + ...effectiveFilters, + page, + limit, + role: user?.role, + }); + return { data, total, page, limit }; + } + + async findOne(id: string, user?: CurrentUserPayload) { + const deliverable = await this.repository.findByIdPublic(id, user?.role); + + if (!deliverable) { + throw new NotFoundException('Entregável não encontrado'); + } + + if ( + (user?.role === Role.CLIENT || + user?.role === Role.PO || + user?.role === Role.FISCAL_CONTRATO || + user?.role === Role.GESTOR_CONTRATO) && + user.clientId && + deliverable.client.id !== user.clientId + ) { + throw new NotFoundException('Entregável não encontrado'); + } + + return deliverable; + } + + async validateDeliverableOwnership(deliverableId: string, clientId: string): Promise { + const deliverable = await this.repository.findById(deliverableId); + + if (!deliverable) { + throw new NotFoundException('Entregável não encontrado'); + } + + if (deliverable.client.id !== clientId) { + throw new NotFoundException('Entregável não encontrado'); + } + } + + async create(dto: CreateDeliverableDto, userId: string) { + const workOrder = await this.loadActiveWorkOrder(dto.workOrderId); + this.assertProjectInWorkOrder(dto.projectId, workOrder); + await this.assertSprintActive(dto.sprintId); + this.validateDates(dto.startDate, dto.expectedEndDate); + await this.validateUniqueCode(dto.code); + + const startDate = new Date(dto.startDate); + const expectedEndDate = new Date(dto.expectedEndDate); + const numWeeks = computeNumWeeks(startDate, expectedEndDate); + + const timeboxManutencao = this.normalizeTimeboxManutencao(dto.type, dto.timeboxManutencao); + const contractItem = await this.loadContractItemForCalculation(workOrder.contractItemId); + this.assertDeliverableTypeMatchesContractItem(dto.type, contractItem.itemType); + const totalValue = this.valueCalculator.calculate({ + type: dto.type, + numWeeks, + timeboxManutencao, + contractItem, + }); + + const deliverable = await this.repository.create({ + title: dto.title, + code: dto.code, + description: dto.description, + type: dto.type, + workOrderId: workOrder.id, + clientId: workOrder.contract.clientId, + contractId: workOrder.contractId, + contractItemId: workOrder.contractItemId, + projectId: dto.projectId, + sprintId: dto.sprintId, + numWeeks, + timeboxManutencao, + totalValue, + startDate: startDate.toISOString(), + expectedEndDate: expectedEndDate.toISOString(), + createdBy: userId, + updatedBy: userId, + }); + + try { + await this.timelineService.createEvent({ + deliverableId: deliverable.id, + type: 'CRIACAO', + title: 'Entregável criado', + description: `${deliverable.code} - ${deliverable.title}`, + createdBy: userId, + }); + } catch (error) { + this.logger.error('Falha ao criar evento de timeline para criação de entregável', error); + } + + await this.syncWorkOrderTotalValue(workOrder.id); + await this.syncWorkOrderStatus(workOrder.id, userId); + + return deliverable; + } + + async update(id: string, dto: UpdateDeliverableDto, userId: string) { + const existing = await this.repository.findById(id); + + if (!existing) throw new NotFoundException('Entregável não encontrado'); + + if (dto.projectId && dto.projectId !== existing.project.id) { + const workOrder = await this.loadActiveWorkOrder(existing.workOrder?.id ?? '', { + allowAnyStatus: true, + }); + this.assertProjectInWorkOrder(dto.projectId, workOrder); + } + + if (dto.sprintId && dto.sprintId !== existing.sprint.id) { + await this.assertSprintActive(dto.sprintId); + } + + const effectiveStartDate = + dto.startDate !== undefined ? new Date(dto.startDate) : new Date(existing.startDate); + const effectiveExpectedEndDate = + dto.expectedEndDate !== undefined + ? new Date(dto.expectedEndDate) + : new Date(existing.expectedEndDate); + + if (effectiveExpectedEndDate < effectiveStartDate) { + throw new BadRequestException( + 'Data prevista de término deve ser igual ou posterior à data de início', + ); + } + + if (dto.code && dto.code !== existing.code) { + await this.validateUniqueCode(dto.code); + } + + const datesChanged = dto.startDate !== undefined || dto.expectedEndDate !== undefined; + const effectiveNumWeeks = datesChanged + ? computeNumWeeks(effectiveStartDate, effectiveExpectedEndDate) + : existing.numWeeks; + + const effectiveType = dto.type ?? existing.type; + const incomingTimebox = + dto.timeboxManutencao !== undefined + ? dto.timeboxManutencao + : existing.timeboxManutencao !== null && existing.timeboxManutencao !== undefined + ? Number(existing.timeboxManutencao) + : null; + const normalizedTimebox = this.normalizeTimeboxManutencao(effectiveType, incomingTimebox); + + const contractItem = await this.loadContractItemForCalculation(existing.contractItem.id); + this.assertDeliverableTypeMatchesContractItem(effectiveType, contractItem.itemType); + const previousTotalValue = existing.totalValue ? new Prisma.Decimal(existing.totalValue) : null; + + const shouldRecalculate = + datesChanged || dto.type !== undefined || dto.timeboxManutencao !== undefined; + + const newTotalValue = shouldRecalculate + ? this.valueCalculator.calculate({ + type: effectiveType, + numWeeks: effectiveNumWeeks, + timeboxManutencao: normalizedTimebox, + contractItem, + }) + : null; + + const updatedDeliverable = await this.repository.update(id, { + title: dto.title, + code: dto.code, + description: dto.description, + type: dto.type, + projectId: dto.projectId, + sprintId: dto.sprintId, + ...(datesChanged ? { numWeeks: effectiveNumWeeks } : {}), + ...(dto.type !== undefined || dto.timeboxManutencao !== undefined + ? { timeboxManutencao: normalizedTimebox } + : {}), + ...(shouldRecalculate ? { totalValue: newTotalValue } : {}), + startDate: dto.startDate ? new Date(dto.startDate).toISOString() : undefined, + expectedEndDate: dto.expectedEndDate + ? new Date(dto.expectedEndDate).toISOString() + : undefined, + updatedBy: userId, + }); + + try { + const fieldLabels: Record = { + title: 'título', + code: 'código', + description: 'descrição', + type: 'tipo de entrega', + projectId: 'projeto', + sprintId: 'sprint', + timeboxManutencao: 'time-box de manutenção', + startDate: 'data de início', + expectedEndDate: 'data prevista de término', + }; + const changes = Object.keys(dto) + .filter((key) => dto[key as keyof UpdateDeliverableDto] !== undefined) + .map((key) => fieldLabels[key] || key); + + await this.timelineService.createEvent({ + deliverableId: id, + type: 'EDICAO', + title: 'Dados do entregável atualizados', + description: `Campos alterados: ${changes.join(', ')}`, + createdBy: userId, + }); + } catch (error) { + this.logger.error('Falha ao criar evento de timeline para edição de entregável', error); + } + + if (shouldRecalculate && newTotalValue) { + const totalValueChanged = + previousTotalValue === null || !previousTotalValue.equals(newTotalValue); + if (totalValueChanged) { + try { + await this.timelineService.createEvent({ + deliverableId: id, + type: 'VALOR_RECALCULADO', + title: 'Valor do entregável recalculado', + description: `Novo valor: R$ ${newTotalValue.toFixed(2)}`, + metadata: { + previousValue: previousTotalValue ? previousTotalValue.toFixed(2) : null, + newValue: newTotalValue.toFixed(2), + }, + createdBy: userId, + }); + } catch (error) { + this.logger.error('Falha ao criar evento de timeline para recálculo de valor', error); + } + await this.syncWorkOrderTotalValue(existing.workOrder?.id ?? null); + } + } + + return updatedDeliverable; + } + + async changeStatus(id: string, dto: ChangeStatusDto, userId: string) { + const deliverable = await this.findOne(id); + const currentStatus = deliverable.status; + const newStatus = dto.status; + + const allowedTransitions = STATUS_TRANSITIONS[currentStatus] ?? []; + if (!allowedTransitions.includes(newStatus)) { + throw new BadRequestException( + `Transição de ${currentStatus} para ${newStatus} não é permitida`, + ); + } + + if (isObservationRequired(currentStatus, newStatus) && !dto.observation?.trim()) { + throw new BadRequestException('Observação é obrigatória para esta transição de status'); + } + + let lowBalanceWarning = false; + + if (currentStatus === DeliverableStatus.RASCUNHO && newStatus === DeliverableStatus.EMITIDA) { + lowBalanceWarning = await this.assertPoolHasBalanceAndComputeWarning(id); + } + + const updatedDeliverable = await this.repository.updateStatus(id, newStatus); + + await this.statusHistoryRepository.create({ + deliverableId: id, + previousStatus: currentStatus, + newStatus, + observation: dto.observation, + createdBy: userId, + }); + + try { + await this.timelineService.createEvent({ + deliverableId: id, + type: 'STATUS_CHANGE', + title: 'Status alterado', + description: `De ${currentStatus} para ${newStatus}`, + metadata: { previousStatus: currentStatus, newStatus }, + createdBy: userId, + }); + } catch (error) { + this.logger.error('Falha ao criar evento de timeline para mudança de status', error); + } + + await this.syncWorkOrderStatus(deliverable.workOrder?.id ?? null, userId); + + return { ...updatedDeliverable, lowBalanceWarning }; + } + + async getAllowedTransitions(id: string, user?: CurrentUserPayload) { + const deliverable = await this.findOne(id, user); + const currentStatus = deliverable.status; + const transitions = STATUS_TRANSITIONS[currentStatus] ?? []; + return transitions; + } + + async getStatusHistory(id: string, user?: CurrentUserPayload) { + await this.findOne(id, user); + const history = await this.statusHistoryRepository.findByDeliverableId(id); + + const userIds = [...new Set(history.map((h) => h.createdBy).filter(Boolean))] as string[]; + + if (userIds.length > 0) { + const users = await this.prisma.user.findMany({ + where: { id: { in: userIds } }, + select: { id: true, name: true }, + }); + const userMap = new Map(users.map((u) => [u.id, u.name])); + + return history.map((entry) => ({ + ...entry, + createdBy: entry.createdBy ? (userMap.get(entry.createdBy) ?? entry.createdBy) : null, + })); + } + + return history; + } + + async remove(id: string, userId?: string) { + const deliverable = await this.findOne(id); + + if (deliverable.status !== 'RASCUNHO') { + throw new BadRequestException('Apenas entregáveis em rascunho podem ser removidos'); + } + + const result = await this.repository.softDelete(id); + await this.syncWorkOrderTotalValue(deliverable.workOrder?.id ?? null); + await this.syncWorkOrderStatus(deliverable.workOrder?.id ?? null, userId); + return result; + } + + async recalculateForContractItem( + contractItemId: string, + userId?: string, + tx?: Prisma.TransactionClient, + ): Promise<{ recalculatedCount: number; recalculatedWorkOrdersCount: number }> { + const client = tx ?? this.prisma; + + const contractItem = await client.contractItem.findUnique({ + where: { id: contractItemId }, + select: { + itemType: true, + ustValue: true, + timeboxDescoberta: true, + timeboxDesign: true, + timeboxArquitetura: true, + timeboxConstrucao: true, + }, + }); + + if (!contractItem) { + throw new NotFoundException('Item de contrato não encontrado'); + } + + const deliverables = await client.deliverable.findMany({ + where: { contractItemId, isActive: true }, + select: { + id: true, + type: true, + numWeeks: true, + timeboxManutencao: true, + totalValue: true, + }, + }); + + type RecalcChange = { + id: string; + previous: Prisma.Decimal | null; + next: Prisma.Decimal; + }; + const changes: RecalcChange[] = []; + + for (const deliverable of deliverables) { + let next: Prisma.Decimal; + try { + next = this.valueCalculator.calculate({ + type: deliverable.type, + numWeeks: deliverable.numWeeks, + timeboxManutencao: deliverable.timeboxManutencao, + contractItem, + }); + } catch (error) { + this.logger.warn( + `Não foi possível recalcular o entregável ${deliverable.id} ao propagar mudança no item ${contractItemId}: ${(error as Error).message}`, + ); + continue; + } + + const previous = deliverable.totalValue ? new Prisma.Decimal(deliverable.totalValue) : null; + if (previous && previous.equals(next)) { + continue; + } + + await client.deliverable.update({ + where: { id: deliverable.id }, + data: { totalValue: next, updatedBy: userId }, + }); + + changes.push({ id: deliverable.id, previous, next }); + } + + for (const change of changes) { + try { + await this.timelineService.createEvent({ + deliverableId: change.id, + type: 'VALOR_RECALCULADO', + title: 'Valor do entregável recalculado', + description: `Novo valor: R$ ${change.next.toFixed(2)}`, + metadata: { + previousValue: change.previous ? change.previous.toFixed(2) : null, + newValue: change.next.toFixed(2), + reason: 'CONTRACT_ITEM_UPDATE', + contractItemId, + }, + createdBy: userId, + }); + } catch (error) { + this.logger.error( + `Falha ao registrar timeline VALOR_RECALCULADO para entregável ${change.id}`, + error, + ); + } + } + + const workOrderResult = await this.workOrderValueCalculator.recalculateAllForContractItem( + contractItemId, + tx, + ); + + return { + recalculatedCount: changes.length, + recalculatedWorkOrdersCount: workOrderResult.recalculatedCount, + }; + } + + private async loadActiveWorkOrder(workOrderId: string, opts: { allowAnyStatus?: boolean } = {}) { + const workOrder = await this.prisma.workOrder.findUnique({ + where: { id: workOrderId }, + select: { + id: true, + status: true, + isActive: true, + contractId: true, + contractItemId: true, + reservedUst: true, + contract: { select: { clientId: true } }, + projects: { select: { projectId: true } }, + }, + }); + + if (!workOrder || !workOrder.isActive) { + throw new NotFoundException('Ordem de serviço não encontrada'); + } + + if (!opts.allowAnyStatus && workOrder.status === 'CANCELADA') { + throw new BadRequestException('Ordem de serviço cancelada não aceita novos entregáveis'); + } + + return workOrder; + } + + private assertProjectInWorkOrder( + projectId: string, + workOrder: { projects: { projectId: string }[] }, + ) { + const allowed = workOrder.projects.some((p) => p.projectId === projectId); + if (!allowed) { + throw new BadRequestException('Projeto não pertence à ordem de serviço informada'); + } + } + + private async assertSprintActive(sprintId: string) { + const sprint = await this.prisma.sprint.findUnique({ + where: { id: sprintId }, + select: { id: true, isActive: true }, + }); + if (!sprint || !sprint.isActive) { + throw new BadRequestException('Sprint não encontrada ou inativa'); + } + } + + private async assertPoolHasBalanceAndComputeWarning(deliverableId: string): Promise { + const target = await this.prisma.deliverable.findUnique({ + where: { id: deliverableId }, + select: { workOrderId: true, ustQuantity: true }, + }); + + if (!target) { + throw new NotFoundException('Entregável não encontrado'); + } + + const requested = target.ustQuantity ?? new Prisma.Decimal(0); + + const workOrder = await this.prisma.workOrder.findUnique({ + where: { id: target.workOrderId }, + select: { reservedUst: true }, + }); + + if (!workOrder) { + throw new NotFoundException('Ordem de serviço não encontrada'); + } + + const used = await this.prisma.deliverable.aggregate({ + where: { + workOrderId: target.workOrderId, + isActive: true, + id: { not: deliverableId }, + status: { in: COMMITTED_STATUSES }, + }, + _sum: { ustQuantity: true }, + }); + + const usedDecimal = used._sum.ustQuantity ?? new Prisma.Decimal(0); + const reserved = new Prisma.Decimal(workOrder.reservedUst); + const available = reserved.minus(usedDecimal); + + if (new Prisma.Decimal(requested).greaterThan(available)) { + throw new BadRequestException('Saldo insuficiente no pool da OS Mãe'); + } + + if (reserved.lessThanOrEqualTo(0)) { + return false; + } + + const remainingAfterEmission = available.minus(requested); + const ratio = remainingAfterEmission.dividedBy(reserved); + return ratio.lessThan(LOW_BALANCE_THRESHOLD); + } + + private validateDates(startDate?: string, expectedEndDate?: string): void { + if (startDate && expectedEndDate && new Date(expectedEndDate) < new Date(startDate)) { + throw new BadRequestException( + 'Data prevista de término deve ser igual ou posterior à data de início', + ); + } + } + + private async validateUniqueCode(code: string) { + const existing = await this.repository.findByCode(code); + if (existing) { + throw new BadRequestException('Já existe um entregável com este código'); + } + } +} diff --git a/src/modules/deliverables/dto/.gitkeep b/src/modules/deliverables/dto/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/src/modules/deliverables/dto/bulk-set-deliverable-allocations.dto.ts b/src/modules/deliverables/dto/bulk-set-deliverable-allocations.dto.ts new file mode 100644 index 0000000..ec4a34b --- /dev/null +++ b/src/modules/deliverables/dto/bulk-set-deliverable-allocations.dto.ts @@ -0,0 +1,9 @@ +import { ValidateNested } from 'class-validator'; +import { Type } from 'class-transformer'; +import { CreateDeliverableAllocationDto } from './create-deliverable-allocation.dto'; + +export class BulkSetDeliverableAllocationsDto { + @ValidateNested({ each: true }) + @Type(() => CreateDeliverableAllocationDto) + items!: CreateDeliverableAllocationDto[]; +} diff --git a/src/modules/deliverables/dto/change-status.dto.ts b/src/modules/deliverables/dto/change-status.dto.ts new file mode 100644 index 0000000..31f58c3 --- /dev/null +++ b/src/modules/deliverables/dto/change-status.dto.ts @@ -0,0 +1,12 @@ +import { IsEnum, IsNotEmpty, IsOptional, IsString } from 'class-validator'; +import { DeliverableStatus } from '@prisma/client'; + +export class ChangeStatusDto { + @IsEnum(DeliverableStatus) + @IsNotEmpty() + status!: DeliverableStatus; + + @IsOptional() + @IsString() + observation?: string; +} diff --git a/src/modules/deliverables/dto/create-assignment.dto.ts b/src/modules/deliverables/dto/create-assignment.dto.ts new file mode 100644 index 0000000..bf46235 --- /dev/null +++ b/src/modules/deliverables/dto/create-assignment.dto.ts @@ -0,0 +1,23 @@ +import { IsDateString, IsNotEmpty, IsOptional, IsString, IsUUID } from 'class-validator'; + +export class CreateAssignmentDto { + @IsUUID('4', { message: 'O ID do profissional deve ser um UUID válido' }) + @IsNotEmpty({ message: 'O ID do profissional é obrigatório' }) + professionalId!: string; + + @IsString({ message: 'O papel deve ser uma string' }) + @IsNotEmpty({ message: 'O papel no entregável é obrigatório' }) + role!: string; + + @IsOptional() + @IsDateString({}, { message: 'A data de início deve ser uma data válida' }) + startDate?: string; + + @IsOptional() + @IsDateString({}, { message: 'A data de fim deve ser uma data válida' }) + endDate?: string; + + @IsOptional() + @IsString({ message: 'A observação deve ser uma string' }) + observation?: string; +} diff --git a/src/modules/deliverables/dto/create-deliverable-allocation.dto.ts b/src/modules/deliverables/dto/create-deliverable-allocation.dto.ts new file mode 100644 index 0000000..2dbc6a9 --- /dev/null +++ b/src/modules/deliverables/dto/create-deliverable-allocation.dto.ts @@ -0,0 +1,17 @@ +import { IsInt, IsNumber, IsUUID, Max, Min } from 'class-validator'; +import { Type } from 'class-transformer'; + +export class CreateDeliverableAllocationDto { + @IsUUID() + profileId!: string; + + @IsInt() + @Min(1) + quantity!: number; + + @Type(() => Number) + @IsNumber() + @Min(0) + @Max(100) + allocationPercentage!: number; +} diff --git a/src/modules/deliverables/dto/create-deliverable.dto.ts b/src/modules/deliverables/dto/create-deliverable.dto.ts new file mode 100644 index 0000000..290e37d --- /dev/null +++ b/src/modules/deliverables/dto/create-deliverable.dto.ts @@ -0,0 +1,48 @@ +import { + IsDateString, + IsEnum, + IsNotEmpty, + IsNumber, + IsOptional, + IsPositive, + IsString, + IsUUID, +} from 'class-validator'; +import { DeliverableType } from '@prisma/client'; + +export class CreateDeliverableDto { + @IsString() + @IsNotEmpty() + title!: string; + + @IsString() + @IsNotEmpty() + code!: string; + + @IsUUID() + workOrderId!: string; + + @IsEnum(DeliverableType) + type!: DeliverableType; + + @IsUUID() + projectId!: string; + + @IsUUID() + sprintId!: string; + + @IsOptional() + @IsNumber() + @IsPositive() + timeboxManutencao?: number; + + @IsOptional() + @IsString() + description?: string; + + @IsDateString() + startDate!: string; + + @IsDateString() + expectedEndDate!: string; +} diff --git a/src/modules/deliverables/dto/list-deliverables-query.dto.ts b/src/modules/deliverables/dto/list-deliverables-query.dto.ts new file mode 100644 index 0000000..218d425 --- /dev/null +++ b/src/modules/deliverables/dto/list-deliverables-query.dto.ts @@ -0,0 +1,50 @@ +import { IsEnum, IsInt, IsOptional, IsString, IsUUID, Max, Min } from 'class-validator'; +import { Transform } from 'class-transformer'; +import { DeliverableStatus, DeliverableType } from '@prisma/client'; + +export class ListDeliverablesQueryDto { + @IsOptional() + @IsString() + search?: string; + + @IsOptional() + @IsEnum(DeliverableStatus) + status?: DeliverableStatus; + + @IsOptional() + @IsEnum(DeliverableType) + type?: DeliverableType; + + @IsOptional() + @IsUUID() + clientId?: string; + + @IsOptional() + @IsUUID() + contractId?: string; + + @IsOptional() + @IsUUID() + workOrderId?: string; + + @IsOptional() + @IsUUID() + projectId?: string; + + @IsOptional() + @IsUUID() + sprintId?: string; + + @IsOptional() + @Transform(({ value }) => parseInt(value, 10)) + @IsInt() + @Min(1) + page: number = 1; + + @IsOptional() + @Transform(({ value }) => parseInt(value, 10)) + @IsInt() + @Min(1) + @Max(100) + limit: number = 20; +} diff --git a/src/modules/deliverables/dto/update-assignment.dto.ts b/src/modules/deliverables/dto/update-assignment.dto.ts new file mode 100644 index 0000000..af9522c --- /dev/null +++ b/src/modules/deliverables/dto/update-assignment.dto.ts @@ -0,0 +1,19 @@ +import { IsDateString, IsOptional, IsString } from 'class-validator'; + +export class UpdateAssignmentDto { + @IsOptional() + @IsString({ message: 'O papel deve ser uma string' }) + role?: string; + + @IsOptional() + @IsDateString({}, { message: 'A data de início deve ser uma data válida' }) + startDate?: string; + + @IsOptional() + @IsDateString({}, { message: 'A data de fim deve ser uma data válida' }) + endDate?: string; + + @IsOptional() + @IsString({ message: 'A observação deve ser uma string' }) + observation?: string; +} diff --git a/src/modules/deliverables/dto/update-deliverable-allocation.dto.ts b/src/modules/deliverables/dto/update-deliverable-allocation.dto.ts new file mode 100644 index 0000000..c3bd02a --- /dev/null +++ b/src/modules/deliverables/dto/update-deliverable-allocation.dto.ts @@ -0,0 +1,20 @@ +import { IsInt, IsNumber, IsOptional, IsUUID, Max, Min } from 'class-validator'; +import { Type } from 'class-transformer'; + +export class UpdateDeliverableAllocationDto { + @IsOptional() + @IsUUID() + profileId?: string; + + @IsOptional() + @IsInt() + @Min(1) + quantity?: number; + + @IsOptional() + @Type(() => Number) + @IsNumber() + @Min(0) + @Max(100) + allocationPercentage?: number; +} diff --git a/src/modules/deliverables/dto/update-deliverable.dto.ts b/src/modules/deliverables/dto/update-deliverable.dto.ts new file mode 100644 index 0000000..d46535a --- /dev/null +++ b/src/modules/deliverables/dto/update-deliverable.dto.ts @@ -0,0 +1,51 @@ +import { + IsDateString, + IsEnum, + IsNumber, + IsOptional, + IsPositive, + IsString, + IsUUID, + ValidateIf, +} from 'class-validator'; +import { DeliverableType } from '@prisma/client'; + +export class UpdateDeliverableDto { + @IsOptional() + @IsString() + title?: string; + + @IsOptional() + @IsString() + code?: string; + + @IsOptional() + @IsEnum(DeliverableType) + type?: DeliverableType; + + @IsOptional() + @IsUUID() + projectId?: string; + + @IsOptional() + @IsUUID() + sprintId?: string; + + @IsOptional() + @ValidateIf((_o, value) => value !== null) + @IsNumber() + @IsPositive() + timeboxManutencao?: number | null; + + @IsOptional() + @IsString() + description?: string; + + @IsOptional() + @IsDateString() + startDate?: string; + + @IsOptional() + @IsDateString() + expectedEndDate?: string; +} diff --git a/src/modules/deliverables/repositories/assignments.repository.ts b/src/modules/deliverables/repositories/assignments.repository.ts new file mode 100644 index 0000000..7132abd --- /dev/null +++ b/src/modules/deliverables/repositories/assignments.repository.ts @@ -0,0 +1,88 @@ +import { Injectable } from '@nestjs/common'; +import { PrismaService } from '../../../config/database/prisma.service'; + +const assignmentSelect = { + id: true, + deliverableId: true, + professionalId: true, + role: true, + startDate: true, + endDate: true, + observation: true, + isActive: true, + createdAt: true, + createdBy: true, + professional: { + select: { + id: true, + name: true, + role: true, + }, + }, +} as const; + +@Injectable() +export class AssignmentsRepository { + constructor(private readonly prisma: PrismaService) {} + + async findByDeliverableId(deliverableId: string) { + return this.prisma.deliverableAssignment.findMany({ + where: { deliverableId, isActive: true }, + select: assignmentSelect, + orderBy: { createdAt: 'desc' }, + }); + } + + async findById(id: string) { + return this.prisma.deliverableAssignment.findUnique({ + where: { id }, + select: assignmentSelect, + }); + } + + async findDuplicate(deliverableId: string, professionalId: string) { + return this.prisma.deliverableAssignment.findFirst({ + where: { deliverableId, professionalId, isActive: true }, + select: assignmentSelect, + }); + } + + async create(data: { + deliverableId: string; + professionalId: string; + role: string; + startDate?: Date; + endDate?: Date; + observation?: string; + createdBy?: string; + }) { + return this.prisma.deliverableAssignment.create({ + data, + select: assignmentSelect, + }); + } + + async update( + id: string, + data: { + role?: string; + startDate?: Date; + endDate?: Date; + observation?: string; + }, + ) { + return this.prisma.deliverableAssignment.update({ + where: { id }, + data, + select: assignmentSelect, + }); + } + + async softDelete(id: string) { + return this.prisma.deliverableAssignment.update({ + where: { id }, + data: { isActive: false }, + select: assignmentSelect, + }); + } +} diff --git a/src/modules/deliverables/repositories/status-history.repository.spec.ts b/src/modules/deliverables/repositories/status-history.repository.spec.ts new file mode 100644 index 0000000..b760955 --- /dev/null +++ b/src/modules/deliverables/repositories/status-history.repository.spec.ts @@ -0,0 +1,117 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { DeliverableStatus } from '@prisma/client'; +import { PrismaService } from '../../../config/database/prisma.service'; +import { StatusHistoryRepository } from './status-history.repository'; + +describe('StatusHistoryRepository', () => { + let repository: StatusHistoryRepository; + let prisma: { + deliverableStatusHistory: { + create: jest.Mock; + findMany: jest.Mock; + }; + }; + + beforeEach(async () => { + prisma = { + deliverableStatusHistory: { + create: jest.fn(), + findMany: jest.fn(), + }, + }; + + const module: TestingModule = await Test.createTestingModule({ + providers: [StatusHistoryRepository, { provide: PrismaService, useValue: prisma }], + }).compile(); + + repository = module.get(StatusHistoryRepository); + }); + + describe('create', () => { + it('deve criar registro de histórico com dados corretos', async () => { + const data = { + deliverableId: 'deliverable-id-1', + previousStatus: DeliverableStatus.RASCUNHO, + newStatus: DeliverableStatus.EMITIDA, + observation: 'Entregável emitido para execução', + createdBy: 'user-id-1', + }; + + const mockResult = { + id: 'history-id-1', + ...data, + createdAt: new Date(), + }; + + prisma.deliverableStatusHistory.create.mockResolvedValue(mockResult); + + const result = await repository.create(data); + + expect(result).toEqual(mockResult); + expect(prisma.deliverableStatusHistory.create).toHaveBeenCalledWith({ + data, + select: { + id: true, + deliverableId: true, + previousStatus: true, + newStatus: true, + observation: true, + createdAt: true, + createdBy: true, + }, + }); + }); + }); + + describe('findByDeliverableId', () => { + it('deve retornar lista ordenada por createdAt desc', async () => { + const mockHistory = [ + { + id: 'history-id-2', + deliverableId: 'deliverable-id-1', + previousStatus: DeliverableStatus.EMITIDA, + newStatus: DeliverableStatus.EM_EXECUCAO, + observation: null, + createdAt: new Date('2026-04-04T15:00:00Z'), + createdBy: 'user-id-1', + }, + { + id: 'history-id-1', + deliverableId: 'deliverable-id-1', + previousStatus: DeliverableStatus.RASCUNHO, + newStatus: DeliverableStatus.EMITIDA, + observation: 'Entregável emitido', + createdAt: new Date('2026-04-04T14:00:00Z'), + createdBy: 'user-id-1', + }, + ]; + + prisma.deliverableStatusHistory.findMany.mockResolvedValue(mockHistory); + + const result = await repository.findByDeliverableId('deliverable-id-1'); + + expect(result).toEqual(mockHistory); + expect(prisma.deliverableStatusHistory.findMany).toHaveBeenCalledWith({ + where: { deliverableId: 'deliverable-id-1' }, + select: { + id: true, + deliverableId: true, + previousStatus: true, + newStatus: true, + observation: true, + createdAt: true, + createdBy: true, + }, + orderBy: { createdAt: 'desc' }, + }); + }); + + it('deve retornar lista vazia quando não há histórico', async () => { + prisma.deliverableStatusHistory.findMany.mockResolvedValue([]); + + const result = await repository.findByDeliverableId('deliverable-sem-historico'); + + expect(result).toEqual([]); + }); + }); +}); diff --git a/src/modules/deliverables/repositories/status-history.repository.ts b/src/modules/deliverables/repositories/status-history.repository.ts new file mode 100644 index 0000000..3a9eae5 --- /dev/null +++ b/src/modules/deliverables/repositories/status-history.repository.ts @@ -0,0 +1,39 @@ +import { Injectable } from '@nestjs/common'; +import { DeliverableStatus } from '@prisma/client'; +import { PrismaService } from '../../../config/database/prisma.service'; + +const statusHistorySelect = { + id: true, + deliverableId: true, + previousStatus: true, + newStatus: true, + observation: true, + createdAt: true, + createdBy: true, +} as const; + +@Injectable() +export class StatusHistoryRepository { + constructor(private readonly prisma: PrismaService) {} + + async create(data: { + deliverableId: string; + previousStatus: DeliverableStatus | null; + newStatus: DeliverableStatus; + observation?: string; + createdBy?: string; + }) { + return this.prisma.deliverableStatusHistory.create({ + data, + select: statusHistorySelect, + }); + } + + async findByDeliverableId(deliverableId: string) { + return this.prisma.deliverableStatusHistory.findMany({ + where: { deliverableId }, + select: statusHistorySelect, + orderBy: { createdAt: 'desc' }, + }); + } +} diff --git a/src/modules/health/health.controller.spec.ts b/src/modules/health/health.controller.spec.ts new file mode 100644 index 0000000..70a1225 --- /dev/null +++ b/src/modules/health/health.controller.spec.ts @@ -0,0 +1,20 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { HealthController } from './health.controller'; + +describe('HealthController', () => { + let controller: HealthController; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + controllers: [HealthController], + }).compile(); + + controller = module.get(HealthController); + }); + + describe('check', () => { + it('should return { status: "ok" }', () => { + expect(controller.check()).toEqual({ status: 'ok' }); + }); + }); +}); diff --git a/src/modules/health/health.controller.ts b/src/modules/health/health.controller.ts new file mode 100644 index 0000000..c3d14da --- /dev/null +++ b/src/modules/health/health.controller.ts @@ -0,0 +1,9 @@ +import { Controller, Get } from '@nestjs/common'; + +@Controller('health') +export class HealthController { + @Get() + check(): { status: string } { + return { status: 'ok' }; + } +} diff --git a/src/modules/health/health.module.ts b/src/modules/health/health.module.ts new file mode 100644 index 0000000..7476abe --- /dev/null +++ b/src/modules/health/health.module.ts @@ -0,0 +1,7 @@ +import { Module } from '@nestjs/common'; +import { HealthController } from './health.controller'; + +@Module({ + controllers: [HealthController], +}) +export class HealthModule {} diff --git a/src/modules/integration-api-keys/dto/create-integration-api-key.dto.ts b/src/modules/integration-api-keys/dto/create-integration-api-key.dto.ts new file mode 100644 index 0000000..df19aa8 --- /dev/null +++ b/src/modules/integration-api-keys/dto/create-integration-api-key.dto.ts @@ -0,0 +1,41 @@ +import { IntegrationScope } from '@prisma/client'; +import { Type } from 'class-transformer'; +import { + ArrayMinSize, + ArrayUnique, + IsArray, + IsEnum, + IsInt, + IsISO8601, + IsOptional, + IsString, + Length, + Max, + Min, +} from 'class-validator'; + +export class CreateIntegrationApiKeyDto { + @IsString({ message: 'Nome é obrigatório' }) + @Length(3, 120, { message: 'Nome deve ter entre 3 e 120 caracteres' }) + name!: string; + + @IsArray({ message: 'Escopos deve ser uma lista' }) + @ArrayMinSize(1, { message: 'Informe ao menos um escopo' }) + @ArrayUnique({ message: 'Escopos não podem ter valores duplicados' }) + @IsEnum(IntegrationScope, { + each: true, + message: 'Escopo inválido', + }) + scopes!: IntegrationScope[]; + + @IsOptional() + @IsISO8601({}, { message: 'Data de expiração inválida' }) + expiresAt?: string; + + @IsOptional() + @Type(() => Number) + @IsInt({ message: 'rateLimitPerMinute deve ser inteiro' }) + @Min(1, { message: 'rateLimitPerMinute deve ser ao menos 1' }) + @Max(6000, { message: 'rateLimitPerMinute não pode exceder 6000' }) + rateLimitPerMinute?: number; +} diff --git a/src/modules/integration-api-keys/dto/list-integration-api-keys-query.dto.ts b/src/modules/integration-api-keys/dto/list-integration-api-keys-query.dto.ts new file mode 100644 index 0000000..f8943cd --- /dev/null +++ b/src/modules/integration-api-keys/dto/list-integration-api-keys-query.dto.ts @@ -0,0 +1,27 @@ +import { Transform } from 'class-transformer'; +import { IsBoolean, IsInt, IsOptional, Max, Min } from 'class-validator'; + +export class ListIntegrationApiKeysQueryDto { + @IsOptional() + @Transform(({ value }) => parseInt(value as string, 10)) + @IsInt() + @Min(1) + page: number = 1; + + @IsOptional() + @Transform(({ value }) => parseInt(value as string, 10)) + @IsInt() + @Min(1) + @Max(200) + pageSize: number = 50; + + @IsOptional() + @Transform(({ value }) => { + if (typeof value === 'boolean') return value; + if (value === 'true') return true; + if (value === 'false') return false; + return undefined; + }) + @IsBoolean() + isActive?: boolean; +} diff --git a/src/modules/integration-api-keys/integration-api-keys.controller.ts b/src/modules/integration-api-keys/integration-api-keys.controller.ts new file mode 100644 index 0000000..49db703 --- /dev/null +++ b/src/modules/integration-api-keys/integration-api-keys.controller.ts @@ -0,0 +1,61 @@ +import { + Body, + Controller, + Delete, + Get, + HttpCode, + HttpStatus, + Param, + ParseUUIDPipe, + Post, + Query, + UseGuards, +} from '@nestjs/common'; +import { JwtAuthGuard } from '../../config/auth/jwt-auth.guard'; +import { RolesGuard } from '../../common/guards/roles.guard'; +import { Roles } from '../../common/decorators/roles.decorator'; +import { Role } from '../../common/enums/role.enum'; +import { CurrentUser } from '../../common/decorators/user.decorator'; +import type { CurrentUserPayload } from '../../common/decorators/user.decorator'; +import { + CreatedIntegrationApiKey, + IntegrationApiKeysService, +} from './integration-api-keys.service'; +import { CreateIntegrationApiKeyDto } from './dto/create-integration-api-key.dto'; +import { ListIntegrationApiKeysQueryDto } from './dto/list-integration-api-keys-query.dto'; +import type { IntegrationApiKeyPublic } from './integration-api-keys.repository'; + +@Controller('admin/integration-api-keys') +@UseGuards(JwtAuthGuard, RolesGuard) +@Roles(Role.ADMIN) +export class IntegrationApiKeysController { + constructor(private readonly service: IntegrationApiKeysService) {} + + @Post() + async create( + @Body() dto: CreateIntegrationApiKeyDto, + @CurrentUser() user: CurrentUserPayload, + ): Promise { + return this.service.create(dto, user?.id ?? null); + } + + @Get() + async findAll(@Query() query: ListIntegrationApiKeysQueryDto): Promise<{ + data: IntegrationApiKeyPublic[]; + total: number; + page: number; + pageSize: number; + totalPages: number; + }> { + return this.service.findAll(query); + } + + @Delete(':id') + @HttpCode(HttpStatus.NO_CONTENT) + async revoke( + @Param('id', new ParseUUIDPipe()) id: string, + @CurrentUser() user: CurrentUserPayload, + ): Promise { + await this.service.revoke(id, user?.id ?? null); + } +} diff --git a/src/modules/integration-api-keys/integration-api-keys.module.ts b/src/modules/integration-api-keys/integration-api-keys.module.ts new file mode 100644 index 0000000..736b65e --- /dev/null +++ b/src/modules/integration-api-keys/integration-api-keys.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common'; +import { ApiKeyGuard } from '../../common/guards/api-key.guard'; +import { IntegrationApiKeysController } from './integration-api-keys.controller'; +import { IntegrationApiKeysRepository } from './integration-api-keys.repository'; +import { IntegrationApiKeysService } from './integration-api-keys.service'; + +@Module({ + controllers: [IntegrationApiKeysController], + providers: [IntegrationApiKeysService, IntegrationApiKeysRepository, ApiKeyGuard], + exports: [IntegrationApiKeysRepository, ApiKeyGuard], +}) +export class IntegrationApiKeysModule {} diff --git a/src/modules/integration-api-keys/integration-api-keys.repository.ts b/src/modules/integration-api-keys/integration-api-keys.repository.ts new file mode 100644 index 0000000..db6f303 --- /dev/null +++ b/src/modules/integration-api-keys/integration-api-keys.repository.ts @@ -0,0 +1,143 @@ +import { Injectable } from '@nestjs/common'; +import { IntegrationScope, Prisma } from '@prisma/client'; +import { PrismaService } from '../../config/database/prisma.service'; + +const apiKeyPublicSelect = { + id: true, + name: true, + lastFourChars: true, + scopes: true, + isActive: true, + expiresAt: true, + lastUsedAt: true, + rateLimitPerMinute: true, + createdAt: true, + updatedAt: true, + createdBy: true, + updatedBy: true, +} as const; + +const apiKeyGuardSelect = { + id: true, + scopes: true, + isActive: true, + expiresAt: true, + rateLimitPerMinute: true, + hashedKey: true, +} as const; + +export type IntegrationApiKeyPublic = Prisma.IntegrationApiKeyGetPayload<{ + select: typeof apiKeyPublicSelect; +}>; + +export type IntegrationApiKeyForGuard = Prisma.IntegrationApiKeyGetPayload<{ + select: typeof apiKeyGuardSelect; +}>; + +export interface CreateIntegrationApiKeyData { + name: string; + hashedKey: string; + lastFourChars: string; + scopes: IntegrationScope[]; + expiresAt?: Date | null; + rateLimitPerMinute: number; + createdBy?: string | null; +} + +export interface ListIntegrationApiKeysParams { + page: number; + pageSize: number; + isActive?: boolean; +} + +export interface LogUsagePayload { + apiKeyId: string; + endpoint: string; + statusCode: number; + ipAddress?: string | null; + userAgent?: string | null; +} + +@Injectable() +export class IntegrationApiKeysRepository { + constructor(private readonly prisma: PrismaService) {} + + async create(data: CreateIntegrationApiKeyData): Promise { + return this.prisma.integrationApiKey.create({ + data: { + name: data.name, + hashedKey: data.hashedKey, + lastFourChars: data.lastFourChars, + scopes: data.scopes, + expiresAt: data.expiresAt ?? null, + rateLimitPerMinute: data.rateLimitPerMinute, + createdBy: data.createdBy ?? null, + }, + select: apiKeyPublicSelect, + }); + } + + async findManyPaginated( + params: ListIntegrationApiKeysParams, + ): Promise<{ data: IntegrationApiKeyPublic[]; total: number }> { + const where: Prisma.IntegrationApiKeyWhereInput = {}; + if (params.isActive !== undefined) where.isActive = params.isActive; + + const [data, total] = await Promise.all([ + this.prisma.integrationApiKey.findMany({ + where, + select: apiKeyPublicSelect, + orderBy: { createdAt: 'desc' }, + skip: (params.page - 1) * params.pageSize, + take: params.pageSize, + }), + this.prisma.integrationApiKey.count({ where }), + ]); + + return { data, total }; + } + + async findById(id: string): Promise { + return this.prisma.integrationApiKey.findUnique({ + where: { id }, + select: apiKeyPublicSelect, + }); + } + + async findByHashedKey(hashedKey: string): Promise { + return this.prisma.integrationApiKey.findUnique({ + where: { hashedKey }, + select: apiKeyGuardSelect, + }); + } + + async revoke(id: string, userId: string | null): Promise { + return this.prisma.integrationApiKey.update({ + where: { id }, + data: { isActive: false, updatedBy: userId ?? undefined }, + select: apiKeyPublicSelect, + }); + } + + async touchLastUsed(id: string): Promise { + await this.prisma.integrationApiKey.update({ + where: { id }, + data: { lastUsedAt: new Date() }, + select: { id: true }, + }); + } + + async logUsage(payload: LogUsagePayload): Promise { + // TODO: cleanup job to drop entries older than 90 days + await this.prisma.integrationApiKeyUsage.create({ + data: { + apiKeyId: payload.apiKeyId, + endpoint: payload.endpoint, + statusCode: payload.statusCode, + ipAddress: payload.ipAddress ?? null, + userAgent: payload.userAgent ?? null, + }, + select: { id: true }, + }); + } +} diff --git a/src/modules/integration-api-keys/integration-api-keys.service.spec.ts b/src/modules/integration-api-keys/integration-api-keys.service.spec.ts new file mode 100644 index 0000000..a2f6219 --- /dev/null +++ b/src/modules/integration-api-keys/integration-api-keys.service.spec.ts @@ -0,0 +1,147 @@ +import { BadRequestException, NotFoundException } from '@nestjs/common'; +import { IntegrationScope } from '@prisma/client'; +import { createHash } from 'crypto'; +import { IntegrationApiKeysService } from './integration-api-keys.service'; +import { IntegrationApiKeysRepository } from './integration-api-keys.repository'; + +function makeRepository(): jest.Mocked { + return { + create: jest.fn(), + findManyPaginated: jest.fn(), + findById: jest.fn(), + findByHashedKey: jest.fn(), + revoke: jest.fn(), + touchLastUsed: jest.fn(), + logUsage: jest.fn(), + } as unknown as jest.Mocked; +} + +describe('IntegrationApiKeysService', () => { + let repository: jest.Mocked; + let service: IntegrationApiKeysService; + + beforeEach(() => { + repository = makeRepository(); + service = new IntegrationApiKeysService(repository); + }); + + describe('create', () => { + it('gera plaintext, persiste hash e retorna plaintext apenas nesta resposta', async () => { + repository.create.mockImplementation((data) => + Promise.resolve({ + id: 'k1', + name: data.name, + lastFourChars: data.lastFourChars, + scopes: data.scopes, + isActive: true, + expiresAt: data.expiresAt ?? null, + lastUsedAt: null, + rateLimitPerMinute: data.rateLimitPerMinute, + createdAt: new Date(), + updatedAt: new Date(), + createdBy: data.createdBy ?? null, + updatedBy: null, + }), + ); + + const result = await service.create( + { + name: 'Iasis Task — Produção', + scopes: [IntegrationScope.DELIVERABLES_READ, IntegrationScope.CLIENTS_READ], + }, + 'admin-1', + ); + + expect(result.plainKey).toHaveLength(48); + expect(result.lastFourChars).toBe(result.plainKey.slice(-4)); + + const callArgs = repository.create.mock.calls[0][0]; + expect(callArgs.hashedKey).toBe(createHash('sha256').update(result.plainKey).digest('hex')); + expect(callArgs.rateLimitPerMinute).toBe(60); + expect(callArgs.createdBy).toBe('admin-1'); + }); + + it('rejeita expiresAt no passado', async () => { + const past = new Date(Date.now() - 86_400_000).toISOString(); + await expect( + service.create( + { + name: 'Stale', + scopes: [IntegrationScope.DELIVERABLES_READ], + expiresAt: past, + }, + null, + ), + ).rejects.toBeInstanceOf(BadRequestException); + }); + + it('aceita expiresAt no futuro', async () => { + const future = new Date(Date.now() + 86_400_000).toISOString(); + repository.create.mockResolvedValue({ + id: 'k1', + name: 'F', + lastFourChars: 'XXXX', + scopes: [IntegrationScope.CLIENTS_READ], + isActive: true, + expiresAt: new Date(future), + lastUsedAt: null, + rateLimitPerMinute: 60, + createdAt: new Date(), + updatedAt: new Date(), + createdBy: null, + updatedBy: null, + }); + + const result = await service.create( + { name: 'F', scopes: [IntegrationScope.CLIENTS_READ], expiresAt: future }, + null, + ); + expect(result.expiresAt).toEqual(new Date(future)); + }); + }); + + describe('findAll', () => { + it('repassa filtros e calcula totalPages', async () => { + repository.findManyPaginated.mockResolvedValue({ + data: [], + total: 95, + }); + const result = await service.findAll({ page: 2, pageSize: 50, isActive: true }); + expect(repository.findManyPaginated).toHaveBeenCalledWith({ + page: 2, + pageSize: 50, + isActive: true, + }); + expect(result.totalPages).toBe(2); + }); + }); + + describe('revoke', () => { + it('lança NotFoundException quando id não existe', async () => { + repository.findById.mockResolvedValue(null); + await expect(service.revoke('missing', 'admin-1')).rejects.toBeInstanceOf(NotFoundException); + expect(repository.revoke).not.toHaveBeenCalled(); + }); + + it('chama repository.revoke com userId', async () => { + repository.findById.mockResolvedValue({ + id: 'k1', + name: 'F', + lastFourChars: 'XXXX', + scopes: [], + isActive: true, + expiresAt: null, + lastUsedAt: null, + rateLimitPerMinute: 60, + createdAt: new Date(), + updatedAt: new Date(), + createdBy: null, + updatedBy: null, + }); + repository.revoke.mockResolvedValue({} as never); + + await service.revoke('k1', 'admin-1'); + expect(repository.revoke).toHaveBeenCalledWith('k1', 'admin-1'); + }); + }); +}); diff --git a/src/modules/integration-api-keys/integration-api-keys.service.ts b/src/modules/integration-api-keys/integration-api-keys.service.ts new file mode 100644 index 0000000..b847487 --- /dev/null +++ b/src/modules/integration-api-keys/integration-api-keys.service.ts @@ -0,0 +1,75 @@ +import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; +import { randomBytes, createHash } from 'crypto'; +import { + IntegrationApiKeyPublic, + IntegrationApiKeysRepository, +} from './integration-api-keys.repository'; +import { CreateIntegrationApiKeyDto } from './dto/create-integration-api-key.dto'; +import { ListIntegrationApiKeysQueryDto } from './dto/list-integration-api-keys-query.dto'; + +export interface CreatedIntegrationApiKey extends IntegrationApiKeyPublic { + plainKey: string; +} + +@Injectable() +export class IntegrationApiKeysService { + constructor(private readonly repository: IntegrationApiKeysRepository) {} + + async create( + dto: CreateIntegrationApiKeyDto, + currentUserId: string | null, + ): Promise { + let expiresAt: Date | null = null; + if (dto.expiresAt) { + expiresAt = new Date(dto.expiresAt); + if (Number.isNaN(expiresAt.getTime())) { + throw new BadRequestException('Data de expiração inválida'); + } + if (expiresAt.getTime() <= Date.now()) { + throw new BadRequestException('Data de expiração deve ser no futuro'); + } + } + + const plainKey = randomBytes(36).toString('base64url'); + const hashedKey = createHash('sha256').update(plainKey).digest('hex'); + const lastFourChars = plainKey.slice(-4); + + const created = await this.repository.create({ + name: dto.name, + hashedKey, + lastFourChars, + scopes: dto.scopes, + expiresAt, + rateLimitPerMinute: dto.rateLimitPerMinute ?? 60, + createdBy: currentUserId, + }); + + return { ...created, plainKey }; + } + + async findAll(query: ListIntegrationApiKeysQueryDto): Promise<{ + data: IntegrationApiKeyPublic[]; + total: number; + page: number; + pageSize: number; + totalPages: number; + }> { + const { page, pageSize, isActive } = query; + const { data, total } = await this.repository.findManyPaginated({ page, pageSize, isActive }); + return { + data, + total, + page, + pageSize, + totalPages: pageSize > 0 ? Math.ceil(total / pageSize) : 0, + }; + } + + async revoke(id: string, currentUserId: string | null): Promise { + const existing = await this.repository.findById(id); + if (!existing) { + throw new NotFoundException('API Key não encontrada'); + } + await this.repository.revoke(id, currentUserId); + } +} diff --git a/src/modules/integrations/dto/list-integration-clients-query.dto.ts b/src/modules/integrations/dto/list-integration-clients-query.dto.ts new file mode 100644 index 0000000..6e5be46 --- /dev/null +++ b/src/modules/integrations/dto/list-integration-clients-query.dto.ts @@ -0,0 +1,31 @@ +import { Transform } from 'class-transformer'; +import { IsBoolean, IsInt, IsISO8601, IsOptional, Max, Min } from 'class-validator'; + +export class ListIntegrationClientsQueryDto { + @IsOptional() + @Transform(({ value }) => parseInt(value as string, 10)) + @IsInt() + @Min(1) + page: number = 1; + + @IsOptional() + @Transform(({ value }) => parseInt(value as string, 10)) + @IsInt() + @Min(1) + @Max(200) + pageSize: number = 50; + + @IsOptional() + @Transform(({ value }) => { + if (typeof value === 'boolean') return value; + if (value === 'true') return true; + if (value === 'false') return false; + return undefined; + }) + @IsBoolean() + isActive?: boolean; + + @IsOptional() + @IsISO8601({}, { message: 'updatedSince inválido' }) + updatedSince?: string; +} diff --git a/src/modules/integrations/dto/list-integration-deliverables-query.dto.ts b/src/modules/integrations/dto/list-integration-deliverables-query.dto.ts new file mode 100644 index 0000000..1d4f149 --- /dev/null +++ b/src/modules/integrations/dto/list-integration-deliverables-query.dto.ts @@ -0,0 +1,34 @@ +import { DeliverableStatus } from '@prisma/client'; +import { Transform } from 'class-transformer'; +import { IsEnum, IsInt, IsISO8601, IsOptional, IsUUID, Max, Min } from 'class-validator'; + +export class ListIntegrationDeliverablesQueryDto { + @IsOptional() + @Transform(({ value }) => parseInt(value as string, 10)) + @IsInt() + @Min(1) + page: number = 1; + + @IsOptional() + @Transform(({ value }) => parseInt(value as string, 10)) + @IsInt() + @Min(1) + @Max(200) + pageSize: number = 50; + + @IsOptional() + @IsEnum(DeliverableStatus, { message: 'Status inválido' }) + status?: DeliverableStatus; + + @IsOptional() + @IsUUID('4', { message: 'clientId inválido' }) + clientId?: string; + + @IsOptional() + @IsUUID('4', { message: 'projectId inválido' }) + projectId?: string; + + @IsOptional() + @IsISO8601({}, { message: 'updatedSince inválido' }) + updatedSince?: string; +} diff --git a/src/modules/integrations/integrations.controller.ts b/src/modules/integrations/integrations.controller.ts new file mode 100644 index 0000000..9e3839d --- /dev/null +++ b/src/modules/integrations/integrations.controller.ts @@ -0,0 +1,58 @@ +import { Controller, Get, Query, UseGuards, UseInterceptors } from '@nestjs/common'; +import { ApiOperation, ApiQuery, ApiResponse, ApiSecurity, ApiTags } from '@nestjs/swagger'; +import { DeliverableStatus, IntegrationScope } from '@prisma/client'; +import { ApiKeyGuard } from '../../common/guards/api-key.guard'; +import { ApiKeyThrottlerGuard } from '../../common/guards/api-key-throttler.guard'; +import { RequireScope } from '../../common/decorators/require-scope.decorator'; +import { IntegrationApiKeyUsageInterceptor } from '../../common/interceptors/integration-api-key-usage.interceptor'; +import { IntegrationsService, PaginatedResult } from './integrations.service'; +import { IntegrationClient, IntegrationDeliverable } from './integrations.repository'; +import { ListIntegrationDeliverablesQueryDto } from './dto/list-integration-deliverables-query.dto'; +import { ListIntegrationClientsQueryDto } from './dto/list-integration-clients-query.dto'; + +@ApiTags('integrations') +@ApiSecurity('api-key') +@Controller('api/v1/integrations') +@UseGuards(ApiKeyGuard, ApiKeyThrottlerGuard) +@UseInterceptors(IntegrationApiKeyUsageInterceptor) +export class IntegrationsController { + constructor(private readonly service: IntegrationsService) {} + + @Get('deliverables') + @RequireScope(IntegrationScope.DELIVERABLES_READ) + @ApiOperation({ summary: 'Lista paginada de Entregáveis' }) + @ApiQuery({ name: 'page', required: false, type: Number }) + @ApiQuery({ name: 'pageSize', required: false, type: Number }) + @ApiQuery({ name: 'status', required: false, enum: DeliverableStatus }) + @ApiQuery({ name: 'clientId', required: false, type: String }) + @ApiQuery({ name: 'projectId', required: false, type: String }) + @ApiQuery({ name: 'updatedSince', required: false, type: String }) + @ApiResponse({ status: 200, description: 'Lista de Entregáveis paginada' }) + @ApiResponse({ status: 400, description: 'Parâmetros inválidos' }) + @ApiResponse({ status: 401, description: 'API Key ausente, inválida, revogada ou expirada' }) + @ApiResponse({ status: 403, description: 'Escopo insuficiente' }) + @ApiResponse({ status: 429, description: 'Limite de requisições excedido' }) + async listDeliverables( + @Query() query: ListIntegrationDeliverablesQueryDto, + ): Promise> { + return this.service.listDeliverables(query); + } + + @Get('clients') + @RequireScope(IntegrationScope.CLIENTS_READ) + @ApiOperation({ summary: 'Lista paginada de Clientes' }) + @ApiQuery({ name: 'page', required: false, type: Number }) + @ApiQuery({ name: 'pageSize', required: false, type: Number }) + @ApiQuery({ name: 'isActive', required: false, type: Boolean }) + @ApiQuery({ name: 'updatedSince', required: false, type: String }) + @ApiResponse({ status: 200, description: 'Lista de Clientes paginada' }) + @ApiResponse({ status: 400, description: 'Parâmetros inválidos' }) + @ApiResponse({ status: 401, description: 'API Key ausente, inválida, revogada ou expirada' }) + @ApiResponse({ status: 403, description: 'Escopo insuficiente' }) + @ApiResponse({ status: 429, description: 'Limite de requisições excedido' }) + async listClients( + @Query() query: ListIntegrationClientsQueryDto, + ): Promise> { + return this.service.listClients(query); + } +} diff --git a/src/modules/integrations/integrations.module.ts b/src/modules/integrations/integrations.module.ts new file mode 100644 index 0000000..748d5c0 --- /dev/null +++ b/src/modules/integrations/integrations.module.ts @@ -0,0 +1,19 @@ +import { Module } from '@nestjs/common'; +import { ApiKeyThrottlerGuard } from '../../common/guards/api-key-throttler.guard'; +import { IntegrationApiKeyUsageInterceptor } from '../../common/interceptors/integration-api-key-usage.interceptor'; +import { IntegrationApiKeysModule } from '../integration-api-keys/integration-api-keys.module'; +import { IntegrationsController } from './integrations.controller'; +import { IntegrationsRepository } from './integrations.repository'; +import { IntegrationsService } from './integrations.service'; + +@Module({ + imports: [IntegrationApiKeysModule], + controllers: [IntegrationsController], + providers: [ + IntegrationsService, + IntegrationsRepository, + ApiKeyThrottlerGuard, + IntegrationApiKeyUsageInterceptor, + ], +}) +export class IntegrationsModule {} diff --git a/src/modules/integrations/integrations.repository.ts b/src/modules/integrations/integrations.repository.ts new file mode 100644 index 0000000..484982b --- /dev/null +++ b/src/modules/integrations/integrations.repository.ts @@ -0,0 +1,93 @@ +import { Injectable } from '@nestjs/common'; +import { Prisma } from '@prisma/client'; +import { PrismaService } from '../../config/database/prisma.service'; +import { ListIntegrationClientsQueryDto } from './dto/list-integration-clients-query.dto'; +import { ListIntegrationDeliverablesQueryDto } from './dto/list-integration-deliverables-query.dto'; + +const integrationDeliverableSelect = { + id: true, + code: true, + title: true, + status: true, + clientId: true, + projectId: true, + workOrderId: true, + totalValue: true, + numWeeks: true, + startDate: true, + expectedEndDate: true, + createdAt: true, + updatedAt: true, +} as const; + +const integrationClientSelect = { + id: true, + name: true, + document: true, + email: true, + phone: true, + isActive: true, + createdAt: true, + updatedAt: true, +} as const; + +export type IntegrationDeliverable = Prisma.DeliverableGetPayload<{ + select: typeof integrationDeliverableSelect; +}>; + +export type IntegrationClient = Prisma.ClientGetPayload<{ + select: typeof integrationClientSelect; +}>; + +@Injectable() +export class IntegrationsRepository { + constructor(private readonly prisma: PrismaService) {} + + async findManyDeliverables( + query: ListIntegrationDeliverablesQueryDto, + ): Promise<{ data: IntegrationDeliverable[]; total: number }> { + const where: Prisma.DeliverableWhereInput = { isActive: true }; + if (query.status) where.status = query.status; + if (query.clientId) where.clientId = query.clientId; + if (query.projectId) where.projectId = query.projectId; + if (query.updatedSince) where.updatedAt = { gte: new Date(query.updatedSince) }; + + const [data, total] = await Promise.all([ + this.prisma.deliverable.findMany({ + where, + select: integrationDeliverableSelect, + orderBy: { updatedAt: 'desc' }, + skip: (query.page - 1) * query.pageSize, + take: query.pageSize, + }), + this.prisma.deliverable.count({ where }), + ]); + + return { data, total }; + } + + async findManyClients( + query: ListIntegrationClientsQueryDto, + ): Promise<{ data: IntegrationClient[]; total: number }> { + const where: Prisma.ClientWhereInput = {}; + if (query.isActive !== undefined) { + where.isActive = query.isActive; + } else { + where.isActive = true; + } + if (query.updatedSince) where.updatedAt = { gte: new Date(query.updatedSince) }; + + const [data, total] = await Promise.all([ + this.prisma.client.findMany({ + where, + select: integrationClientSelect, + orderBy: { updatedAt: 'desc' }, + skip: (query.page - 1) * query.pageSize, + take: query.pageSize, + }), + this.prisma.client.count({ where }), + ]); + + return { data, total }; + } +} diff --git a/src/modules/integrations/integrations.service.ts b/src/modules/integrations/integrations.service.ts new file mode 100644 index 0000000..c4110a7 --- /dev/null +++ b/src/modules/integrations/integrations.service.ts @@ -0,0 +1,50 @@ +import { Injectable } from '@nestjs/common'; +import { + IntegrationClient, + IntegrationDeliverable, + IntegrationsRepository, +} from './integrations.repository'; +import { ListIntegrationClientsQueryDto } from './dto/list-integration-clients-query.dto'; +import { ListIntegrationDeliverablesQueryDto } from './dto/list-integration-deliverables-query.dto'; + +export interface PaginatedResult { + data: T[]; + total: number; + page: number; + pageSize: number; + totalPages: number; +} + +@Injectable() +export class IntegrationsService { + constructor(private readonly repository: IntegrationsRepository) {} + + async listDeliverables( + query: ListIntegrationDeliverablesQueryDto, + ): Promise> { + const { data, total } = await this.repository.findManyDeliverables(query); + return this.paginate(data, total, query.page, query.pageSize); + } + + async listClients( + query: ListIntegrationClientsQueryDto, + ): Promise> { + const { data, total } = await this.repository.findManyClients(query); + return this.paginate(data, total, query.page, query.pageSize); + } + + private paginate( + data: T[], + total: number, + page: number, + pageSize: number, + ): PaginatedResult { + return { + data, + total, + page, + pageSize, + totalPages: pageSize > 0 ? Math.ceil(total / pageSize) : 0, + }; + } +} diff --git a/src/modules/mailer/interfaces/mailer-provider.interface.ts b/src/modules/mailer/interfaces/mailer-provider.interface.ts new file mode 100644 index 0000000..ecb6b6b --- /dev/null +++ b/src/modules/mailer/interfaces/mailer-provider.interface.ts @@ -0,0 +1,12 @@ +export const MAILER_PROVIDER = 'MAILER_PROVIDER'; + +export interface MailPayload { + to: string; + subject: string; + html: string; + text?: string; +} + +export interface IMailerProvider { + sendMail(payload: MailPayload): Promise; +} diff --git a/src/modules/mailer/mailer.module.ts b/src/modules/mailer/mailer.module.ts new file mode 100644 index 0000000..e3751b5 --- /dev/null +++ b/src/modules/mailer/mailer.module.ts @@ -0,0 +1,23 @@ +import { Global, Module } from '@nestjs/common'; +import { EnvModule } from '../../config/env/env.module'; +import { EnvService } from '../../config/env/env.service'; +import { MAILER_PROVIDER } from './interfaces/mailer-provider.interface'; +import { LogMailerProvider } from './providers/log-mailer.provider'; +import { SmtpMailerProvider } from './providers/smtp-mailer.provider'; +import { MailerService } from './mailer.service'; + +@Global() +@Module({ + imports: [EnvModule], + providers: [ + { + provide: MAILER_PROVIDER, + useFactory: (env: EnvService) => + env.mailerProvider === 'smtp' ? new SmtpMailerProvider(env) : new LogMailerProvider(), + inject: [EnvService], + }, + MailerService, + ], + exports: [MailerService], +}) +export class MailerModule {} diff --git a/src/modules/mailer/mailer.service.spec.ts b/src/modules/mailer/mailer.service.spec.ts new file mode 100644 index 0000000..3d0f57d --- /dev/null +++ b/src/modules/mailer/mailer.service.spec.ts @@ -0,0 +1,85 @@ +import { InternalServerErrorException, Logger } from '@nestjs/common'; +import { Test, TestingModule } from '@nestjs/testing'; +import * as nodemailer from 'nodemailer'; +import { LogMailerProvider } from './providers/log-mailer.provider'; +import { SmtpMailerProvider } from './providers/smtp-mailer.provider'; +import { MailerService } from './mailer.service'; +import { MAILER_PROVIDER, MailPayload } from './interfaces/mailer-provider.interface'; + +jest.mock('nodemailer'); + +const payload: MailPayload = { to: 'test@example.com', subject: 'Test Subject', html: '

Hello

' }; + +describe('LogMailerProvider', () => { + let provider: LogMailerProvider; + + beforeEach(() => { + provider = new LogMailerProvider(); + }); + + it('does not throw when sending mail', async () => { + await expect(provider.sendMail(payload)).resolves.toBeUndefined(); + }); + + it('logs only metadata (to and subject) without body content', async () => { + const logSpy = jest.spyOn(Logger.prototype, 'debug'); + await provider.sendMail(payload); + expect(logSpy).toHaveBeenCalledWith({ to: payload.to, subject: payload.subject }); + expect(logSpy).not.toHaveBeenCalledWith(expect.objectContaining({ html: expect.anything() })); + }); +}); + +describe('SmtpMailerProvider', () => { + let provider: SmtpMailerProvider; + let mockSendMail: jest.Mock; + + beforeEach(() => { + mockSendMail = jest.fn(); + (nodemailer.createTransport as jest.Mock).mockReturnValue({ sendMail: mockSendMail }); + const mockEnv = { + smtpHost: 'smtp.example.com', + smtpPort: 587, + smtpUser: 'user@example.com', + smtpPass: 'pass', + smtpFrom: 'noreply@example.com', + } as any; + provider = new SmtpMailerProvider(mockEnv); + }); + + it('calls nodemailer transport.sendMail with correct fields', async () => { + mockSendMail.mockResolvedValue({}); + await provider.sendMail(payload); + expect(mockSendMail).toHaveBeenCalledWith( + expect.objectContaining({ + to: payload.to, + subject: payload.subject, + html: payload.html, + from: 'noreply@example.com', + }), + ); + }); + + it('throws InternalServerErrorException when transport fails', async () => { + mockSendMail.mockRejectedValue(new Error('SMTP connection refused')); + await expect(provider.sendMail(payload)).rejects.toThrow(InternalServerErrorException); + }); +}); + +describe('MailerService', () => { + let service: MailerService; + let mockProvider: { sendMail: jest.Mock }; + + beforeEach(async () => { + mockProvider = { sendMail: jest.fn() }; + const module: TestingModule = await Test.createTestingModule({ + providers: [MailerService, { provide: MAILER_PROVIDER, useValue: mockProvider }], + }).compile(); + service = module.get(MailerService); + }); + + it('delegates sendMail to the injected provider', async () => { + mockProvider.sendMail.mockResolvedValue(undefined); + await service.sendMail(payload); + expect(mockProvider.sendMail).toHaveBeenCalledWith(payload); + }); +}); diff --git a/src/modules/mailer/mailer.service.ts b/src/modules/mailer/mailer.service.ts new file mode 100644 index 0000000..8d04e41 --- /dev/null +++ b/src/modules/mailer/mailer.service.ts @@ -0,0 +1,12 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { MAILER_PROVIDER } from './interfaces/mailer-provider.interface'; +import type { IMailerProvider, MailPayload } from './interfaces/mailer-provider.interface'; + +@Injectable() +export class MailerService { + constructor(@Inject(MAILER_PROVIDER) private readonly provider: IMailerProvider) {} + + async sendMail(payload: MailPayload): Promise { + return this.provider.sendMail(payload); + } +} diff --git a/src/modules/mailer/providers/log-mailer.provider.ts b/src/modules/mailer/providers/log-mailer.provider.ts new file mode 100644 index 0000000..e1c4099 --- /dev/null +++ b/src/modules/mailer/providers/log-mailer.provider.ts @@ -0,0 +1,10 @@ +import { Logger } from '@nestjs/common'; +import { IMailerProvider, MailPayload } from '../interfaces/mailer-provider.interface'; + +export class LogMailerProvider implements IMailerProvider { + private readonly logger = new Logger(LogMailerProvider.name); + + async sendMail(payload: MailPayload): Promise { + this.logger.debug({ to: payload.to, subject: payload.subject }); + } +} diff --git a/src/modules/mailer/providers/smtp-mailer.provider.ts b/src/modules/mailer/providers/smtp-mailer.provider.ts new file mode 100644 index 0000000..0725aa6 --- /dev/null +++ b/src/modules/mailer/providers/smtp-mailer.provider.ts @@ -0,0 +1,37 @@ +import { InternalServerErrorException, Logger } from '@nestjs/common'; +import * as nodemailer from 'nodemailer'; +import { EnvService } from '../../../config/env/env.service'; +import { IMailerProvider, MailPayload } from '../interfaces/mailer-provider.interface'; + +export class SmtpMailerProvider implements IMailerProvider { + private readonly logger = new Logger(SmtpMailerProvider.name); + private readonly transport: nodemailer.Transporter; + private readonly from: string | undefined; + + constructor(env: EnvService) { + this.from = env.smtpFrom; + this.transport = nodemailer.createTransport({ + host: env.smtpHost, + port: env.smtpPort, + auth: { + user: env.smtpUser, + pass: env.smtpPass, + }, + }); + } + + async sendMail(payload: MailPayload): Promise { + try { + await this.transport.sendMail({ + from: this.from, + to: payload.to, + subject: payload.subject, + html: payload.html, + text: payload.text, + }); + } catch (e) { + this.logger.error('Falha ao enviar email', { error: (e as Error).message }); + throw new InternalServerErrorException('Falha ao enviar email'); + } + } +} diff --git a/src/modules/notes/dto/.gitkeep b/src/modules/notes/dto/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/src/modules/notes/dto/create-note.dto.ts b/src/modules/notes/dto/create-note.dto.ts new file mode 100644 index 0000000..a3d58bf --- /dev/null +++ b/src/modules/notes/dto/create-note.dto.ts @@ -0,0 +1,20 @@ +import { IsBoolean, IsEnum, IsNotEmpty, IsOptional, IsString } from 'class-validator'; +import { NoteType } from '@prisma/client'; + +export class CreateNoteDto { + @IsEnum(NoteType, { message: 'Tipo de anotação inválido' }) + @IsNotEmpty({ message: 'O tipo da anotação é obrigatório' }) + type!: NoteType; + + @IsString({ message: 'O título deve ser uma string' }) + @IsNotEmpty({ message: 'O título é obrigatório' }) + title!: string; + + @IsOptional() + @IsString({ message: 'A descrição deve ser uma string' }) + description?: string; + + @IsOptional() + @IsBoolean({ message: 'O campo de relevância deve ser um booleano' }) + isRelevant?: boolean; +} diff --git a/src/modules/notes/dto/list-notes-query.dto.ts b/src/modules/notes/dto/list-notes-query.dto.ts new file mode 100644 index 0000000..da5516a --- /dev/null +++ b/src/modules/notes/dto/list-notes-query.dto.ts @@ -0,0 +1,8 @@ +import { IsEnum, IsOptional } from 'class-validator'; +import { NoteType } from '@prisma/client'; + +export class ListNotesQueryDto { + @IsOptional() + @IsEnum(NoteType, { message: 'Tipo de anotação inválido' }) + type?: NoteType; +} diff --git a/src/modules/notes/dto/update-note.dto.ts b/src/modules/notes/dto/update-note.dto.ts new file mode 100644 index 0000000..49c3d43 --- /dev/null +++ b/src/modules/notes/dto/update-note.dto.ts @@ -0,0 +1,20 @@ +import { IsBoolean, IsEnum, IsOptional, IsString } from 'class-validator'; +import { NoteType } from '@prisma/client'; + +export class UpdateNoteDto { + @IsOptional() + @IsEnum(NoteType, { message: 'Tipo de anotação inválido' }) + type?: NoteType; + + @IsOptional() + @IsString({ message: 'O título deve ser uma string' }) + title?: string; + + @IsOptional() + @IsString({ message: 'A descrição deve ser uma string' }) + description?: string; + + @IsOptional() + @IsBoolean({ message: 'O campo de relevância deve ser um booleano' }) + isRelevant?: boolean; +} diff --git a/src/modules/notes/notes.controller.ts b/src/modules/notes/notes.controller.ts new file mode 100644 index 0000000..1e78e63 --- /dev/null +++ b/src/modules/notes/notes.controller.ts @@ -0,0 +1,54 @@ +import { Body, Controller, Get, Param, Patch, Post, Query, UseGuards } from '@nestjs/common'; +import { JwtAuthGuard } from '../../config/auth/jwt-auth.guard'; +import { ReadOnlyClientGuard } from '../../common/guards/read-only-client.guard'; +import { RolesGuard } from '../../common/guards/roles.guard'; +import { Roles } from '../../common/decorators/roles.decorator'; +import { Role } from '../../common/enums/role.enum'; +import { CurrentUser } from '../../common/decorators/user.decorator'; +import type { CurrentUserPayload } from '../../common/decorators/user.decorator'; +import { DeliverablesService } from '../deliverables/deliverables.service'; +import { NotesService } from './notes.service'; +import { CreateNoteDto } from './dto/create-note.dto'; +import { UpdateNoteDto } from './dto/update-note.dto'; +import { ListNotesQueryDto } from './dto/list-notes-query.dto'; + +@Controller('deliverables/:deliverableId/notes') +@UseGuards(JwtAuthGuard, ReadOnlyClientGuard, RolesGuard) +@Roles(Role.ADMIN, Role.OPERATOR, Role.CLIENT) +export class NotesController { + constructor( + private readonly notesService: NotesService, + private readonly deliverablesService: DeliverablesService, + ) {} + + @Post() + async create( + @Param('deliverableId') deliverableId: string, + @Body() dto: CreateNoteDto, + @CurrentUser() user: CurrentUserPayload, + ) { + return this.notesService.create(deliverableId, dto, user.id); + } + + @Get() + async findAll( + @Param('deliverableId') deliverableId: string, + @Query() query: ListNotesQueryDto, + @CurrentUser() user: CurrentUserPayload, + ) { + if (user.role === Role.CLIENT && user.clientId) { + await this.deliverablesService.validateDeliverableOwnership(deliverableId, user.clientId); + } + return this.notesService.findAll(deliverableId, query); + } + + @Patch(':id') + async update( + @Param('deliverableId') deliverableId: string, + @Param('id') id: string, + @Body() dto: UpdateNoteDto, + @CurrentUser() user: CurrentUserPayload, + ) { + return this.notesService.update(deliverableId, id, dto, user.id); + } +} diff --git a/src/modules/notes/notes.module.ts b/src/modules/notes/notes.module.ts new file mode 100644 index 0000000..3abec5c --- /dev/null +++ b/src/modules/notes/notes.module.ts @@ -0,0 +1,13 @@ +import { Module, forwardRef } from '@nestjs/common'; +import { DeliverablesModule } from '../deliverables/deliverables.module'; +import { TimelineModule } from '../timeline/timeline.module'; +import { NotesController } from './notes.controller'; +import { NotesService } from './notes.service'; +import { NotesRepository } from './notes.repository'; + +@Module({ + imports: [TimelineModule, forwardRef(() => DeliverablesModule)], + controllers: [NotesController], + providers: [NotesService, NotesRepository], +}) +export class NotesModule {} diff --git a/src/modules/notes/notes.repository.ts b/src/modules/notes/notes.repository.ts new file mode 100644 index 0000000..05d0d40 --- /dev/null +++ b/src/modules/notes/notes.repository.ts @@ -0,0 +1,73 @@ +import { Injectable } from '@nestjs/common'; +import { NoteType } from '@prisma/client'; +import { PrismaService } from '../../config/database/prisma.service'; +import { ListNotesQueryDto } from './dto/list-notes-query.dto'; + +const noteSelect = { + id: true, + deliverableId: true, + type: true, + title: true, + description: true, + isRelevant: true, + isActive: true, + createdAt: true, + updatedAt: true, + createdBy: true, + updatedBy: true, +} as const; + +@Injectable() +export class NotesRepository { + constructor(private readonly prisma: PrismaService) {} + + async findByDeliverableId(deliverableId: string, query: ListNotesQueryDto) { + return this.prisma.note.findMany({ + where: { + deliverableId, + isActive: true, + ...(query.type && { type: query.type }), + }, + select: noteSelect, + orderBy: { createdAt: 'desc' }, + }); + } + + async findById(id: string) { + return this.prisma.note.findUnique({ + where: { id }, + select: noteSelect, + }); + } + + async create(data: { + deliverableId: string; + type: NoteType; + title: string; + description?: string; + isRelevant?: boolean; + createdBy: string; + }) { + return this.prisma.note.create({ + data, + select: noteSelect, + }); + } + + async update( + id: string, + data: { + type?: NoteType; + title?: string; + description?: string; + isRelevant?: boolean; + updatedBy: string; + }, + ) { + return this.prisma.note.update({ + where: { id }, + data, + select: noteSelect, + }); + } +} diff --git a/src/modules/notes/notes.service.spec.ts b/src/modules/notes/notes.service.spec.ts new file mode 100644 index 0000000..66659fd --- /dev/null +++ b/src/modules/notes/notes.service.spec.ts @@ -0,0 +1,162 @@ +import { NotFoundException } from '@nestjs/common'; +import { Test, TestingModule } from '@nestjs/testing'; +import { NoteType } from '@prisma/client'; +import { NotesService } from './notes.service'; +import { NotesRepository } from './notes.repository'; +import { PrismaService } from '../../config/database/prisma.service'; +import { TimelineService } from '../timeline/timeline.service'; + +const mockNote = (overrides = {}) => ({ + id: 'note-1', + deliverableId: 'deliverable-id-1', + type: NoteType.OBSERVACAO, + title: 'Nota de teste', + description: 'Descrição da nota', + isRelevant: false, + isActive: true, + createdAt: new Date(), + updatedAt: new Date(), + createdBy: 'user-id-1', + updatedBy: null, + ...overrides, +}); + +describe('NotesService', () => { + let service: NotesService; + let repository: jest.Mocked; + let timelineService: jest.Mocked; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + NotesService, + { + provide: NotesRepository, + useValue: { + findByDeliverableId: jest.fn(), + findById: jest.fn(), + create: jest.fn(), + update: jest.fn(), + }, + }, + { + provide: PrismaService, + useValue: { + deliverable: { + findUnique: jest.fn().mockResolvedValue({ id: 'deliverable-id-1', isActive: true }), + }, + user: { + findMany: jest.fn().mockResolvedValue([{ id: 'user-id-1', name: 'Raphael Soares' }]), + }, + }, + }, + { + provide: TimelineService, + useValue: { + createEvent: jest.fn(), + }, + }, + ], + }).compile(); + + service = module.get(NotesService); + repository = module.get(NotesRepository); + timelineService = module.get(TimelineService); + }); + + describe('create', () => { + it('deve criar nota com dados válidos', async () => { + const note = mockNote(); + repository.create.mockResolvedValue(note); + + const result = await service.create( + 'deliverable-id-1', + { type: NoteType.OBSERVACAO, title: 'Nota de teste' }, + 'user-id-1', + ); + + expect(result).toEqual(note); + expect(repository.create).toHaveBeenCalledWith({ + deliverableId: 'deliverable-id-1', + type: NoteType.OBSERVACAO, + title: 'Nota de teste', + description: undefined, + isRelevant: undefined, + createdBy: 'user-id-1', + }); + }); + + it('deve emitir evento ANOTACAO na timeline ao criar nota', async () => { + repository.create.mockResolvedValue(mockNote()); + + await service.create( + 'deliverable-id-1', + { type: NoteType.RISCO, title: 'Risco identificado' }, + 'user-id-1', + ); + + expect(timelineService.createEvent).toHaveBeenCalledWith( + expect.objectContaining({ + deliverableId: 'deliverable-id-1', + type: 'ANOTACAO', + title: 'Anotação criada: Risco', + description: 'Risco identificado', + createdBy: 'user-id-1', + }), + ); + }); + }); + + describe('findAll', () => { + it('deve retornar notas ativas ordenadas por data desc com nome do usuário', async () => { + const notes = [mockNote(), mockNote({ id: 'note-2' })]; + repository.findByDeliverableId.mockResolvedValue(notes); + + const result = await service.findAll('deliverable-id-1', {}); + + expect(result).toHaveLength(2); + expect(result[0].createdBy).toBe('Raphael Soares'); + expect(repository.findByDeliverableId).toHaveBeenCalledWith('deliverable-id-1', {}); + }); + + it('deve passar filtro de type para o repository', async () => { + repository.findByDeliverableId.mockResolvedValue([]); + + await service.findAll('deliverable-id-1', { type: NoteType.IMPEDIMENTO }); + + expect(repository.findByDeliverableId).toHaveBeenCalledWith('deliverable-id-1', { + type: NoteType.IMPEDIMENTO, + }); + }); + }); + + describe('update', () => { + it('deve atualizar nota e registrar updatedBy', async () => { + const note = mockNote(); + const updated = mockNote({ updatedBy: 'user-id-2', title: 'Título atualizado' }); + repository.findById.mockResolvedValue(note); + repository.update.mockResolvedValue(updated); + + const result = await service.update( + 'deliverable-id-1', + 'note-1', + { title: 'Título atualizado' }, + 'user-id-2', + ); + + expect(result).toEqual(updated); + expect(repository.update).toHaveBeenCalledWith('note-1', { + title: 'Título atualizado', + updatedBy: 'user-id-2', + }); + }); + + it('deve retornar 404 se nota não existir', async () => { + repository.findById.mockResolvedValue(null); + + await expect( + service.update('deliverable-id-1', 'nota-inexistente', { title: 'Novo' }, 'user-id-1'), + ).rejects.toThrow(NotFoundException); + }); + }); +}); diff --git a/src/modules/notes/notes.service.ts b/src/modules/notes/notes.service.ts new file mode 100644 index 0000000..c71b4cd --- /dev/null +++ b/src/modules/notes/notes.service.ts @@ -0,0 +1,102 @@ +import { Injectable, Logger, NotFoundException } from '@nestjs/common'; +import { PrismaService } from '../../config/database/prisma.service'; +import { TimelineService } from '../timeline/timeline.service'; +import { NotesRepository } from './notes.repository'; +import { CreateNoteDto } from './dto/create-note.dto'; +import { UpdateNoteDto } from './dto/update-note.dto'; +import { ListNotesQueryDto } from './dto/list-notes-query.dto'; + +const NOTE_TYPE_LABELS: Record = { + OBSERVACAO: 'Observação', + RISCO: 'Risco', + IMPEDIMENTO: 'Impedimento', + DECISAO: 'Decisão', +}; + +@Injectable() +export class NotesService { + private readonly logger = new Logger(NotesService.name); + + constructor( + private readonly repository: NotesRepository, + private readonly prisma: PrismaService, + private readonly timelineService: TimelineService, + ) {} + + async findAll(deliverableId: string, query: ListNotesQueryDto) { + const notes = await this.repository.findByDeliverableId(deliverableId, query); + + const userIds = [ + ...new Set(notes.flatMap((n) => [n.createdBy, n.updatedBy]).filter(Boolean)), + ] as string[]; + + if (userIds.length > 0) { + const users = await this.prisma.user.findMany({ + where: { id: { in: userIds } }, + select: { id: true, name: true }, + }); + const userMap = new Map(users.map((u) => [u.id, u.name])); + + return notes.map((note) => ({ + ...note, + createdBy: note.createdBy ? (userMap.get(note.createdBy) ?? note.createdBy) : null, + updatedBy: note.updatedBy ? (userMap.get(note.updatedBy) ?? note.updatedBy) : null, + })); + } + + return notes; + } + + async create(deliverableId: string, dto: CreateNoteDto, userId: string) { + await this.validateDeliverableExists(deliverableId); + + const note = await this.repository.create({ + deliverableId, + type: dto.type, + title: dto.title, + description: dto.description, + isRelevant: dto.isRelevant, + createdBy: userId, + }); + + try { + const typeLabel = NOTE_TYPE_LABELS[dto.type] || dto.type; + await this.timelineService.createEvent({ + deliverableId, + type: 'ANOTACAO', + title: `Anotação criada: ${typeLabel}`, + description: dto.title, + metadata: { noteId: note.id, noteType: dto.type }, + createdBy: userId, + }); + } catch (error) { + this.logger.error('Falha ao criar evento de timeline para criação de anotação', error); + } + + return note; + } + + async update(deliverableId: string, noteId: string, dto: UpdateNoteDto, userId: string) { + const existing = await this.repository.findById(noteId); + + if (!existing || !existing.isActive || existing.deliverableId !== deliverableId) { + throw new NotFoundException('Anotação não encontrada'); + } + + return this.repository.update(noteId, { + ...dto, + updatedBy: userId, + }); + } + + private async validateDeliverableExists(deliverableId: string) { + const deliverable = await this.prisma.deliverable.findUnique({ + where: { id: deliverableId }, + select: { id: true, isActive: true }, + }); + + if (!deliverable || !deliverable.isActive) { + throw new NotFoundException('Entregável não encontrado'); + } + } +} diff --git a/src/modules/professionals/dto/.gitkeep b/src/modules/professionals/dto/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/src/modules/professionals/dto/create-professional.dto.ts b/src/modules/professionals/dto/create-professional.dto.ts new file mode 100644 index 0000000..070f466 --- /dev/null +++ b/src/modules/professionals/dto/create-professional.dto.ts @@ -0,0 +1,23 @@ +import { IsEmail, IsNotEmpty, IsOptional, IsString } from 'class-validator'; + +export class CreateProfessionalDto { + @IsString() + @IsNotEmpty() + name!: string; + + @IsOptional() + @IsString() + document?: string; + + @IsOptional() + @IsEmail() + email?: string; + + @IsOptional() + @IsString() + phone?: string; + + @IsOptional() + @IsString() + role?: string; +} diff --git a/src/modules/professionals/dto/list-professionals-query.dto.ts b/src/modules/professionals/dto/list-professionals-query.dto.ts new file mode 100644 index 0000000..bb8d24f --- /dev/null +++ b/src/modules/professionals/dto/list-professionals-query.dto.ts @@ -0,0 +1,25 @@ +import { IsInt, IsOptional, IsString, Max, Min } from 'class-validator'; +import { Transform } from 'class-transformer'; + +export class ListProfessionalsQueryDto { + @IsOptional() + @IsString() + name?: string; + + @IsOptional() + @Transform(({ value }) => value === 'true') + isActive?: boolean; + + @IsOptional() + @Transform(({ value }: { value: unknown }) => parseInt(String(value), 10)) + @IsInt() + @Min(1) + page: number = 1; + + @IsOptional() + @Transform(({ value }: { value: unknown }) => parseInt(String(value), 10)) + @IsInt() + @Min(1) + @Max(100) + limit: number = 20; +} diff --git a/src/modules/professionals/dto/update-professional.dto.ts b/src/modules/professionals/dto/update-professional.dto.ts new file mode 100644 index 0000000..7edae2d --- /dev/null +++ b/src/modules/professionals/dto/update-professional.dto.ts @@ -0,0 +1,27 @@ +import { IsString, IsEmail, IsBoolean, IsOptional } from 'class-validator'; + +export class UpdateProfessionalDto { + @IsOptional() + @IsString() + name?: string; + + @IsOptional() + @IsString() + document?: string; + + @IsOptional() + @IsEmail() + email?: string; + + @IsOptional() + @IsString() + phone?: string; + + @IsOptional() + @IsString() + role?: string; + + @IsOptional() + @IsBoolean() + isActive?: boolean; +} diff --git a/src/modules/professionals/professionals.controller.ts b/src/modules/professionals/professionals.controller.ts new file mode 100644 index 0000000..d1e2d29 --- /dev/null +++ b/src/modules/professionals/professionals.controller.ts @@ -0,0 +1,42 @@ +import { Body, Controller, Get, Param, Patch, Post, Query, UseGuards } from '@nestjs/common'; +import { JwtAuthGuard } from '../../config/auth/jwt-auth.guard'; +import { RolesGuard } from '../../common/guards/roles.guard'; +import { Roles } from '../../common/decorators/roles.decorator'; +import { Role } from '../../common/enums/role.enum'; +import { ProfessionalsService } from './professionals.service'; +import { CreateProfessionalDto } from './dto/create-professional.dto'; +import { UpdateProfessionalDto } from './dto/update-professional.dto'; +import { ListProfessionalsQueryDto } from './dto/list-professionals-query.dto'; + +@Controller('professionals') +export class ProfessionalsController { + constructor(private readonly professionalsService: ProfessionalsService) {} + + @Get() + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles(Role.ADMIN, Role.OPERATOR) + async findAll(@Query() query: ListProfessionalsQueryDto) { + return this.professionalsService.findAll(query); + } + + @Post() + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles(Role.ADMIN, Role.OPERATOR) + async create(@Body() dto: CreateProfessionalDto) { + return this.professionalsService.create(dto); + } + + @Get(':id') + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles(Role.ADMIN, Role.OPERATOR) + async findOne(@Param('id') id: string) { + return this.professionalsService.findOne(id); + } + + @Patch(':id') + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles(Role.ADMIN, Role.OPERATOR) + async update(@Param('id') id: string, @Body() dto: UpdateProfessionalDto) { + return this.professionalsService.update(id, dto); + } +} diff --git a/src/modules/professionals/professionals.module.ts b/src/modules/professionals/professionals.module.ts new file mode 100644 index 0000000..9f5ff77 --- /dev/null +++ b/src/modules/professionals/professionals.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { ProfessionalsController } from './professionals.controller'; +import { ProfessionalsService } from './professionals.service'; +import { ProfessionalsRepository } from './professionals.repository'; + +@Module({ + controllers: [ProfessionalsController], + providers: [ProfessionalsService, ProfessionalsRepository], +}) +export class ProfessionalsModule {} diff --git a/src/modules/professionals/professionals.repository.ts b/src/modules/professionals/professionals.repository.ts new file mode 100644 index 0000000..a41d775 --- /dev/null +++ b/src/modules/professionals/professionals.repository.ts @@ -0,0 +1,79 @@ +import { Injectable } from '@nestjs/common'; +import { Prisma } from '@prisma/client'; +import { PrismaService } from '../../config/database/prisma.service'; + +const professionalSelect = { + id: true, + name: true, + document: true, + email: true, + phone: true, + role: true, + isActive: true, + createdAt: true, + updatedAt: true, +} as const; + +@Injectable() +export class ProfessionalsRepository { + constructor(private readonly prisma: PrismaService) {} + + async findAll(params: { name?: string; isActive?: boolean; page: number; limit: number }) { + const where: Prisma.ProfessionalWhereInput = {}; + + if (params.name) where.name = { contains: params.name, mode: 'insensitive' }; + if (params.isActive !== undefined) where.isActive = params.isActive; + + const [data, total] = await Promise.all([ + this.prisma.professional.findMany({ + where, + select: professionalSelect, + orderBy: { name: 'asc' }, + skip: (params.page - 1) * params.limit, + take: params.limit, + }), + this.prisma.professional.count({ where }), + ]); + + return { data, total }; + } + + async findById(id: string) { + return this.prisma.professional.findUnique({ + where: { id }, + select: professionalSelect, + }); + } + + async create(data: { + name: string; + document?: string; + email?: string; + phone?: string; + role?: string; + }) { + return this.prisma.professional.create({ + data, + select: professionalSelect, + }); + } + + async update( + id: string, + data: { + name?: string; + document?: string; + email?: string; + phone?: string; + role?: string; + + isActive?: boolean; + }, + ) { + return this.prisma.professional.update({ + where: { id }, + data, + select: professionalSelect, + }); + } +} diff --git a/src/modules/professionals/professionals.service.ts b/src/modules/professionals/professionals.service.ts new file mode 100644 index 0000000..1a534de --- /dev/null +++ b/src/modules/professionals/professionals.service.ts @@ -0,0 +1,39 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { ProfessionalsRepository } from './professionals.repository'; +import { CreateProfessionalDto } from './dto/create-professional.dto'; +import { UpdateProfessionalDto } from './dto/update-professional.dto'; +import { ListProfessionalsQueryDto } from './dto/list-professionals-query.dto'; + +@Injectable() +export class ProfessionalsService { + constructor(private readonly repository: ProfessionalsRepository) {} + + async findAll(query: ListProfessionalsQueryDto) { + const { page, limit, ...filters } = query; + const { data, total } = await this.repository.findAll({ + ...filters, + page, + limit, + }); + return { data, total, page, limit }; + } + + async findOne(id: string) { + const professional = await this.repository.findById(id); + + if (!professional) { + throw new NotFoundException('Profissional não encontrado'); + } + + return professional; + } + + async create(dto: CreateProfessionalDto) { + return this.repository.create(dto); + } + + async update(id: string, dto: UpdateProfessionalDto) { + await this.findOne(id); + return this.repository.update(id, dto); + } +} diff --git a/src/modules/projects/dto/.gitkeep b/src/modules/projects/dto/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/src/modules/projects/dto/create-project.dto.ts b/src/modules/projects/dto/create-project.dto.ts new file mode 100644 index 0000000..11d8699 --- /dev/null +++ b/src/modules/projects/dto/create-project.dto.ts @@ -0,0 +1,28 @@ +import { IsDateString, IsNotEmpty, IsOptional, IsString, IsUUID } from 'class-validator'; + +export class CreateProjectDto { + @IsString() + @IsNotEmpty() + name!: string; + + @IsString() + @IsNotEmpty() + @IsUUID() + contractId!: string; + + @IsOptional() + @IsString() + code?: string; + + @IsOptional() + @IsString() + description?: string; + + @IsOptional() + @IsDateString() + startDate?: string; + + @IsOptional() + @IsDateString() + endDate?: string; +} diff --git a/src/modules/projects/dto/list-projects-query.dto.ts b/src/modules/projects/dto/list-projects-query.dto.ts new file mode 100644 index 0000000..2bc6ff0 --- /dev/null +++ b/src/modules/projects/dto/list-projects-query.dto.ts @@ -0,0 +1,25 @@ +import { IsInt, IsOptional, IsString, Max, Min } from 'class-validator'; +import { Transform } from 'class-transformer'; + +export class ListProjectsQueryDto { + @IsOptional() + @IsString() + name?: string; + + @IsOptional() + @Transform(({ value }) => value === 'true') + isActive?: boolean; + + @IsOptional() + @Transform(({ value }: { value: unknown }) => parseInt(String(value), 10)) + @IsInt() + @Min(1) + page: number = 1; + + @IsOptional() + @Transform(({ value }: { value: unknown }) => parseInt(String(value), 10)) + @IsInt() + @Min(1) + @Max(100) + limit: number = 20; +} diff --git a/src/modules/projects/dto/update-project.dto.ts b/src/modules/projects/dto/update-project.dto.ts new file mode 100644 index 0000000..05a7193 --- /dev/null +++ b/src/modules/projects/dto/update-project.dto.ts @@ -0,0 +1,32 @@ +import { IsBoolean, IsDateString, IsOptional, IsString, IsUUID } from 'class-validator'; + +export class UpdateProjectDto { + @IsOptional() + @IsString() + name?: string; + + @IsOptional() + @IsString() + @IsUUID() + contractId?: string; + + @IsOptional() + @IsString() + code?: string; + + @IsOptional() + @IsString() + description?: string; + + @IsOptional() + @IsDateString() + startDate?: string; + + @IsOptional() + @IsDateString() + endDate?: string; + + @IsOptional() + @IsBoolean() + isActive?: boolean; +} diff --git a/src/modules/projects/projects.controller.ts b/src/modules/projects/projects.controller.ts new file mode 100644 index 0000000..d896cfc --- /dev/null +++ b/src/modules/projects/projects.controller.ts @@ -0,0 +1,48 @@ +import { Body, Controller, Get, Param, Patch, Post, Query, UseGuards } from '@nestjs/common'; +import { JwtAuthGuard } from '../../config/auth/jwt-auth.guard'; +import { ReadOnlyClientGuard } from '../../common/guards/read-only-client.guard'; +import { RolesGuard } from '../../common/guards/roles.guard'; +import { Roles } from '../../common/decorators/roles.decorator'; +import { Role } from '../../common/enums/role.enum'; +import { CurrentUser } from '../../common/decorators/user.decorator'; +import type { CurrentUserPayload } from '../../common/decorators/user.decorator'; +import { ProjectsService } from './projects.service'; +import { CreateProjectDto } from './dto/create-project.dto'; +import { UpdateProjectDto } from './dto/update-project.dto'; +import { ListProjectsQueryDto } from './dto/list-projects-query.dto'; + +@Controller('projects') +export class ProjectsController { + constructor(private readonly projectsService: ProjectsService) {} + + @Get() + @UseGuards(JwtAuthGuard, ReadOnlyClientGuard, RolesGuard) + @Roles(Role.ADMIN, Role.OPERATOR, Role.CLIENT) + async findAll(@Query() query: ListProjectsQueryDto, @CurrentUser() user: CurrentUserPayload) { + if (user.role === Role.CLIENT && user.clientId) { + return this.projectsService.findByClientId(user.clientId, query); + } + return this.projectsService.findAll(query); + } + + @Post() + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles(Role.ADMIN, Role.OPERATOR) + async create(@Body() dto: CreateProjectDto) { + return this.projectsService.create(dto); + } + + @Get(':id') + @UseGuards(JwtAuthGuard, ReadOnlyClientGuard, RolesGuard) + @Roles(Role.ADMIN, Role.OPERATOR, Role.CLIENT) + async findOne(@Param('id') id: string, @CurrentUser() user: CurrentUserPayload) { + return this.projectsService.findOne(id, user); + } + + @Patch(':id') + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles(Role.ADMIN, Role.OPERATOR) + async update(@Param('id') id: string, @Body() dto: UpdateProjectDto) { + return this.projectsService.update(id, dto); + } +} diff --git a/src/modules/projects/projects.module.ts b/src/modules/projects/projects.module.ts new file mode 100644 index 0000000..aac7c00 --- /dev/null +++ b/src/modules/projects/projects.module.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common'; +import { ProjectsController } from './projects.controller'; +import { ProjectsService } from './projects.service'; +import { ProjectsRepository } from './projects.repository'; + +@Module({ + controllers: [ProjectsController], + providers: [ProjectsService, ProjectsRepository], + exports: [ProjectsService], +}) +export class ProjectsModule {} diff --git a/src/modules/projects/projects.repository.ts b/src/modules/projects/projects.repository.ts new file mode 100644 index 0000000..146580c --- /dev/null +++ b/src/modules/projects/projects.repository.ts @@ -0,0 +1,115 @@ +import { Injectable } from '@nestjs/common'; +import { Prisma } from '@prisma/client'; +import { PrismaService } from '../../config/database/prisma.service'; + +const projectSelect = { + id: true, + name: true, + code: true, + description: true, + startDate: true, + endDate: true, + isActive: true, + createdAt: true, + updatedAt: true, + contract: { + select: { + id: true, + name: true, + client: { select: { id: true, name: true } }, + }, + }, +} as const; + +@Injectable() +export class ProjectsRepository { + constructor(private readonly prisma: PrismaService) {} + + async findAll(params: { name?: string; isActive?: boolean; page: number; limit: number }) { + const where: Prisma.ProjectWhereInput = {}; + + if (params.name) where.name = { contains: params.name, mode: 'insensitive' }; + if (params.isActive !== undefined) where.isActive = params.isActive; + + const [data, total] = await Promise.all([ + this.prisma.project.findMany({ + where, + select: projectSelect, + orderBy: { name: 'asc' }, + skip: (params.page - 1) * params.limit, + take: params.limit, + }), + this.prisma.project.count({ where }), + ]); + + return { data, total }; + } + + async findByClientId(params: { + clientId: string; + name?: string; + isActive?: boolean; + page: number; + limit: number; + }) { + const where: Prisma.ProjectWhereInput = { + contract: { clientId: params.clientId }, + }; + + if (params.name) where.name = { contains: params.name, mode: 'insensitive' }; + if (params.isActive !== undefined) where.isActive = params.isActive; + + const [data, total] = await Promise.all([ + this.prisma.project.findMany({ + where, + select: projectSelect, + orderBy: { name: 'asc' }, + skip: (params.page - 1) * params.limit, + take: params.limit, + }), + this.prisma.project.count({ where }), + ]); + + return { data, total }; + } + + async findById(id: string) { + return this.prisma.project.findUnique({ + where: { id }, + select: projectSelect, + }); + } + + async create(data: { + name: string; + contractId: string; + code?: string; + description?: string; + startDate?: string; + endDate?: string; + }) { + return this.prisma.project.create({ + data, + select: projectSelect, + }); + } + + async update( + id: string, + data: { + name?: string; + contractId?: string; + code?: string; + description?: string; + startDate?: string; + endDate?: string; + isActive?: boolean; + }, + ) { + return this.prisma.project.update({ + where: { id }, + data, + select: projectSelect, + }); + } +} diff --git a/src/modules/projects/projects.service.ts b/src/modules/projects/projects.service.ts new file mode 100644 index 0000000..9adad9f --- /dev/null +++ b/src/modules/projects/projects.service.ts @@ -0,0 +1,90 @@ +import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; +import { Role } from '../../common/enums/role.enum'; +import type { CurrentUserPayload } from '../../common/decorators/user.decorator'; +import { ProjectsRepository } from './projects.repository'; +import { PrismaService } from '../../config/database/prisma.service'; +import { CreateProjectDto } from './dto/create-project.dto'; +import { UpdateProjectDto } from './dto/update-project.dto'; +import { ListProjectsQueryDto } from './dto/list-projects-query.dto'; + +@Injectable() +export class ProjectsService { + constructor( + private readonly repository: ProjectsRepository, + private readonly prisma: PrismaService, + ) {} + + async findAll(query: ListProjectsQueryDto) { + const { page, limit, ...filters } = query; + const { data, total } = await this.repository.findAll({ + ...filters, + page, + limit, + }); + return { data, total, page, limit }; + } + + async findByClientId(clientId: string, query: ListProjectsQueryDto) { + const { page, limit, ...filters } = query; + const { data, total } = await this.repository.findByClientId({ + clientId, + ...filters, + page, + limit, + }); + return { data, total, page, limit }; + } + + async findOne(id: string, user?: CurrentUserPayload) { + const project = await this.repository.findById(id); + + if (!project) { + throw new NotFoundException('Projeto não encontrado'); + } + + if ( + user?.role === Role.CLIENT && + user.clientId && + project.contract.client.id !== user.clientId + ) { + throw new NotFoundException('Projeto não encontrado'); + } + + return project; + } + + async create(dto: CreateProjectDto) { + const contract = await this.prisma.contract.findUnique({ + where: { id: dto.contractId }, + select: { id: true, isActive: true }, + }); + + if (!contract || !contract.isActive) { + throw new BadRequestException('Contrato não encontrado ou inativo'); + } + + this.validateDates(dto.startDate, dto.endDate); + + return this.repository.create({ + ...dto, + startDate: dto.startDate ? new Date(dto.startDate).toISOString() : undefined, + endDate: dto.endDate ? new Date(dto.endDate).toISOString() : undefined, + }); + } + + async update(id: string, dto: UpdateProjectDto) { + await this.findOne(id); + this.validateDates(dto.startDate, dto.endDate); + return this.repository.update(id, { + ...dto, + startDate: dto.startDate ? new Date(dto.startDate).toISOString() : undefined, + endDate: dto.endDate ? new Date(dto.endDate).toISOString() : undefined, + }); + } + + private validateDates(startDate?: string, endDate?: string): void { + if (startDate && endDate && new Date(endDate) < new Date(startDate)) { + throw new BadRequestException('Data de término deve ser igual ou posterior à data de início'); + } + } +} diff --git a/src/modules/sprints/constants/sprint-status-transitions.ts b/src/modules/sprints/constants/sprint-status-transitions.ts new file mode 100644 index 0000000..a01402d --- /dev/null +++ b/src/modules/sprints/constants/sprint-status-transitions.ts @@ -0,0 +1,8 @@ +import { SprintStatus } from '@prisma/client'; + +export const SPRINT_STATUS_TRANSITIONS: Record = { + RASCUNHO: [SprintStatus.EM_EXECUCAO, SprintStatus.CANCELADA], + EM_EXECUCAO: [SprintStatus.FINALIZADA], + FINALIZADA: [], + CANCELADA: [], +}; diff --git a/src/modules/sprints/dto/.gitkeep b/src/modules/sprints/dto/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/src/modules/sprints/dto/add-deliverables.dto.ts b/src/modules/sprints/dto/add-deliverables.dto.ts new file mode 100644 index 0000000..736001e --- /dev/null +++ b/src/modules/sprints/dto/add-deliverables.dto.ts @@ -0,0 +1,7 @@ +import { IsArray, IsUUID } from 'class-validator'; + +export class AddDeliverablesDto { + @IsArray() + @IsUUID('4', { each: true }) + deliverableIds!: string[]; +} diff --git a/src/modules/sprints/dto/create-sprint.dto.ts b/src/modules/sprints/dto/create-sprint.dto.ts new file mode 100644 index 0000000..b3f6d3b --- /dev/null +++ b/src/modules/sprints/dto/create-sprint.dto.ts @@ -0,0 +1,21 @@ +import { IsDateString, IsNotEmpty, IsOptional, IsString } from 'class-validator'; + +export class CreateSprintDto { + @IsString() + @IsNotEmpty() + name!: string; + + @IsOptional() + @IsString() + code?: string; + + @IsDateString() + startDate!: string; + + @IsDateString() + endDate!: string; + + @IsOptional() + @IsString() + goal?: string; +} diff --git a/src/modules/sprints/dto/finish-sprint.dto.ts b/src/modules/sprints/dto/finish-sprint.dto.ts new file mode 100644 index 0000000..8fd4524 --- /dev/null +++ b/src/modules/sprints/dto/finish-sprint.dto.ts @@ -0,0 +1,27 @@ +import { Type } from 'class-transformer'; +import { IsArray, IsEnum, IsOptional, IsUUID, ValidateNested, ValidateIf } from 'class-validator'; + +export enum PendingAction { + MOVE = 'MOVE', + KEEP = 'KEEP', +} + +class PendingActionDto { + @IsUUID('4') + deliverableId!: string; + + @IsEnum(PendingAction) + action!: PendingAction; + + @ValidateIf((o: PendingActionDto) => o.action === PendingAction.MOVE) + @IsUUID('4') + targetSprintId?: string; +} + +export class FinishSprintDto { + @IsOptional() + @IsArray() + @ValidateNested({ each: true }) + @Type(() => PendingActionDto) + pendingActions?: PendingActionDto[]; +} diff --git a/src/modules/sprints/dto/list-sprints-query.dto.ts b/src/modules/sprints/dto/list-sprints-query.dto.ts new file mode 100644 index 0000000..3fb9c37 --- /dev/null +++ b/src/modules/sprints/dto/list-sprints-query.dto.ts @@ -0,0 +1,30 @@ +import { IsEnum, IsInt, IsOptional, IsString, Max, Min } from 'class-validator'; +import { Transform } from 'class-transformer'; +import { SprintStatus } from '@prisma/client'; + +export class ListSprintsQueryDto { + @IsOptional() + @IsString() + name?: string; + + @IsOptional() + @IsEnum(SprintStatus) + status?: SprintStatus; + + @IsOptional() + @Transform(({ value }) => value === 'true') + isActive?: boolean; + + @IsOptional() + @Transform(({ value }: { value: unknown }) => parseInt(String(value), 10)) + @IsInt() + @Min(1) + page: number = 1; + + @IsOptional() + @Transform(({ value }: { value: unknown }) => parseInt(String(value), 10)) + @IsInt() + @Min(1) + @Max(100) + limit: number = 20; +} diff --git a/src/modules/sprints/dto/update-sprint.dto.ts b/src/modules/sprints/dto/update-sprint.dto.ts new file mode 100644 index 0000000..9a4a536 --- /dev/null +++ b/src/modules/sprints/dto/update-sprint.dto.ts @@ -0,0 +1,27 @@ +import { IsBoolean, IsDateString, IsOptional, IsString } from 'class-validator'; + +export class UpdateSprintDto { + @IsOptional() + @IsString() + name?: string; + + @IsOptional() + @IsString() + code?: string; + + @IsOptional() + @IsDateString() + startDate?: string; + + @IsOptional() + @IsDateString() + endDate?: string; + + @IsOptional() + @IsString() + goal?: string; + + @IsOptional() + @IsBoolean() + isActive?: boolean; +} diff --git a/src/modules/sprints/sprint-history.repository.ts b/src/modules/sprints/sprint-history.repository.ts new file mode 100644 index 0000000..c16e33f --- /dev/null +++ b/src/modules/sprints/sprint-history.repository.ts @@ -0,0 +1,21 @@ +import { Injectable } from '@nestjs/common'; +import { SprintHistoryEventType, SprintStatus } from '@prisma/client'; +import { PrismaService } from '../../config/database/prisma.service'; + +@Injectable() +export class SprintHistoryRepository { + constructor(private readonly prisma: PrismaService) {} + + async create(data: { + sprintId: string; + eventType: SprintHistoryEventType; + description: string; + deliverableId?: string; + targetSprintId?: string; + previousStatus?: SprintStatus; + newStatus?: SprintStatus; + createdBy?: string; + }) { + return this.prisma.sprintHistory.create({ data }); + } +} diff --git a/src/modules/sprints/sprints.controller.ts b/src/modules/sprints/sprints.controller.ts new file mode 100644 index 0000000..da2a8dc --- /dev/null +++ b/src/modules/sprints/sprints.controller.ts @@ -0,0 +1,105 @@ +import { + Body, + Controller, + Delete, + Get, + Param, + Patch, + Post, + Query, + UseGuards, +} from '@nestjs/common'; +import { JwtAuthGuard } from '../../config/auth/jwt-auth.guard'; +import { ReadOnlyClientGuard } from '../../common/guards/read-only-client.guard'; +import { RolesGuard } from '../../common/guards/roles.guard'; +import { Roles } from '../../common/decorators/roles.decorator'; +import { CurrentUser } from '../../common/decorators/user.decorator'; +import type { CurrentUserPayload } from '../../common/decorators/user.decorator'; +import { Role } from '../../common/enums/role.enum'; +import { SprintsService } from './sprints.service'; +import { CreateSprintDto } from './dto/create-sprint.dto'; +import { UpdateSprintDto } from './dto/update-sprint.dto'; +import { ListSprintsQueryDto } from './dto/list-sprints-query.dto'; +import { AddDeliverablesDto } from './dto/add-deliverables.dto'; +import { FinishSprintDto } from './dto/finish-sprint.dto'; + +@Controller('sprints') +export class SprintsController { + constructor(private readonly sprintsService: SprintsService) {} + + @Get() + @UseGuards(JwtAuthGuard, ReadOnlyClientGuard, RolesGuard) + @Roles(Role.ADMIN, Role.OPERATOR, Role.CLIENT) + async findAll(@Query() query: ListSprintsQueryDto) { + return this.sprintsService.findAll(query); + } + + @Post() + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles(Role.ADMIN, Role.OPERATOR) + async create(@Body() dto: CreateSprintDto) { + return this.sprintsService.create(dto); + } + + @Get(':id') + @UseGuards(JwtAuthGuard, ReadOnlyClientGuard, RolesGuard) + @Roles(Role.ADMIN, Role.OPERATOR, Role.CLIENT) + async findOne(@Param('id') id: string) { + return this.sprintsService.findOneDetailed(id); + } + + @Patch(':id') + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles(Role.ADMIN, Role.OPERATOR) + async update(@Param('id') id: string, @Body() dto: UpdateSprintDto) { + return this.sprintsService.update(id, dto); + } + + @Patch(':id/start') + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles(Role.ADMIN, Role.OPERATOR) + async startSprint(@Param('id') id: string, @CurrentUser() user: CurrentUserPayload) { + return this.sprintsService.startSprint(id, user.id); + } + + @Patch(':id/cancel') + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles(Role.ADMIN, Role.OPERATOR) + async cancelSprint(@Param('id') id: string, @CurrentUser() user: CurrentUserPayload) { + return this.sprintsService.cancelSprint(id, user.id); + } + + @Post(':id/finish') + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles(Role.ADMIN, Role.OPERATOR) + async finishSprint( + @Param('id') id: string, + @Body() dto: FinishSprintDto, + @CurrentUser() user: CurrentUserPayload, + ) { + return this.sprintsService.finishSprint(id, dto, user.id); + } + + @Post(':id/deliverables') + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles(Role.ADMIN, Role.OPERATOR) + async addDeliverables( + @Param('id') id: string, + @Body() dto: AddDeliverablesDto, + @CurrentUser() user: CurrentUserPayload, + ) { + return this.sprintsService.addDeliverables(id, dto.deliverableIds, user.id); + } + + @Delete(':id/deliverables/:deliverableId') + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles(Role.ADMIN, Role.OPERATOR) + async removeDeliverable( + @Param('id') id: string, + @Param('deliverableId') deliverableId: string, + @Query('targetSprintId') targetSprintId: string, + @CurrentUser() user: CurrentUserPayload, + ) { + return this.sprintsService.removeDeliverable(id, deliverableId, targetSprintId, user.id); + } +} diff --git a/src/modules/sprints/sprints.module.ts b/src/modules/sprints/sprints.module.ts new file mode 100644 index 0000000..4944a7a --- /dev/null +++ b/src/modules/sprints/sprints.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common'; +import { SprintsController } from './sprints.controller'; +import { SprintsService } from './sprints.service'; +import { SprintsRepository } from './sprints.repository'; +import { SprintHistoryRepository } from './sprint-history.repository'; + +@Module({ + controllers: [SprintsController], + providers: [SprintsService, SprintsRepository, SprintHistoryRepository], + exports: [SprintsRepository, SprintHistoryRepository], +}) +export class SprintsModule {} diff --git a/src/modules/sprints/sprints.repository.ts b/src/modules/sprints/sprints.repository.ts new file mode 100644 index 0000000..2538a73 --- /dev/null +++ b/src/modules/sprints/sprints.repository.ts @@ -0,0 +1,131 @@ +import { Injectable } from '@nestjs/common'; +import { Prisma, SprintStatus } from '@prisma/client'; +import { PrismaService } from '../../config/database/prisma.service'; + +const sprintSelect = { + id: true, + name: true, + code: true, + status: true, + startDate: true, + endDate: true, + goal: true, + isActive: true, + createdAt: true, + updatedAt: true, +} as const; + +@Injectable() +export class SprintsRepository { + constructor(private readonly prisma: PrismaService) {} + + async findAll(params: { + name?: string; + status?: SprintStatus; + isActive?: boolean; + page: number; + limit: number; + }) { + const where: Prisma.SprintWhereInput = {}; + + if (params.name) where.name = { contains: params.name, mode: 'insensitive' }; + if (params.status) where.status = params.status; + if (params.isActive !== undefined) where.isActive = params.isActive; + + const [data, total] = await Promise.all([ + this.prisma.sprint.findMany({ + where, + select: sprintSelect, + orderBy: { name: 'asc' }, + skip: (params.page - 1) * params.limit, + take: params.limit, + }), + this.prisma.sprint.count({ where }), + ]); + + return { data, total }; + } + + async findById(id: string) { + return this.prisma.sprint.findUnique({ + where: { id }, + select: sprintSelect, + }); + } + + async findByIdDetailed(id: string) { + return this.prisma.sprint.findUnique({ + where: { id }, + select: { + ...sprintSelect, + deliverables: { + select: { + id: true, + code: true, + title: true, + status: true, + type: true, + client: { select: { id: true, name: true } }, + project: { select: { id: true, name: true } }, + workOrder: { select: { id: true, code: true, name: true } }, + }, + orderBy: { code: 'asc' }, + }, + history: { + select: { + id: true, + eventType: true, + description: true, + deliverableId: true, + targetSprintId: true, + previousStatus: true, + newStatus: true, + createdAt: true, + createdBy: true, + deliverable: { select: { code: true, title: true } }, + }, + orderBy: { createdAt: 'desc' }, + }, + }, + }); + } + + async create(data: { + name: string; + code?: string; + startDate: string; + endDate: string; + goal?: string; + }) { + return this.prisma.sprint.create({ + data, + select: sprintSelect, + }); + } + + async update( + id: string, + data: { + name?: string; + code?: string; + startDate?: string; + endDate?: string; + goal?: string; + isActive?: boolean; + }, + ) { + return this.prisma.sprint.update({ + where: { id }, + data, + select: sprintSelect, + }); + } + + async updateStatus(id: string, status: SprintStatus) { + return this.prisma.sprint.update({ + where: { id }, + data: { status }, + select: sprintSelect, + }); + } +} diff --git a/src/modules/sprints/sprints.service.ts b/src/modules/sprints/sprints.service.ts new file mode 100644 index 0000000..8b369f4 --- /dev/null +++ b/src/modules/sprints/sprints.service.ts @@ -0,0 +1,396 @@ +import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; +import { DeliverableStatus, SprintHistoryEventType, SprintStatus } from '@prisma/client'; +import { PrismaService } from '../../config/database/prisma.service'; +import { SprintsRepository } from './sprints.repository'; +import { SprintHistoryRepository } from './sprint-history.repository'; +import { CreateSprintDto } from './dto/create-sprint.dto'; +import { UpdateSprintDto } from './dto/update-sprint.dto'; +import { FinishSprintDto, PendingAction } from './dto/finish-sprint.dto'; +import { ListSprintsQueryDto } from './dto/list-sprints-query.dto'; +import { SPRINT_STATUS_TRANSITIONS } from './constants/sprint-status-transitions'; + +const TERMINAL_DELIVERABLE_STATUSES: DeliverableStatus[] = [ + DeliverableStatus.ENCERRADA, + DeliverableStatus.PAGA, + DeliverableStatus.CANCELADA, +]; + +const SPRINT_STATUS_LABELS: Record = { + RASCUNHO: 'Rascunho', + EM_EXECUCAO: 'Em Execução', + FINALIZADA: 'Finalizada', + CANCELADA: 'Cancelada', +}; + +@Injectable() +export class SprintsService { + constructor( + private readonly repository: SprintsRepository, + private readonly historyRepository: SprintHistoryRepository, + private readonly prisma: PrismaService, + ) {} + + async findAll(query: ListSprintsQueryDto) { + const { page, limit, ...filters } = query; + const { data, total } = await this.repository.findAll({ + ...filters, + page, + limit, + }); + return { data, total, page, limit }; + } + + async findOne(id: string) { + const sprint = await this.repository.findById(id); + + if (!sprint) { + throw new NotFoundException('Sprint não encontrada'); + } + + return sprint; + } + + async findOneDetailed(id: string) { + const sprint = await this.repository.findByIdDetailed(id); + + if (!sprint) { + throw new NotFoundException('Sprint não encontrada'); + } + + const statusSummary: Record = {}; + for (const deliverable of sprint.deliverables) { + statusSummary[deliverable.status] = (statusSummary[deliverable.status] || 0) + 1; + } + + return { + ...sprint, + totalDeliverables: sprint.deliverables.length, + statusSummary, + }; + } + + async create(dto: CreateSprintDto) { + this.validateDates(dto.startDate, dto.endDate); + return this.repository.create({ + ...dto, + startDate: new Date(dto.startDate).toISOString(), + endDate: new Date(dto.endDate).toISOString(), + }); + } + + async update(id: string, dto: UpdateSprintDto) { + const existing = await this.findOne(id); + + if (existing.status === SprintStatus.FINALIZADA || existing.status === SprintStatus.CANCELADA) { + throw new BadRequestException('Não é possível editar uma sprint finalizada ou cancelada'); + } + + const startDate = dto.startDate ?? existing.startDate?.toISOString(); + const endDate = dto.endDate ?? existing.endDate?.toISOString(); + + this.validateDates(startDate, endDate); + + return this.repository.update(id, { + ...dto, + startDate: dto.startDate ? new Date(dto.startDate).toISOString() : undefined, + endDate: dto.endDate ? new Date(dto.endDate).toISOString() : undefined, + }); + } + + async startSprint(id: string, userId?: string) { + const sprint = await this.findOne(id); + this.validateStatusTransition(sprint.status, SprintStatus.EM_EXECUCAO); + + const updated = await this.repository.updateStatus(id, SprintStatus.EM_EXECUCAO); + + await this.historyRepository.create({ + sprintId: id, + eventType: SprintHistoryEventType.STATUS_CHANGE, + description: 'Sprint iniciada', + previousStatus: SprintStatus.RASCUNHO, + newStatus: SprintStatus.EM_EXECUCAO, + createdBy: userId, + }); + + return updated; + } + + async cancelSprint(id: string, userId?: string) { + const sprint = await this.findOne(id); + this.validateStatusTransition(sprint.status, SprintStatus.CANCELADA); + + const updated = await this.repository.updateStatus(id, SprintStatus.CANCELADA); + + await this.historyRepository.create({ + sprintId: id, + eventType: SprintHistoryEventType.STATUS_CHANGE, + description: 'Sprint cancelada', + previousStatus: SprintStatus.RASCUNHO, + newStatus: SprintStatus.CANCELADA, + createdBy: userId, + }); + + return updated; + } + + async addDeliverables(sprintId: string, deliverableIds: string[], userId?: string) { + const sprint = await this.findOne(sprintId); + this.validateSprintMutable(sprint.status); + + const deliverables = await this.prisma.deliverable.findMany({ + where: { id: { in: deliverableIds } }, + select: { id: true, code: true, sprintId: true, sprint: { select: { status: true } } }, + }); + + if (deliverables.length !== deliverableIds.length) { + const foundIds = new Set(deliverables.map((d) => d.id)); + const missing = deliverableIds.filter((id) => !foundIds.has(id)); + throw new NotFoundException(`Entregável(eis) não encontrado(s): ${missing.join(', ')}`); + } + + const inActiveSprint = deliverables.filter( + (d) => d.sprintId && d.sprintId !== sprintId && d.sprint?.status === SprintStatus.EM_EXECUCAO, + ); + + if (inActiveSprint.length > 0) { + const codes = inActiveSprint.map((d) => d.code).join(', '); + throw new BadRequestException( + `Os seguintes entregáveis já estão em uma sprint em execução: ${codes}`, + ); + } + + await this.prisma.deliverable.updateMany({ + where: { id: { in: deliverableIds } }, + data: { sprintId }, + }); + + await Promise.all( + deliverables.map((d) => + this.historyRepository.create({ + sprintId, + eventType: SprintHistoryEventType.ENTREGAVEL_ADICIONADO, + description: `Entregável ${d.code} adicionado à sprint`, + deliverableId: d.id, + createdBy: userId, + }), + ), + ); + + return { message: `${deliverables.length} entregável(eis) adicionado(s) à sprint` }; + } + + async finishSprint(id: string, dto: FinishSprintDto, userId?: string) { + const sprint = await this.findOne(id); + this.validateStatusTransition(sprint.status, SprintStatus.FINALIZADA); + + const deliverables = await this.prisma.deliverable.findMany({ + where: { sprintId: id }, + select: { id: true, code: true, title: true, status: true }, + }); + + const pendingDeliverables = deliverables.filter( + (d) => !TERMINAL_DELIVERABLE_STATUSES.includes(d.status), + ); + + if (pendingDeliverables.length > 0) { + const actions = dto.pendingActions ?? []; + const actionMap = new Map(actions.map((a) => [a.deliverableId, a])); + + const pendingWithoutAction = pendingDeliverables.filter((d) => !actionMap.has(d.id)); + + if (pendingWithoutAction.length > 0) { + throw new BadRequestException({ + message: 'Existem entregáveis pendentes que precisam de uma ação definida', + pendingDeliverables: pendingDeliverables.map((d) => ({ + id: d.id, + code: d.code, + title: d.title, + status: d.status, + })), + }); + } + + await this.prisma.$transaction(async (tx) => { + for (const pending of pendingDeliverables) { + const action = actionMap.get(pending.id); + if (!action) continue; + + if (action.action === PendingAction.MOVE) { + if (!action.targetSprintId) { + throw new BadRequestException( + `Sprint destino é obrigatória para mover o entregável ${pending.code}`, + ); + } + + const targetSprint = await tx.sprint.findUnique({ + where: { id: action.targetSprintId }, + select: { id: true, name: true, status: true }, + }); + + if (!targetSprint) { + throw new NotFoundException( + `Sprint destino não encontrada para o entregável ${pending.code}`, + ); + } + + if ( + targetSprint.status !== SprintStatus.RASCUNHO && + targetSprint.status !== SprintStatus.EM_EXECUCAO + ) { + throw new BadRequestException( + `Sprint destino "${targetSprint.name}" não está em Rascunho ou Em Execução`, + ); + } + + await tx.deliverable.update({ + where: { id: pending.id }, + data: { sprintId: action.targetSprintId }, + }); + + await tx.sprintHistory.create({ + data: { + sprintId: id, + eventType: SprintHistoryEventType.ENTREGAVEL_MOVIDO, + description: `Entregável ${pending.code} movido para sprint "${targetSprint.name}"`, + deliverableId: pending.id, + targetSprintId: action.targetSprintId, + createdBy: userId, + }, + }); + + await tx.sprintHistory.create({ + data: { + sprintId: action.targetSprintId, + eventType: SprintHistoryEventType.ENTREGAVEL_ADICIONADO, + description: `Entregável ${pending.code} recebido da sprint finalizada`, + deliverableId: pending.id, + createdBy: userId, + }, + }); + } + // KEEP: não faz nada, entregável permanece na sprint finalizada + } + + await tx.sprint.update({ + where: { id }, + data: { status: SprintStatus.FINALIZADA }, + }); + + await tx.sprintHistory.create({ + data: { + sprintId: id, + eventType: SprintHistoryEventType.STATUS_CHANGE, + description: 'Sprint finalizada', + previousStatus: SprintStatus.EM_EXECUCAO, + newStatus: SprintStatus.FINALIZADA, + createdBy: userId, + }, + }); + }); + } else { + await this.repository.updateStatus(id, SprintStatus.FINALIZADA); + + await this.historyRepository.create({ + sprintId: id, + eventType: SprintHistoryEventType.STATUS_CHANGE, + description: 'Sprint finalizada', + previousStatus: SprintStatus.EM_EXECUCAO, + newStatus: SprintStatus.FINALIZADA, + createdBy: userId, + }); + } + + return this.findOne(id); + } + + async removeDeliverable( + sprintId: string, + deliverableId: string, + targetSprintId: string, + userId?: string, + ) { + if (!targetSprintId) { + throw new BadRequestException('Sprint destino é obrigatória (targetSprintId)'); + } + + const sprint = await this.findOne(sprintId); + this.validateSprintMutable(sprint.status); + + const deliverable = await this.prisma.deliverable.findUnique({ + where: { id: deliverableId }, + select: { id: true, code: true, sprintId: true }, + }); + + if (!deliverable) { + throw new NotFoundException('Entregável não encontrado'); + } + + if (deliverable.sprintId !== sprintId) { + throw new BadRequestException('Este entregável não pertence a esta sprint'); + } + + const targetSprint = await this.prisma.sprint.findUnique({ + where: { id: targetSprintId }, + select: { id: true, name: true, status: true }, + }); + + if (!targetSprint) { + throw new NotFoundException('Sprint destino não encontrada'); + } + + if ( + targetSprint.status !== SprintStatus.RASCUNHO && + targetSprint.status !== SprintStatus.EM_EXECUCAO + ) { + throw new BadRequestException( + `Sprint destino "${targetSprint.name}" não está em Rascunho ou Em Execução`, + ); + } + + await this.prisma.deliverable.update({ + where: { id: deliverableId }, + data: { sprintId: targetSprintId }, + }); + + await this.historyRepository.create({ + sprintId, + eventType: SprintHistoryEventType.ENTREGAVEL_REMOVIDO, + description: `Entregável ${deliverable.code} movido para sprint "${targetSprint.name}"`, + deliverableId: deliverable.id, + targetSprintId, + createdBy: userId, + }); + + await this.historyRepository.create({ + sprintId: targetSprintId, + eventType: SprintHistoryEventType.ENTREGAVEL_ADICIONADO, + description: `Entregável ${deliverable.code} recebido da sprint "${sprint.name}"`, + deliverableId: deliverable.id, + createdBy: userId, + }); + + return { message: `Entregável ${deliverable.code} movido para sprint "${targetSprint.name}"` }; + } + + private validateSprintMutable(status: SprintStatus): void { + if (status === SprintStatus.FINALIZADA || status === SprintStatus.CANCELADA) { + throw new BadRequestException('Não é possível modificar uma sprint finalizada ou cancelada'); + } + } + + private validateStatusTransition(currentStatus: SprintStatus, targetStatus: SprintStatus): void { + const allowedTransitions = SPRINT_STATUS_TRANSITIONS[currentStatus]; + + if (!allowedTransitions.includes(targetStatus)) { + throw new BadRequestException( + `Transição de status inválida: ${SPRINT_STATUS_LABELS[currentStatus]} → ${SPRINT_STATUS_LABELS[targetStatus]}`, + ); + } + } + + private validateDates(startDate?: string | null, endDate?: string | null): void { + if (startDate && endDate && new Date(endDate) < new Date(startDate)) { + throw new BadRequestException('Data de término deve ser igual ou posterior à data de início'); + } + } +} diff --git a/src/modules/timeline/dto/.gitkeep b/src/modules/timeline/dto/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/src/modules/timeline/dto/list-timeline-query.dto.ts b/src/modules/timeline/dto/list-timeline-query.dto.ts new file mode 100644 index 0000000..a2fbef4 --- /dev/null +++ b/src/modules/timeline/dto/list-timeline-query.dto.ts @@ -0,0 +1,22 @@ +import { IsEnum, IsInt, IsOptional, Max, Min } from 'class-validator'; +import { Transform } from 'class-transformer'; +import { TimelineEventType } from '@prisma/client'; + +export class ListTimelineQueryDto { + @IsOptional() + @IsEnum(TimelineEventType, { message: 'Tipo de evento inválido' }) + type?: TimelineEventType; + + @IsOptional() + @Transform(({ value }: { value: unknown }) => parseInt(String(value), 10)) + @IsInt() + @Min(1) + page: number = 1; + + @IsOptional() + @Transform(({ value }: { value: unknown }) => parseInt(String(value), 10)) + @IsInt() + @Min(1) + @Max(100) + limit: number = 20; +} diff --git a/src/modules/timeline/timeline.controller.ts b/src/modules/timeline/timeline.controller.ts new file mode 100644 index 0000000..10e44dd --- /dev/null +++ b/src/modules/timeline/timeline.controller.ts @@ -0,0 +1,33 @@ +import { Controller, Get, Param, Query, UseGuards } from '@nestjs/common'; +import { JwtAuthGuard } from '../../config/auth/jwt-auth.guard'; +import { ReadOnlyClientGuard } from '../../common/guards/read-only-client.guard'; +import { RolesGuard } from '../../common/guards/roles.guard'; +import { Roles } from '../../common/decorators/roles.decorator'; +import { Role } from '../../common/enums/role.enum'; +import { CurrentUser } from '../../common/decorators/user.decorator'; +import type { CurrentUserPayload } from '../../common/decorators/user.decorator'; +import { DeliverablesService } from '../deliverables/deliverables.service'; +import { TimelineService } from './timeline.service'; +import { ListTimelineQueryDto } from './dto/list-timeline-query.dto'; + +@Controller('deliverables/:deliverableId/timeline') +@UseGuards(JwtAuthGuard, ReadOnlyClientGuard, RolesGuard) +@Roles(Role.ADMIN, Role.OPERATOR, Role.CLIENT) +export class TimelineController { + constructor( + private readonly timelineService: TimelineService, + private readonly deliverablesService: DeliverablesService, + ) {} + + @Get() + async findAll( + @Param('deliverableId') deliverableId: string, + @Query() query: ListTimelineQueryDto, + @CurrentUser() user: CurrentUserPayload, + ) { + if (user.role === Role.CLIENT && user.clientId) { + await this.deliverablesService.validateDeliverableOwnership(deliverableId, user.clientId); + } + return this.timelineService.findByDeliverableId(deliverableId, query); + } +} diff --git a/src/modules/timeline/timeline.module.ts b/src/modules/timeline/timeline.module.ts new file mode 100644 index 0000000..25b144e --- /dev/null +++ b/src/modules/timeline/timeline.module.ts @@ -0,0 +1,13 @@ +import { Module, forwardRef } from '@nestjs/common'; +import { DeliverablesModule } from '../deliverables/deliverables.module'; +import { TimelineController } from './timeline.controller'; +import { TimelineService } from './timeline.service'; +import { TimelineRepository } from './timeline.repository'; + +@Module({ + imports: [forwardRef(() => DeliverablesModule)], + controllers: [TimelineController], + providers: [TimelineService, TimelineRepository], + exports: [TimelineService], +}) +export class TimelineModule {} diff --git a/src/modules/timeline/timeline.repository.ts b/src/modules/timeline/timeline.repository.ts new file mode 100644 index 0000000..4e17470 --- /dev/null +++ b/src/modules/timeline/timeline.repository.ts @@ -0,0 +1,54 @@ +import { Injectable } from '@nestjs/common'; +import { Prisma, TimelineEventType } from '@prisma/client'; +import { PrismaService } from '../../config/database/prisma.service'; +import { ListTimelineQueryDto } from './dto/list-timeline-query.dto'; + +const timelineEventSelect = { + id: true, + deliverableId: true, + type: true, + title: true, + description: true, + metadata: true, + createdAt: true, + createdBy: true, +} as const; + +@Injectable() +export class TimelineRepository { + constructor(private readonly prisma: PrismaService) {} + + async create(data: { + deliverableId: string; + type: TimelineEventType; + title: string; + description?: string; + metadata?: Prisma.InputJsonValue; + createdBy?: string; + }) { + return this.prisma.timelineEvent.create({ + data, + select: timelineEventSelect, + }); + } + + async findByDeliverableId(deliverableId: string, query: ListTimelineQueryDto) { + const where: Prisma.TimelineEventWhereInput = { + deliverableId, + ...(query.type && { type: query.type }), + }; + + const [data, total] = await this.prisma.$transaction([ + this.prisma.timelineEvent.findMany({ + where, + select: timelineEventSelect, + orderBy: { createdAt: 'desc' }, + skip: (query.page - 1) * query.limit, + take: query.limit, + }), + this.prisma.timelineEvent.count({ where }), + ]); + + return { data, total, page: query.page, limit: query.limit }; + } +} diff --git a/src/modules/timeline/timeline.service.spec.ts b/src/modules/timeline/timeline.service.spec.ts new file mode 100644 index 0000000..d5cffde --- /dev/null +++ b/src/modules/timeline/timeline.service.spec.ts @@ -0,0 +1,114 @@ +import { NotFoundException } from '@nestjs/common'; +import { Test, TestingModule } from '@nestjs/testing'; +import { TimelineEventType } from '@prisma/client'; +import { TimelineService } from './timeline.service'; +import { TimelineRepository } from './timeline.repository'; +import { PrismaService } from '../../config/database/prisma.service'; +import { ListTimelineQueryDto } from './dto/list-timeline-query.dto'; + +const mockTimelineEvent = (type: TimelineEventType = TimelineEventType.CRIACAO) => ({ + id: 'event-1', + deliverableId: 'deliverable-id-1', + type, + title: 'Entregável criado', + description: 'ENT-001 - Entregável de Teste', + metadata: null, + createdAt: new Date('2026-04-01T10:00:00Z'), + createdBy: 'user-id-1', +}); + +describe('TimelineService', () => { + let service: TimelineService; + let repository: jest.Mocked; + let prisma: { deliverable: { findUnique: jest.Mock } }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + TimelineService, + { + provide: TimelineRepository, + useValue: { + create: jest.fn(), + findByDeliverableId: jest.fn(), + }, + }, + { + provide: PrismaService, + useValue: { + deliverable: { + findUnique: jest.fn(), + }, + user: { + findMany: jest.fn().mockResolvedValue([{ id: 'user-id-1', name: 'Raphael Soares' }]), + }, + }, + }, + ], + }).compile(); + + service = module.get(TimelineService); + repository = module.get(TimelineRepository); + prisma = module.get(PrismaService); + }); + + describe('findByDeliverableId', () => { + const query: ListTimelineQueryDto = { page: 1, limit: 20 }; + + it('deve retornar eventos ordenados por data desc', async () => { + const events = [ + mockTimelineEvent(TimelineEventType.EDICAO), + mockTimelineEvent(TimelineEventType.CRIACAO), + ]; + const result = { data: events, total: 2, page: 1, limit: 20 }; + + prisma.deliverable.findUnique.mockResolvedValue({ id: 'deliverable-id-1' }); + repository.findByDeliverableId.mockResolvedValue(result); + + const response = await service.findByDeliverableId('deliverable-id-1', query); + + expect(response).toEqual(result); + expect(repository.findByDeliverableId).toHaveBeenCalledWith('deliverable-id-1', query); + }); + + it('deve passar filtro de type para o repository', async () => { + const queryWithType: ListTimelineQueryDto = { + page: 1, + limit: 20, + type: TimelineEventType.BACKLOG, + }; + const result = { data: [], total: 0, page: 1, limit: 20 }; + + prisma.deliverable.findUnique.mockResolvedValue({ id: 'deliverable-id-1' }); + repository.findByDeliverableId.mockResolvedValue(result); + + await service.findByDeliverableId('deliverable-id-1', queryWithType); + + expect(repository.findByDeliverableId).toHaveBeenCalledWith( + 'deliverable-id-1', + queryWithType, + ); + }); + + it('deve passar paginação para o repository', async () => { + const queryPage2: ListTimelineQueryDto = { page: 2, limit: 10 }; + const result = { data: [], total: 15, page: 2, limit: 10 }; + + prisma.deliverable.findUnique.mockResolvedValue({ id: 'deliverable-id-1' }); + repository.findByDeliverableId.mockResolvedValue(result); + + const response = await service.findByDeliverableId('deliverable-id-1', queryPage2); + + expect(response).toEqual(result); + expect(repository.findByDeliverableId).toHaveBeenCalledWith('deliverable-id-1', queryPage2); + }); + + it('deve retornar 404 se o entregável não existir', async () => { + prisma.deliverable.findUnique.mockResolvedValue(null); + + await expect(service.findByDeliverableId('deliverable-inexistente', query)).rejects.toThrow( + NotFoundException, + ); + }); + }); +}); diff --git a/src/modules/timeline/timeline.service.ts b/src/modules/timeline/timeline.service.ts new file mode 100644 index 0000000..e2166cd --- /dev/null +++ b/src/modules/timeline/timeline.service.ts @@ -0,0 +1,65 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { Prisma, TimelineEventType } from '@prisma/client'; +import { PrismaService } from '../../config/database/prisma.service'; +import { TimelineRepository } from './timeline.repository'; +import { ListTimelineQueryDto } from './dto/list-timeline-query.dto'; + +export interface CreateTimelineEventData { + deliverableId: string; + type: TimelineEventType; + title: string; + description?: string; + metadata?: Prisma.InputJsonValue; + createdBy?: string; +} + +@Injectable() +export class TimelineService { + constructor( + private readonly repository: TimelineRepository, + private readonly prisma: PrismaService, + ) {} + + async createEvent(data: CreateTimelineEventData) { + const deliverable = await this.prisma.deliverable.findUnique({ + where: { id: data.deliverableId }, + select: { id: true }, + }); + + if (!deliverable) { + throw new NotFoundException('Entregável não encontrado'); + } + + return this.repository.create(data); + } + + async findByDeliverableId(deliverableId: string, query: ListTimelineQueryDto) { + const deliverable = await this.prisma.deliverable.findUnique({ + where: { id: deliverableId }, + select: { id: true }, + }); + + if (!deliverable) { + throw new NotFoundException('Entregável não encontrado'); + } + + const result = await this.repository.findByDeliverableId(deliverableId, query); + + const userIds = [...new Set(result.data.map((e) => e.createdBy).filter(Boolean))] as string[]; + + if (userIds.length > 0) { + const users = await this.prisma.user.findMany({ + where: { id: { in: userIds } }, + select: { id: true, name: true }, + }); + const userMap = new Map(users.map((u) => [u.id, u.name])); + + result.data = result.data.map((event) => ({ + ...event, + createdBy: event.createdBy ? (userMap.get(event.createdBy) ?? event.createdBy) : null, + })); + } + + return result; + } +} diff --git a/src/modules/users/dto/.gitkeep b/src/modules/users/dto/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/src/modules/users/dto/create-user.dto.ts b/src/modules/users/dto/create-user.dto.ts new file mode 100644 index 0000000..86dd0ba --- /dev/null +++ b/src/modules/users/dto/create-user.dto.ts @@ -0,0 +1,35 @@ +import { + IsEmail, + IsEnum, + IsNotEmpty, + IsOptional, + IsString, + IsUUID, + MinLength, +} from 'class-validator'; +import { ALL_ROLES, Role } from '../../../common/enums/role.enum'; + +export class CreateUserDto { + @IsString() + @IsNotEmpty() + name!: string; + + @IsEmail() + email!: string; + + @IsOptional() + @IsString() + @MinLength(8) + password?: string; + + @IsEnum(ALL_ROLES, { + message: + 'Perfil inválido. Valores aceitos: ADMIN, GESTOR_PROJETOS, PO, FISCAL_CONTRATO, GESTOR_CONTRATO. ' + + 'Os perfis OPERATOR e CLIENT foram substituídos pelos novos perfis.', + }) + role!: Role; + + @IsOptional() + @IsUUID() + clientId?: string; +} diff --git a/src/modules/users/dto/list-users-query.dto.ts b/src/modules/users/dto/list-users-query.dto.ts new file mode 100644 index 0000000..5568f68 --- /dev/null +++ b/src/modules/users/dto/list-users-query.dto.ts @@ -0,0 +1,34 @@ +import { IsEnum, IsInt, IsOptional, IsString, Max, Min } from 'class-validator'; +import { Transform } from 'class-transformer'; +import { Role } from '../../../common/enums/role.enum'; + +export class ListUsersQueryDto { + @IsOptional() + @IsString() + name?: string; + + @IsOptional() + @IsString() + email?: string; + + @IsOptional() + @IsEnum(Role) + role?: Role; + + @IsOptional() + @Transform(({ value }) => value === 'true') + isActive?: boolean; + + @IsOptional() + @Transform(({ value }: { value: unknown }) => parseInt(String(value), 10)) + @IsInt() + @Min(1) + page: number = 1; + + @IsOptional() + @Transform(({ value }: { value: unknown }) => parseInt(String(value), 10)) + @IsInt() + @Min(1) + @Max(100) + limit: number = 20; +} diff --git a/src/modules/users/dto/reset-password.dto.ts b/src/modules/users/dto/reset-password.dto.ts new file mode 100644 index 0000000..2982e1e --- /dev/null +++ b/src/modules/users/dto/reset-password.dto.ts @@ -0,0 +1,3 @@ +// Reset password no longer requires a request body. +// The password is auto-generated by the backend. +export class ResetPasswordDto {} diff --git a/src/modules/users/dto/update-user.dto.ts b/src/modules/users/dto/update-user.dto.ts new file mode 100644 index 0000000..739a637 --- /dev/null +++ b/src/modules/users/dto/update-user.dto.ts @@ -0,0 +1,24 @@ +import { IsString, IsEmail, IsEnum, IsBoolean, IsOptional, IsUUID } from 'class-validator'; +import { Role } from '../../../common/enums/role.enum'; + +export class UpdateUserDto { + @IsOptional() + @IsString() + name?: string; + + @IsOptional() + @IsEmail() + email?: string; + + @IsOptional() + @IsEnum(Role) + role?: Role; + + @IsOptional() + @IsBoolean() + isActive?: boolean; + + @IsOptional() + @IsUUID() + clientId?: string | null; +} diff --git a/src/modules/users/users.controller.spec.ts b/src/modules/users/users.controller.spec.ts new file mode 100644 index 0000000..86d2abf --- /dev/null +++ b/src/modules/users/users.controller.spec.ts @@ -0,0 +1,82 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { Role } from '../../common/enums/role.enum'; +import { UsersController } from './users.controller'; +import { UsersService } from './users.service'; + +const mockCreatedUser = { + id: 'user-id-1', + name: 'João Silva', + email: 'joao@iasis.com.br', + role: Role.OPERATOR, + isActive: true, + createdAt: new Date(), + updatedAt: new Date(), +}; + +describe('UsersController', () => { + let controller: UsersController; + let usersService: jest.Mocked; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + controllers: [UsersController], + providers: [ + { + provide: UsersService, + useValue: { + create: jest.fn().mockResolvedValue(mockCreatedUser), + update: jest.fn().mockResolvedValue(mockCreatedUser), + resetPassword: jest.fn().mockResolvedValue({ + generatedPassword: 'Abc123!@xYzW', + message: 'Senha redefinida com sucesso', + }), + }, + }, + ], + }).compile(); + + controller = module.get(UsersController); + usersService = module.get(UsersService); + }); + + describe('create', () => { + it('should call usersService.create and return the result', async () => { + const dto = { + name: 'João Silva', + email: 'joao@iasis.com.br', + password: 'senhaforte123', + role: Role.OPERATOR, + }; + + const result = await controller.create(dto); + + expect(usersService.create).toHaveBeenCalledWith(dto); + expect(result).toEqual(mockCreatedUser); + }); + }); + + describe('update', () => { + const currentUser = { id: 'admin-id', email: 'admin@iasis.com.br', role: Role.ADMIN }; + + it('should call usersService.update and return the result', async () => { + const dto = { name: 'Novo Nome', role: Role.ADMIN }; + + const result = await controller.update('user-id-1', dto, currentUser); + + expect(usersService.update).toHaveBeenCalledWith('user-id-1', dto, 'admin-id'); + expect(result).toEqual(mockCreatedUser); + }); + }); + + describe('resetPassword', () => { + it('should call usersService.resetPassword and return generatedPassword', async () => { + const result = await controller.resetPassword('user-id-1'); + + expect(usersService.resetPassword).toHaveBeenCalledWith('user-id-1'); + expect(result).toEqual({ + generatedPassword: 'Abc123!@xYzW', + message: 'Senha redefinida com sucesso', + }); + }); + }); +}); diff --git a/src/modules/users/users.controller.ts b/src/modules/users/users.controller.ts new file mode 100644 index 0000000..e3c3e84 --- /dev/null +++ b/src/modules/users/users.controller.ts @@ -0,0 +1,95 @@ +import { Body, Controller, Get, Param, Patch, Post, Query, UseGuards } from '@nestjs/common'; +import { JwtAuthGuard } from '../../config/auth/jwt-auth.guard'; +import { RolesGuard } from '../../common/guards/roles.guard'; +import { Roles } from '../../common/decorators/roles.decorator'; +import { CurrentUser, type CurrentUserPayload } from '../../common/decorators/user.decorator'; +import { Role } from '../../common/enums/role.enum'; +import { IsEnum, IsString } from 'class-validator'; +import { ALL_ROLES } from '../../common/enums/role.enum'; +import { UsersService } from './users.service'; +import { CreateUserDto } from './dto/create-user.dto'; +import { UpdateUserDto } from './dto/update-user.dto'; +import { ListUsersQueryDto } from './dto/list-users-query.dto'; + +class ChangeRoleDto { + @IsEnum(ALL_ROLES, { message: 'Perfil inválido' }) + role!: Role; +} + +class SetClientSubRoleDto { + @IsString() + subRole!: string; +} + +@Controller('users') +export class UsersController { + constructor(private readonly usersService: UsersService) {} + + @Get() + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles(Role.ADMIN) + async findAll(@Query() query: ListUsersQueryDto) { + return this.usersService.findAll(query); + } + + @Get('pending-client-sub-role') + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles(Role.ADMIN) + async findPendingClientSubRole() { + return this.usersService.findPendingClientSubRole(); + } + + @Post() + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles(Role.ADMIN) + async create(@Body() dto: CreateUserDto) { + return this.usersService.create(dto); + } + + @Get(':id') + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles(Role.ADMIN) + async findOne(@Param('id') id: string) { + return this.usersService.findOne(id); + } + + @Patch(':id') + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles(Role.ADMIN) + async update( + @Param('id') id: string, + @Body() dto: UpdateUserDto, + @CurrentUser() currentUser: CurrentUserPayload, + ) { + return this.usersService.update(id, dto, currentUser.id); + } + + @Patch(':id/reset-password') + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles(Role.ADMIN) + async resetPassword(@Param('id') id: string) { + return this.usersService.resetPassword(id); + } + + @Patch(':id/role') + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles(Role.ADMIN) + async changeRole( + @Param('id') id: string, + @Body() dto: ChangeRoleDto, + @CurrentUser() currentUser: CurrentUserPayload, + ) { + return this.usersService.changeRole(id, dto.role, currentUser.id); + } + + @Patch(':id/client-sub-role') + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles(Role.ADMIN) + async setClientSubRole( + @Param('id') id: string, + @Body() dto: SetClientSubRoleDto, + @CurrentUser() currentUser: CurrentUserPayload, + ) { + return this.usersService.setClientSubRole(id, dto.subRole, currentUser.id); + } +} diff --git a/src/modules/users/users.module.ts b/src/modules/users/users.module.ts new file mode 100644 index 0000000..6249cae --- /dev/null +++ b/src/modules/users/users.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common'; +import { EnvModule } from '../../config/env/env.module'; +import { UsersController } from './users.controller'; +import { UsersService } from './users.service'; +import { UsersRepository } from './users.repository'; + +@Module({ + imports: [EnvModule], + controllers: [UsersController], + providers: [UsersService, UsersRepository], +}) +export class UsersModule {} diff --git a/src/modules/users/users.repository.ts b/src/modules/users/users.repository.ts new file mode 100644 index 0000000..103e9dd --- /dev/null +++ b/src/modules/users/users.repository.ts @@ -0,0 +1,110 @@ +import { Injectable } from '@nestjs/common'; +import { Prisma } from '@prisma/client'; +import { Role } from '../../common/enums/role.enum'; +import { PrismaService } from '../../config/database/prisma.service'; + +const userWithoutPassword = { + id: true, + name: true, + email: true, + role: true, + clientSubRole: true, + isActive: true, + mustChangePassword: true, + clientId: true, + client: { select: { id: true, name: true } }, + createdAt: true, + updatedAt: true, +} as const; + +@Injectable() +export class UsersRepository { + constructor(private readonly prisma: PrismaService) {} + + async findByEmail(email: string) { + return this.prisma.user.findUnique({ where: { email } }); + } + + async findAll(params: { + name?: string; + email?: string; + role?: Role; + isActive?: boolean; + page: number; + limit: number; + }) { + const where: Prisma.UserWhereInput = {}; + + if (params.name) where.name = { contains: params.name, mode: 'insensitive' }; + if (params.email) where.email = { contains: params.email, mode: 'insensitive' }; + if (params.role) where.role = params.role; + if (params.isActive !== undefined) where.isActive = params.isActive; + + const [data, total] = await Promise.all([ + this.prisma.user.findMany({ + where, + select: userWithoutPassword, + orderBy: { name: 'asc' }, + skip: (params.page - 1) * params.limit, + take: params.limit, + }), + this.prisma.user.count({ where }), + ]); + + return { data, total }; + } + + async findById(id: string) { + return this.prisma.user.findUnique({ + where: { id }, + select: userWithoutPassword, + }); + } + + async findByEmailExcludingId(email: string, excludeId: string) { + return this.prisma.user.findFirst({ + where: { email, id: { not: excludeId } }, + }); + } + + async findPendingClientSubRole() { + return this.prisma.user.findMany({ + where: { role: Role.CLIENT, clientSubRole: null }, + select: userWithoutPassword, + }); + } + + async create(data: { + name: string; + email: string; + password: string; + role: Role; + mustChangePassword?: boolean; + clientId?: string | null; + }) { + return this.prisma.user.create({ + data, + select: userWithoutPassword, + }); + } + + async update( + id: string, + data: { + name?: string; + email?: string; + password?: string; + role?: Role; + clientSubRole?: string | null; + isActive?: boolean; + mustChangePassword?: boolean; + clientId?: string | null; + }, + ) { + return this.prisma.user.update({ + where: { id }, + data, + select: userWithoutPassword, + }); + } +} diff --git a/src/modules/users/users.service.spec.ts b/src/modules/users/users.service.spec.ts new file mode 100644 index 0000000..2e29a16 --- /dev/null +++ b/src/modules/users/users.service.spec.ts @@ -0,0 +1,615 @@ +import { BadRequestException, ConflictException, NotFoundException } from '@nestjs/common'; +import { Test, TestingModule } from '@nestjs/testing'; +import * as bcrypt from 'bcrypt'; +import { Role } from '../../common/enums/role.enum'; +import { PrismaService } from '../../config/database/prisma.service'; +import { MailerService } from '../mailer/mailer.service'; +import { EnvService } from '../../config/env/env.service'; +import { UsersRepository } from './users.repository'; +import { UsersService } from './users.service'; +import { ListUsersQueryDto } from './dto/list-users-query.dto'; +import * as passwordGenerator from '../../common/utils/password-generator'; + +jest.mock('bcrypt'); + +const mockCreatedUser = { + id: 'user-id-1', + name: 'João Silva', + email: 'joao@iasis.com.br', + role: Role.GESTOR_PROJETOS, + clientSubRole: null, + isActive: true, + mustChangePassword: false, + clientId: null, + client: null, + createdAt: new Date(), + updatedAt: new Date(), +}; + +const mockPrismaClient = { + client: { + findUnique: jest.fn(), + }, +}; + +describe('UsersService', () => { + let service: UsersService; + let repository: jest.Mocked; + let prisma: typeof mockPrismaClient; + let mailerService: jest.Mocked; + + beforeEach(async () => { + jest.clearAllMocks(); + const module: TestingModule = await Test.createTestingModule({ + providers: [ + UsersService, + { + provide: UsersRepository, + useValue: { + findAll: jest.fn(), + findById: jest.fn(), + findByEmail: jest.fn(), + findByEmailExcludingId: jest.fn(), + findPendingClientSubRole: jest.fn(), + create: jest.fn(), + update: jest.fn(), + }, + }, + { + provide: PrismaService, + useValue: mockPrismaClient, + }, + { + provide: MailerService, + useValue: { + sendMail: jest.fn().mockResolvedValue(undefined), + }, + }, + { + provide: EnvService, + useValue: { + frontendUrl: 'http://localhost:5173', + }, + }, + ], + }).compile(); + + service = module.get(UsersService); + repository = module.get(UsersRepository); + prisma = module.get(PrismaService); + mailerService = module.get(MailerService); + }); + + describe('findAll', () => { + const mockUsers = [mockCreatedUser]; + + it('should return paginated users list', async () => { + repository.findAll.mockResolvedValue({ + data: mockUsers, + total: 1, + }); + + const query: ListUsersQueryDto = { page: 1, limit: 20 }; + const result = await service.findAll(query); + + expect(repository.findAll).toHaveBeenCalledWith({ page: 1, limit: 20 }); + expect(result).toEqual({ + data: mockUsers, + total: 1, + page: 1, + limit: 20, + }); + }); + + it('should pass filters to repository', async () => { + repository.findAll.mockResolvedValue({ + data: mockUsers, + total: 1, + }); + + const query: ListUsersQueryDto = { + name: 'João', + role: Role.OPERATOR, + page: 1, + limit: 10, + }; + await service.findAll(query); + + expect(repository.findAll).toHaveBeenCalledWith({ + name: 'João', + role: Role.OPERATOR, + page: 1, + limit: 10, + }); + }); + + it('should return empty data when no users match', async () => { + repository.findAll.mockResolvedValue({ + data: [], + total: 0, + }); + + const query: ListUsersQueryDto = { + name: 'inexistente', + page: 1, + limit: 20, + }; + const result = await service.findAll(query); + + expect(result).toEqual({ + data: [], + total: 0, + page: 1, + limit: 20, + }); + }); + }); + + describe('create', () => { + const createDto = { + name: 'João Silva', + email: 'joao@iasis.com.br', + password: 'senhaforte123', + role: Role.OPERATOR, + }; + + it('should create a user with provided password and return without generatedPassword', async () => { + repository.findByEmail.mockResolvedValue(null); + (bcrypt.hash as jest.Mock).mockResolvedValue('hashed-password'); + repository.create.mockResolvedValue(mockCreatedUser); + + const result = await service.create(createDto); + + expect(repository.findByEmail).toHaveBeenCalledWith(createDto.email); + expect(bcrypt.hash).toHaveBeenCalledWith(createDto.password, 10); + expect(repository.create).toHaveBeenCalledWith({ + name: createDto.name, + email: createDto.email, + password: 'hashed-password', + role: createDto.role, + mustChangePassword: false, + clientId: null, + }); + expect(result).toEqual({ ...mockCreatedUser, generatedPassword: undefined }); + }); + + it('should not send invite email when password is explicitly provided', async () => { + repository.findByEmail.mockResolvedValue(null); + (bcrypt.hash as jest.Mock).mockResolvedValue('hashed-password'); + repository.create.mockResolvedValue(mockCreatedUser); + + await service.create(createDto); + + await new Promise((r) => setTimeout(r, 50)); + expect(mailerService.sendMail).not.toHaveBeenCalled(); + }); + + it('should generate password and send invite email when mustChangePassword=true', async () => { + const dtoWithoutPassword = { + name: 'Maria Silva', + email: 'maria@iasis.com.br', + role: Role.OPERATOR, + }; + const createdUser = { ...mockCreatedUser, email: 'maria@iasis.com.br', mustChangePassword: true }; + + repository.findByEmail.mockResolvedValue(null); + (bcrypt.hash as jest.Mock).mockResolvedValue('hashed-generated'); + repository.create.mockResolvedValue(createdUser); + + const result = await service.create(dtoWithoutPassword); + + expect(result.generatedPassword).toBeDefined(); + expect(typeof result.generatedPassword).toBe('string'); + + await new Promise((r) => setTimeout(r, 50)); + expect(mailerService.sendMail).toHaveBeenCalledWith( + expect.objectContaining({ to: createdUser.email }), + ); + }); + + it('should return user normally when mailer throws', async () => { + const dtoWithoutPassword = { + name: 'Maria Silva', + email: 'maria@iasis.com.br', + role: Role.OPERATOR, + }; + const createdUser = { ...mockCreatedUser, email: 'maria@iasis.com.br', mustChangePassword: true }; + + repository.findByEmail.mockResolvedValue(null); + (bcrypt.hash as jest.Mock).mockResolvedValue('hashed-generated'); + repository.create.mockResolvedValue(createdUser); + mailerService.sendMail.mockRejectedValue(new Error('SMTP error')); + + const result = await service.create(dtoWithoutPassword); + + expect(result.id).toBe(createdUser.id); + expect(result.generatedPassword).toBeDefined(); + }); + + it('should create a PO user with valid clientId', async () => { + const clientDto = { + name: 'PO User', + email: 'po@empresa.com.br', + role: Role.PO, + clientId: 'client-id-1', + }; + const poUser = { + ...mockCreatedUser, + role: Role.PO, + clientSubRole: null, + clientId: 'client-id-1', + client: { id: 'client-id-1', name: 'Empresa X' }, + }; + + prisma.client.findUnique.mockResolvedValue({ id: 'client-id-1', isActive: true }); + repository.findByEmail.mockResolvedValue(null); + (bcrypt.hash as jest.Mock).mockResolvedValue('hashed-password'); + repository.create.mockResolvedValue(poUser); + + const result = await service.create(clientDto); + + expect(prisma.client.findUnique).toHaveBeenCalledWith({ + where: { id: 'client-id-1' }, + select: { id: true, isActive: true }, + }); + expect(repository.create).toHaveBeenCalledWith( + expect.objectContaining({ clientId: 'client-id-1', role: Role.PO }), + ); + expect(result.role).toBe(Role.PO); + }); + + it('should throw BadRequestException when legacy CLIENT role is used', async () => { + const dto = { name: 'User', email: 'u@empresa.com.br', role: Role.CLIENT }; + await expect(service.create(dto)).rejects.toThrow(BadRequestException); + await expect(service.create(dto)).rejects.toThrow('O perfil CLIENT foi substituído'); + expect(repository.create).not.toHaveBeenCalled(); + }); + + it('should throw BadRequestException when PO without clientId', async () => { + const dto = { name: 'PO User', email: 'po@empresa.com.br', role: Role.PO }; + await expect(service.create(dto)).rejects.toThrow(BadRequestException); + await expect(service.create(dto)).rejects.toThrow( + 'O campo clientId é obrigatório para o perfil PO', + ); + expect(repository.create).not.toHaveBeenCalled(); + }); + + it('should throw BadRequestException when ADMIN with clientId', async () => { + const dto = { name: 'Admin User', email: 'admin@iasis.com.br', role: Role.ADMIN, clientId: 'client-id-1' }; + await expect(service.create(dto)).rejects.toThrow(BadRequestException); + await expect(service.create(dto)).rejects.toThrow('O campo clientId deve ser nulo para perfis ISIS'); + expect(repository.create).not.toHaveBeenCalled(); + }); + + it('should throw BadRequestException when linking to inactive client', async () => { + const dto = { name: 'PO User', email: 'po@empresa.com.br', role: Role.PO, clientId: 'inactive-client-id' }; + prisma.client.findUnique.mockResolvedValue({ id: 'inactive-client-id', isActive: false }); + await expect(service.create(dto)).rejects.toThrow(BadRequestException); + await expect(service.create(dto)).rejects.toThrow('Não é possível vincular a um cliente inativo'); + expect(repository.create).not.toHaveBeenCalled(); + }); + + it('should throw NotFoundException when client does not exist', async () => { + const dto = { name: 'PO User', email: 'po@empresa.com.br', role: Role.PO, clientId: 'non-existent-client' }; + prisma.client.findUnique.mockResolvedValue(null); + await expect(service.create(dto)).rejects.toThrow(NotFoundException); + await expect(service.create(dto)).rejects.toThrow('Cliente não encontrado'); + expect(repository.create).not.toHaveBeenCalled(); + }); + + it('should throw ConflictException when email already exists', async () => { + repository.findByEmail.mockResolvedValue({ + id: 'existing-id', + name: 'Existing', + email: createDto.email, + password: 'hashed', + role: Role.GESTOR_PROJETOS, + clientSubRole: null, + isActive: true, + mustChangePassword: false, + createdAt: new Date(), + updatedAt: new Date(), + }); + + await expect(service.create(createDto)).rejects.toThrow(ConflictException); + await expect(service.create(createDto)).rejects.toThrow('E-mail já cadastrado'); + expect(repository.create).not.toHaveBeenCalled(); + }); + }); + + describe('update', () => { + const updateDto = { + name: 'João Atualizado', + email: 'joao.novo@iasis.com.br', + }; + + const updatedUser = { + ...mockCreatedUser, + name: 'João Atualizado', + email: 'joao.novo@iasis.com.br', + }; + + it('should update user and return without password', async () => { + repository.findById.mockResolvedValue(mockCreatedUser); + repository.findByEmailExcludingId.mockResolvedValue(null); + repository.update.mockResolvedValue(updatedUser); + + const result = await service.update('user-id-1', updateDto, 'other-user-id'); + + expect(repository.findById).toHaveBeenCalledWith('user-id-1'); + expect(repository.findByEmailExcludingId).toHaveBeenCalledWith(updateDto.email, 'user-id-1'); + expect(repository.update).toHaveBeenCalledWith('user-id-1', { ...updateDto, clientId: null }); + expect(result).toEqual(updatedUser); + expect(result).not.toHaveProperty('password'); + }); + + it('should throw NotFoundException when user does not exist', async () => { + repository.findById.mockResolvedValue(null); + + await expect(service.update('non-existent-id', updateDto, 'other-user-id')).rejects.toThrow( + NotFoundException, + ); + await expect(service.update('non-existent-id', updateDto, 'other-user-id')).rejects.toThrow( + 'Usuário não encontrado', + ); + expect(repository.update).not.toHaveBeenCalled(); + }); + + it('should throw ConflictException when email belongs to another user', async () => { + repository.findById.mockResolvedValue(mockCreatedUser); + repository.findByEmailExcludingId.mockResolvedValue({ + id: 'other-user-id', + name: 'Outro', + email: updateDto.email, + password: 'hashed', + role: Role.GESTOR_PROJETOS, + clientSubRole: null, + isActive: true, + mustChangePassword: false, + createdAt: new Date(), + updatedAt: new Date(), + }); + + await expect(service.update('user-id-1', updateDto, 'other-user-id')).rejects.toThrow( + ConflictException, + ); + await expect(service.update('user-id-1', updateDto, 'other-user-id')).rejects.toThrow( + 'E-mail já cadastrado', + ); + expect(repository.update).not.toHaveBeenCalled(); + }); + + it('should skip email conflict check when email is not provided', async () => { + const dtoWithoutEmail = { name: 'Novo Nome' }; + repository.findById.mockResolvedValue(mockCreatedUser); + repository.update.mockResolvedValue({ ...mockCreatedUser, name: 'Novo Nome' }); + + await service.update('user-id-1', dtoWithoutEmail, 'other-user-id'); + + expect(repository.findByEmailExcludingId).not.toHaveBeenCalled(); + expect(repository.update).toHaveBeenCalledWith('user-id-1', { + ...dtoWithoutEmail, + clientId: null, + }); + }); + + it('should throw BadRequestException when user tries to deactivate themselves', async () => { + await expect(service.update('user-id-1', { isActive: false }, 'user-id-1')).rejects.toThrow( + BadRequestException, + ); + await expect(service.update('user-id-1', { isActive: false }, 'user-id-1')).rejects.toThrow( + 'Não é possível inativar seu próprio usuário', + ); + + expect(repository.findById).not.toHaveBeenCalled(); + expect(repository.update).not.toHaveBeenCalled(); + }); + + it('should allow deactivating another user', async () => { + const deactivatedUser = { ...mockCreatedUser, isActive: false }; + repository.findById.mockResolvedValue(mockCreatedUser); + repository.update.mockResolvedValue(deactivatedUser); + + const result = await service.update('user-id-1', { isActive: false }, 'admin-id'); + + expect(repository.update).toHaveBeenCalledWith('user-id-1', { + isActive: false, + clientId: null, + }); + expect(result.isActive).toBe(false); + }); + + it('should throw BadRequestException when changing role to legacy CLIENT', async () => { + repository.findById.mockResolvedValue(mockCreatedUser); + + await expect(service.update('user-id-1', { role: Role.CLIENT }, 'admin-id')).rejects.toThrow( + BadRequestException, + ); + await expect(service.update('user-id-1', { role: Role.CLIENT }, 'admin-id')).rejects.toThrow( + 'O perfil CLIENT foi substituído', + ); + expect(repository.update).not.toHaveBeenCalled(); + }); + + it('should throw BadRequestException when changing role to PO without clientId', async () => { + repository.findById.mockResolvedValue({ ...mockCreatedUser, clientId: null, clientSubRole: null }); + + await expect(service.update('user-id-1', { role: Role.PO }, 'admin-id')).rejects.toThrow( + BadRequestException, + ); + await expect(service.update('user-id-1', { role: Role.PO }, 'admin-id')).rejects.toThrow( + 'O campo clientId é obrigatório para o perfil PO', + ); + expect(repository.update).not.toHaveBeenCalled(); + }); + + it('should clear clientId when changing role from PO to ADMIN', async () => { + const poUser = { ...mockCreatedUser, role: Role.PO, clientSubRole: null, clientId: 'client-id-1', client: { id: 'client-id-1', name: 'Empresa X' } }; + const updatedUser = { ...mockCreatedUser, role: Role.ADMIN, clientId: null, client: null }; + repository.findById.mockResolvedValue(poUser); + repository.update.mockResolvedValue(updatedUser); + + await service.update('user-id-1', { role: Role.ADMIN }, 'admin-id'); + + expect(repository.update).toHaveBeenCalledWith('user-id-1', { + role: Role.ADMIN, + clientId: null, + }); + }); + + it('should update role to PO with valid clientId', async () => { + const updatedUser = { ...mockCreatedUser, role: Role.PO, clientSubRole: null, clientId: 'client-id-1', client: { id: 'client-id-1', name: 'Empresa X' } }; + repository.findById.mockResolvedValue(mockCreatedUser); + prisma.client.findUnique.mockResolvedValue({ id: 'client-id-1', isActive: true }); + repository.update.mockResolvedValue(updatedUser); + + const result = await service.update('user-id-1', { role: Role.PO, clientId: 'client-id-1' }, 'admin-id'); + + expect(result.role).toBe(Role.PO); + expect(repository.update).toHaveBeenCalledWith('user-id-1', { role: Role.PO, clientId: 'client-id-1' }); + }); + + it('should throw BadRequestException when linking to inactive client on update', async () => { + repository.findById.mockResolvedValue(mockCreatedUser); + prisma.client.findUnique.mockResolvedValue({ id: 'client-id-1', isActive: false }); + + await expect( + service.update('user-id-1', { role: Role.PO, clientId: 'client-id-1' }, 'admin-id'), + ).rejects.toThrow(BadRequestException); + await expect( + service.update('user-id-1', { role: Role.PO, clientId: 'client-id-1' }, 'admin-id'), + ).rejects.toThrow('Não é possível vincular a um cliente inativo'); + }); + }); + + describe('validateClientId — perfis do Epic 27', () => { + it.each([Role.PO, Role.FISCAL_CONTRATO, Role.GESTOR_CONTRATO])( + '%s sem clientId lança BadRequestException', + async (role) => { + await expect(service.validateClientId(role, null)).rejects.toThrow(BadRequestException); + }, + ); + + it.each([Role.ADMIN, Role.GESTOR_PROJETOS])( + '%s com clientId lança BadRequestException', + async (role) => { + await expect(service.validateClientId(role, 'some-client-id')).rejects.toThrow( + BadRequestException, + ); + }, + ); + + it('PO com clientId ativo passa validação', async () => { + prisma.client.findUnique.mockResolvedValue({ id: 'client-id', isActive: true }); + await expect(service.validateClientId(Role.PO, 'client-id')).resolves.toBeUndefined(); + }); + }); + + describe('changeRole', () => { + it('muda de PO para FISCAL_CONTRATO mantendo clientId', async () => { + const poUser = { + ...mockCreatedUser, + role: Role.PO, + clientId: 'client-id', + clientSubRole: null, + client: { id: 'client-id', name: 'Empresa X' }, + }; + repository.findById.mockResolvedValue(poUser); + prisma.client.findUnique.mockResolvedValue({ id: 'client-id', isActive: true }); + repository.update.mockResolvedValue({ ...poUser, role: Role.FISCAL_CONTRATO }); + + await service.changeRole('user-id-1', Role.FISCAL_CONTRATO, 'admin-id'); + + expect(repository.update).toHaveBeenCalledWith('user-id-1', { + role: Role.FISCAL_CONTRATO, + clientId: 'client-id', + }); + }); + + it('muda de PO para GESTOR_PROJETOS limpando clientId', async () => { + const poUser = { + ...mockCreatedUser, + role: Role.PO, + clientId: 'client-id', + clientSubRole: null, + client: { id: 'client-id', name: 'Empresa X' }, + }; + repository.findById.mockResolvedValue(poUser); + repository.update.mockResolvedValue({ ...poUser, role: Role.GESTOR_PROJETOS, clientId: null }); + + await service.changeRole('user-id-1', Role.GESTOR_PROJETOS, 'admin-id'); + + expect(repository.update).toHaveBeenCalledWith('user-id-1', { + role: Role.GESTOR_PROJETOS, + clientId: null, + }); + }); + }); + + describe('setClientSubRole', () => { + it('persiste subperfil e registra log de auditoria', async () => { + const clientUser = { + ...mockCreatedUser, + role: Role.CLIENT, + clientSubRole: null, + clientId: 'client-id', + client: { id: 'client-id', name: 'Empresa X' }, + }; + repository.findById.mockResolvedValue(clientUser); + repository.update.mockResolvedValue({ ...clientUser, clientSubRole: 'PO' }); + const logSpy = jest.spyOn(service['logger'], 'log'); + + await service.setClientSubRole('user-id-1', 'PO', 'admin-id'); + + expect(repository.update).toHaveBeenCalledWith('user-id-1', { clientSubRole: 'PO' }); + expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('PO')); + }); + + it('lança BadRequestException para subperfil inválido', async () => { + const clientUser = { + ...mockCreatedUser, + role: Role.CLIENT, + clientSubRole: null, + clientId: 'client-id', + client: { id: 'client-id', name: 'Empresa X' }, + }; + repository.findById.mockResolvedValue(clientUser); + + await expect( + service.setClientSubRole('user-id-1', 'INVALID_ROLE', 'admin-id'), + ).rejects.toThrow(BadRequestException); + }); + }); + + describe('resetPassword', () => { + it('should generate password, set mustChangePassword and return generatedPassword', async () => { + jest.spyOn(passwordGenerator, 'generatePassword').mockReturnValue('AutoPass123!'); + repository.findById.mockResolvedValue(mockCreatedUser); + (bcrypt.hash as jest.Mock).mockResolvedValue('new-hashed-password'); + repository.update.mockResolvedValue(mockCreatedUser); + + const result = await service.resetPassword('user-id-1'); + + expect(repository.findById).toHaveBeenCalledWith('user-id-1'); + expect(bcrypt.hash).toHaveBeenCalledWith('AutoPass123!', 10); + expect(repository.update).toHaveBeenCalledWith('user-id-1', { + password: 'new-hashed-password', + mustChangePassword: true, + }); + expect(result).toEqual({ + generatedPassword: 'AutoPass123!', + message: 'Senha redefinida com sucesso', + }); + }); + + it('should throw NotFoundException when user does not exist', async () => { + repository.findById.mockResolvedValue(null); + + await expect(service.resetPassword('non-existent-id')).rejects.toThrow(NotFoundException); + await expect(service.resetPassword('non-existent-id')).rejects.toThrow( + 'Usuário não encontrado', + ); + expect(repository.update).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/src/modules/users/users.service.ts b/src/modules/users/users.service.ts new file mode 100644 index 0000000..cd01450 --- /dev/null +++ b/src/modules/users/users.service.ts @@ -0,0 +1,232 @@ +import { + BadRequestException, + ConflictException, + Injectable, + Logger, + NotFoundException, +} from '@nestjs/common'; +import * as bcrypt from 'bcrypt'; +import { Role, CLIENT_ROLES, ISIS_ROLES } from '../../common/enums/role.enum'; +import { PrismaService } from '../../config/database/prisma.service'; +import { MailerService } from '../mailer/mailer.service'; +import { EnvService } from '../../config/env/env.service'; +import { UsersRepository } from './users.repository'; +import { CreateUserDto } from './dto/create-user.dto'; +import { UpdateUserDto } from './dto/update-user.dto'; +import { ListUsersQueryDto } from './dto/list-users-query.dto'; +import { generatePassword } from '../../common/utils/password-generator'; + +@Injectable() +export class UsersService { + private readonly logger = new Logger(UsersService.name); + + constructor( + private readonly repository: UsersRepository, + private readonly prisma: PrismaService, + private readonly mailerService: MailerService, + private readonly env: EnvService, + ) {} + + async findAll(query: ListUsersQueryDto) { + const { page, limit, ...filters } = query; + const { data, total } = await this.repository.findAll({ + ...filters, + page, + limit, + }); + return { data, total, page, limit }; + } + + async create(dto: CreateUserDto) { + await this.validateClientId(dto.role, dto.clientId); + + const existing = await this.repository.findByEmail(dto.email); + + if (existing) { + throw new ConflictException('E-mail já cadastrado'); + } + + const plainPassword = dto.password ?? generatePassword(); + const hashedPassword = await bcrypt.hash(plainPassword, 10); + + const isClientRole = (CLIENT_ROLES as readonly Role[]).includes(dto.role); + + const user = await this.repository.create({ + name: dto.name, + email: dto.email, + password: hashedPassword, + role: dto.role, + mustChangePassword: !dto.password, + clientId: isClientRole ? dto.clientId : null, + }); + + if (!dto.password) { + void this.mailerService + .sendMail({ + to: user.email, + subject: 'Bem-vindo ao ISIS Gestão — acesse sua conta', + html: ` +

Olá, ${user.name}!

+

Sua conta no ISIS Gestão foi criada. Utilize as credenciais abaixo para fazer seu primeiro acesso:

+

E-mail: ${user.email}

+

Senha temporária: ${plainPassword}

+

Você precisará definir uma nova senha no primeiro acesso.

+

Acessar o sistema

+ `, + }) + .catch((e: Error) => { + this.logger.error('Falha ao enviar email de convite', { error: e.message }); + }); + } + + return { ...user, generatedPassword: dto.password ? undefined : plainPassword }; + } + + async findOne(id: string) { + const user = await this.repository.findById(id); + + if (!user) { + throw new NotFoundException('Usuário não encontrado'); + } + + return user; + } + + async update(id: string, dto: UpdateUserDto, currentUserId: string) { + if (dto.isActive === false && id === currentUserId) { + throw new BadRequestException('Não é possível inativar seu próprio usuário'); + } + + const user = await this.repository.findById(id); + + if (!user) { + throw new NotFoundException('Usuário não encontrado'); + } + + const effectiveRole = (dto.role ?? user.role) as Role; + const isClientRole = (CLIENT_ROLES as readonly Role[]).includes(effectiveRole); + const effectiveClientId = !isClientRole + ? null + : dto.clientId !== undefined + ? dto.clientId + : user.clientId; + + await this.validateClientId(effectiveRole, effectiveClientId); + + if (dto.email) { + const conflict = await this.repository.findByEmailExcludingId(dto.email, id); + if (conflict) { + throw new ConflictException('E-mail já cadastrado'); + } + } + + const updateData = { ...dto, clientId: effectiveClientId }; + + return this.repository.update(id, updateData); + } + + async validateClientId(role: Role, clientId?: string | null): Promise { + // Reject legacy roles at service level (DTO validation is the first line of defense) + if (role === Role.OPERATOR) { + throw new BadRequestException( + 'O perfil OPERATOR foi substituído por GESTOR_PROJETOS. Por favor, utilize o novo perfil.', + ); + } + if (role === Role.CLIENT) { + throw new BadRequestException( + 'O perfil CLIENT foi substituído por PO, FISCAL_CONTRATO ou GESTOR_CONTRATO. ' + + 'Por favor, utilize um dos novos perfis cliente.', + ); + } + + const isClientRole = (CLIENT_ROLES as readonly Role[]).includes(role); + const isIsisRole = (ISIS_ROLES as readonly Role[]).includes(role); + + if (isClientRole) { + if (!clientId) { + throw new BadRequestException( + `O campo clientId é obrigatório para o perfil ${role}`, + ); + } + + const client = await this.prisma.client.findUnique({ + where: { id: clientId }, + select: { id: true, isActive: true }, + }); + + if (!client) { + throw new NotFoundException('Cliente não encontrado'); + } + + if (!client.isActive) { + throw new BadRequestException('Não é possível vincular a um cliente inativo'); + } + } else if (isIsisRole && clientId) { + throw new BadRequestException( + `O campo clientId deve ser nulo para perfis ISIS (${role})`, + ); + } + } + + async changeRole(id: string, newRole: Role, changedBy: string) { + const user = await this.repository.findById(id); + if (!user) throw new NotFoundException('Usuário não encontrado'); + + const isClientRole = (CLIENT_ROLES as readonly Role[]).includes(newRole); + const effectiveClientId = isClientRole ? user.clientId : null; + + await this.validateClientId(newRole, effectiveClientId); + + this.logger.log( + `Perfil alterado: userId=${id} de=${user.role} para=${newRole} por=${changedBy}`, + ); + + return this.repository.update(id, { role: newRole, clientId: effectiveClientId }); + } + + async setClientSubRole(id: string, subRole: string, changedBy: string) { + const user = await this.repository.findById(id); + if (!user) throw new NotFoundException('Usuário não encontrado'); + + if (user.role !== Role.CLIENT) { + throw new BadRequestException( + 'O subperfil só pode ser definido para usuários com papel CLIENT', + ); + } + + const validSubRoles = [Role.PO, Role.FISCAL_CONTRATO, Role.GESTOR_CONTRATO] as const; + if (!validSubRoles.includes(subRole as (typeof validSubRoles)[number])) { + throw new BadRequestException( + `Subperfil inválido: ${subRole}. Valores aceitos: PO, FISCAL_CONTRATO, GESTOR_CONTRATO`, + ); + } + + this.logger.log(JSON.stringify({ + userId: id, + previousRole: user.role, + newSubRole: subRole, + changedBy, + timestamp: new Date().toISOString(), + })); + + return this.repository.update(id, { clientSubRole: subRole }); + } + + async findPendingClientSubRole() { + return this.repository.findPendingClientSubRole(); + } + + async resetPassword(id: string) { + const user = await this.repository.findById(id); + + if (!user) { + throw new NotFoundException('Usuário não encontrado'); + } + + const plainPassword = generatePassword(); + const hashedPassword = await bcrypt.hash(plainPassword, 10); + await this.repository.update(id, { password: hashedPassword, mustChangePassword: true }); + + return { generatedPassword: plainPassword, message: 'Senha redefinida com sucesso' }; + } +} diff --git a/src/modules/work-orders/constants/work-order-status-transitions.ts b/src/modules/work-orders/constants/work-order-status-transitions.ts new file mode 100644 index 0000000..5d41f6d --- /dev/null +++ b/src/modules/work-orders/constants/work-order-status-transitions.ts @@ -0,0 +1,51 @@ +import { DeliverableStatus, WorkOrderStatus } from '@prisma/client'; + +export const WORK_ORDER_STATUS_TRANSITIONS: Record = { + [WorkOrderStatus.RASCUNHO]: [WorkOrderStatus.EMITIDA, WorkOrderStatus.CANCELADA], + [WorkOrderStatus.EMITIDA]: [WorkOrderStatus.EM_EXECUCAO], + [WorkOrderStatus.EM_EXECUCAO]: [WorkOrderStatus.TOTALMENTE_PAGA], + [WorkOrderStatus.TOTALMENTE_PAGA]: [], + [WorkOrderStatus.CANCELADA]: [], +}; + +export function isWorkOrderTransitionValid( + current: WorkOrderStatus, + next: WorkOrderStatus, +): boolean { + if (current === next) return false; + return WORK_ORDER_STATUS_TRANSITIONS[current]?.includes(next) ?? false; +} + +const POST_EXECUTION_NON_TERMINAL: DeliverableStatus[] = [ + DeliverableStatus.EM_EXECUCAO, + DeliverableStatus.AGUARDANDO_VALIDACAO, + DeliverableStatus.EM_REVISAO, + DeliverableStatus.APROVADA, + DeliverableStatus.AGUARDANDO_PAGAMENTO, + DeliverableStatus.ENCERRADA, + DeliverableStatus.GLOSADA, +]; + +export function deriveWorkOrderStatusFromDeliverables( + deliverables: { status: DeliverableStatus }[], +): WorkOrderStatus { + const active = deliverables.filter((d) => d.status !== DeliverableStatus.CANCELADA); + + if (active.length === 0) { + return WorkOrderStatus.RASCUNHO; + } + + if (active.every((d) => d.status === DeliverableStatus.PAGA)) { + return WorkOrderStatus.TOTALMENTE_PAGA; + } + + if (active.some((d) => POST_EXECUTION_NON_TERMINAL.includes(d.status))) { + return WorkOrderStatus.EM_EXECUCAO; + } + + if (active.some((d) => d.status === DeliverableStatus.EMITIDA)) { + return WorkOrderStatus.EMITIDA; + } + + return WorkOrderStatus.RASCUNHO; +} diff --git a/src/modules/work-orders/dto/cancel-work-order.dto.ts b/src/modules/work-orders/dto/cancel-work-order.dto.ts new file mode 100644 index 0000000..ebb83b0 --- /dev/null +++ b/src/modules/work-orders/dto/cancel-work-order.dto.ts @@ -0,0 +1,8 @@ +import { IsOptional, IsString, MaxLength } from 'class-validator'; + +export class CancelWorkOrderDto { + @IsOptional() + @IsString() + @MaxLength(1000) + observation?: string; +} diff --git a/src/modules/work-orders/dto/create-work-order.dto.ts b/src/modules/work-orders/dto/create-work-order.dto.ts new file mode 100644 index 0000000..6420ef1 --- /dev/null +++ b/src/modules/work-orders/dto/create-work-order.dto.ts @@ -0,0 +1,53 @@ +import { + ArrayNotEmpty, + ArrayUnique, + IsArray, + IsDateString, + IsNotEmpty, + IsNumber, + IsOptional, + IsPositive, + IsString, + IsUUID, +} from 'class-validator'; +import { Type } from 'class-transformer'; + +export class CreateWorkOrderDto { + @IsString() + @IsNotEmpty() + code!: string; + + @IsString() + @IsNotEmpty() + name!: string; + + @IsOptional() + @IsString() + description?: string; + + @IsString() + @IsUUID() + contractId!: string; + + @IsString() + @IsUUID() + contractItemId!: string; + + @IsArray() + @ArrayNotEmpty() + @ArrayUnique() + @IsUUID('4', { each: true }) + projectIds!: string[]; + + @Type(() => Number) + @IsNumber() + @IsPositive() + reservedUst!: number; + + @IsDateString() + startDate!: string; + + @IsOptional() + @IsDateString() + endDate?: string; +} diff --git a/src/modules/work-orders/dto/list-work-orders-query.dto.ts b/src/modules/work-orders/dto/list-work-orders-query.dto.ts new file mode 100644 index 0000000..9584fc7 --- /dev/null +++ b/src/modules/work-orders/dto/list-work-orders-query.dto.ts @@ -0,0 +1,46 @@ +import { IsEnum, IsInt, IsOptional, IsString, IsUUID, Max, Min } from 'class-validator'; +import { Transform } from 'class-transformer'; +import { WorkOrderStatus } from '@prisma/client'; + +export class ListWorkOrdersQueryDto { + @IsOptional() + @IsString() + search?: string; + + @IsOptional() + @IsUUID() + clientId?: string; + + @IsOptional() + @IsUUID() + contractId?: string; + + @IsOptional() + @IsUUID() + contractItemId?: string; + + @IsOptional() + @IsUUID() + projectId?: string; + + @IsOptional() + @IsEnum(WorkOrderStatus) + status?: WorkOrderStatus; + + @IsOptional() + @Transform(({ value }) => value === 'true') + isActive?: boolean; + + @IsOptional() + @Transform(({ value }: { value: unknown }) => parseInt(String(value), 10)) + @IsInt() + @Min(1) + page: number = 1; + + @IsOptional() + @Transform(({ value }: { value: unknown }) => parseInt(String(value), 10)) + @IsInt() + @Min(1) + @Max(100) + limit: number = 20; +} diff --git a/src/modules/work-orders/dto/update-work-order.dto.ts b/src/modules/work-orders/dto/update-work-order.dto.ts new file mode 100644 index 0000000..9e13d95 --- /dev/null +++ b/src/modules/work-orders/dto/update-work-order.dto.ts @@ -0,0 +1,57 @@ +import { + ArrayNotEmpty, + ArrayUnique, + IsArray, + IsBoolean, + IsDateString, + IsNumber, + IsOptional, + IsPositive, + IsString, + IsUUID, +} from 'class-validator'; +import { Type } from 'class-transformer'; + +export class UpdateWorkOrderDto { + @IsOptional() + @IsString() + code?: string; + + @IsOptional() + @IsString() + name?: string; + + @IsOptional() + @IsString() + description?: string; + + @IsOptional() + @IsString() + @IsUUID() + contractItemId?: string; + + @IsOptional() + @IsArray() + @ArrayNotEmpty() + @ArrayUnique() + @IsUUID('4', { each: true }) + projectIds?: string[]; + + @IsOptional() + @Type(() => Number) + @IsNumber() + @IsPositive() + reservedUst?: number; + + @IsOptional() + @IsDateString() + startDate?: string; + + @IsOptional() + @IsDateString() + endDate?: string; + + @IsOptional() + @IsBoolean() + isActive?: boolean; +} diff --git a/src/modules/work-orders/helpers/calculate-work-order-summary.spec.ts b/src/modules/work-orders/helpers/calculate-work-order-summary.spec.ts new file mode 100644 index 0000000..cf5500a --- /dev/null +++ b/src/modules/work-orders/helpers/calculate-work-order-summary.spec.ts @@ -0,0 +1,163 @@ +import { DeliverableStatus, Prisma } from '@prisma/client'; +import { calculateWorkOrderSummary } from './calculate-work-order-summary'; + +describe('calculateWorkOrderSummary', () => { + const ustValue = new Prisma.Decimal('100'); + + it('zero deliverables: ustAvailable = ustReserved, consumption 0%, no alert', () => { + const result = calculateWorkOrderSummary({ + reservedUst: 1000, + deliverables: [], + ustValue, + }); + + expect(result.totalDeliverables).toBe(0); + expect(result.deliverablesByStatus).toEqual({}); + expect(result.ustReserved.equals(1000)).toBe(true); + expect(result.ustInExecution.equals(0)).toBe(true); + expect(result.ustPaid.equals(0)).toBe(true); + expect(result.ustConsumed.equals(0)).toBe(true); + expect(result.ustAvailable.equals(1000)).toBe(true); + expect(result.consumptionPercent.equals(0)).toBe(true); + expect(result.lowBalanceAlert).toBe(false); + expect(result.valueReserved.equals(100000)).toBe(true); + expect(result.valueConsumed.equals(0)).toBe(true); + expect(result.valueAvailable.equals(100000)).toBe(true); + }); + + it('30% consumed: no alert', () => { + const result = calculateWorkOrderSummary({ + reservedUst: 1000, + deliverables: [ + { status: DeliverableStatus.EM_EXECUCAO, ustQuantity: 200 }, + { status: DeliverableStatus.PAGA, ustQuantity: 100 }, + ], + ustValue, + }); + + expect(result.totalDeliverables).toBe(2); + expect(result.deliverablesByStatus).toEqual({ + EM_EXECUCAO: 1, + PAGA: 1, + }); + expect(result.ustInExecution.equals(200)).toBe(true); + expect(result.ustPaid.equals(100)).toBe(true); + expect(result.ustConsumed.equals(300)).toBe(true); + expect(result.ustAvailable.equals(700)).toBe(true); + expect(result.consumptionPercent.equals(30)).toBe(true); + expect(result.lowBalanceAlert).toBe(false); + }); + + it('50% consumed: no alert (boundary above threshold)', () => { + const result = calculateWorkOrderSummary({ + reservedUst: 1000, + deliverables: [{ status: DeliverableStatus.EM_EXECUCAO, ustQuantity: 500 }], + ustValue, + }); + + expect(result.consumptionPercent.equals(50)).toBe(true); + expect(result.lowBalanceAlert).toBe(false); + }); + + it('80% consumed: no alert (available exactly = 20%)', () => { + const result = calculateWorkOrderSummary({ + reservedUst: 1000, + deliverables: [{ status: DeliverableStatus.APROVADA, ustQuantity: 800 }], + ustValue, + }); + + expect(result.ustAvailable.equals(200)).toBe(true); + expect(result.consumptionPercent.equals(80)).toBe(true); + expect(result.lowBalanceAlert).toBe(false); + }); + + it('85% consumed: alert true', () => { + const result = calculateWorkOrderSummary({ + reservedUst: 1000, + deliverables: [ + { status: DeliverableStatus.EMITIDA, ustQuantity: 400 }, + { status: DeliverableStatus.PAGA, ustQuantity: 450 }, + ], + ustValue, + }); + + expect(result.ustConsumed.equals(850)).toBe(true); + expect(result.ustAvailable.equals(150)).toBe(true); + expect(result.consumptionPercent.equals(85)).toBe(true); + expect(result.lowBalanceAlert).toBe(true); + }); + + it('100% consumed: ustAvailable = 0, consumption = 100', () => { + const result = calculateWorkOrderSummary({ + reservedUst: 1000, + deliverables: [{ status: DeliverableStatus.PAGA, ustQuantity: 1000 }], + ustValue, + }); + + expect(result.ustAvailable.equals(0)).toBe(true); + expect(result.consumptionPercent.equals(100)).toBe(true); + expect(result.lowBalanceAlert).toBe(true); + }); + + it('arbitrary ustValue: monetary values correct', () => { + const result = calculateWorkOrderSummary({ + reservedUst: 500, + deliverables: [{ status: DeliverableStatus.EM_EXECUCAO, ustQuantity: 100 }], + ustValue: new Prisma.Decimal('123.45'), + }); + + expect(result.valueReserved.equals(new Prisma.Decimal('61725.00'))).toBe(true); + expect(result.valueConsumed.equals(new Prisma.Decimal('12345.00'))).toBe(true); + expect(result.valueAvailable.equals(new Prisma.Decimal('49380.00'))).toBe(true); + }); + + it('reservedUst = 0: consumption = 0, no alert (avoid div by zero)', () => { + const result = calculateWorkOrderSummary({ + reservedUst: 0, + deliverables: [], + ustValue, + }); + + expect(result.consumptionPercent.equals(0)).toBe(true); + expect(result.lowBalanceAlert).toBe(false); + }); + + it('groups counts by status across multiple rows', () => { + const result = calculateWorkOrderSummary({ + reservedUst: 5000, + deliverables: [ + { status: DeliverableStatus.EM_EXECUCAO, ustQuantity: 100 }, + { status: DeliverableStatus.EM_EXECUCAO, ustQuantity: 200 }, + { status: DeliverableStatus.PAGA, ustQuantity: 300 }, + { status: DeliverableStatus.RASCUNHO, ustQuantity: 50 }, + ], + ustValue, + }); + + expect(result.totalDeliverables).toBe(4); + expect(result.deliverablesByStatus).toEqual({ + EM_EXECUCAO: 2, + PAGA: 1, + RASCUNHO: 1, + }); + expect(result.ustInExecution.equals(300)).toBe(true); + expect(result.ustPaid.equals(300)).toBe(true); + expect(result.ustConsumed.equals(600)).toBe(true); + }); + + it('RASCUNHO/CANCELADA/GLOSADA do not count in execution or paid', () => { + const result = calculateWorkOrderSummary({ + reservedUst: 1000, + deliverables: [ + { status: DeliverableStatus.RASCUNHO, ustQuantity: 100 }, + { status: DeliverableStatus.CANCELADA, ustQuantity: 200 }, + { status: DeliverableStatus.GLOSADA, ustQuantity: 300 }, + ], + ustValue, + }); + + expect(result.ustInExecution.equals(0)).toBe(true); + expect(result.ustPaid.equals(0)).toBe(true); + expect(result.ustConsumed.equals(0)).toBe(true); + }); +}); diff --git a/src/modules/work-orders/helpers/calculate-work-order-summary.ts b/src/modules/work-orders/helpers/calculate-work-order-summary.ts new file mode 100644 index 0000000..a011c05 --- /dev/null +++ b/src/modules/work-orders/helpers/calculate-work-order-summary.ts @@ -0,0 +1,84 @@ +import { DeliverableStatus, Prisma } from '@prisma/client'; + +export type DeliverableSummaryInput = { + status: DeliverableStatus; + ustQuantity: Prisma.Decimal | string | number | null; +}; + +export type CalculateWorkOrderSummaryInput = { + reservedUst: Prisma.Decimal | string | number; + deliverables: DeliverableSummaryInput[]; + ustValue: Prisma.Decimal | string | number; +}; + +export type WorkOrderSummary = { + totalDeliverables: number; + deliverablesByStatus: Record; + ustReserved: Prisma.Decimal; + ustInExecution: Prisma.Decimal; + ustPaid: Prisma.Decimal; + ustConsumed: Prisma.Decimal; + ustAvailable: Prisma.Decimal; + consumptionPercent: Prisma.Decimal; + valueReserved: Prisma.Decimal; + valueConsumed: Prisma.Decimal; + valueAvailable: Prisma.Decimal; + lowBalanceAlert: boolean; +}; + +const IN_EXECUTION_STATUSES: DeliverableStatus[] = [ + DeliverableStatus.EMITIDA, + DeliverableStatus.EM_EXECUCAO, + DeliverableStatus.AGUARDANDO_VALIDACAO, + DeliverableStatus.EM_REVISAO, + DeliverableStatus.APROVADA, + DeliverableStatus.AGUARDANDO_PAGAMENTO, +]; + +const LOW_BALANCE_THRESHOLD = new Prisma.Decimal('0.20'); + +export function calculateWorkOrderSummary(input: CalculateWorkOrderSummaryInput): WorkOrderSummary { + const ustReserved = new Prisma.Decimal(input.reservedUst); + const ustValue = new Prisma.Decimal(input.ustValue); + + const deliverablesByStatus: Record = {}; + let totalDeliverables = 0; + let ustInExecution = new Prisma.Decimal(0); + let ustPaid = new Prisma.Decimal(0); + + for (const row of input.deliverables) { + deliverablesByStatus[row.status] = (deliverablesByStatus[row.status] ?? 0) + 1; + totalDeliverables += 1; + const qty = + row.ustQuantity == null ? new Prisma.Decimal(0) : new Prisma.Decimal(row.ustQuantity); + if (IN_EXECUTION_STATUSES.includes(row.status)) { + ustInExecution = ustInExecution.plus(qty); + } else if (row.status === DeliverableStatus.PAGA) { + ustPaid = ustPaid.plus(qty); + } + } + + const ustConsumed = ustInExecution.plus(ustPaid); + const ustAvailable = ustReserved.minus(ustConsumed); + const consumptionPercent = ustReserved.greaterThan(0) + ? ustConsumed.dividedBy(ustReserved).mul(100) + : new Prisma.Decimal(0); + const lowBalanceAlert = + ustReserved.greaterThan(0) && + ustAvailable.dividedBy(ustReserved).lessThan(LOW_BALANCE_THRESHOLD); + + return { + totalDeliverables, + deliverablesByStatus, + ustReserved, + ustInExecution, + ustPaid, + ustConsumed, + ustAvailable, + consumptionPercent, + valueReserved: ustReserved.mul(ustValue), + valueConsumed: ustConsumed.mul(ustValue), + valueAvailable: ustAvailable.mul(ustValue), + lowBalanceAlert, + }; +} diff --git a/src/modules/work-orders/repositories/work-order-status-history.repository.ts b/src/modules/work-orders/repositories/work-order-status-history.repository.ts new file mode 100644 index 0000000..863d9e1 --- /dev/null +++ b/src/modules/work-orders/repositories/work-order-status-history.repository.ts @@ -0,0 +1,44 @@ +import { Injectable } from '@nestjs/common'; +import { WorkOrderStatus } from '@prisma/client'; +import { PrismaService } from '../../../config/database/prisma.service'; + +@Injectable() +export class WorkOrderStatusHistoryRepository { + constructor(private readonly prisma: PrismaService) {} + + async create(data: { + workOrderId: string; + previousStatus: WorkOrderStatus | null; + newStatus: WorkOrderStatus; + observation?: string; + createdBy?: string; + }) { + return this.prisma.workOrderStatusHistory.create({ + data, + select: { + id: true, + workOrderId: true, + previousStatus: true, + newStatus: true, + observation: true, + createdAt: true, + createdBy: true, + }, + }); + } + + async findByWorkOrderId(workOrderId: string) { + return this.prisma.workOrderStatusHistory.findMany({ + where: { workOrderId }, + orderBy: { createdAt: 'desc' }, + select: { + id: true, + previousStatus: true, + newStatus: true, + observation: true, + createdAt: true, + createdBy: true, + }, + }); + } +} diff --git a/src/modules/work-orders/work-order-value-calculator.service.spec.ts b/src/modules/work-orders/work-order-value-calculator.service.spec.ts new file mode 100644 index 0000000..c5eed72 --- /dev/null +++ b/src/modules/work-orders/work-order-value-calculator.service.spec.ts @@ -0,0 +1,194 @@ +import { BadRequestException, NotFoundException } from '@nestjs/common'; +import { Test, TestingModule } from '@nestjs/testing'; +import { ContractItemType, Prisma } from '@prisma/client'; +import { WorkOrderValueCalculatorService } from './work-order-value-calculator.service'; +import { PrismaService } from '../../config/database/prisma.service'; + +describe('WorkOrderValueCalculatorService', () => { + let service: WorkOrderValueCalculatorService; + let prisma: { + workOrder: { findUnique: jest.Mock; update: jest.Mock; findMany: jest.Mock }; + deliverable: { aggregate: jest.Mock }; + }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + WorkOrderValueCalculatorService, + { + provide: PrismaService, + useValue: { + workOrder: { + findUnique: jest.fn(), + update: jest.fn(), + findMany: jest.fn(), + }, + deliverable: { + aggregate: jest.fn(), + }, + }, + }, + ], + }).compile(); + + service = module.get(WorkOrderValueCalculatorService); + prisma = module.get(PrismaService); + }); + + describe('calculateForSaas', () => { + it('SaaS reservedUst=5 × ustValue=2000 → 10000', () => { + const result = service.calculateForSaas({ + reservedUst: 5, + contractItem: { + itemType: ContractItemType.SAAS_LICENSE, + ustValue: new Prisma.Decimal(2000), + }, + }); + + expect(result.equals(new Prisma.Decimal(10000))).toBe(true); + }); + + it('SaaS sem ustValue → BadRequestException', () => { + expect(() => + service.calculateForSaas({ + reservedUst: 5, + contractItem: { itemType: ContractItemType.SAAS_LICENSE, ustValue: null }, + }), + ).toThrow(BadRequestException); + }); + + it('itemType UST chamado em calculateForSaas → BadRequestException', () => { + expect(() => + service.calculateForSaas({ + reservedUst: 5, + contractItem: { + itemType: ContractItemType.UST, + ustValue: new Prisma.Decimal(100), + }, + }), + ).toThrow(BadRequestException); + }); + }); + + describe('calculateForUst', () => { + it('UST sem Entregáveis → 0', async () => { + prisma.deliverable.aggregate.mockResolvedValue({ _sum: { totalValue: null } }); + + const result = await service.calculateForUst('wo-1'); + + expect(result.equals(new Prisma.Decimal(0))).toBe(true); + }); + + it('UST com 3 Entregáveis (10000 + 21320 + 5000) → 36320', async () => { + prisma.deliverable.aggregate.mockResolvedValue({ + _sum: { totalValue: new Prisma.Decimal(36320) }, + }); + + const result = await service.calculateForUst('wo-1'); + + expect(result.equals(new Prisma.Decimal(36320))).toBe(true); + expect(prisma.deliverable.aggregate).toHaveBeenCalledWith({ + where: { workOrderId: 'wo-1', isActive: true }, + _sum: { totalValue: true }, + }); + }); + }); + + describe('recalculateAndPersist', () => { + it('SaaS: persiste totalValue calculado', async () => { + prisma.workOrder.findUnique.mockResolvedValue({ + id: 'wo-1', + reservedUst: new Prisma.Decimal(5), + totalValue: null, + contractItem: { + itemType: ContractItemType.SAAS_LICENSE, + ustValue: new Prisma.Decimal(2000), + }, + }); + prisma.workOrder.update.mockResolvedValue({}); + + const result = await service.recalculateAndPersist('wo-1'); + + expect(result.totalValue.equals(new Prisma.Decimal(10000))).toBe(true); + expect(result.changed).toBe(true); + expect(prisma.workOrder.update).toHaveBeenCalledWith({ + where: { id: 'wo-1' }, + data: { totalValue: expect.any(Prisma.Decimal) as Prisma.Decimal }, + }); + }); + + it('UST: agrega Entregáveis e persiste', async () => { + prisma.workOrder.findUnique.mockResolvedValue({ + id: 'wo-1', + reservedUst: new Prisma.Decimal(100), + totalValue: null, + contractItem: { + itemType: ContractItemType.UST, + ustValue: new Prisma.Decimal(100), + }, + }); + prisma.deliverable.aggregate.mockResolvedValue({ + _sum: { totalValue: new Prisma.Decimal(36320) }, + }); + prisma.workOrder.update.mockResolvedValue({}); + + const result = await service.recalculateAndPersist('wo-1'); + + expect(result.totalValue.equals(new Prisma.Decimal(36320))).toBe(true); + expect(result.changed).toBe(true); + }); + + it('não persiste quando totalValue não muda (changed=false)', async () => { + prisma.workOrder.findUnique.mockResolvedValue({ + id: 'wo-1', + reservedUst: new Prisma.Decimal(5), + totalValue: new Prisma.Decimal(10000), + contractItem: { + itemType: ContractItemType.SAAS_LICENSE, + ustValue: new Prisma.Decimal(2000), + }, + }); + + const result = await service.recalculateAndPersist('wo-1'); + + expect(result.changed).toBe(false); + expect(prisma.workOrder.update).not.toHaveBeenCalled(); + }); + + it('404 quando WorkOrder inexistente', async () => { + prisma.workOrder.findUnique.mockResolvedValue(null); + + await expect(service.recalculateAndPersist('wo-x')).rejects.toThrow(NotFoundException); + }); + }); + + describe('recalculateAllForContractItem', () => { + it('itera WOs ativas e conta apenas as que mudaram', async () => { + prisma.workOrder.findMany.mockResolvedValue([{ id: 'wo-1' }, { id: 'wo-2' }]); + prisma.workOrder.findUnique + .mockResolvedValueOnce({ + id: 'wo-1', + reservedUst: new Prisma.Decimal(5), + totalValue: new Prisma.Decimal(10000), + contractItem: { + itemType: ContractItemType.SAAS_LICENSE, + ustValue: new Prisma.Decimal(2000), + }, + }) + .mockResolvedValueOnce({ + id: 'wo-2', + reservedUst: new Prisma.Decimal(3), + totalValue: null, + contractItem: { + itemType: ContractItemType.SAAS_LICENSE, + ustValue: new Prisma.Decimal(2000), + }, + }); + prisma.workOrder.update.mockResolvedValue({}); + + const result = await service.recalculateAllForContractItem('item-1'); + + expect(result.recalculatedCount).toBe(1); + }); + }); +}); diff --git a/src/modules/work-orders/work-order-value-calculator.service.ts b/src/modules/work-orders/work-order-value-calculator.service.ts new file mode 100644 index 0000000..702e079 --- /dev/null +++ b/src/modules/work-orders/work-order-value-calculator.service.ts @@ -0,0 +1,121 @@ +import { BadRequestException, Injectable, Logger, NotFoundException } from '@nestjs/common'; +import { ContractItemType, Prisma } from '@prisma/client'; +import { PrismaService } from '../../config/database/prisma.service'; + +type DecimalLike = Prisma.Decimal | number | string | null | undefined; + +export type WorkOrderValueCalculatorContractItem = { + itemType: ContractItemType; + ustValue: Prisma.Decimal | null; +}; + +@Injectable() +export class WorkOrderValueCalculatorService { + private readonly logger = new Logger(WorkOrderValueCalculatorService.name); + + constructor(private readonly prisma: PrismaService) {} + + calculateForSaas(input: { + reservedUst: DecimalLike; + contractItem: WorkOrderValueCalculatorContractItem; + }): Prisma.Decimal { + const { reservedUst, contractItem } = input; + + if (contractItem.itemType !== ContractItemType.SAAS_LICENSE) { + throw new BadRequestException( + 'calculateForSaas chamado para item de contrato que não é SAAS_LICENSE', + ); + } + + if (contractItem.ustValue === null || contractItem.ustValue === undefined) { + throw new BadRequestException('Item Licença SaaS sem valor unitário configurado'); + } + + if (reservedUst === null || reservedUst === undefined) { + return new Prisma.Decimal(0); + } + + return new Prisma.Decimal(reservedUst).mul(contractItem.ustValue); + } + + async calculateForUst( + workOrderId: string, + tx?: Prisma.TransactionClient, + ): Promise { + const client = tx ?? this.prisma; + const result = await client.deliverable.aggregate({ + where: { workOrderId, isActive: true }, + _sum: { totalValue: true }, + }); + return result._sum.totalValue ?? new Prisma.Decimal(0); + } + + async recalculateAndPersist( + workOrderId: string, + tx?: Prisma.TransactionClient, + ): Promise<{ totalValue: Prisma.Decimal; changed: boolean }> { + const client = tx ?? this.prisma; + + const workOrder = await client.workOrder.findUnique({ + where: { id: workOrderId }, + select: { + id: true, + reservedUst: true, + totalValue: true, + contractItem: { select: { itemType: true, ustValue: true } }, + }, + }); + + if (!workOrder) { + throw new NotFoundException('Ordem de serviço não encontrada'); + } + + let next: Prisma.Decimal; + if (workOrder.contractItem.itemType === ContractItemType.SAAS_LICENSE) { + next = this.calculateForSaas({ + reservedUst: workOrder.reservedUst, + contractItem: workOrder.contractItem, + }); + } else { + next = await this.calculateForUst(workOrderId, tx); + } + + const previous = workOrder.totalValue ? new Prisma.Decimal(workOrder.totalValue) : null; + const changed = !previous || !previous.equals(next); + + if (changed) { + await client.workOrder.update({ + where: { id: workOrderId }, + data: { totalValue: next }, + }); + } + + return { totalValue: next, changed }; + } + + async recalculateAllForContractItem( + contractItemId: string, + tx?: Prisma.TransactionClient, + ): Promise<{ recalculatedCount: number }> { + const client = tx ?? this.prisma; + + const workOrders = await client.workOrder.findMany({ + where: { contractItemId, isActive: true }, + select: { id: true }, + }); + + let recalculatedCount = 0; + for (const wo of workOrders) { + try { + const { changed } = await this.recalculateAndPersist(wo.id, tx); + if (changed) recalculatedCount += 1; + } catch (error) { + this.logger.warn( + `Não foi possível recalcular WorkOrder ${wo.id} ao propagar mudança no item ${contractItemId}: ${(error as Error).message}`, + ); + } + } + + return { recalculatedCount }; + } +} diff --git a/src/modules/work-orders/work-orders.controller.ts b/src/modules/work-orders/work-orders.controller.ts new file mode 100644 index 0000000..edd477d --- /dev/null +++ b/src/modules/work-orders/work-orders.controller.ts @@ -0,0 +1,88 @@ +import { + Body, + Controller, + Delete, + Get, + HttpCode, + HttpStatus, + Param, + Patch, + Post, + Query, + UseGuards, +} from '@nestjs/common'; +import { JwtAuthGuard } from '../../config/auth/jwt-auth.guard'; +import { ReadOnlyClientGuard } from '../../common/guards/read-only-client.guard'; +import { RolesGuard } from '../../common/guards/roles.guard'; +import { Roles } from '../../common/decorators/roles.decorator'; +import { Role } from '../../common/enums/role.enum'; +import { CurrentUser } from '../../common/decorators/user.decorator'; +import type { CurrentUserPayload } from '../../common/decorators/user.decorator'; +import { WorkOrdersService } from './work-orders.service'; +import { CreateWorkOrderDto } from './dto/create-work-order.dto'; +import { UpdateWorkOrderDto } from './dto/update-work-order.dto'; +import { ListWorkOrdersQueryDto } from './dto/list-work-orders-query.dto'; +import { CancelWorkOrderDto } from './dto/cancel-work-order.dto'; + +@Controller('work-orders') +export class WorkOrdersController { + constructor(private readonly workOrdersService: WorkOrdersService) {} + + @Get() + @UseGuards(JwtAuthGuard, ReadOnlyClientGuard, RolesGuard) + @Roles(Role.ADMIN, Role.OPERATOR, Role.CLIENT) + async findAll(@Query() query: ListWorkOrdersQueryDto, @CurrentUser() user: CurrentUserPayload) { + return this.workOrdersService.findAll(query, user); + } + + @Post() + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles(Role.ADMIN, Role.OPERATOR) + async create(@Body() dto: CreateWorkOrderDto, @CurrentUser() user: CurrentUserPayload) { + return this.workOrdersService.create(dto, user.id); + } + + @Get(':id') + @UseGuards(JwtAuthGuard, ReadOnlyClientGuard, RolesGuard) + @Roles(Role.ADMIN, Role.OPERATOR, Role.CLIENT) + async findOne(@Param('id') id: string, @CurrentUser() user: CurrentUserPayload) { + return this.workOrdersService.findOne(id, user); + } + + @Get(':id/summary') + @UseGuards(JwtAuthGuard, ReadOnlyClientGuard, RolesGuard) + @Roles(Role.ADMIN, Role.OPERATOR, Role.CLIENT) + async getSummary(@Param('id') id: string, @CurrentUser() user: CurrentUserPayload) { + return this.workOrdersService.getSummary(id, user); + } + + @Patch(':id/cancel') + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles(Role.ADMIN, Role.OPERATOR) + async cancel( + @Param('id') id: string, + @Body() dto: CancelWorkOrderDto, + @CurrentUser() user: CurrentUserPayload, + ) { + return this.workOrdersService.cancel(id, dto, user.id); + } + + @Patch(':id') + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles(Role.ADMIN, Role.OPERATOR) + async update( + @Param('id') id: string, + @Body() dto: UpdateWorkOrderDto, + @CurrentUser() user: CurrentUserPayload, + ) { + return this.workOrdersService.update(id, dto, user.id); + } + + @Delete(':id') + @HttpCode(HttpStatus.NO_CONTENT) + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles(Role.ADMIN, Role.OPERATOR) + async remove(@Param('id') id: string, @CurrentUser() user: CurrentUserPayload) { + await this.workOrdersService.remove(id, user.id); + } +} diff --git a/src/modules/work-orders/work-orders.module.ts b/src/modules/work-orders/work-orders.module.ts new file mode 100644 index 0000000..a0569f2 --- /dev/null +++ b/src/modules/work-orders/work-orders.module.ts @@ -0,0 +1,18 @@ +import { Module } from '@nestjs/common'; +import { WorkOrdersController } from './work-orders.controller'; +import { WorkOrdersService } from './work-orders.service'; +import { WorkOrdersRepository } from './work-orders.repository'; +import { WorkOrderStatusHistoryRepository } from './repositories/work-order-status-history.repository'; +import { WorkOrderValueCalculatorService } from './work-order-value-calculator.service'; + +@Module({ + controllers: [WorkOrdersController], + providers: [ + WorkOrdersService, + WorkOrdersRepository, + WorkOrderStatusHistoryRepository, + WorkOrderValueCalculatorService, + ], + exports: [WorkOrdersService, WorkOrderValueCalculatorService], +}) +export class WorkOrdersModule {} diff --git a/src/modules/work-orders/work-orders.repository.ts b/src/modules/work-orders/work-orders.repository.ts new file mode 100644 index 0000000..9c50e4a --- /dev/null +++ b/src/modules/work-orders/work-orders.repository.ts @@ -0,0 +1,288 @@ +import { Injectable } from '@nestjs/common'; +import { Prisma, WorkOrderStatus } from '@prisma/client'; +import { PrismaService } from '../../config/database/prisma.service'; +import { Role } from '../../common/enums/role.enum'; + +const workOrderListSelect = { + id: true, + code: true, + name: true, + description: true, + status: true, + reservedUst: true, + totalValue: true, + startDate: true, + endDate: true, + isActive: true, + createdAt: true, + updatedAt: true, + contract: { select: { id: true, name: true, clientId: true } }, + contractItem: { select: { id: true, code: true, name: true, itemType: true } }, + _count: { select: { deliverables: { where: { isActive: true } } } }, +} as const; + +const workOrderDetailSelect = { + id: true, + code: true, + name: true, + description: true, + status: true, + reservedUst: true, + totalValue: true, + startDate: true, + endDate: true, + isActive: true, + createdAt: true, + updatedAt: true, + createdBy: true, + updatedBy: true, + contractId: true, + contractItemId: true, + contract: { select: { id: true, name: true, code: true, clientId: true } }, + contractItem: { + select: { + id: true, + code: true, + name: true, + itemType: true, + totalUst: true, + ustValue: true, + timeboxDescoberta: true, + timeboxDesign: true, + timeboxArquitetura: true, + timeboxConstrucao: true, + }, + }, + projects: { + select: { + project: { select: { id: true, name: true, code: true } }, + }, + }, + _count: { select: { deliverables: { where: { isActive: true } } } }, +} as const; + +const workOrderListSelectRestricted = { + id: true, + code: true, + name: true, + description: true, + status: true, + startDate: true, + endDate: true, + isActive: true, + createdAt: true, + updatedAt: true, + contract: { select: { id: true, name: true, clientId: true } }, + contractItem: { select: { id: true, code: true, name: true, itemType: true } }, + _count: { select: { deliverables: { where: { isActive: true } } } }, +} as const; + +const workOrderDetailSelectRestricted = { + id: true, + code: true, + name: true, + description: true, + status: true, + startDate: true, + endDate: true, + isActive: true, + createdAt: true, + updatedAt: true, + contractId: true, + contractItemId: true, + contract: { select: { id: true, name: true, code: true, clientId: true } }, + contractItem: { + select: { + id: true, + code: true, + name: true, + itemType: true, + totalUst: true, + }, + }, + projects: { + select: { + project: { select: { id: true, name: true, code: true } }, + }, + }, + _count: { select: { deliverables: { where: { isActive: true } } } }, +} as const; + +export interface ListParams { + search?: string; + clientId?: string; + contractId?: string; + contractItemId?: string; + projectId?: string; + status?: WorkOrderStatus; + isActive?: boolean; + page: number; + limit: number; + role?: Role; +} + +export interface CreateData { + code: string; + name: string; + description?: string; + contractId: string; + contractItemId: string; + projectIds: string[]; + reservedUst: number; + startDate: string; + endDate?: string; + createdBy?: string; + updatedBy?: string; +} + +export interface UpdateData { + code?: string; + name?: string; + description?: string; + contractItemId?: string; + projectIds?: string[]; + reservedUst?: number; + startDate?: string; + endDate?: string; + isActive?: boolean; + updatedBy?: string; +} + +@Injectable() +export class WorkOrdersRepository { + constructor(private readonly prisma: PrismaService) {} + + async findAll(params: ListParams) { + const where: Prisma.WorkOrderWhereInput = {}; + + if (params.search) { + where.OR = [ + { code: { contains: params.search, mode: 'insensitive' } }, + { name: { contains: params.search, mode: 'insensitive' } }, + ]; + } + if (params.clientId) where.contract = { clientId: params.clientId }; + if (params.contractId) where.contractId = params.contractId; + if (params.contractItemId) where.contractItemId = params.contractItemId; + if (params.projectId) where.projects = { some: { projectId: params.projectId } }; + if (params.status) where.status = params.status; + if (params.isActive !== undefined) where.isActive = params.isActive; + + const select = + params.role === undefined || params.role === Role.ADMIN + ? workOrderListSelect + : workOrderListSelectRestricted; + + const [data, total] = await Promise.all([ + this.prisma.workOrder.findMany({ + where, + select, + orderBy: { createdAt: 'desc' }, + skip: (params.page - 1) * params.limit, + take: params.limit, + }), + this.prisma.workOrder.count({ where }), + ]); + + return { data, total }; + } + + async findById(id: string) { + return this.prisma.workOrder.findUnique({ + where: { id }, + select: workOrderDetailSelect, + }); + } + + async findByIdPublic(id: string, role?: Role) { + const select = + role === undefined || role === Role.ADMIN ? workOrderDetailSelect : workOrderDetailSelectRestricted; + return this.prisma.workOrder.findUnique({ where: { id }, select }); + } + + async findByCodeInContract(code: string, contractId: string, excludeId?: string) { + return this.prisma.workOrder.findFirst({ + where: { + code, + contractId, + ...(excludeId ? { NOT: { id: excludeId } } : {}), + }, + select: { id: true }, + }); + } + + async deliverableCountByStatus(workOrderId: string) { + const grouped = await this.prisma.deliverable.groupBy({ + by: ['status'], + where: { workOrderId, isActive: true }, + _count: { _all: true }, + }); + return grouped.map((g) => ({ status: g.status, count: g._count._all })); + } + + async create(data: CreateData) { + const { projectIds, ...rest } = data; + return this.prisma.workOrder.create({ + data: { + ...rest, + projects: { + create: projectIds.map((projectId) => ({ projectId })), + }, + }, + select: workOrderDetailSelect, + }); + } + + async update(id: string, data: UpdateData) { + const { projectIds, ...rest } = data; + return this.prisma.$transaction(async (tx) => { + if (projectIds) { + await tx.workOrderProject.deleteMany({ where: { workOrderId: id } }); + await tx.workOrderProject.createMany({ + data: projectIds.map((projectId) => ({ workOrderId: id, projectId })), + }); + } + return tx.workOrder.update({ + where: { id }, + data: rest, + select: workOrderDetailSelect, + }); + }); + } + + async sumActiveReservedUstByContractItem(contractItemId: string, excludeId?: string) { + const result = await this.prisma.workOrder.aggregate({ + where: { + contractItemId, + isActive: true, + status: { not: WorkOrderStatus.CANCELADA }, + ...(excludeId ? { NOT: { id: excludeId } } : {}), + }, + _sum: { reservedUst: true }, + }); + return result._sum.reservedUst ?? new Prisma.Decimal(0); + } + + async updateStatus(id: string, status: WorkOrderStatus, userId?: string) { + return this.prisma.workOrder.update({ + where: { id }, + data: { status, updatedBy: userId }, + select: { id: true, status: true }, + }); + } + + async findActiveDeliverableStatuses(workOrderId: string) { + return this.prisma.deliverable.findMany({ + where: { workOrderId, isActive: true }, + select: { status: true }, + }); + } + + async softDelete(id: string, userId?: string) { + return this.prisma.workOrder.update({ + where: { id }, + data: { isActive: false, updatedBy: userId }, + select: { id: true, isActive: true }, + }); + } +} diff --git a/src/modules/work-orders/work-orders.service.spec.ts b/src/modules/work-orders/work-orders.service.spec.ts new file mode 100644 index 0000000..15c1626 --- /dev/null +++ b/src/modules/work-orders/work-orders.service.spec.ts @@ -0,0 +1,419 @@ +import { BadRequestException, ConflictException, NotFoundException } from '@nestjs/common'; +import { Test, TestingModule } from '@nestjs/testing'; +import { Prisma, WorkOrderStatus } from '@prisma/client'; +import { WorkOrdersService } from './work-orders.service'; +import { WorkOrdersRepository } from './work-orders.repository'; +import { WorkOrderStatusHistoryRepository } from './repositories/work-order-status-history.repository'; +import { PrismaService } from '../../config/database/prisma.service'; +import { WorkOrderValueCalculatorService } from './work-order-value-calculator.service'; + +const mockWorkOrder = (overrides: Record = {}) => ({ + id: 'wo-1', + code: 'OS-001', + name: 'OS Teste', + description: null, + status: WorkOrderStatus.RASCUNHO, + reservedUst: new Prisma.Decimal(100), + startDate: new Date('2026-01-01'), + endDate: new Date('2026-12-31'), + isActive: true, + createdAt: new Date(), + updatedAt: new Date(), + createdBy: 'user-1', + updatedBy: 'user-1', + contractId: 'contract-1', + contractItemId: 'contract-item-1', + contract: { id: 'contract-1', name: 'Contrato', code: 'C-1', clientId: 'client-1' }, + contractItem: { + id: 'contract-item-1', + code: 'IC-1', + name: 'Item', + itemType: 'UST', + totalUst: new Prisma.Decimal(1000), + }, + projects: [{ project: { id: 'project-1', name: 'Projeto', code: 'P-1' } }], + _count: { deliverables: 0 }, + ...overrides, +}); + +describe('WorkOrdersService', () => { + let service: WorkOrdersService; + let repository: jest.Mocked; + let prisma: { + contract: { findUnique: jest.Mock }; + contractItem: { findUnique: jest.Mock }; + project: { findMany: jest.Mock }; + deliverable: { count: jest.Mock; findMany: jest.Mock }; + }; + let valueCalculator: jest.Mocked>; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + WorkOrdersService, + { + provide: WorkOrdersRepository, + useValue: { + findById: jest.fn(), + findAll: jest.fn(), + findByCodeInContract: jest.fn(), + deliverableCountByStatus: jest.fn().mockResolvedValue([]), + sumActiveReservedUstByContractItem: jest.fn().mockResolvedValue(new Prisma.Decimal(0)), + create: jest.fn(), + update: jest.fn(), + updateStatus: jest.fn(), + findActiveDeliverableStatuses: jest.fn(), + softDelete: jest.fn(), + }, + }, + { + provide: WorkOrderStatusHistoryRepository, + useValue: { create: jest.fn(), findByWorkOrderId: jest.fn() }, + }, + { + provide: PrismaService, + useValue: { + contract: { + findUnique: jest.fn().mockResolvedValue({ + id: 'contract-1', + clientId: 'client-1', + isActive: true, + ustValue: new Prisma.Decimal(50), + }), + }, + contractItem: { + findUnique: jest.fn().mockResolvedValue({ + id: 'contract-item-1', + clientId: 'client-1', + isActive: true, + itemType: 'UST', + totalUst: new Prisma.Decimal(1000), + ustValue: new Prisma.Decimal(100), + }), + }, + project: { + findMany: jest + .fn() + .mockResolvedValue([{ id: 'project-1', contractId: 'contract-1', isActive: true }]), + }, + deliverable: { + count: jest.fn().mockResolvedValue(0), + findMany: jest.fn().mockResolvedValue([]), + }, + }, + }, + { + provide: WorkOrderValueCalculatorService, + useValue: { + recalculateAndPersist: jest + .fn() + .mockResolvedValue({ totalValue: new Prisma.Decimal(0), changed: false }), + }, + }, + ], + }).compile(); + + service = module.get(WorkOrdersService); + repository = module.get(WorkOrdersRepository); + prisma = module.get(PrismaService); + valueCalculator = module.get(WorkOrderValueCalculatorService); + }); + + describe('create', () => { + const dto = { + code: 'OS-001', + name: 'OS Teste', + contractId: 'contract-1', + contractItemId: 'contract-item-1', + projectIds: ['project-1'], + reservedUst: 100, + startDate: '2026-01-01T00:00:00.000Z', + endDate: '2026-12-31T00:00:00.000Z', + }; + + it('cria WorkOrder retornando RASCUNHO', async () => { + repository.findByCodeInContract.mockResolvedValue(null); + repository.create.mockResolvedValue(mockWorkOrder() as never); + repository.findById.mockResolvedValue(mockWorkOrder() as never); + + const result = await service.create(dto, 'user-1'); + + expect(result.status).toBe(WorkOrderStatus.RASCUNHO); + expect(repository.create).toHaveBeenCalled(); + expect(valueCalculator.recalculateAndPersist).toHaveBeenCalledWith('wo-1'); + }); + + it('falha com 409 quando code duplicado no contrato', async () => { + repository.findByCodeInContract.mockResolvedValue({ id: 'wo-other' } as never); + + await expect(service.create(dto, 'user-1')).rejects.toThrow(ConflictException); + }); + + it('falha com 400 quando contractItem pertence a outro cliente', async () => { + prisma.contractItem.findUnique.mockResolvedValue({ + id: 'contract-item-1', + clientId: 'client-2', + isActive: true, + totalUst: new Prisma.Decimal(1000), + }); + + await expect(service.create(dto, 'user-1')).rejects.toThrow(BadRequestException); + }); + + it('falha com 400 quando projectId fora do contrato', async () => { + prisma.project.findMany.mockResolvedValue([ + { id: 'project-1', contractId: 'contract-other', isActive: true }, + ]); + repository.findByCodeInContract.mockResolvedValue(null); + + await expect(service.create(dto, 'user-1')).rejects.toThrow(BadRequestException); + }); + + it('falha com 400 quando reservedUst excede saldo do ContractItem', async () => { + repository.findByCodeInContract.mockResolvedValue(null); + repository.sumActiveReservedUstByContractItem.mockResolvedValue(new Prisma.Decimal(950)); + + await expect(service.create({ ...dto, reservedUst: 100 }, 'user-1')).rejects.toThrow( + /Pool excede saldo/, + ); + }); + + it('cria WorkOrder sobre Item SaaS quando ustValue está configurado', async () => { + prisma.contractItem.findUnique.mockResolvedValue({ + id: 'contract-item-1', + clientId: 'client-1', + isActive: true, + itemType: 'SAAS_LICENSE', + totalUst: new Prisma.Decimal(1000), + ustValue: new Prisma.Decimal(100), + }); + repository.findByCodeInContract.mockResolvedValue(null); + repository.create.mockResolvedValue(mockWorkOrder() as never); + repository.findById.mockResolvedValue(mockWorkOrder() as never); + valueCalculator.recalculateAndPersist.mockResolvedValue({ + totalValue: new Prisma.Decimal(10000), + changed: true, + }); + + const result = await service.create(dto, 'user-1'); + + expect(result.status).toBe(WorkOrderStatus.RASCUNHO); + expect(repository.create).toHaveBeenCalled(); + expect(valueCalculator.recalculateAndPersist).toHaveBeenCalledWith('wo-1'); + }); + + it('falha com 400 quando Item SaaS não tem ustValue configurado', async () => { + prisma.contractItem.findUnique.mockResolvedValue({ + id: 'contract-item-1', + clientId: 'client-1', + isActive: true, + itemType: 'SAAS_LICENSE', + totalUst: new Prisma.Decimal(1000), + ustValue: null, + }); + repository.findByCodeInContract.mockResolvedValue(null); + + await expect(service.create(dto, 'user-1')).rejects.toThrow( + /Item Licença SaaS sem valor unitário configurado/, + ); + }); + }); + + describe('findAll', () => { + it('repassa filtros para o repository', async () => { + repository.findAll.mockResolvedValue({ data: [], total: 0 }); + + await service.findAll({ + page: 1, + limit: 20, + clientId: 'client-1', + contractId: 'contract-1', + status: WorkOrderStatus.EMITIDA, + search: 'foo', + }); + + expect(repository.findAll).toHaveBeenCalledWith( + expect.objectContaining({ + clientId: 'client-1', + contractId: 'contract-1', + status: WorkOrderStatus.EMITIDA, + search: 'foo', + page: 1, + limit: 20, + }), + ); + }); + + it('CLIENT força clientId do usuário', async () => { + repository.findAll.mockResolvedValue({ data: [], total: 0 }); + + await service.findAll({ page: 1, limit: 20, clientId: 'outro' } as never, { + id: 'u', + email: 'c@c', + role: 'CLIENT' as never, + clientId: 'client-1', + }); + + expect(repository.findAll).toHaveBeenCalledWith( + expect.objectContaining({ clientId: 'client-1' }), + ); + }); + }); + + describe('findOne', () => { + it('retorna WorkOrder com contadores', async () => { + repository.findById.mockResolvedValue(mockWorkOrder() as never); + repository.deliverableCountByStatus.mockResolvedValue([ + { status: 'EM_EXECUCAO' as never, count: 2 }, + ]); + + const result = await service.findOne('wo-1'); + + expect(result.id).toBe('wo-1'); + expect(result.deliverableCounts).toEqual([{ status: 'EM_EXECUCAO', count: 2 }]); + }); + + it('falha com 404 quando inexistente', async () => { + repository.findById.mockResolvedValue(null); + await expect(service.findOne('nope')).rejects.toThrow(NotFoundException); + }); + + it('CLIENT de outro cliente recebe 404', async () => { + repository.findById.mockResolvedValue(mockWorkOrder() as never); + + await expect( + service.findOne('wo-1', { + id: 'u', + email: 'c@c', + role: 'CLIENT' as never, + clientId: 'client-x', + }), + ).rejects.toThrow(NotFoundException); + }); + }); + + describe('update', () => { + it('permite editar nome em status EMITIDA', async () => { + repository.findById.mockResolvedValue( + mockWorkOrder({ status: WorkOrderStatus.EMITIDA }) as never, + ); + repository.update.mockResolvedValue(mockWorkOrder({ name: 'novo' }) as never); + + const result = await service.update('wo-1', { name: 'novo' }, 'user-1'); + + expect(repository.update).toHaveBeenCalled(); + expect(result.name).toBe('novo'); + expect(valueCalculator.recalculateAndPersist).not.toHaveBeenCalled(); + }); + + it('mudança de reservedUst dispara recálculo de totalValue', async () => { + repository.findById.mockResolvedValueOnce(mockWorkOrder() as never); + repository.update.mockResolvedValue( + mockWorkOrder({ reservedUst: new Prisma.Decimal(200) }) as never, + ); + repository.findById.mockResolvedValueOnce( + mockWorkOrder({ reservedUst: new Prisma.Decimal(200) }) as never, + ); + valueCalculator.recalculateAndPersist.mockResolvedValue({ + totalValue: new Prisma.Decimal(20000), + changed: true, + }); + + await service.update('wo-1', { reservedUst: 200 }, 'user-1'); + + expect(valueCalculator.recalculateAndPersist).toHaveBeenCalledWith('wo-1'); + }); + + it('bloqueia edição de contractItemId fora de RASCUNHO', async () => { + repository.findById.mockResolvedValue( + mockWorkOrder({ status: WorkOrderStatus.EMITIDA }) as never, + ); + + await expect( + service.update('wo-1', { contractItemId: 'contract-item-2' }, 'user-1'), + ).rejects.toThrow(/RASCUNHO/); + }); + + it('bloqueia edição em status EM_EXECUCAO', async () => { + repository.findById.mockResolvedValue( + mockWorkOrder({ status: WorkOrderStatus.EM_EXECUCAO }) as never, + ); + + await expect(service.update('wo-1', { name: 'x' }, 'user-1')).rejects.toThrow( + /RASCUNHO ou EMITIDA/, + ); + }); + + it('falha com 404 quando WorkOrder inexistente', async () => { + repository.findById.mockResolvedValue(null); + await expect(service.update('nope', { name: 'x' }, 'user-1')).rejects.toThrow( + NotFoundException, + ); + }); + + it('aceita mudar contractItemId para item SAAS_LICENSE em RASCUNHO quando ustValue configurado', async () => { + repository.findById.mockResolvedValue(mockWorkOrder() as never); + prisma.contractItem.findUnique.mockResolvedValue({ + id: 'contract-item-2', + clientId: 'client-1', + isActive: true, + itemType: 'SAAS_LICENSE', + totalUst: new Prisma.Decimal(500), + ustValue: new Prisma.Decimal(50), + }); + repository.update.mockResolvedValue( + mockWorkOrder({ contractItemId: 'contract-item-2' }) as never, + ); + + const result = await service.update('wo-1', { contractItemId: 'contract-item-2' }, 'user-1'); + + expect(repository.update).toHaveBeenCalled(); + expect(result.id).toBe('wo-1'); + }); + + it('falha com 400 ao mudar contractItemId para item SAAS_LICENSE sem ustValue', async () => { + repository.findById.mockResolvedValue(mockWorkOrder() as never); + prisma.contractItem.findUnique.mockResolvedValue({ + id: 'contract-item-2', + clientId: 'client-1', + isActive: true, + itemType: 'SAAS_LICENSE', + totalUst: new Prisma.Decimal(500), + ustValue: null, + }); + + await expect( + service.update('wo-1', { contractItemId: 'contract-item-2' }, 'user-1'), + ).rejects.toThrow(/Item Licença SaaS sem valor unitário configurado/); + }); + }); + + describe('remove', () => { + it('soft delete em RASCUNHO sem deliverables', async () => { + repository.findById.mockResolvedValue( + mockWorkOrder({ _count: { deliverables: 0 } }) as never, + ); + repository.softDelete.mockResolvedValue({ id: 'wo-1', isActive: false } as never); + + await service.remove('wo-1', 'user-1'); + + expect(repository.softDelete).toHaveBeenCalledWith('wo-1', 'user-1'); + }); + + it('bloqueia remove em status diferente de RASCUNHO', async () => { + repository.findById.mockResolvedValue( + mockWorkOrder({ status: WorkOrderStatus.EMITIDA }) as never, + ); + + await expect(service.remove('wo-1', 'user-1')).rejects.toThrow(/RASCUNHO/); + }); + + it('bloqueia remove se houver deliverables vinculados', async () => { + repository.findById.mockResolvedValue( + mockWorkOrder({ _count: { deliverables: 3 } }) as never, + ); + + await expect(service.remove('wo-1', 'user-1')).rejects.toThrow(/entregáveis vinculados/); + }); + }); +}); diff --git a/src/modules/work-orders/work-orders.service.ts b/src/modules/work-orders/work-orders.service.ts new file mode 100644 index 0000000..89d7047 --- /dev/null +++ b/src/modules/work-orders/work-orders.service.ts @@ -0,0 +1,390 @@ +import { + BadRequestException, + ConflictException, + Injectable, + NotFoundException, +} from '@nestjs/common'; +import { Prisma, WorkOrderStatus } from '@prisma/client'; +import { Role } from '../../common/enums/role.enum'; +import type { CurrentUserPayload } from '../../common/decorators/user.decorator'; +import { PrismaService } from '../../config/database/prisma.service'; +import { WorkOrdersRepository } from './work-orders.repository'; +import { WorkOrderStatusHistoryRepository } from './repositories/work-order-status-history.repository'; +import { CreateWorkOrderDto } from './dto/create-work-order.dto'; +import { UpdateWorkOrderDto } from './dto/update-work-order.dto'; +import { ListWorkOrdersQueryDto } from './dto/list-work-orders-query.dto'; +import { CancelWorkOrderDto } from './dto/cancel-work-order.dto'; +import { + deriveWorkOrderStatusFromDeliverables, + isWorkOrderTransitionValid, +} from './constants/work-order-status-transitions'; +import { calculateWorkOrderSummary } from './helpers/calculate-work-order-summary'; +import { WorkOrderValueCalculatorService } from './work-order-value-calculator.service'; + +const TERMINAL_STATUSES: WorkOrderStatus[] = [ + WorkOrderStatus.CANCELADA, + WorkOrderStatus.TOTALMENTE_PAGA, +]; + +function flattenProjects( + workOrder: T, +): Omit & { projects: P[] } { + return { ...workOrder, projects: workOrder.projects.map((wp) => wp.project) }; +} + +@Injectable() +export class WorkOrdersService { + constructor( + private readonly repository: WorkOrdersRepository, + private readonly statusHistoryRepository: WorkOrderStatusHistoryRepository, + private readonly prisma: PrismaService, + private readonly valueCalculator: WorkOrderValueCalculatorService, + ) {} + + async updateStatusFromDeliverableLifecycle(workOrderId: string, userId?: string) { + const workOrder = await this.repository.findById(workOrderId); + if (!workOrder) return; + if (TERMINAL_STATUSES.includes(workOrder.status)) return; + + const deliverables = await this.repository.findActiveDeliverableStatuses(workOrderId); + const target = deriveWorkOrderStatusFromDeliverables(deliverables); + + if (target === workOrder.status) return; + if (!isWorkOrderTransitionValid(workOrder.status, target)) return; + + await this.repository.updateStatus(workOrderId, target, userId); + await this.statusHistoryRepository.create({ + workOrderId, + previousStatus: workOrder.status, + newStatus: target, + observation: 'Transição automática a partir do ciclo de vida dos entregáveis', + createdBy: userId, + }); + } + + async findAll(query: ListWorkOrdersQueryDto, user?: CurrentUserPayload) { + const { page, limit, ...filters } = query; + const effective = { ...filters }; + + if ( + (user?.role === Role.CLIENT || + user?.role === Role.PO || + user?.role === Role.FISCAL_CONTRATO || + user?.role === Role.GESTOR_CONTRATO) && + user.clientId + ) { + effective.clientId = user.clientId; + } + + const { data, total } = await this.repository.findAll({ + ...effective, + page, + limit, + role: user?.role, + }); + return { data, total, page, limit }; + } + + async getSummary(id: string, user?: CurrentUserPayload) { + const workOrder = await this.repository.findById(id); + if (!workOrder) { + throw new NotFoundException('Ordem de serviço não encontrada'); + } + if ( + (user?.role === Role.CLIENT || + user?.role === Role.PO || + user?.role === Role.FISCAL_CONTRATO || + user?.role === Role.GESTOR_CONTRATO) && + user.clientId && + workOrder.contract.clientId !== user.clientId + ) { + throw new NotFoundException('Ordem de serviço não encontrada'); + } + + const contractItem = await this.prisma.contractItem.findUnique({ + where: { id: workOrder.contractItemId }, + select: { ustValue: true }, + }); + const contract = await this.prisma.contract.findUnique({ + where: { id: workOrder.contractId }, + select: { ustValue: true }, + }); + const ustValueRaw = contractItem?.ustValue ?? contract?.ustValue ?? null; + const ustValue = ustValueRaw ? new Prisma.Decimal(ustValueRaw) : new Prisma.Decimal(0); + + const deliverables = await this.prisma.deliverable.findMany({ + where: { workOrderId: id, isActive: true }, + select: { status: true, ustQuantity: true }, + }); + + const summary = calculateWorkOrderSummary({ + reservedUst: workOrder.reservedUst, + deliverables, + ustValue, + }); + + return { + workOrderId: workOrder.id, + ...summary, + }; + } + + async findOne(id: string, user?: CurrentUserPayload) { + const workOrder = await this.repository.findById(id); + + if (!workOrder) { + throw new NotFoundException('Ordem de serviço não encontrada'); + } + + if ( + user?.role === Role.CLIENT && + user.clientId && + workOrder.contract.clientId !== user.clientId + ) { + throw new NotFoundException('Ordem de serviço não encontrada'); + } + + const deliverableCounts = await this.repository.deliverableCountByStatus(id); + + return { ...flattenProjects(workOrder), deliverableCounts }; + } + + async create(dto: CreateWorkOrderDto, userId: string) { + await this.assertContractAndItem(dto.contractId, dto.contractItemId); + await this.assertProjects(dto.projectIds, dto.contractId); + await this.assertCodeUnique(dto.code, dto.contractId); + this.validateDates(dto.startDate, dto.endDate); + this.assertReservedUstPositive(dto.reservedUst); + await this.assertPoolAvailable(dto.contractItemId, dto.reservedUst); + + const created = await this.repository.create({ + ...dto, + startDate: new Date(dto.startDate).toISOString(), + endDate: dto.endDate ? new Date(dto.endDate).toISOString() : undefined, + createdBy: userId, + updatedBy: userId, + }); + + await this.valueCalculator.recalculateAndPersist(created.id); + const refreshed = await this.repository.findById(created.id); + return flattenProjects(refreshed ?? created); + } + + async update(id: string, dto: UpdateWorkOrderDto, userId: string) { + const existing = await this.repository.findById(id); + if (!existing) { + throw new NotFoundException('Ordem de serviço não encontrada'); + } + + if ( + dto.contractItemId !== undefined && + dto.contractItemId !== existing.contractItemId && + existing.status !== WorkOrderStatus.RASCUNHO + ) { + throw new BadRequestException( + 'Item de contrato só pode ser alterado em ordens de serviço em RASCUNHO', + ); + } + + if ( + existing.status !== WorkOrderStatus.RASCUNHO && + existing.status !== WorkOrderStatus.EMITIDA + ) { + throw new BadRequestException( + 'Edição permitida apenas em ordens de serviço em RASCUNHO ou EMITIDA', + ); + } + + if (dto.contractItemId && dto.contractItemId !== existing.contractItemId) { + await this.assertContractAndItem(existing.contractId, dto.contractItemId); + } + + if (dto.projectIds) { + await this.assertProjects(dto.projectIds, existing.contractId); + } + + if (dto.code && dto.code !== existing.code) { + await this.assertCodeUnique(dto.code, existing.contractId, id); + } + + if (dto.reservedUst !== undefined) { + this.assertReservedUstPositive(dto.reservedUst); + } + + if (dto.reservedUst !== undefined || dto.contractItemId !== undefined) { + const targetItemId = dto.contractItemId ?? existing.contractItemId; + const targetReservedUst = dto.reservedUst ?? Number(existing.reservedUst); + await this.assertPoolAvailable(targetItemId, targetReservedUst, id); + } + + this.validateDates( + dto.startDate ?? existing.startDate.toISOString(), + dto.endDate ?? existing.endDate?.toISOString(), + ); + + const updated = await this.repository.update(id, { + ...dto, + startDate: dto.startDate ? new Date(dto.startDate).toISOString() : undefined, + endDate: dto.endDate ? new Date(dto.endDate).toISOString() : undefined, + updatedBy: userId, + }); + + if (dto.reservedUst !== undefined || dto.contractItemId !== undefined) { + await this.valueCalculator.recalculateAndPersist(id); + const refreshed = await this.repository.findById(id); + return flattenProjects(refreshed ?? updated); + } + + return flattenProjects(updated); + } + + async cancel(id: string, dto: CancelWorkOrderDto, userId: string) { + const existing = await this.repository.findById(id); + if (!existing) { + throw new NotFoundException('Ordem de serviço não encontrada'); + } + + if (existing.status !== WorkOrderStatus.RASCUNHO) { + throw new BadRequestException('Apenas OS em rascunho podem ser canceladas'); + } + + const blocking = await this.prisma.deliverable.count({ + where: { + workOrderId: id, + isActive: true, + status: { not: 'RASCUNHO' }, + }, + }); + + if (blocking > 0) { + throw new BadRequestException('OS possui entregáveis emitidos e não pode ser cancelada'); + } + + await this.repository.updateStatus(id, WorkOrderStatus.CANCELADA, userId); + await this.statusHistoryRepository.create({ + workOrderId: id, + previousStatus: existing.status, + newStatus: WorkOrderStatus.CANCELADA, + observation: dto.observation, + createdBy: userId, + }); + + const cancelled = await this.repository.findById(id); + return cancelled ? flattenProjects(cancelled) : null; + } + + async remove(id: string, userId: string) { + const existing = await this.repository.findById(id); + if (!existing) { + throw new NotFoundException('Ordem de serviço não encontrada'); + } + + if (existing.status !== WorkOrderStatus.RASCUNHO) { + throw new BadRequestException('Apenas ordens de serviço em RASCUNHO podem ser removidas'); + } + + if (existing._count.deliverables > 0) { + throw new BadRequestException( + 'Ordem de serviço com entregáveis vinculados não pode ser removida', + ); + } + + return this.repository.softDelete(id, userId); + } + + private async assertContractAndItem(contractId: string, contractItemId: string) { + const contract = await this.prisma.contract.findUnique({ + where: { id: contractId }, + select: { id: true, clientId: true, isActive: true }, + }); + if (!contract || !contract.isActive) { + throw new BadRequestException('Contrato não encontrado ou inativo'); + } + + const item = await this.prisma.contractItem.findUnique({ + where: { id: contractItemId }, + select: { id: true, clientId: true, isActive: true, itemType: true, ustValue: true }, + }); + if (!item || !item.isActive) { + throw new BadRequestException('Item de contrato não encontrado ou inativo'); + } + + if (item.clientId !== contract.clientId) { + throw new BadRequestException('Item de contrato não pertence ao cliente do contrato'); + } + + if (item.itemType === 'SAAS_LICENSE') { + if (item.ustValue == null || new Prisma.Decimal(item.ustValue).lte(0)) { + throw new BadRequestException('Item Licença SaaS sem valor unitário configurado'); + } + } + } + + private async assertProjects(projectIds: string[], contractId: string) { + if (projectIds.length === 0) { + throw new BadRequestException('Informe ao menos um projeto'); + } + + const projects = await this.prisma.project.findMany({ + where: { id: { in: projectIds } }, + select: { id: true, contractId: true, isActive: true }, + }); + + if (projects.length !== projectIds.length) { + throw new BadRequestException('Um ou mais projetos não foram encontrados'); + } + + const invalid = projects.find((p) => !p.isActive || p.contractId !== contractId); + if (invalid) { + throw new BadRequestException( + 'Todos os projetos devem estar ativos e pertencer ao contrato informado', + ); + } + } + + private async assertCodeUnique(code: string, contractId: string, excludeId?: string) { + const conflict = await this.repository.findByCodeInContract(code, contractId, excludeId); + if (conflict) { + throw new ConflictException('Já existe ordem de serviço com este código no contrato'); + } + } + + private assertReservedUstPositive(reservedUst: number) { + if (!(reservedUst > 0)) { + throw new BadRequestException('UST reservada deve ser maior que zero'); + } + } + + private async assertPoolAvailable( + contractItemId: string, + reservedUst: number, + excludeId?: string, + ) { + const item = await this.prisma.contractItem.findUnique({ + where: { id: contractItemId }, + select: { totalUst: true }, + }); + if (!item) { + throw new BadRequestException('Item de contrato não encontrado'); + } + + const used = await this.repository.sumActiveReservedUstByContractItem( + contractItemId, + excludeId, + ); + const available = new Prisma.Decimal(item.totalUst).minus(used); + const requested = new Prisma.Decimal(reservedUst); + + if (requested.greaterThan(available)) { + throw new BadRequestException( + `Pool excede saldo do item de contrato (disponível: ${available.toString()}, solicitado: ${requested.toString()})`, + ); + } + } + + private validateDates(startDate?: string, endDate?: string) { + if (startDate && endDate && new Date(endDate) < new Date(startDate)) { + throw new BadRequestException('Data de término deve ser igual ou posterior à data de início'); + } + } +} diff --git a/src/modules/work-orders/work-orders.status.spec.ts b/src/modules/work-orders/work-orders.status.spec.ts new file mode 100644 index 0000000..df9c5ee --- /dev/null +++ b/src/modules/work-orders/work-orders.status.spec.ts @@ -0,0 +1,245 @@ +import { BadRequestException, NotFoundException } from '@nestjs/common'; +import { Test, TestingModule } from '@nestjs/testing'; +import { DeliverableStatus, Prisma, WorkOrderStatus } from '@prisma/client'; +import { WorkOrdersService } from './work-orders.service'; +import { WorkOrdersRepository } from './work-orders.repository'; +import { WorkOrderStatusHistoryRepository } from './repositories/work-order-status-history.repository'; +import { PrismaService } from '../../config/database/prisma.service'; +import { WorkOrderValueCalculatorService } from './work-order-value-calculator.service'; + +const baseWorkOrder = (overrides: Record = {}) => ({ + id: 'wo-1', + code: 'OS-001', + name: 'OS Teste', + status: WorkOrderStatus.RASCUNHO, + reservedUst: new Prisma.Decimal(100), + startDate: new Date('2026-01-01'), + endDate: null, + isActive: true, + contractId: 'contract-1', + contractItemId: 'contract-item-1', + contract: { id: 'contract-1', clientId: 'client-1' }, + contractItem: { id: 'contract-item-1', totalUst: new Prisma.Decimal(1000) }, + projects: [], + _count: { deliverables: 0 }, + ...overrides, +}); + +describe('WorkOrdersService - status transitions', () => { + let service: WorkOrdersService; + let repository: jest.Mocked; + let history: jest.Mocked; + let prisma: { deliverable: { count: jest.Mock } }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + WorkOrdersService, + { + provide: WorkOrdersRepository, + useValue: { + findById: jest.fn(), + findActiveDeliverableStatuses: jest.fn(), + updateStatus: jest.fn(), + }, + }, + { + provide: WorkOrderStatusHistoryRepository, + useValue: { create: jest.fn() }, + }, + { + provide: PrismaService, + useValue: { deliverable: { count: jest.fn().mockResolvedValue(0) } }, + }, + { + provide: WorkOrderValueCalculatorService, + useValue: { + recalculateAndPersist: jest + .fn() + .mockResolvedValue({ totalValue: new Prisma.Decimal(0), changed: false }), + }, + }, + ], + }).compile(); + + service = module.get(WorkOrdersService); + repository = module.get(WorkOrdersRepository); + history = module.get(WorkOrderStatusHistoryRepository); + prisma = module.get(PrismaService); + }); + + describe('updateStatusFromDeliverableLifecycle', () => { + it('RASCUNHO → EMITIDA quando primeiro Deliverable EMITIDA', async () => { + repository.findById.mockResolvedValue(baseWorkOrder() as never); + repository.findActiveDeliverableStatuses.mockResolvedValue([ + { status: DeliverableStatus.EMITIDA }, + ]); + + await service.updateStatusFromDeliverableLifecycle('wo-1', 'user-1'); + + expect(repository.updateStatus).toHaveBeenCalledWith( + 'wo-1', + WorkOrderStatus.EMITIDA, + 'user-1', + ); + expect(history.create).toHaveBeenCalledWith( + expect.objectContaining({ + previousStatus: WorkOrderStatus.RASCUNHO, + newStatus: WorkOrderStatus.EMITIDA, + }), + ); + }); + + it('EMITIDA → EM_EXECUCAO quando algum Deliverable EM_EXECUCAO', async () => { + repository.findById.mockResolvedValue( + baseWorkOrder({ status: WorkOrderStatus.EMITIDA }) as never, + ); + repository.findActiveDeliverableStatuses.mockResolvedValue([ + { status: DeliverableStatus.EM_EXECUCAO }, + { status: DeliverableStatus.EMITIDA }, + ]); + + await service.updateStatusFromDeliverableLifecycle('wo-1', 'user-1'); + + expect(repository.updateStatus).toHaveBeenCalledWith( + 'wo-1', + WorkOrderStatus.EM_EXECUCAO, + 'user-1', + ); + }); + + it('EM_EXECUCAO → TOTALMENTE_PAGA quando todos Deliverables PAGA', async () => { + repository.findById.mockResolvedValue( + baseWorkOrder({ status: WorkOrderStatus.EM_EXECUCAO }) as never, + ); + repository.findActiveDeliverableStatuses.mockResolvedValue([ + { status: DeliverableStatus.PAGA }, + { status: DeliverableStatus.PAGA }, + ]); + + await service.updateStatusFromDeliverableLifecycle('wo-1', 'user-1'); + + expect(repository.updateStatus).toHaveBeenCalledWith( + 'wo-1', + WorkOrderStatus.TOTALMENTE_PAGA, + 'user-1', + ); + }); + + it('Deliverables CANCELADA são ignorados (mantém RASCUNHO se vazio)', async () => { + repository.findById.mockResolvedValue(baseWorkOrder() as never); + repository.findActiveDeliverableStatuses.mockResolvedValue([ + { status: DeliverableStatus.CANCELADA }, + ]); + + await service.updateStatusFromDeliverableLifecycle('wo-1', 'user-1'); + + expect(repository.updateStatus).not.toHaveBeenCalled(); + }); + + it('não muda quando target = status atual', async () => { + repository.findById.mockResolvedValue( + baseWorkOrder({ status: WorkOrderStatus.EMITIDA }) as never, + ); + repository.findActiveDeliverableStatuses.mockResolvedValue([ + { status: DeliverableStatus.EMITIDA }, + ]); + + await service.updateStatusFromDeliverableLifecycle('wo-1', 'user-1'); + + expect(repository.updateStatus).not.toHaveBeenCalled(); + expect(history.create).not.toHaveBeenCalled(); + }); + + it('status terminal (TOTALMENTE_PAGA) não transiciona', async () => { + repository.findById.mockResolvedValue( + baseWorkOrder({ status: WorkOrderStatus.TOTALMENTE_PAGA }) as never, + ); + + await service.updateStatusFromDeliverableLifecycle('wo-1', 'user-1'); + + expect(repository.findActiveDeliverableStatuses).not.toHaveBeenCalled(); + expect(repository.updateStatus).not.toHaveBeenCalled(); + }); + + it('status terminal (CANCELADA) não transiciona', async () => { + repository.findById.mockResolvedValue( + baseWorkOrder({ status: WorkOrderStatus.CANCELADA }) as never, + ); + + await service.updateStatusFromDeliverableLifecycle('wo-1', 'user-1'); + + expect(repository.updateStatus).not.toHaveBeenCalled(); + }); + + it('WorkOrder inexistente é no-op silencioso', async () => { + repository.findById.mockResolvedValue(null); + + await service.updateStatusFromDeliverableLifecycle('nope', 'user-1'); + + expect(repository.updateStatus).not.toHaveBeenCalled(); + }); + + it('transição inválida (RASCUNHO → TOTALMENTE_PAGA direto) é descartada', async () => { + repository.findById.mockResolvedValue(baseWorkOrder() as never); + repository.findActiveDeliverableStatuses.mockResolvedValue([ + { status: DeliverableStatus.PAGA }, + ]); + + await service.updateStatusFromDeliverableLifecycle('wo-1', 'user-1'); + + expect(repository.updateStatus).not.toHaveBeenCalled(); + }); + }); + + describe('cancel', () => { + it('cancela WorkOrder em RASCUNHO e registra history', async () => { + const wo = baseWorkOrder(); + repository.findById.mockResolvedValueOnce(wo as never).mockResolvedValueOnce(wo as never); + prisma.deliverable.count.mockResolvedValue(0); + + await service.cancel('wo-1', { observation: 'motivo' }, 'user-1'); + + expect(repository.updateStatus).toHaveBeenCalledWith( + 'wo-1', + WorkOrderStatus.CANCELADA, + 'user-1', + ); + expect(history.create).toHaveBeenCalledWith( + expect.objectContaining({ + workOrderId: 'wo-1', + previousStatus: WorkOrderStatus.RASCUNHO, + newStatus: WorkOrderStatus.CANCELADA, + observation: 'motivo', + }), + ); + }); + + it('falha com 400 quando WorkOrder não está em RASCUNHO', async () => { + repository.findById.mockResolvedValue( + baseWorkOrder({ status: WorkOrderStatus.EMITIDA }) as never, + ); + + await expect(service.cancel('wo-1', { observation: 'x' }, 'user-1')).rejects.toThrow( + BadRequestException, + ); + }); + + it('falha com 400 quando há entregáveis emitidos', async () => { + repository.findById.mockResolvedValue(baseWorkOrder() as never); + prisma.deliverable.count.mockResolvedValue(2); + + await expect(service.cancel('wo-1', { observation: 'x' }, 'user-1')).rejects.toThrow( + /entregáveis emitidos/, + ); + }); + + it('falha com 404 quando WorkOrder inexistente', async () => { + repository.findById.mockResolvedValue(null); + + await expect(service.cancel('nope', { observation: 'x' }, 'user-1')).rejects.toThrow( + NotFoundException, + ); + }); + }); +}); diff --git a/src/modules/work-orders/work-orders.summary.spec.ts b/src/modules/work-orders/work-orders.summary.spec.ts new file mode 100644 index 0000000..6dc1e1f --- /dev/null +++ b/src/modules/work-orders/work-orders.summary.spec.ts @@ -0,0 +1,176 @@ +import { NotFoundException } from '@nestjs/common'; +import { Test, TestingModule } from '@nestjs/testing'; +import { DeliverableStatus, Prisma, WorkOrderStatus } from '@prisma/client'; +import { WorkOrdersService } from './work-orders.service'; +import { WorkOrdersRepository } from './work-orders.repository'; +import { WorkOrderStatusHistoryRepository } from './repositories/work-order-status-history.repository'; +import { PrismaService } from '../../config/database/prisma.service'; +import { WorkOrderValueCalculatorService } from './work-order-value-calculator.service'; + +const buildWorkOrder = (reservedUst: number) => ({ + id: 'wo-1', + status: WorkOrderStatus.EMITIDA, + reservedUst: new Prisma.Decimal(reservedUst), + contractId: 'contract-1', + contractItemId: 'contract-item-1', + contract: { id: 'contract-1', clientId: 'client-1' }, + contractItem: { id: 'contract-item-1' }, + projects: [], + _count: { deliverables: 0 }, +}); + +describe('WorkOrdersService - getSummary', () => { + let service: WorkOrdersService; + let repository: jest.Mocked; + let prisma: { + contract: { findUnique: jest.Mock }; + contractItem: { findUnique: jest.Mock }; + deliverable: { findMany: jest.Mock }; + }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + WorkOrdersService, + { + provide: WorkOrdersRepository, + useValue: { findById: jest.fn() }, + }, + { + provide: WorkOrderStatusHistoryRepository, + useValue: { create: jest.fn() }, + }, + { + provide: PrismaService, + useValue: { + contract: { + findUnique: jest.fn().mockResolvedValue({ ustValue: new Prisma.Decimal(50) }), + }, + contractItem: { + findUnique: jest.fn().mockResolvedValue({ ustValue: new Prisma.Decimal(100) }), + }, + deliverable: { findMany: jest.fn().mockResolvedValue([]) }, + }, + }, + { + provide: WorkOrderValueCalculatorService, + useValue: { + recalculateAndPersist: jest + .fn() + .mockResolvedValue({ totalValue: new Prisma.Decimal(0), changed: false }), + }, + }, + ], + }).compile(); + + service = module.get(WorkOrdersService); + repository = module.get(WorkOrdersRepository); + prisma = module.get(PrismaService); + }); + + it('falha com 404 quando WorkOrder inexistente', async () => { + repository.findById.mockResolvedValue(null); + await expect(service.getSummary('nope')).rejects.toThrow(NotFoundException); + }); + + it('CLIENT de outro cliente recebe 404', async () => { + repository.findById.mockResolvedValue(buildWorkOrder(1000) as never); + + await expect( + service.getSummary('wo-1', { + id: 'u', + email: 'c@c', + role: 'CLIENT' as never, + clientId: 'outro', + }), + ).rejects.toThrow(NotFoundException); + }); + + it('pool 0% (sem deliverables): ustAvailable = ustReserved, lowBalanceAlert false', async () => { + repository.findById.mockResolvedValue(buildWorkOrder(1000) as never); + prisma.deliverable.findMany.mockResolvedValue([]); + + const result = await service.getSummary('wo-1'); + + expect(result.totalDeliverables).toBe(0); + expect(result.ustAvailable.equals(1000)).toBe(true); + expect(result.consumptionPercent.equals(0)).toBe(true); + expect(result.lowBalanceAlert).toBe(false); + }); + + it('pool 30%: lowBalanceAlert false', async () => { + repository.findById.mockResolvedValue(buildWorkOrder(1000) as never); + prisma.deliverable.findMany.mockResolvedValue([ + { status: DeliverableStatus.EM_EXECUCAO, ustQuantity: new Prisma.Decimal(200) }, + { status: DeliverableStatus.PAGA, ustQuantity: new Prisma.Decimal(100) }, + ]); + + const result = await service.getSummary('wo-1'); + + expect(result.consumptionPercent.equals(30)).toBe(true); + expect(result.ustAvailable.equals(700)).toBe(true); + expect(result.lowBalanceAlert).toBe(false); + }); + + it('pool 80%: lowBalanceAlert false (boundary)', async () => { + repository.findById.mockResolvedValue(buildWorkOrder(1000) as never); + prisma.deliverable.findMany.mockResolvedValue([ + { status: DeliverableStatus.APROVADA, ustQuantity: new Prisma.Decimal(800) }, + ]); + + const result = await service.getSummary('wo-1'); + + expect(result.consumptionPercent.equals(80)).toBe(true); + expect(result.lowBalanceAlert).toBe(false); + }); + + it('pool 85%: lowBalanceAlert true', async () => { + repository.findById.mockResolvedValue(buildWorkOrder(1000) as never); + prisma.deliverable.findMany.mockResolvedValue([ + { status: DeliverableStatus.PAGA, ustQuantity: new Prisma.Decimal(850) }, + ]); + + const result = await service.getSummary('wo-1'); + + expect(result.consumptionPercent.equals(85)).toBe(true); + expect(result.lowBalanceAlert).toBe(true); + }); + + it('pool 100%: ustAvailable = 0, consumption = 100', async () => { + repository.findById.mockResolvedValue(buildWorkOrder(1000) as never); + prisma.deliverable.findMany.mockResolvedValue([ + { status: DeliverableStatus.PAGA, ustQuantity: new Prisma.Decimal(1000) }, + ]); + + const result = await service.getSummary('wo-1'); + + expect(result.ustAvailable.equals(0)).toBe(true); + expect(result.consumptionPercent.equals(100)).toBe(true); + expect(result.lowBalanceAlert).toBe(true); + }); + + it('valores em moeda corretos com ustValue arbitrário do contractItem', async () => { + repository.findById.mockResolvedValue(buildWorkOrder(500) as never); + prisma.contractItem.findUnique.mockResolvedValue({ ustValue: new Prisma.Decimal('123.45') }); + prisma.deliverable.findMany.mockResolvedValue([ + { status: DeliverableStatus.EM_EXECUCAO, ustQuantity: new Prisma.Decimal(100) }, + ]); + + const result = await service.getSummary('wo-1'); + + expect(result.valueReserved.equals(new Prisma.Decimal('61725.00'))).toBe(true); + expect(result.valueConsumed.equals(new Prisma.Decimal('12345.00'))).toBe(true); + expect(result.valueAvailable.equals(new Prisma.Decimal('49380.00'))).toBe(true); + }); + + it('fallback para ustValue do contract quando contractItem sem valor', async () => { + repository.findById.mockResolvedValue(buildWorkOrder(100) as never); + prisma.contractItem.findUnique.mockResolvedValue({ ustValue: null }); + prisma.contract.findUnique.mockResolvedValue({ ustValue: new Prisma.Decimal(75) }); + prisma.deliverable.findMany.mockResolvedValue([]); + + const result = await service.getSummary('wo-1'); + + expect(result.valueReserved.equals(7500)).toBe(true); + }); +}); diff --git a/tsconfig.build.json b/tsconfig.build.json new file mode 100644 index 0000000..bde3e41 --- /dev/null +++ b/tsconfig.build.json @@ -0,0 +1,9 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "rootDir": "./src", + "incremental": false + }, + "include": ["src/**/*.ts"], + "exclude": ["node_modules", "test", "dist", "prisma", "**/*spec.ts"] +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..2ce96f7 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,28 @@ +{ + "compilerOptions": { + "module": "nodenext", + "moduleResolution": "nodenext", + "resolvePackageJsonExports": true, + "esModuleInterop": true, + "isolatedModules": true, + "declaration": true, + "removeComments": true, + "emitDecoratorMetadata": true, + "experimentalDecorators": true, + "allowSyntheticDefaultImports": true, + "target": "ES2023", + "sourceMap": true, + "outDir": "./dist", + "baseUrl": "./", + "incremental": true, + "skipLibCheck": true, + "strict": true, + "strictNullChecks": true, + "noImplicitAny": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "forceConsistentCasingInFileNames": true, + "strictBindCallApply": false, + "noFallthroughCasesInSwitch": false + } +}