From 1587620e140c1a2af034773a394fa80a7a23152f Mon Sep 17 00:00:00 2001 From: Muluhabt Date: Wed, 26 Aug 2026 23:57:26 +0300 Subject: [PATCH] Adding all functionalities --- .../services/appraisal.service.spec.ts | 494 +++ .../services/attendance-day.service.spec.ts | 264 ++ .../services/attendance.service.spec.ts | 497 +++ .../services/regularizations.service.spec.ts | 306 ++ .../employees/employees.service.spec.ts | 138 + .../job-positions.repository.spec.ts | 131 + .../services/leave-balances.service.spec.ts | 484 +++ .../services/leave-requests.service.spec.ts | 816 ++++ .../payroll-calculator.service.spec.ts | 521 +++ .../services/payroll-runs.service.spec.ts | 630 +++ .../services/recruitment.service.spec.ts | 358 ++ .../reports/services/reports.service.spec.ts | 101 + .../reports/services/reports.service.ts | 20 +- .../appraisal/AppraisalReviewsPage.tsx | 1 + .../features/appraisal/MyAppraisalsPage.tsx | 1 + .../appraisal/components/ScoringForm.tsx | 13 +- .../attendance/AttendanceApprovalsPage.tsx | 14 +- .../features/attendance/MyAttendancePage.tsx | 10 +- .../attendance/components/ClockWidget.tsx | 1 + .../src/features/leave/LeaveApprovalsPage.tsx | 4 +- .../leave/components/RequestLeaveModal.tsx | 6 +- .../src/features/payroll/PayrollRunsPage.tsx | 11 +- .../recruitment/OpeningDetailPage.tsx | 1 + .../components/ApplicationDrawer.tsx | 2 + .../src/features/reports/ReportsPage.tsx | 28 +- .../shared/components/EthiopianDateInput.tsx | 3 + .../src/modules/assets/assets.service.spec.ts | 416 ++ .../assets/depreciation.calculator.spec.ts | 170 + .../budgeting/budgeting.service.spec.ts | 140 + .../modules/cutover/cutover.service.spec.ts | 536 +++ .../modules/journals/journals.service.spec.ts | 582 +++ .../modules/payables/payables.service.spec.ts | 444 ++ .../src/modules/payables/payables.service.ts | 90 +- .../payables/payroll-posting.service.spec.ts | 390 ++ .../revenue/payment-events.consumer.spec.ts | 163 + .../revenue/revenue-posting.service.spec.ts | 524 +++ .../src/features/assets/AssetsPage.tsx | 16 +- .../src/features/budgeting/BudgetsPage.tsx | 18 +- .../src/features/cutover/CutoverPage.tsx | 14 +- .../features/journals/JournalDetailPage.tsx | 10 +- .../src/features/journals/NewJournalPage.tsx | 16 +- .../src/features/payables/PayablesPage.tsx | 16 +- .../src/features/reports/ReportsPage.tsx | 18 +- docs/hr-finance-ui-e2e-matrix.md | 74 +- e2e-hr-finance/run.sh | 11 +- .../asset-migrated-depreciates.spec.ts | 304 ++ .../cutover-suspense-draft-only.spec.ts | 173 + e2e-hr-finance/specs/finance/gating.spec.ts | 19 +- .../journal-post-outside-period.spec.ts | 157 + .../finance/payment-pre-cutover-skip.spec.ts | 179 + .../hr/appraisal-manager-score-final.spec.ts | 176 + .../specs/hr/attendance-night-shift.spec.ts | 206 + e2e-hr-finance/specs/hr/gating.spec.ts | 7 +- e2e-hr-finance/specs/hr/leave-approve.spec.ts | 158 + e2e-hr-finance/specs/hr/leave-cancel.spec.ts | 180 + e2e-hr-finance/specs/hr/leave-submit.spec.ts | 152 + .../specs/hr/payroll-no-recalc.spec.ts | 171 + .../hr/payroll-self-service-scoping.spec.ts | 271 ++ .../hr/recruitment-rehire-conflict.spec.ts | 158 + .../hr/reports-leave-liability-filter.spec.ts | 141 + package.json | 2 + pnpm-lock.yaml | 3731 +++++++++++++++-- 62 files changed, 14331 insertions(+), 357 deletions(-) create mode 100644 apps/edr-hr-api/src/modules/appraisal/services/appraisal.service.spec.ts create mode 100644 apps/edr-hr-api/src/modules/attendance/services/attendance-day.service.spec.ts create mode 100644 apps/edr-hr-api/src/modules/attendance/services/attendance.service.spec.ts create mode 100644 apps/edr-hr-api/src/modules/attendance/services/regularizations.service.spec.ts create mode 100644 apps/edr-hr-api/src/modules/employees/employees.service.spec.ts create mode 100644 apps/edr-hr-api/src/modules/job-positions/job-positions.repository.spec.ts create mode 100644 apps/edr-hr-api/src/modules/leave/services/leave-balances.service.spec.ts create mode 100644 apps/edr-hr-api/src/modules/leave/services/leave-requests.service.spec.ts create mode 100644 apps/edr-hr-api/src/modules/payroll/services/payroll-calculator.service.spec.ts create mode 100644 apps/edr-hr-api/src/modules/payroll/services/payroll-runs.service.spec.ts create mode 100644 apps/edr-hr-api/src/modules/recruitment/services/recruitment.service.spec.ts create mode 100644 apps/edr-hr-api/src/modules/reports/services/reports.service.spec.ts create mode 100644 apps/finance-api/src/modules/assets/assets.service.spec.ts create mode 100644 apps/finance-api/src/modules/assets/depreciation.calculator.spec.ts create mode 100644 apps/finance-api/src/modules/budgeting/budgeting.service.spec.ts create mode 100644 apps/finance-api/src/modules/cutover/cutover.service.spec.ts create mode 100644 apps/finance-api/src/modules/journals/journals.service.spec.ts create mode 100644 apps/finance-api/src/modules/payables/payables.service.spec.ts create mode 100644 apps/finance-api/src/modules/payables/payroll-posting.service.spec.ts create mode 100644 apps/finance-api/src/modules/revenue/payment-events.consumer.spec.ts create mode 100644 apps/finance-api/src/modules/revenue/revenue-posting.service.spec.ts create mode 100644 e2e-hr-finance/specs/finance/asset-migrated-depreciates.spec.ts create mode 100644 e2e-hr-finance/specs/finance/cutover-suspense-draft-only.spec.ts create mode 100644 e2e-hr-finance/specs/finance/journal-post-outside-period.spec.ts create mode 100644 e2e-hr-finance/specs/finance/payment-pre-cutover-skip.spec.ts create mode 100644 e2e-hr-finance/specs/hr/appraisal-manager-score-final.spec.ts create mode 100644 e2e-hr-finance/specs/hr/attendance-night-shift.spec.ts create mode 100644 e2e-hr-finance/specs/hr/leave-approve.spec.ts create mode 100644 e2e-hr-finance/specs/hr/leave-cancel.spec.ts create mode 100644 e2e-hr-finance/specs/hr/leave-submit.spec.ts create mode 100644 e2e-hr-finance/specs/hr/payroll-no-recalc.spec.ts create mode 100644 e2e-hr-finance/specs/hr/payroll-self-service-scoping.spec.ts create mode 100644 e2e-hr-finance/specs/hr/recruitment-rehire-conflict.spec.ts create mode 100644 e2e-hr-finance/specs/hr/reports-leave-liability-filter.spec.ts diff --git a/apps/edr-hr-api/src/modules/appraisal/services/appraisal.service.spec.ts b/apps/edr-hr-api/src/modules/appraisal/services/appraisal.service.spec.ts new file mode 100644 index 000000000..d1f6ac487 --- /dev/null +++ b/apps/edr-hr-api/src/modules/appraisal/services/appraisal.service.spec.ts @@ -0,0 +1,494 @@ +import { BadRequestException, ForbiddenException } from "@nestjs/common"; + +import { AppraisalService } from "./appraisal.service"; +import { ActorContext } from "../../employees/employees.service"; +import { + Appraisal, + AppraisalCriterion, + AppraisalGoal, + AppraisalRating, + AppraisalTemplate, + EAppraisalStatus, + ECycleStatus, + RatingBand, +} from "../entities/appraisal.entity"; + +/** + * A minimal stand-in for the transaction `EntityManager`. Tracks every + * `.save()`/`.update()` call per-entity so tests can assert exactly what was + * persisted, mirroring the freight billing/wagon spec convention. + */ +function makeManager() { + const calls: { + appraisalSaves: Record[]; + ratingSaves: Record[]; + appraisalUpdates: { id: string; data: Record }[]; + ratingUpdates: { id: string; data: Record }[]; + goalUpdates: { id: string; data: Record }[]; + } = { + appraisalSaves: [], + ratingSaves: [], + appraisalUpdates: [], + ratingUpdates: [], + goalUpdates: [], + }; + + let seq = 0; + + const manager = { + getRepository: (entity: { name: string }) => { + const name = entity.name; + return { + create: (data: Record) => data, + save: jest.fn(async (data: Record) => { + const row = { id: (data.id as string) ?? `${name}-${seq++}`, ...data }; + if (name === "Appraisal") calls.appraisalSaves.push(row); + if (name === "AppraisalRating") calls.ratingSaves.push(row); + return row; + }), + update: jest.fn(async (id: string, data: Record) => { + if (name === "Appraisal") calls.appraisalUpdates.push({ id, data }); + if (name === "AppraisalRating") calls.ratingUpdates.push({ id, data }); + if (name === "AppraisalGoal") calls.goalUpdates.push({ id, data }); + return undefined; + }), + }; + }, + }; + + return { manager, calls }; +} + +const actorFixture = (over: Partial = {}): ActorContext => ({ + employeeId: "mgr-1", + userId: "user-1", + organizationId: "org-1", + isSuperAdmin: false, + ...over, +}); + +const criterionFixture = ( + over: Partial = {}, +): AppraisalCriterion => + ({ + id: "c1", + templateId: "tpl-1", + code: "QUALITY", + name: { am: "ጥራት", en: "Quality" }, + weight: "50.00", + sortOrder: 10, + ...over, + }) as AppraisalCriterion; + +const bandFixtures: RatingBand[] = [ + { min: 90, label: { am: "", en: "Excellent" } }, + { min: 75, label: { am: "", en: "Good" } }, + { min: 50, label: { am: "", en: "Fair" } }, +]; + +const templateFixture = ( + over: Partial = {}, +): AppraisalTemplate => + ({ + id: "tpl-1", + organizationId: "org-1", + code: "ANNUAL", + name: { am: "", en: "Annual" }, + maxScore: "10.00", + ratingBands: bandFixtures, + requiresSelfAssessment: true, + requiresAcknowledgement: true, + criteria: [ + criterionFixture({ id: "c1", code: "QUALITY", weight: "50.00", sortOrder: 10 }), + criterionFixture({ id: "c2", code: "SPEED", weight: "50.00", sortOrder: 20 }), + ], + ...over, + }) as AppraisalTemplate; + +const cycleFixture = (over: Partial = {}) => + ({ + id: "cycle-1", + organizationId: "org-1", + code: "FY2026", + name: { am: "", en: "FY2026" }, + templateId: "tpl-1", + periodStart: "2026-01-01", + periodEnd: "2026-12-31", + status: ECycleStatus.DRAFT, + ...over, + }) as import("../entities/appraisal.entity").AppraisalCycle; + +const appraisalFixture = (over: Partial = {}): Appraisal => + ({ + id: "appraisal-1", + organizationId: "org-1", + cycleId: "cycle-1", + employeeId: "emp-1", + appraiserEmployeeId: "mgr-1", + status: EAppraisalStatus.PENDING_MANAGER, + ratings: [ + { + id: "r1", + appraisalId: "appraisal-1", + code: "QUALITY", + name: { am: "", en: "Quality" }, + weight: "50.00", + maxScore: "10.00", + selfScore: "4.00", + sortOrder: 10, + }, + { + id: "r2", + appraisalId: "appraisal-1", + code: "SPEED", + name: { am: "", en: "Speed" }, + weight: "50.00", + maxScore: "10.00", + selfScore: "3.00", + sortOrder: 20, + }, + ] as AppraisalRating[], + goals: [] as AppraisalGoal[], + ...over, + }) as Appraisal; + +describe("AppraisalService", () => { + let dataSource: { transaction: jest.Mock; query: jest.Mock }; + let templates: { findOne: jest.Mock; find: jest.Mock; save: jest.Mock; create: jest.Mock }; + let criteria: { save: jest.Mock; create: jest.Mock }; + let cycles: { findOne: jest.Mock; update: jest.Mock; save: jest.Mock; create: jest.Mock }; + let appraisals: { + findOne: jest.Mock; + find: jest.Mock; + createQueryBuilder: jest.Mock; + }; + let goals: { count: jest.Mock; save: jest.Mock; create: jest.Mock }; + let employees: { findByEmployeeId: jest.Mock }; + let iamDirectory: { findLineManagerEmployeeId: jest.Mock }; + let service: AppraisalService; + let managerCalls: ReturnType["calls"]; + + beforeEach(() => { + const built = makeManager(); + managerCalls = built.calls; + + dataSource = { + transaction: jest.fn(async (cb: (m: unknown) => unknown) => cb(built.manager)), + query: jest.fn().mockResolvedValue([]), + }; + templates = { + findOne: jest.fn().mockResolvedValue(templateFixture()), + find: jest.fn().mockResolvedValue([]), + save: jest.fn(), + create: jest.fn((d) => d), + }; + criteria = { save: jest.fn(), create: jest.fn((d) => d) }; + cycles = { + findOne: jest.fn().mockResolvedValue(cycleFixture()), + update: jest.fn().mockResolvedValue(undefined), + save: jest.fn(), + create: jest.fn((d) => d), + }; + appraisals = { + findOne: jest.fn().mockResolvedValue(null), + find: jest.fn().mockResolvedValue([]), + createQueryBuilder: jest.fn(), + }; + goals = { count: jest.fn().mockResolvedValue(0), save: jest.fn(), create: jest.fn((d) => d) }; + employees = { findByEmployeeId: jest.fn().mockResolvedValue({ managerEmployeeId: null }) }; + iamDirectory = { findLineManagerEmployeeId: jest.fn().mockResolvedValue(null) }; + + service = new AppraisalService( + dataSource as never, + templates as never, + criteria as never, + cycles as never, + appraisals as never, + goals as never, + employees as never, + iamDirectory as never, + ); + }); + + // ── openCycle ────────────────────────────────────────────────────────────── + + describe("openCycle", () => { + it("only opens a DRAFT or OPEN cycle", async () => { + cycles.findOne.mockResolvedValue(cycleFixture({ status: ECycleStatus.CLOSED })); + + await expect( + service.openCycle("cycle-1", ["emp-1"], actorFixture()), + ).rejects.toBeInstanceOf(BadRequestException); + expect(dataSource.transaction).not.toHaveBeenCalled(); + }); + + it("opens a DRAFT cycle and also allows re-opening an already-OPEN one", async () => { + cycles.findOne.mockResolvedValue(cycleFixture({ status: ECycleStatus.OPEN })); + appraisals.findOne.mockResolvedValue(null); + employees.findByEmployeeId.mockResolvedValue({ managerEmployeeId: null }); + + await expect( + service.openCycle("cycle-1", ["emp-1"], actorFixture()), + ).resolves.toBeDefined(); + }); + + it("copies the template's criteria onto AppraisalRating rows AT OPEN TIME — a later template edit does not retroactively change an already-opened appraisal", async () => { + const template = templateFixture(); + templates.findOne.mockResolvedValue(template); + appraisals.findOne.mockResolvedValue(null); + employees.findByEmployeeId.mockResolvedValue({ managerEmployeeId: null }); + + const result = await service.openCycle("cycle-1", ["emp-1"], actorFixture()); + expect(result.created).toBe(1); + expect(result.skipped).toBe(0); + expect(managerCalls.ratingSaves).toHaveLength(2); + + const qualityRating = managerCalls.ratingSaves.find((r) => r.code === "QUALITY")!; + expect(qualityRating.weight).toBe("50.00"); + expect(qualityRating.maxScore).toBe("10.00"); + + // Mutate the template's criteria AFTER the appraisal was opened. + template.criteria![0].weight = "999.00"; + template.criteria![0].name = { am: "changed", en: "changed" }; + + // The rows already saved for the opened appraisal are untouched — they + // were copied, not a live reference into the mutable template. + expect(qualityRating.weight).toBe("50.00"); + + // And re-running openCycle for the SAME employee/cycle is a no-op skip — + // the mutated template criteria never reach the existing appraisal. + appraisals.findOne.mockResolvedValue(appraisalFixture()); + const second = await service.openCycle("cycle-1", ["emp-1"], actorFixture()); + expect(second.skipped).toBe(1); + expect(second.created).toBe(0); + // Still only the original 2 rating rows were ever saved. + expect(managerCalls.ratingSaves).toHaveLength(2); + }); + + it("defaults to everyone employed with an HR profile, excluding TERMINATED/RETIRED", async () => { + dataSource.query.mockResolvedValue([{ employeeId: "emp-1" }, { employeeId: "emp-2" }]); + appraisals.findOne.mockResolvedValue(null); + employees.findByEmployeeId.mockResolvedValue({ managerEmployeeId: null }); + + const result = await service.openCycle("cycle-1", undefined, actorFixture()); + + expect(dataSource.query).toHaveBeenCalledWith( + expect.stringContaining("NOT IN ('TERMINATED','RETIRED')"), + ["org-1"], + ); + expect(result.created).toBe(2); + expect(employees.findByEmployeeId).toHaveBeenCalledWith("emp-1"); + expect(employees.findByEmployeeId).toHaveBeenCalledWith("emp-2"); + }); + + it("skips (without erroring) an employee who has no HR profile", async () => { + appraisals.findOne.mockResolvedValue(null); + employees.findByEmployeeId.mockResolvedValue(null); + + const result = await service.openCycle("cycle-1", ["emp-no-profile"], actorFixture()); + + expect(result.created).toBe(0); + expect(result.skipped).toBe(1); + expect(dataSource.transaction).not.toHaveBeenCalled(); + }); + + it("skips (without erroring) an employee who already has an appraisal for the cycle", async () => { + appraisals.findOne.mockResolvedValue(appraisalFixture()); + + const result = await service.openCycle("cycle-1", ["emp-1"], actorFixture()); + + expect(result.created).toBe(0); + expect(result.skipped).toBe(1); + expect(dataSource.transaction).not.toHaveBeenCalled(); + }); + + it("wraps each employee's work in its own transaction", async () => { + appraisals.findOne.mockResolvedValue(null); + employees.findByEmployeeId.mockResolvedValue({ managerEmployeeId: null }); + + await service.openCycle("cycle-1", ["emp-1", "emp-2", "emp-3"], actorFixture()); + + expect(dataSource.transaction).toHaveBeenCalledTimes(3); + }); + }); + + // ── submitManager ────────────────────────────────────────────────────────── + + describe("submitManager", () => { + const dto = { + ratings: [ + { code: "QUALITY", score: 9 }, + { code: "SPEED", score: 8 }, + ], + }; + + beforeEach(() => { + appraisals.findOne.mockResolvedValue(appraisalFixture()); + cycles.findOne.mockResolvedValue(cycleFixture()); + templates.findOne.mockResolvedValue(templateFixture()); + }); + + it("only accepts submission for a PENDING_MANAGER appraisal", async () => { + appraisals.findOne.mockResolvedValue( + appraisalFixture({ status: EAppraisalStatus.PENDING_SELF }), + ); + + await expect( + service.submitManager("appraisal-1", dto, actorFixture()), + ).rejects.toBeInstanceOf(BadRequestException); + }); + + it("refuses self-appraisal", async () => { + appraisals.findOne.mockResolvedValue( + appraisalFixture({ employeeId: "mgr-1", appraiserEmployeeId: "mgr-1" }), + ); + + await expect( + service.submitManager("appraisal-1", dto, actorFixture({ employeeId: "mgr-1" })), + ).rejects.toBeInstanceOf(ForbiddenException); + }); + + it("rejects submission missing a score for any criterion", async () => { + await expect( + service.submitManager( + "appraisal-1", + { ratings: [{ code: "QUALITY", score: 9 }] }, + actorFixture(), + ), + ).rejects.toThrow(/SPEED/); + await expect( + service.submitManager( + "appraisal-1", + { ratings: [{ code: "QUALITY", score: 9 }] }, + actorFixture(), + ), + ).rejects.toBeInstanceOf(BadRequestException); + }); + + it("uses the manager's score as the FINAL score — never averaged with the self-score", async () => { + // Self scores (4 and 3 out of 10, weights 50/50) would weight to 35. + // Manager scores (9 and 8 out of 10) weight to 85. An average would be 60. + await service.submitManager("appraisal-1", dto, actorFixture()); + + expect(managerCalls.appraisalUpdates).toHaveLength(1); + const update = managerCalls.appraisalUpdates[0].data; + expect(update.managerScore).toBe("85.00"); + expect(update.finalScore).toBe("85.00"); + expect(update.finalScore).not.toBe("60.00"); + }); + + it("moves to PENDING_ACKNOWLEDGEMENT when the template requires acknowledgement", async () => { + templates.findOne.mockResolvedValue( + templateFixture({ requiresAcknowledgement: true }), + ); + + await service.submitManager("appraisal-1", dto, actorFixture()); + + const update = managerCalls.appraisalUpdates[0].data; + expect(update.status).toBe(EAppraisalStatus.PENDING_ACKNOWLEDGEMENT); + expect(update.acknowledgedAt).toBeNull(); + }); + + it("moves straight to COMPLETED when the template does not require acknowledgement", async () => { + templates.findOne.mockResolvedValue( + templateFixture({ requiresAcknowledgement: false }), + ); + + await service.submitManager("appraisal-1", dto, actorFixture()); + + const update = managerCalls.appraisalUpdates[0].data; + expect(update.status).toBe(EAppraisalStatus.COMPLETED); + expect(update.acknowledgedAt).toBeInstanceOf(Date); + }); + }); + + // ── pure static functions ───────────────────────────────────────────────── + + describe("weightedScore (pure)", () => { + it("sums score/maxScore*weight across entries", () => { + const result = AppraisalService.weightedScore([ + { weight: 50, maxScore: 10, score: 5 }, + { weight: 50, maxScore: 10, score: 10 }, + ]); + expect(result).toBe(75); + }); + + it("clamps above 100 down to 100", () => { + const result = AppraisalService.weightedScore([ + { weight: 200, maxScore: 10, score: 10 }, + ]); + expect(result).toBe(100); + }); + + it("clamps below 0 up to 0", () => { + const result = AppraisalService.weightedScore([ + { weight: 100, maxScore: 10, score: -5 }, + ]); + expect(result).toBe(0); + }); + + it("skips an entry whose maxScore is zero or negative rather than dividing by it", () => { + const result = AppraisalService.weightedScore([ + { weight: 50, maxScore: 0, score: 5 }, + { weight: 50, maxScore: 10, score: 10 }, + ]); + expect(result).toBe(50); + }); + + it("rounds to 2 decimal places", () => { + const result = AppraisalService.weightedScore([ + { weight: 33.33, maxScore: 3, score: 1 }, + ]); + expect(result).toBe(Math.round((33.33 / 3) * 100) / 100); + }); + + it("returns 0 for an empty entry list", () => { + expect(AppraisalService.weightedScore([])).toBe(0); + }); + }); + + describe("bandFor (pure)", () => { + it("returns the highest matching band, regardless of input order", () => { + const shuffled: RatingBand[] = [ + { min: 50, label: { am: "", en: "Fair" } }, + { min: 90, label: { am: "", en: "Excellent" } }, + { min: 75, label: { am: "", en: "Good" } }, + ]; + expect(AppraisalService.bandFor(95, shuffled)).toBe("Excellent"); + expect(AppraisalService.bandFor(90, shuffled)).toBe("Excellent"); + expect(AppraisalService.bandFor(80, shuffled)).toBe("Good"); + expect(AppraisalService.bandFor(60, shuffled)).toBe("Fair"); + }); + + it("returns null when the score is below every band's minimum", () => { + expect(AppraisalService.bandFor(10, bandFixtures)).toBeNull(); + }); + + it("returns null for an empty band list", () => { + expect(AppraisalService.bandFor(100, [])).toBeNull(); + }); + }); + + describe("assertWeights (pure, private)", () => { + const assertWeights = (weights: number[]): void => + ( + AppraisalService as unknown as { + assertWeights(w: number[]): void; + } + ).assertWeights(weights); + + it("accepts weights that sum to exactly 100", () => { + expect(() => assertWeights([50, 50])).not.toThrow(); + expect(() => assertWeights([33.33, 33.33, 33.34])).not.toThrow(); + }); + + it("rejects weights that sum to anything other than exactly 100", () => { + expect(() => assertWeights([50, 49.99])).toThrow(BadRequestException); + expect(() => assertWeights([50, 50.01])).toThrow(BadRequestException); + }); + + it("rejects an empty criteria list", () => { + expect(() => assertWeights([])).toThrow(BadRequestException); + }); + }); +}); diff --git a/apps/edr-hr-api/src/modules/attendance/services/attendance-day.service.spec.ts b/apps/edr-hr-api/src/modules/attendance/services/attendance-day.service.spec.ts new file mode 100644 index 000000000..7bfa6b1b3 --- /dev/null +++ b/apps/edr-hr-api/src/modules/attendance/services/attendance-day.service.spec.ts @@ -0,0 +1,264 @@ +import { AttendanceDayService, atTime, type DayContext } from "./attendance-day.service"; +import { EAttendanceStatus } from "../entities/attendance-record.entity"; +import type { WorkSchedule } from "../entities/work-schedule.entity"; + +/** + * Both methods under test are `static` and side-effect free — no service + * instantiation, no mocks. Every Date fixture goes through the same `atTime` + * helper the source uses, so local-timezone parsing is consistent on both + * sides of every subtraction. + */ +describe("AttendanceDayService — pure day math", () => { + const schedule = (over: Partial = {}): WorkSchedule => + ({ + id: "sched-1", + startTime: "08:30", + endTime: "17:30", + breakMinutes: 60, + gracePeriodMinutes: 10, + minMinutesFullDay: 420, + minMinutesHalfDay: 210, + crossesMidnight: false, + workingDays: null, + ...over, + }) as WorkSchedule; + + describe("measure", () => { + it("returns all zeros when there is no check-out yet", () => { + const result = AttendanceDayService.measure( + schedule(), + "2026-03-10", + atTime("2026-03-10", "08:30"), + null, + ); + expect(result).toEqual({ workedMinutes: 0, lateMinutes: 0, earlyLeaveMinutes: 0 }); + }); + + it("with no schedule, worked minutes are the raw gross — no break, no lateness", () => { + const result = AttendanceDayService.measure( + null, + "2026-03-10", + atTime("2026-03-10", "09:00"), + atTime("2026-03-10", "17:00"), + ); + expect(result).toEqual({ workedMinutes: 480, lateMinutes: 0, earlyLeaveMinutes: 0 }); + }); + + it("an ordinary on-time day: break deducted, nothing late or early", () => { + const result = AttendanceDayService.measure( + schedule(), + "2026-03-10", + atTime("2026-03-10", "08:30"), + atTime("2026-03-10", "17:30"), + ); + // gross 540 - break 60 = 480; checkIn == scheduledStart; checkOut == scheduledEnd + expect(result).toEqual({ workedMinutes: 480, lateMinutes: 0, earlyLeaveMinutes: 0 }); + }); + + it("deducts the break only when the shift is long enough to have taken one", () => { + // gross = 45 minutes, which does not exceed the 60-minute break — so + // nothing is deducted, and workedMinutes is NOT clamped to zero. + const result = AttendanceDayService.measure( + schedule(), + "2026-03-10", + atTime("2026-03-10", "08:30"), + atTime("2026-03-10", "09:15"), + ); + expect(result.workedMinutes).toBe(45); + }); + + it("charges lateness only past the grace period, and reports the full overage", () => { + // 15 minutes late against a 10-minute grace period. + const result = AttendanceDayService.measure( + schedule(), + "2026-03-10", + atTime("2026-03-10", "08:45"), + atTime("2026-03-10", "17:30"), + ); + expect(result.lateMinutes).toBe(15); + }); + + it("grace is all-or-nothing: inside it, nothing is recorded at all", () => { + // 8 minutes late, inside the 10-minute grace period. + const result = AttendanceDayService.measure( + schedule(), + "2026-03-10", + atTime("2026-03-10", "08:38"), + atTime("2026-03-10", "17:30"), + ); + expect(result.lateMinutes).toBe(0); + }); + + it("grace is all-or-nothing: exactly AT the grace boundary is still not late (strictly greater-than)", () => { + // 10 minutes late == a 10-minute grace period exactly. + const result = AttendanceDayService.measure( + schedule({ gracePeriodMinutes: 10 }), + "2026-03-10", + atTime("2026-03-10", "08:40"), + atTime("2026-03-10", "17:30"), + ); + expect(result.lateMinutes).toBe(0); + }); + + it("grace is all-or-nothing: one minute past the boundary charges the FULL lateness, not just the excess", () => { + // 11 minutes late — one minute past a 10-minute grace period. If grace were + // partial, this would report 1 minute; it must report the full 11. + const result = AttendanceDayService.measure( + schedule({ gracePeriodMinutes: 10 }), + "2026-03-10", + atTime("2026-03-10", "08:41"), + atTime("2026-03-10", "17:30"), + ); + expect(result.lateMinutes).toBe(11); + }); + + it("checking in early is never lateness, however early", () => { + const result = AttendanceDayService.measure( + schedule(), + "2026-03-10", + atTime("2026-03-10", "07:00"), + atTime("2026-03-10", "17:30"), + ); + expect(result.lateMinutes).toBe(0); + }); + + it("charges an early leave in minutes short of the scheduled end", () => { + const result = AttendanceDayService.measure( + schedule(), + "2026-03-10", + atTime("2026-03-10", "08:30"), + atTime("2026-03-10", "17:00"), + ); + expect(result.earlyLeaveMinutes).toBe(30); + }); + + it("staying past the scheduled end is never an early leave", () => { + const result = AttendanceDayService.measure( + schedule(), + "2026-03-10", + atTime("2026-03-10", "08:30"), + atTime("2026-03-10", "19:00"), + ); + expect(result.earlyLeaveMinutes).toBe(0); + }); + + it("a night shift pushes the scheduled end 24h forward before comparing", () => { + const nightSchedule = schedule({ + startTime: "22:00", + endTime: "06:00", + breakMinutes: 30, + crossesMidnight: true, + }); + // On time both ends — checked against the +24h scheduled end, not 06:00 + // on the shift's own start date. + const result = AttendanceDayService.measure( + nightSchedule, + "2026-03-10", + atTime("2026-03-10", "22:00"), + atTime("2026-03-11", "06:00"), + ); + // gross = 8h = 480; break 30 (480 > 30) deducted -> 450 + expect(result).toEqual({ workedMinutes: 450, lateMinutes: 0, earlyLeaveMinutes: 0 }); + }); + + it("a night shift leaving an hour early is charged against the pushed-forward end", () => { + const nightSchedule = schedule({ + startTime: "22:00", + endTime: "06:00", + breakMinutes: 30, + crossesMidnight: true, + }); + const result = AttendanceDayService.measure( + nightSchedule, + "2026-03-10", + atTime("2026-03-10", "22:00"), + atTime("2026-03-11", "05:00"), + ); + expect(result.earlyLeaveMinutes).toBe(60); + }); + }); + + describe("statusForUnworkedDay", () => { + const context = (over: Partial = {}): DayContext => ({ + workDate: "2026-03-10", + isWorkingDay: true, + isHoliday: false, + holidayName: null, + leave: null, + ...over, + }); + + it("leave wins over everything else, even a holiday on the same day", () => { + const result = AttendanceDayService.statusForUnworkedDay( + context({ + isHoliday: true, + holidayName: "Meskel", + leave: { requestId: "req-1", leaveTypeCode: "ANNUAL", isHalfDay: false }, + }), + ); + expect(result).toEqual({ + status: EAttendanceStatus.ON_LEAVE, + leaveRequestId: "req-1", + notes: "ANNUAL leave", + }); + }); + + it("a holiday wins over a rest day when there is no leave", () => { + const result = AttendanceDayService.statusForUnworkedDay( + context({ isWorkingDay: false, isHoliday: true, holidayName: "Meskel" }), + ); + expect(result).toEqual({ + status: EAttendanceStatus.HOLIDAY, + leaveRequestId: null, + notes: "Meskel", + }); + }); + + it("a holiday wins even on an otherwise-working day (not just over a rest day)", () => { + const result = AttendanceDayService.statusForUnworkedDay( + context({ isWorkingDay: true, isHoliday: true, holidayName: "Adwa Day" }), + ); + expect(result.status).toBe(EAttendanceStatus.HOLIDAY); + }); + + it("leave wins over a rest day too, not just over a holiday", () => { + const result = AttendanceDayService.statusForUnworkedDay( + context({ + isWorkingDay: false, + isHoliday: false, + leave: { requestId: "req-2", leaveTypeCode: "SICK", isHalfDay: false }, + }), + ); + expect(result.status).toBe(EAttendanceStatus.ON_LEAVE); + expect(result.leaveRequestId).toBe("req-2"); + }); + + it("half-day leave still wins this precedence — the half-day distinction belongs to checkIn, not this method", () => { + const result = AttendanceDayService.statusForUnworkedDay( + context({ + isHoliday: true, + leave: { requestId: "req-3", leaveTypeCode: "ANNUAL", isHalfDay: true }, + }), + ); + expect(result.status).toBe(EAttendanceStatus.ON_LEAVE); + }); + + it("a non-working day with no leave or holiday is a rest day", () => { + const result = AttendanceDayService.statusForUnworkedDay(context({ isWorkingDay: false })); + expect(result).toEqual({ + status: EAttendanceStatus.REST_DAY, + leaveRequestId: null, + notes: null, + }); + }); + + it("a working day with nothing excusing it is absent", () => { + const result = AttendanceDayService.statusForUnworkedDay(context()); + expect(result).toEqual({ + status: EAttendanceStatus.ABSENT, + leaveRequestId: null, + notes: null, + }); + }); + }); +}); diff --git a/apps/edr-hr-api/src/modules/attendance/services/attendance.service.spec.ts b/apps/edr-hr-api/src/modules/attendance/services/attendance.service.spec.ts new file mode 100644 index 000000000..7841e33a0 --- /dev/null +++ b/apps/edr-hr-api/src/modules/attendance/services/attendance.service.spec.ts @@ -0,0 +1,497 @@ +import { BadRequestException, ConflictException } from "@nestjs/common"; + +import { AttendanceService } from "./attendance.service"; +import { EAttendanceSource, EAttendanceStatus } from "../entities/attendance-record.entity"; +import type { ActorContext } from "../../employees/employees.service"; + +/** + * Mirrors the freight suite's convention: plain `new AttendanceService(...)` + * with hand-built fakes passed positionally, no `Test.createTestingModule`. + * `AttendanceDayService.measure`/`.statusForWorkedDay` are called as static + * methods directly on the real imported class inside the service, so they run + * for real here — only `this.days.contextFor` (an instance method) is mocked. + */ + +const ORG_ID = "org-1"; +const EMPLOYEE_ID = "emp-1"; + +const actor: ActorContext = { + employeeId: "emp-manager", + userId: "user-1", + organizationId: ORG_ID, + isSuperAdmin: false, +}; + +function employee(over: Record = {}) { + return { + id: EMPLOYEE_ID, + name: { en: "Test Employee", am: "ሙከራ" }, + status: "ACTIVE", + isCurrent: true, + unitId: "unit-1", + organizationId: ORG_ID, + userId: "iam-user-1", + username: "test.employee", + email: "test@example.com", + phoneNumber: null, + createdAt: new Date(), + ...over, + }; +} + +function defaultContext(over: Record = {}) { + return { + workDate: "2026-08-26", + isWorkingDay: true, + isHoliday: false, + holidayName: null, + leave: null, + ...over, + }; +} + +function build(over: { + attendance?: Record; + schedules?: Record; + days?: Record; + employees?: Record; + iamDirectory?: Record; +} = {}) { + const attendance = { + findForDate: jest.fn().mockResolvedValue(null), + create: jest.fn().mockImplementation((data: Record) => + Promise.resolve({ id: "record-1", ...data }), + ), + update: jest.fn().mockImplementation((_id: string, data: Record) => + Promise.resolve({ id: "record-1", ...data }), + ), + findRange: jest.fn().mockResolvedValue([]), + findPage: jest.fn().mockResolvedValue([[], 0]), + summarize: jest.fn().mockResolvedValue({}), + ...over.attendance, + }; + const schedules = { + resolveFor: jest.fn().mockResolvedValue(null), + ...over.schedules, + }; + const days = { + contextFor: jest.fn().mockResolvedValue(defaultContext()), + ...over.days, + }; + const employees = { + findByEmployeeId: jest.fn().mockResolvedValue(null), + ...over.employees, + }; + const iamDirectory = { + requireEmployee: jest.fn().mockResolvedValue(employee()), + ...over.iamDirectory, + }; + + const service = new AttendanceService( + attendance as never, + schedules as never, + days as never, + employees as never, + iamDirectory as never, + ); + + return { service, attendance, schedules, days, employees, iamDirectory }; +} + +describe("AttendanceService.checkIn", () => { + it("computes workDate as the calendar date of the check-in instant (UTC slice)", async () => { + const { service, attendance, days } = build(); + // 21:00 UTC on Aug 25 — still Aug 25 in UTC terms, which is what + // `at.toISOString().slice(0, 10)` yields regardless of server-local zone. + const at = new Date("2026-08-25T21:00:00.000Z"); + + await service.checkIn(EMPLOYEE_ID, at, EAttendanceSource.WEB, actor); + + expect(attendance.findForDate).toHaveBeenCalledWith(EMPLOYEE_ID, "2026-08-25"); + expect(days.contextFor).toHaveBeenCalledWith( + EMPLOYEE_ID, + ORG_ID, + "2026-08-25", + null, + ); + }); + + it("computes workDate correctly for an instant just past midnight UTC (rolls to the next date)", async () => { + const { service, attendance } = build(); + const at = new Date("2026-08-26T00:30:00.000Z"); + + await service.checkIn(EMPLOYEE_ID, at, EAttendanceSource.WEB, actor); + + expect(attendance.findForDate).toHaveBeenCalledWith(EMPLOYEE_ID, "2026-08-26"); + }); + + it("refuses a second check-in on the same day", async () => { + const { service } = build({ + attendance: { + findForDate: jest.fn().mockResolvedValue({ + id: "record-1", + checkIn: new Date("2026-08-26T05:30:00.000Z"), + checkOut: null, + }), + }, + }); + + await expect( + service.checkIn( + EMPLOYEE_ID, + new Date("2026-08-26T06:00:00.000Z"), + EAttendanceSource.WEB, + actor, + ), + ).rejects.toBeInstanceOf(ConflictException); + }); + + it("refuses check-in on a day covered by approved, non-half-day leave", async () => { + const { service, attendance } = build({ + days: { + contextFor: jest.fn().mockResolvedValue( + defaultContext({ + leave: { requestId: "leave-1", leaveTypeCode: "ANNUAL", isHalfDay: false }, + }), + ), + }, + }); + + await expect( + service.checkIn( + EMPLOYEE_ID, + new Date("2026-08-26T05:00:00.000Z"), + EAttendanceSource.WEB, + actor, + ), + ).rejects.toBeInstanceOf(ConflictException); + expect(attendance.create).not.toHaveBeenCalled(); + }); + + it("allows check-in on a day covered by approved HALF-DAY leave", async () => { + const { service, attendance } = build({ + days: { + contextFor: jest.fn().mockResolvedValue( + defaultContext({ + leave: { requestId: "leave-1", leaveTypeCode: "ANNUAL", isHalfDay: true }, + }), + ), + }, + }); + + await service.checkIn( + EMPLOYEE_ID, + new Date("2026-08-26T05:00:00.000Z"), + EAttendanceSource.WEB, + actor, + ); + + expect(attendance.create).toHaveBeenCalled(); + }); + + it("creates a new PRESENT record with lateMinutes 0 at check-in time — lateness is only finalized on check-out", async () => { + const { service, attendance } = build({ + schedules: { + resolveFor: jest.fn().mockResolvedValue({ + id: "sched-1", + startTime: "08:30", + endTime: "17:30", + breakMinutes: 60, + gracePeriodMinutes: 10, + minMinutesFullDay: 420, + minMinutesHalfDay: 210, + crossesMidnight: false, + }), + }, + }); + + // 10:00 local — well past a 08:30 start, would normally be "late". + const at = new Date("2026-08-26T10:00:00.000Z"); + const result = await service.checkIn(EMPLOYEE_ID, at, EAttendanceSource.WEB, actor); + + expect(attendance.create).toHaveBeenCalledWith( + expect.objectContaining({ + organizationId: ORG_ID, + employeeId: EMPLOYEE_ID, + workDate: "2026-08-26", + checkIn: at, + status: EAttendanceStatus.PRESENT, + lateMinutes: 0, + source: EAttendanceSource.WEB, + }), + ); + expect(result.lateMinutes).toBe(0); + }); + + it("re-records an existing check-in-less row rather than creating a duplicate", async () => { + const { service, attendance } = build({ + attendance: { + findForDate: jest.fn().mockResolvedValue({ + id: "record-1", + checkIn: null, + checkOut: null, + notes: "pre-existing note", + }), + }, + }); + const at = new Date("2026-08-26T05:00:00.000Z"); + + await service.checkIn(EMPLOYEE_ID, at, EAttendanceSource.WEB, actor, "arrived late"); + + expect(attendance.update).toHaveBeenCalledWith( + "record-1", + expect.objectContaining({ + checkIn: at, + status: EAttendanceStatus.PRESENT, + lateMinutes: 0, + notes: "arrived late", + }), + ); + expect(attendance.create).not.toHaveBeenCalled(); + }); + + it("passes the notes through as null, not undefined, when none is given for a new record", async () => { + const { service, attendance } = build(); + + await service.checkIn( + EMPLOYEE_ID, + new Date("2026-08-26T05:00:00.000Z"), + EAttendanceSource.WEB, + actor, + ); + + expect(attendance.create).toHaveBeenCalledWith( + expect.objectContaining({ notes: null }), + ); + }); +}); + +describe("AttendanceService.checkOut", () => { + const schedule = (over: Record = {}) => ({ + id: "sched-1", + startTime: "08:30", + endTime: "17:30", + breakMinutes: 60, + gracePeriodMinutes: 10, + minMinutesFullDay: 420, + minMinutesHalfDay: 210, + crossesMidnight: false, + ...over, + }); + + it("closes today's own open record when one exists — no previous-day lookup needed", async () => { + const todayRecord = { + id: "record-today", + workDate: "2026-08-26", + checkIn: new Date("2026-08-26T05:30:00.000Z"), + checkOut: null, + workSchedule: schedule(), + }; + const findForDate = jest.fn().mockResolvedValue(todayRecord); + const { service, attendance } = build({ attendance: { findForDate } }); + + await service.checkOut( + EMPLOYEE_ID, + new Date("2026-08-26T13:00:00.000Z"), + EAttendanceSource.WEB, + actor, + ); + + // Only the today lookup ran — the previous-day fallback never fired. + expect(findForDate).toHaveBeenCalledTimes(1); + expect(findForDate).toHaveBeenCalledWith(EMPLOYEE_ID, "2026-08-26"); + expect(attendance.update).toHaveBeenCalledWith( + "record-today", + expect.objectContaining({ checkOut: new Date("2026-08-26T13:00:00.000Z") }), + ); + }); + + it("falls back to the immediately preceding day's open record on a night shift (crossesMidnight)", async () => { + const previousDayRecord = { + id: "record-prev", + workDate: "2026-08-25", + checkIn: new Date("2026-08-25T20:00:00.000Z"), + checkOut: null, + workSchedule: schedule({ crossesMidnight: true }), + }; + const findForDate = jest.fn().mockImplementation((_empId: string, workDate: string) => + Promise.resolve(workDate === "2026-08-25" ? previousDayRecord : null), + ); + const { service, attendance } = build({ attendance: { findForDate } }); + + const at = new Date("2026-08-26T02:00:00.000Z"); + await service.checkOut(EMPLOYEE_ID, at, EAttendanceSource.WEB, actor); + + expect(findForDate).toHaveBeenCalledWith(EMPLOYEE_ID, "2026-08-26"); + expect(findForDate).toHaveBeenCalledWith(EMPLOYEE_ID, "2026-08-25"); + expect(attendance.update).toHaveBeenCalledWith( + "record-prev", + expect.objectContaining({ checkOut: at }), + ); + }); + + it("does NOT fall back to the previous day when that schedule does not cross midnight", async () => { + const previousDayRecord = { + id: "record-prev", + workDate: "2026-08-25", + checkIn: new Date("2026-08-25T20:00:00.000Z"), + checkOut: null, + // Day schedule, not a night shift — the open record from yesterday is + // just a missed check-out, not evidence of a shift still running. + workSchedule: schedule({ crossesMidnight: false }), + }; + const findForDate = jest.fn().mockImplementation((_empId: string, workDate: string) => + Promise.resolve(workDate === "2026-08-25" ? previousDayRecord : null), + ); + const { service } = build({ attendance: { findForDate } }); + + await expect( + service.checkOut( + EMPLOYEE_ID, + new Date("2026-08-26T02:00:00.000Z"), + EAttendanceSource.WEB, + actor, + ), + ).rejects.toBeInstanceOf(BadRequestException); + }); + + it("does NOT reach past the immediately preceding day even if an older open record exists", async () => { + // Only "two days ago" has an open, crosses-midnight record; yesterday and + // today are both empty. findOpenPreviousDay only ever looks at yesterday. + const twoDaysAgoRecord = { + id: "record-old", + workDate: "2026-08-24", + checkIn: new Date("2026-08-24T20:00:00.000Z"), + checkOut: null, + workSchedule: schedule({ crossesMidnight: true }), + }; + const findForDate = jest.fn().mockImplementation((_empId: string, workDate: string) => + Promise.resolve(workDate === "2026-08-24" ? twoDaysAgoRecord : null), + ); + const { service } = build({ attendance: { findForDate } }); + + await expect( + service.checkOut( + EMPLOYEE_ID, + new Date("2026-08-26T02:00:00.000Z"), + EAttendanceSource.WEB, + actor, + ), + ).rejects.toBeInstanceOf(BadRequestException); + // Confirms it only ever asked for today (08-26) and yesterday (08-25). + expect(findForDate).toHaveBeenCalledWith(EMPLOYEE_ID, "2026-08-26"); + expect(findForDate).toHaveBeenCalledWith(EMPLOYEE_ID, "2026-08-25"); + expect(findForDate).not.toHaveBeenCalledWith(EMPLOYEE_ID, "2026-08-24"); + }); + + it("does not fall back to a previous-day record that has already been checked out", async () => { + const previousDayRecord = { + id: "record-prev", + workDate: "2026-08-25", + checkIn: new Date("2026-08-25T20:00:00.000Z"), + checkOut: new Date("2026-08-26T04:00:00.000Z"), + workSchedule: schedule({ crossesMidnight: true }), + }; + const findForDate = jest.fn().mockImplementation((_empId: string, workDate: string) => + Promise.resolve(workDate === "2026-08-25" ? previousDayRecord : null), + ); + const { service } = build({ attendance: { findForDate } }); + + await expect( + service.checkOut( + EMPLOYEE_ID, + new Date("2026-08-26T05:00:00.000Z"), + EAttendanceSource.WEB, + actor, + ), + ).rejects.toBeInstanceOf(BadRequestException); + }); + + it("refuses when there is no check-in to close at all", async () => { + const { service } = build({ attendance: { findForDate: jest.fn().mockResolvedValue(null) } }); + + await expect( + service.checkOut( + EMPLOYEE_ID, + new Date("2026-08-26T13:00:00.000Z"), + EAttendanceSource.WEB, + actor, + ), + ).rejects.toBeInstanceOf(BadRequestException); + }); + + it("refuses when already checked out", async () => { + const record = { + id: "record-today", + workDate: "2026-08-26", + checkIn: new Date("2026-08-26T05:30:00.000Z"), + checkOut: new Date("2026-08-26T13:00:00.000Z"), + workSchedule: schedule(), + }; + const { service } = build({ + attendance: { findForDate: jest.fn().mockResolvedValue(record) }, + }); + + await expect( + service.checkOut( + EMPLOYEE_ID, + new Date("2026-08-26T14:00:00.000Z"), + EAttendanceSource.WEB, + actor, + ), + ).rejects.toBeInstanceOf(ConflictException); + }); + + it("refuses a check-out timestamped before the check-in", async () => { + const record = { + id: "record-today", + workDate: "2026-08-26", + checkIn: new Date("2026-08-26T05:30:00.000Z"), + checkOut: null, + workSchedule: schedule(), + }; + const { service } = build({ + attendance: { findForDate: jest.fn().mockResolvedValue(record) }, + }); + + await expect( + service.checkOut( + EMPLOYEE_ID, + new Date("2026-08-26T05:00:00.000Z"), + EAttendanceSource.WEB, + actor, + ), + ).rejects.toBeInstanceOf(BadRequestException); + }); + + it("settles worked/late/early minutes and status from the real check-in through AttendanceDayService.measure", async () => { + const record = { + id: "record-today", + workDate: "2026-08-26", + checkIn: new Date("2026-08-26T05:30:00.000Z"), // 08:30 local (EAT), on time + checkOut: null, + workSchedule: schedule(), + }; + const { service, attendance } = build({ + attendance: { findForDate: jest.fn().mockResolvedValue(record) }, + }); + + // Exactly on-schedule end -> full day, present, no lateness/earliness. + await service.checkOut( + EMPLOYEE_ID, + new Date("2026-08-26T14:30:00.000Z"), // 17:30 local (EAT) + EAttendanceSource.WEB, + actor, + ); + + expect(attendance.update).toHaveBeenCalledWith( + "record-today", + expect.objectContaining({ + workedMinutes: 480, // 9h gross - 60min break + lateMinutes: 0, + earlyLeaveMinutes: 0, + status: EAttendanceStatus.PRESENT, + }), + ); + }); +}); diff --git a/apps/edr-hr-api/src/modules/attendance/services/regularizations.service.spec.ts b/apps/edr-hr-api/src/modules/attendance/services/regularizations.service.spec.ts new file mode 100644 index 000000000..3b1c40702 --- /dev/null +++ b/apps/edr-hr-api/src/modules/attendance/services/regularizations.service.spec.ts @@ -0,0 +1,306 @@ +import { BadRequestException, ForbiddenException } from "@nestjs/common"; + +import { RegularizationsService } from "./regularizations.service"; +import { + AttendanceRecord, + EAttendanceSource, + EAttendanceStatus, +} from "../entities/attendance-record.entity"; +import { + AttendanceRegularization, + ERegularizationStatus, +} from "../entities/attendance-regularization.entity"; +import type { ActorContext } from "../../employees/employees.service"; + +/** + * Mirrors the freight suite's convention: plain `new RegularizationsService(...)` + * with hand-built fakes, no `Test.createTestingModule`. `dataSource.transaction` + * runs its callback against a fake manager whose `getRepository` dispatches on + * the entity class, just like the real EntityManager does. + */ + +const ORG_ID = "org-1"; +const APPROVER_EMPLOYEE_ID = "emp-manager"; +const REQUESTER_EMPLOYEE_ID = "emp-1"; + +const approverActor: ActorContext = { + employeeId: APPROVER_EMPLOYEE_ID, + userId: "user-manager", + organizationId: ORG_ID, + isSuperAdmin: false, +}; + +function pendingRequest(over: Record = {}): AttendanceRegularization { + return { + id: "reg-1", + organizationId: ORG_ID, + employeeId: REQUESTER_EMPLOYEE_ID, + attendanceRecordId: "record-1", + workDate: "2026-08-26", + requestedCheckIn: new Date("2026-08-26T05:30:00.000Z"), + requestedCheckOut: new Date("2026-08-26T14:30:00.000Z"), + originalCheckIn: null, + originalCheckOut: null, + originalStatus: null, + reason: "Forgot to punch in", + status: ERegularizationStatus.SUBMITTED, + approverEmployeeId: APPROVER_EMPLOYEE_ID, + decidedByEmployeeId: null, + decidedAt: null, + decisionNote: null, + createdBy: "user-1", + updatedBy: null, + ...over, + } as AttendanceRegularization; +} + +function build(over: { + request?: AttendanceRegularization; + existingRecord?: Record | null; + schedule?: Record | null; +} = {}) { + const request = over.request ?? pendingRequest(); + const existingRecord = over.existingRecord ?? null; + + const regularizationsRepo = { + findOne: jest.fn().mockResolvedValue(request), + save: jest.fn(), + create: jest.fn((data: Record) => data), + }; + + const attendanceRecordsManagerRepo = { + findOne: jest.fn().mockResolvedValue(existingRecord), + update: jest.fn().mockResolvedValue(undefined), + save: jest.fn().mockImplementation((data: Record) => + Promise.resolve({ id: "record-new", ...data }), + ), + create: jest.fn((data: Record) => data), + }; + + const attendanceRegularizationsManagerRepo = { + update: jest.fn().mockResolvedValue(undefined), + }; + + const manager = { + getRepository: jest.fn((entity: unknown) => { + if (entity === AttendanceRecord) return attendanceRecordsManagerRepo; + if (entity === AttendanceRegularization) return attendanceRegularizationsManagerRepo; + throw new Error(`Unexpected getRepository(${String(entity)})`); + }), + }; + + const dataSource = { + transaction: jest.fn().mockImplementation((cb: (mg: unknown) => unknown) => cb(manager)), + }; + + const schedules = { + resolveFor: jest.fn().mockResolvedValue(over.schedule ?? null), + }; + + const service = new RegularizationsService( + dataSource as never, + regularizationsRepo as never, + {} as never, // attendance (AttendanceRepository) — approve() never touches it directly + schedules as never, + {} as never, // employees + {} as never, // iamDirectory + ); + + return { + service, + request, + regularizationsRepo, + attendanceRecordsManagerRepo, + attendanceRegularizationsManagerRepo, + dataSource, + }; +} + +describe("RegularizationsService.approve", () => { + it("refuses to approve one's own attendance correction", async () => { + const { service } = build({ + request: pendingRequest({ employeeId: APPROVER_EMPLOYEE_ID }), + }); + + await expect( + service.approve("reg-1", undefined, approverActor), + ).rejects.toBeInstanceOf(ForbiddenException); + }); + + it("refuses to decide a request that is not SUBMITTED", async () => { + const { service, dataSource } = build({ + request: pendingRequest({ status: ERegularizationStatus.APPROVED }), + }); + + await expect( + service.approve("reg-1", undefined, approverActor), + ).rejects.toBeInstanceOf(BadRequestException); + expect(dataSource.transaction).not.toHaveBeenCalled(); + }); + + it("snapshots the ORIGINAL check-in/out/status onto the request BEFORE the attendance record is overwritten", async () => { + const existingRecord = { + id: "record-1", + employeeId: REQUESTER_EMPLOYEE_ID, + workDate: "2026-08-26", + checkIn: new Date("2026-08-26T06:00:00.000Z"), // the pre-correction punch + checkOut: new Date("2026-08-26T15:00:00.000Z"), + status: EAttendanceStatus.LATE, // the pre-correction status + }; + const request = pendingRequest({ + requestedCheckIn: new Date("2026-08-26T05:30:00.000Z"), // the corrected punch + requestedCheckOut: new Date("2026-08-26T14:30:00.000Z"), + }); + const { service, attendanceRegularizationsManagerRepo, attendanceRecordsManagerRepo } = + build({ request, existingRecord }); + + await service.approve("reg-1", "Confirmed with security log", approverActor); + + // The snapshot on the regularization row is the value BEFORE this approval + // — evidence that survives the overwrite about to happen below. + expect(attendanceRegularizationsManagerRepo.update).toHaveBeenCalledWith( + "reg-1", + expect.objectContaining({ + status: ERegularizationStatus.APPROVED, + originalCheckIn: existingRecord.checkIn, + originalCheckOut: existingRecord.checkOut, + originalStatus: EAttendanceStatus.LATE, + decisionNote: "Confirmed with security log", + decidedByEmployeeId: APPROVER_EMPLOYEE_ID, + }), + ); + + // Meanwhile the attendance record itself is overwritten with the REQUESTED + // (corrected) values, not the original ones. + expect(attendanceRecordsManagerRepo.update).toHaveBeenCalledWith( + "record-1", + expect.objectContaining({ + checkIn: request.requestedCheckIn, + checkOut: request.requestedCheckOut, + isRegularized: true, + }), + ); + }); + + it("re-derives status and measured minutes via AttendanceDayService.measure against the requested punches", async () => { + const existingRecord = { + id: "record-1", + employeeId: REQUESTER_EMPLOYEE_ID, + workDate: "2026-08-26", + checkIn: null, + checkOut: null, + status: EAttendanceStatus.ABSENT, + }; + const schedule = { + id: "sched-1", + startTime: "08:30", + endTime: "17:30", + breakMinutes: 60, + gracePeriodMinutes: 10, + minMinutesFullDay: 420, + minMinutesHalfDay: 210, + crossesMidnight: false, + }; + const request = pendingRequest({ + // Schedule times are parsed as local (EAT, UTC+3) via atTime() — these + // UTC instants are 08:30/17:30 local, matching the 08:30-17:30 schedule. + requestedCheckIn: new Date("2026-08-26T05:30:00.000Z"), // on time + requestedCheckOut: new Date("2026-08-26T14:30:00.000Z"), // full day + }); + const { service, attendanceRecordsManagerRepo } = build({ + request, + existingRecord, + schedule, + }); + + await service.approve("reg-1", undefined, approverActor); + + expect(attendanceRecordsManagerRepo.update).toHaveBeenCalledWith( + "record-1", + expect.objectContaining({ + workedMinutes: 480, // 9h gross - 60min break + lateMinutes: 0, + earlyLeaveMinutes: 0, + status: EAttendanceStatus.PRESENT, + workScheduleId: "sched-1", + source: EAttendanceSource.MANUAL, + }), + ); + }); + + it("requires at least a resulting check-in — a check-out-only request with nothing on record is refused", async () => { + const request = pendingRequest({ + requestedCheckIn: null, + requestedCheckOut: new Date("2026-08-26T14:30:00.000Z"), + }); + const { service, attendanceRecordsManagerRepo } = build({ + request, + existingRecord: null, // nothing on the record to close + }); + + await expect( + service.approve("reg-1", undefined, approverActor), + ).rejects.toBeInstanceOf(BadRequestException); + expect(attendanceRecordsManagerRepo.update).not.toHaveBeenCalled(); + }); + + it("falls back to the EXISTING check-in when the request proposes only a check-out", async () => { + const existingRecord = { + id: "record-1", + employeeId: REQUESTER_EMPLOYEE_ID, + workDate: "2026-08-26", + checkIn: new Date("2026-08-26T08:30:00.000Z"), + checkOut: null, + status: EAttendanceStatus.PRESENT, + }; + const request = pendingRequest({ + requestedCheckIn: null, + requestedCheckOut: new Date("2026-08-26T17:30:00.000Z"), + }); + const { service, attendanceRecordsManagerRepo } = build({ request, existingRecord }); + + await service.approve("reg-1", undefined, approverActor); + + expect(attendanceRecordsManagerRepo.update).toHaveBeenCalledWith( + "record-1", + expect.objectContaining({ + checkIn: existingRecord.checkIn, + checkOut: request.requestedCheckOut, + }), + ); + }); + + it("creates a brand-new attendance record when none existed for the day", async () => { + const request = pendingRequest({ + attendanceRecordId: null, + requestedCheckIn: new Date("2026-08-26T08:30:00.000Z"), + requestedCheckOut: new Date("2026-08-26T17:30:00.000Z"), + }); + const { service, attendanceRecordsManagerRepo } = build({ + request, + existingRecord: null, + }); + + await service.approve("reg-1", undefined, approverActor); + + expect(attendanceRecordsManagerRepo.save).toHaveBeenCalledWith( + expect.objectContaining({ + organizationId: ORG_ID, + employeeId: REQUESTER_EMPLOYEE_ID, + workDate: "2026-08-26", + checkIn: request.requestedCheckIn, + checkOut: request.requestedCheckOut, + isRegularized: true, + }), + ); + expect(attendanceRecordsManagerRepo.update).not.toHaveBeenCalled(); + }); + + it("does everything inside one transaction", async () => { + const { service, dataSource } = build(); + + await service.approve("reg-1", undefined, approverActor); + + expect(dataSource.transaction).toHaveBeenCalledTimes(1); + }); +}); diff --git a/apps/edr-hr-api/src/modules/employees/employees.service.spec.ts b/apps/edr-hr-api/src/modules/employees/employees.service.spec.ts new file mode 100644 index 000000000..47bcef2ab --- /dev/null +++ b/apps/edr-hr-api/src/modules/employees/employees.service.spec.ts @@ -0,0 +1,138 @@ +import { ConflictException, ForbiddenException } from "@nestjs/common"; + +import { ActorContext, EmployeesService } from "./employees.service"; +import { EEmploymentState, EEmploymentType, EmployeeProfile } from "./entities/employee-profile.entity"; +import { CreateEmployeeProfileDto } from "./dto/create-employee-profile.dto"; + +const actorFixture = (over: Partial = {}): ActorContext => ({ + employeeId: null, + userId: "user-1", + organizationId: "org-1", + isSuperAdmin: false, + ...over, +}); + +const iamEmployeeFixture = (over: Record = {}) => ({ + id: "iam-emp-1", + organizationId: "org-1", + name: { am: "", en: "Jane Doe" }, + status: "ACTIVE", + isCurrent: true, + unitId: null, + username: "jane.doe", + email: "jane@example.com", + phoneNumber: "0911000000", + createdAt: new Date("2026-01-01T00:00:00Z"), + ...over, +}); + +const createDto = (over: Partial = {}): CreateEmployeeProfileDto => + ({ + employeeId: "iam-emp-1", + employmentType: EEmploymentType.PERMANENT, + hireDate: "2026-01-15", + ...over, + }) as CreateEmployeeProfileDto; + +describe("EmployeesService.create", () => { + let employeesRepository: { + findByEmployeeId: jest.Mock; + findByEmployeeNumber: jest.Mock; + findHighestEmployeeNumberSuffix: jest.Mock; + create: jest.Mock; + }; + let jobTitlesRepository: { findById: jest.Mock }; + let iamDirectory: { + requireEmployee: jest.Mock; + findEmployee: jest.Mock; + findLineManagerEmployeeId: jest.Mock; + }; + let iamOperations: { activateEmployee: jest.Mock; deactivateEmployee: jest.Mock }; + let service: EmployeesService; + + beforeEach(() => { + employeesRepository = { + findByEmployeeId: jest.fn().mockResolvedValue(null), + findByEmployeeNumber: jest.fn().mockResolvedValue(null), + findHighestEmployeeNumberSuffix: jest.fn().mockResolvedValue(5), + create: jest.fn().mockImplementation(async (data: Partial) => ({ + id: "profile-new", + managerEmployeeId: null, + ...data, + })), + }; + jobTitlesRepository = { findById: jest.fn() }; + iamDirectory = { + requireEmployee: jest.fn().mockResolvedValue(iamEmployeeFixture()), + findEmployee: jest.fn().mockResolvedValue(iamEmployeeFixture()), + findLineManagerEmployeeId: jest.fn().mockResolvedValue(null), + }; + iamOperations = { + activateEmployee: jest.fn().mockResolvedValue(undefined), + deactivateEmployee: jest.fn().mockResolvedValue(undefined), + }; + + service = new EmployeesService( + employeesRepository as never, + jobTitlesRepository as never, + iamDirectory as never, + iamOperations as never, + ); + }); + + it("creates a profile for an IAM employee who does not have one yet", async () => { + const result = await service.create(createDto(), actorFixture()); + + expect(employeesRepository.create).toHaveBeenCalledWith( + expect.objectContaining({ + employeeId: "iam-emp-1", + employeeNumber: "EMP-00006", + employmentState: EEmploymentState.ACTIVE, + isPensionEligible: true, + createdBy: "user-1", + updatedBy: "user-1", + }), + ); + expect(result.employeeId).toBe("iam-emp-1"); + expect(result.iam?.username).toBe("jane.doe"); + }); + + it("throws ConflictException when a profile already exists for that IAM employee id — the re-hire guard", async () => { + employeesRepository.findByEmployeeId.mockResolvedValue({ + id: "existing-profile-1", + employeeId: "iam-emp-1", + }); + + await expect(service.create(createDto(), actorFixture())).rejects.toBeInstanceOf( + ConflictException, + ); + await expect(service.create(createDto(), actorFixture())).rejects.toThrow( + /already has HR profile existing-profile-1/, + ); + expect(employeesRepository.create).not.toHaveBeenCalled(); + }); + + it("is a SEPARATE guard from recruitment's hireFromOffer re-hire check — it fires purely off employeesRepository.findByEmployeeId, independent of any offer/application state", async () => { + employeesRepository.findByEmployeeId.mockResolvedValue({ + id: "existing-profile-2", + employeeId: "iam-emp-1", + }); + + await expect(service.create(createDto(), actorFixture())).rejects.toBeInstanceOf( + ConflictException, + ); + expect(employeesRepository.findByEmployeeId).toHaveBeenCalledWith("iam-emp-1"); + }); + + it("refuses to create a profile for an IAM employee in another organization", async () => { + iamDirectory.requireEmployee.mockResolvedValue( + iamEmployeeFixture({ organizationId: "other-org" }), + ); + + await expect(service.create(createDto(), actorFixture())).rejects.toBeInstanceOf( + ForbiddenException, + ); + expect(employeesRepository.findByEmployeeId).not.toHaveBeenCalled(); + expect(employeesRepository.create).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/edr-hr-api/src/modules/job-positions/job-positions.repository.spec.ts b/apps/edr-hr-api/src/modules/job-positions/job-positions.repository.spec.ts new file mode 100644 index 000000000..3dd93f696 --- /dev/null +++ b/apps/edr-hr-api/src/modules/job-positions/job-positions.repository.spec.ts @@ -0,0 +1,131 @@ +import { JobPositionsRepository } from "./job-positions.repository"; + +/** + * `hr.job_positions` has no `organization_id` column of its own — tenancy is + * resolved via an `EXISTS` against `iam.positions`, never a direct column + * filter. A prior shipped bug referenced `position.organization_id` directly + * and 500'd for every non-super-admin actor; these tests pin the fix. + */ +function makeQueryBuilder(overrides: { + getManyAndCount?: [unknown[], number]; + getRawOne?: Record | undefined; +} = {}) { + const qb: Record = { + leftJoinAndSelect: jest.fn().mockReturnThis(), + where: jest.fn().mockReturnThis(), + andWhere: jest.fn().mockReturnThis(), + select: jest.fn().mockReturnThis(), + addSelect: jest.fn().mockReturnThis(), + orderBy: jest.fn().mockReturnThis(), + skip: jest.fn().mockReturnThis(), + take: jest.fn().mockReturnThis(), + getManyAndCount: jest.fn().mockResolvedValue(overrides.getManyAndCount ?? [[], 0]), + getRawOne: jest.fn().mockResolvedValue(overrides.getRawOne), + }; + return qb; +} + +describe("JobPositionsRepository", () => { + let repository: { createQueryBuilder: jest.Mock }; + let repo: JobPositionsRepository; + + beforeEach(() => { + repository = { createQueryBuilder: jest.fn() }; + repo = new JobPositionsRepository(repository as never); + }); + + describe("findPage", () => { + it("scopes tenancy via EXISTS against iam.positions, not a direct column filter", async () => { + const qb = makeQueryBuilder(); + repository.createQueryBuilder.mockReturnValue(qb); + + await repo.findPage("org-1", {}); + + const existsCall = qb.andWhere.mock.calls.find(([sql]) => + String(sql).includes("EXISTS (SELECT 1 FROM iam.positions p"), + ); + expect(existsCall).toBeDefined(); + const [sql, params] = existsCall!; + expect(sql).toContain("p.id = position.position_id"); + expect(sql).toContain("p.organization_id = :organizationId"); + expect(sql).not.toContain("position.organization_id"); + expect(params).toEqual({ organizationId: "org-1" }); + }); + + it("skips the tenancy filter entirely when organizationId is null (super admin)", async () => { + const qb = makeQueryBuilder(); + repository.createQueryBuilder.mockReturnValue(qb); + + await repo.findPage(null, {}); + + expect(qb.andWhere).not.toHaveBeenCalled(); + }); + + it("still applies the other filters alongside the EXISTS scope", async () => { + const qb = makeQueryBuilder(); + repository.createQueryBuilder.mockReturnValue(qb); + + await repo.findPage("org-1", { unitId: "unit-1", jobTitleId: "jt-1", isOpen: true, vacantOnly: true }); + + const clauses = qb.andWhere.mock.calls.map(([sql]) => String(sql)); + expect(clauses.some((c) => c.includes("iam.positions p"))).toBe(true); + expect(clauses).toContain("position.unit_id = :unitId"); + expect(clauses).toContain("position.job_title_id = :jobTitleId"); + expect(clauses).toContain("position.is_open = :isOpen"); + expect(clauses).toContain("position.current_count < position.budgeted_count"); + }); + + it("orders by the entity property path (createdAt), not a raw column", async () => { + const qb = makeQueryBuilder(); + repository.createQueryBuilder.mockReturnValue(qb); + + await repo.findPage("org-1", {}); + + expect(qb.orderBy).toHaveBeenCalledWith("position.createdAt", "DESC"); + }); + }); + + describe("headcountTotals", () => { + it("scopes tenancy via EXISTS against iam.positions, not a direct column filter", async () => { + const qb = makeQueryBuilder({ getRawOne: { budgeted: "10", current: "6" } }); + repository.createQueryBuilder.mockReturnValue(qb); + + const result = await repo.headcountTotals("org-1"); + + expect(qb.where).toHaveBeenCalledTimes(1); + const [sql, params] = qb.where.mock.calls[0]; + expect(sql).toContain("EXISTS (SELECT 1 FROM iam.positions p"); + expect(sql).toContain("p.organization_id = :organizationId"); + expect(sql).not.toContain("position.organization_id"); + expect(params).toEqual({ organizationId: "org-1" }); + expect(result).toEqual({ budgeted: 10, current: 6, vacant: 4 }); + }); + + it("skips the tenancy filter entirely when organizationId is null (super admin)", async () => { + const qb = makeQueryBuilder({ getRawOne: { budgeted: "10", current: "6" } }); + repository.createQueryBuilder.mockReturnValue(qb); + + await repo.headcountTotals(null); + + expect(qb.where).not.toHaveBeenCalled(); + }); + + it("never returns a negative vacant count when overstaffed", async () => { + const qb = makeQueryBuilder({ getRawOne: { budgeted: "5", current: "8" } }); + repository.createQueryBuilder.mockReturnValue(qb); + + const result = await repo.headcountTotals("org-1"); + + expect(result).toEqual({ budgeted: 5, current: 8, vacant: 0 }); + }); + + it("defaults to zero when the query returns no row", async () => { + const qb = makeQueryBuilder({ getRawOne: undefined }); + repository.createQueryBuilder.mockReturnValue(qb); + + const result = await repo.headcountTotals("org-1"); + + expect(result).toEqual({ budgeted: 0, current: 0, vacant: 0 }); + }); + }); +}); diff --git a/apps/edr-hr-api/src/modules/leave/services/leave-balances.service.spec.ts b/apps/edr-hr-api/src/modules/leave/services/leave-balances.service.spec.ts new file mode 100644 index 000000000..be1bb088a --- /dev/null +++ b/apps/edr-hr-api/src/modules/leave/services/leave-balances.service.spec.ts @@ -0,0 +1,484 @@ +import { BadRequestException, ConflictException, NotFoundException } from "@nestjs/common"; + +import { ActorContext } from "../../employees/employees.service"; +import { ELeaveYearBasis } from "../entities/leave-settings.entity"; +import { ELedgerEntryType } from "../entities/leave-ledger-entry.entity"; +import { EAccrualMethod, EGenderRestriction, LeaveType } from "../entities/leave-type.entity"; +import { LeaveBalancesService } from "./leave-balances.service"; + +/** + * Mirrors the freight suite's convention: plain `new ServiceClass(...)` with + * hand-built fakes passed positionally — no `Test.createTestingModule`. + */ + +function makeActor(overrides: Partial = {}): ActorContext { + return { + employeeId: "emp-1", + userId: "user-1", + organizationId: "org-1", + isSuperAdmin: false, + ...overrides, + }; +} + +function makeType(overrides: Partial = {}): LeaveType { + return { + id: "type-1", + organizationId: "org-1", + code: "ANNUAL", + name: { am: "ዓመታዊ", en: "Annual" }, + isPaid: true, + accrualMethod: EAccrualMethod.ANNUAL_ENTITLEMENT, + baseDaysPerYear: "16", + extraDaysPerPeriod: "1", + servicePeriodYears: 2, + maxDaysPerYear: null, + maxCarryOverDays: "0", + maxConsecutiveDays: null, + minServiceMonths: 0, + genderRestriction: EGenderRestriction.ANY, + countsWorkingDaysOnly: true, + allowsHalfDay: true, + requiresAttachmentAfter: null, + requiresApproval: true, + statuteReference: null, + sortOrder: 100, + isActive: true, + createdBy: null, + updatedBy: null, + ...overrides, + } as LeaveType; +} + +function makeProfile(overrides: Record = {}) { + return { + employeeId: "emp-1", + gender: "MALE", + hireDate: "2020-01-01", + ...overrides, + }; +} + +function makeEntitlement(overrides: Record = {}) { + return { + id: "ent-1", + organizationId: "org-1", + employeeId: "emp-1", + leaveTypeId: "type-1", + leaveYearStart: "2026-01-01", + leaveYearEnd: "2026-12-31", + entitledDays: "16.00", + carriedOverDays: "0.00", + carryOverExpiresOn: null, + serviceYearsAtGrant: "5.00", + ...overrides, + }; +} + +function makeHarness() { + const manager = { + getRepository: jest.fn(), + }; + const dataSource = { + transaction: jest.fn().mockImplementation((cb: (m: unknown) => unknown) => cb(manager)), + query: jest.fn().mockResolvedValue([]), + manager: { getRepository: jest.fn() }, + getRepository: jest.fn(), + }; + const entitlements = { + findForYear: jest.fn(), + findById: jest.fn(), + }; + const leaveTypes = { + findActive: jest.fn().mockResolvedValue([]), + }; + const settingsService = { + resolve: jest.fn().mockResolvedValue({ + leaveYearBasis: ELeaveYearBasis.CALENDAR_YEAR, + fiscalYearStartMonth: 7, + fiscalYearStartDay: 8, + weekendDays: [0], + carryOverDeadlineMonths: 6, + allowNegativeBalance: false, + }), + }; + const employees = { + findByEmployeeId: jest.fn().mockResolvedValue(makeProfile()), + }; + const iamDirectory = { + findEmployee: jest.fn().mockResolvedValue({ organizationId: "org-1" }), + }; + + const service = new LeaveBalancesService( + dataSource as never, + entitlements as never, + leaveTypes as never, + settingsService as never, + employees as never, + iamDirectory as never, + ); + + return { + service, + dataSource, + manager, + entitlements, + leaveTypes, + settingsService, + employees, + iamDirectory, + }; +} + +// ──────────────────────────────────────────────────────────────────────── + +describe("LeaveBalancesService.balancesFor / balanceFor", () => { + it("404s when the employee has no HR profile", async () => { + const h = makeHarness(); + h.employees.findByEmployeeId.mockResolvedValue(null); + await expect(h.service.balancesFor("emp-1", makeActor())).rejects.toBeInstanceOf( + NotFoundException, + ); + }); + + it("404s when the employee is outside the actor's organization scope", async () => { + const h = makeHarness(); + h.iamDirectory.findEmployee.mockResolvedValue({ organizationId: "org-9" }); + await expect( + h.service.balancesFor("emp-1", makeActor({ organizationId: "org-1", isSuperAdmin: false })), + ).rejects.toBeInstanceOf(NotFoundException); + }); + + it("400s when no organization can be resolved for the employee", async () => { + const h = makeHarness(); + h.iamDirectory.findEmployee.mockResolvedValue(null); + await expect( + h.service.balancesFor("emp-1", makeActor({ organizationId: "" as unknown as string, isSuperAdmin: true })), + ).rejects.toBeInstanceOf(BadRequestException); + }); + + it("skips NONE-accrual types", async () => { + const h = makeHarness(); + h.leaveTypes.findActive.mockResolvedValue([makeType({ accrualMethod: EAccrualMethod.NONE })]); + + const result = await h.service.balancesFor("emp-1", makeActor()); + + expect(result).toEqual([]); + expect(h.entitlements.findForYear).not.toHaveBeenCalled(); + }); + + it("skips a type gated by minServiceMonths the employee has not yet reached", async () => { + const h = makeHarness(); + h.employees.findByEmployeeId.mockResolvedValue(makeProfile({ hireDate: "2026-06-01" })); + h.leaveTypes.findActive.mockResolvedValue([makeType({ minServiceMonths: 60 })]); + + const result = await h.service.balancesFor("emp-1", makeActor(), "2026-06-15"); + + expect(result).toEqual([]); + }); + + it("skips a gender-restricted type that does not match a recorded gender", async () => { + const h = makeHarness(); + h.employees.findByEmployeeId.mockResolvedValue(makeProfile({ gender: "MALE" })); + h.leaveTypes.findActive.mockResolvedValue([ + makeType({ genderRestriction: EGenderRestriction.FEMALE }), + ]); + + const result = await h.service.balancesFor("emp-1", makeActor()); + + expect(result).toEqual([]); + }); + + it("does NOT skip a gender-restricted type when the profile has no gender recorded", async () => { + const h = makeHarness(); + h.employees.findByEmployeeId.mockResolvedValue(makeProfile({ gender: null })); + h.leaveTypes.findActive.mockResolvedValue([ + makeType({ genderRestriction: EGenderRestriction.FEMALE }), + ]); + h.entitlements.findForYear.mockResolvedValue(makeEntitlement()); + h.dataSource.query.mockResolvedValue([]); + + const result = await h.service.balancesFor("emp-1", makeActor()); + + expect(result).toHaveLength(1); + }); + + it("derives the balance from a grouped raw-SQL SUM over the ledger, never a stored column", async () => { + const h = makeHarness(); + h.leaveTypes.findActive.mockResolvedValue([makeType()]); + h.entitlements.findForYear.mockResolvedValue(makeEntitlement({ id: "ent-77" })); + h.dataSource.query.mockResolvedValue([ + { entryType: "GRANT", total: "16.00" }, + { entryType: "DEDUCTION", total: "-3.50" }, + ]); + + const [balance] = await h.service.balancesFor("emp-1", makeActor()); + + // The derivation actually ran the raw SQL against the ledger table for + // this entitlement — not a canned return value that bypasses it. + expect(h.dataSource.query).toHaveBeenCalledWith( + expect.stringContaining("hr.leave_ledger_entries"), + ["ent-77"], + ); + expect(balance.grantedDays).toBe(16); + expect(balance.takenDays).toBe(3.5); + expect(balance.adjustmentDays).toBe(0); + expect(balance.availableDays).toBe(12.5); + }); + + it("folds REVERSAL entries into adjustments alongside ADJUSTMENT", async () => { + const h = makeHarness(); + h.leaveTypes.findActive.mockResolvedValue([makeType()]); + h.entitlements.findForYear.mockResolvedValue(makeEntitlement()); + h.dataSource.query.mockResolvedValue([ + { entryType: "GRANT", total: "16.00" }, + { entryType: "DEDUCTION", total: "-4.00" }, + { entryType: "REVERSAL", total: "4.00" }, + { entryType: "ADJUSTMENT", total: "-1.00" }, + ]); + + const [balance] = await h.service.balancesFor("emp-1", makeActor()); + + expect(balance.takenDays).toBe(4); + expect(balance.adjustmentDays).toBe(3); // 4 (reversal) + -1 (adjustment) + expect(balance.availableDays).toBe(15); // 16 + 3 - 4 + }); + + it("creates the leave year's entitlement on first read and posts its GRANT", async () => { + const h = makeHarness(); + // Hire date pinned to the leave year's start so completed service is + // exactly 0 — keeps the expected entitlement at the type's flat base + // rather than also exercising the service-growth arithmetic here. + h.employees.findByEmployeeId.mockResolvedValue(makeProfile({ hireDate: "2026-01-01" })); + h.leaveTypes.findActive.mockResolvedValue([makeType({ baseDaysPerYear: "16", extraDaysPerPeriod: "1", servicePeriodYears: 2 })]); + h.entitlements.findForYear.mockResolvedValue(null); + + const entitlementRepo = { + create: (d: Record) => d, + save: jest.fn().mockResolvedValue(makeEntitlement({ id: "ent-created" })), + }; + const ledgerRepo = { + create: (d: Record) => d, + save: jest.fn().mockImplementation(async (d: Record) => ({ id: "entry-1", ...d })), + }; + h.manager.getRepository.mockImplementation((entity: { name?: string }) => { + // Distinguish by call order: first call is LeaveEntitlement, rest are LeaveLedgerEntry. + return entity && (entity as { name?: string }).name === "LeaveEntitlement" + ? entitlementRepo + : ledgerRepo; + }); + h.dataSource.query.mockResolvedValue([]); + + const result = await h.service.balancesFor("emp-1", makeActor(), "2026-06-15"); + + expect(h.dataSource.transaction).toHaveBeenCalledTimes(1); + expect(entitlementRepo.save).toHaveBeenCalledTimes(1); + expect(ledgerRepo.save).toHaveBeenCalledTimes(1); + const [grantEntry] = ledgerRepo.save.mock.calls[0]; + // post() stores days as entry.days.toFixed(2) — a string, not a number. + expect(grantEntry).toMatchObject({ entryType: ELedgerEntryType.GRANT, days: "16.00" }); + expect(result).toHaveLength(1); + }); + + it("does not post a CARRY_OVER entry when the type caps carry-over at 0", async () => { + const h = makeHarness(); + h.leaveTypes.findActive.mockResolvedValue([makeType({ maxCarryOverDays: "0" })]); + h.entitlements.findForYear.mockResolvedValue(null); + + const entitlementRepo = { + create: (d: Record) => d, + save: jest.fn().mockResolvedValue(makeEntitlement({ id: "ent-created" })), + }; + const ledgerRepo = { + create: (d: Record) => d, + save: jest.fn().mockImplementation(async (d: Record) => ({ id: "entry-1", ...d })), + }; + h.manager.getRepository.mockImplementation((entity: { name?: string }) => + (entity as { name?: string }).name === "LeaveEntitlement" ? entitlementRepo : ledgerRepo, + ); + h.dataSource.query.mockResolvedValue([]); + + await h.service.balancesFor("emp-1", makeActor()); + + // Only the GRANT entry, never CARRY_OVER. + expect(ledgerRepo.save).toHaveBeenCalledTimes(1); + expect(ledgerRepo.save.mock.calls[0][0]).toMatchObject({ entryType: ELedgerEntryType.GRANT }); + }); + + it("caps a carried-over amount at the type's maxCarryOverDays", async () => { + const h = makeHarness(); + h.leaveTypes.findActive.mockResolvedValue([ + makeType({ maxCarryOverDays: "4", baseDaysPerYear: "16" }), + ]); + // First entitlement lookup (current year) -> not found, triggers ensureEntitlement. + // Inside carryOverInto, a second findForYear call looks up the *previous* year. + h.entitlements.findForYear.mockImplementation( + async (_employeeId: string, _typeId: string, yearStart: string) => { + if (yearStart === "2025-01-01") return makeEntitlement({ id: "ent-prev", leaveYearStart: "2025-01-01" }); + return null; + }, + ); + // sumLedger for the previous entitlement reports 10 remaining days — above the cap of 4. + h.dataSource.query.mockResolvedValue([{ total: "10.00" }]); + + const entitlementRepo = { + create: (d: Record) => d, + save: jest.fn().mockImplementation(async (d: Record) => ({ id: "ent-created", ...d })), + }; + const ledgerRepo = { + create: (d: Record) => d, + save: jest.fn().mockImplementation(async (d: Record) => ({ id: "entry-1", ...d })), + }; + h.manager.getRepository.mockImplementation((entity: { name?: string }) => + (entity as { name?: string }).name === "LeaveEntitlement" ? entitlementRepo : ledgerRepo, + ); + + await h.service.balancesFor("emp-1", makeActor(), "2026-06-15"); + + const carryOverCall = ledgerRepo.save.mock.calls.find( + ([entry]: [Record]) => entry.entryType === ELedgerEntryType.CARRY_OVER, + ); + expect(carryOverCall).toBeDefined(); + expect(carryOverCall![0]).toMatchObject({ days: "4.00" }); // capped, not the full 10 + }); +}); + +describe("LeaveBalancesService.post", () => { + it("is the only way days move — saves a signed entry against the entitlement", async () => { + const h = makeHarness(); + const repo = { + create: (d: Record) => d, + save: jest.fn().mockImplementation(async (d: Record) => ({ id: "entry-1", ...d })), + }; + h.dataSource.manager.getRepository.mockReturnValue(repo); + + const result = await h.service.post( + "ent-1", + { entryType: ELedgerEntryType.DEDUCTION, days: -4, effectiveOn: "2026-09-14", reason: "annual" }, + makeActor({ userId: "user-9" }), + ); + + expect(repo.save).toHaveBeenCalledWith( + expect.objectContaining({ + entitlementId: "ent-1", + entryType: ELedgerEntryType.DEDUCTION, + days: "-4.00", + effectiveOn: "2026-09-14", + createdBy: "user-9", + }), + ); + expect(result).toMatchObject({ id: "entry-1" }); + }); + + it("joins the caller's transaction when a manager is supplied", async () => { + const h = makeHarness(); + const txRepo = { + create: (d: Record) => d, + save: jest.fn().mockResolvedValue({ id: "entry-2" }), + }; + h.manager.getRepository.mockReturnValue(txRepo); + + await h.service.post( + "ent-1", + { entryType: ELedgerEntryType.DEDUCTION, days: -1, effectiveOn: "2026-09-14" }, + makeActor(), + h.manager as never, + ); + + expect(txRepo.save).toHaveBeenCalled(); + expect(h.dataSource.manager.getRepository).not.toHaveBeenCalled(); + }); +}); + +describe("LeaveBalancesService.reverse", () => { + it("404s when the original entry does not exist", async () => { + const h = makeHarness(); + const repo = { findOne: jest.fn().mockResolvedValue(null) }; + h.dataSource.manager.getRepository.mockReturnValue(repo); + + await expect(h.service.reverse("entry-1", "cancelled", makeActor())).rejects.toBeInstanceOf( + NotFoundException, + ); + }); + + it("refuses to reverse an entry that already has a reversal (pre-check)", async () => { + const h = makeHarness(); + const original = { id: "entry-1", entitlementId: "ent-1", days: "-4.00" }; + const repo = { + findOne: jest + .fn() + .mockResolvedValueOnce(original) // lookup of the original + .mockResolvedValueOnce({ id: "entry-2", reversesId: "entry-1" }), // an existing reversal + save: jest.fn(), + create: (d: Record) => d, + }; + h.dataSource.manager.getRepository.mockReturnValue(repo); + + await expect(h.service.reverse("entry-1", "twice", makeActor())).rejects.toBeInstanceOf( + ConflictException, + ); + expect(repo.save).not.toHaveBeenCalled(); + }); + + it("posts the mirrored opposite entry when nothing has reversed it yet", async () => { + const h = makeHarness(); + const original = { id: "entry-1", entitlementId: "ent-1", days: "-4.00" }; + const repo = { + findOne: jest + .fn() + .mockResolvedValueOnce(original) + .mockResolvedValueOnce(null), // no existing reversal + save: jest.fn().mockImplementation(async (d: Record) => ({ id: "entry-2", ...d })), + create: (d: Record) => d, + }; + h.dataSource.manager.getRepository.mockReturnValue(repo); + + const result = await h.service.reverse("entry-1", "leave cancelled", makeActor()); + + expect(repo.save).toHaveBeenCalledWith( + expect.objectContaining({ + entitlementId: "ent-1", + entryType: ELedgerEntryType.REVERSAL, + days: "4.00", // opposite sign of the original's -4.00 + reversesId: "entry-1", + reason: "leave cancelled", + }), + ); + expect(result).toMatchObject({ id: "entry-2" }); + }); + + it("turns a caught 23505 unique-violation into ConflictException (the race case)", async () => { + const h = makeHarness(); + const original = { id: "entry-1", entitlementId: "ent-1", days: "-4.00" }; + const repo = { + findOne: jest + .fn() + .mockResolvedValueOnce(original) + .mockResolvedValueOnce(null), // pre-check sees no reversal yet — the race is in the write + save: jest.fn().mockRejectedValue({ code: "23505" }), + create: (d: Record) => d, + }; + h.dataSource.manager.getRepository.mockReturnValue(repo); + + await expect(h.service.reverse("entry-1", "double click", makeActor())).rejects.toBeInstanceOf( + ConflictException, + ); + }); + + it("re-throws an unrelated error from the write unchanged", async () => { + const h = makeHarness(); + const original = { id: "entry-1", entitlementId: "ent-1", days: "-4.00" }; + const boom = new Error("connection reset"); + const repo = { + findOne: jest + .fn() + .mockResolvedValueOnce(original) + .mockResolvedValueOnce(null), + save: jest.fn().mockRejectedValue(boom), + create: (d: Record) => d, + }; + h.dataSource.manager.getRepository.mockReturnValue(repo); + + await expect(h.service.reverse("entry-1", "x", makeActor())).rejects.toBe(boom); + }); +}); diff --git a/apps/edr-hr-api/src/modules/leave/services/leave-requests.service.spec.ts b/apps/edr-hr-api/src/modules/leave/services/leave-requests.service.spec.ts new file mode 100644 index 000000000..1564f1c2a --- /dev/null +++ b/apps/edr-hr-api/src/modules/leave/services/leave-requests.service.spec.ts @@ -0,0 +1,816 @@ +import { BadRequestException, ForbiddenException, NotFoundException } from "@nestjs/common"; + +import { ActorContext } from "../../employees/employees.service"; +import { ELeaveRequestStatus, LeaveRequest } from "../entities/leave-request.entity"; +import { ELedgerEntryType } from "../entities/leave-ledger-entry.entity"; +import { EAccrualMethod, EGenderRestriction, LeaveType } from "../entities/leave-type.entity"; +import { CreateLeaveRequestDto } from "../dto/leave-request.dto"; +import { LeaveRequestsService } from "./leave-requests.service"; + +/** + * Mirrors the freight suite's convention: plain `new ServiceClass(...)` with + * hand-built fakes passed positionally — no `Test.createTestingModule`. + */ + +function makeActor(overrides: Partial = {}): ActorContext { + return { + employeeId: "emp-1", + userId: "user-1", + organizationId: "org-1", + isSuperAdmin: false, + ...overrides, + }; +} + +function makeType(overrides: Partial = {}): LeaveType { + return { + id: "type-1", + organizationId: "org-1", + code: "ANNUAL", + name: { am: "ዓመታዊ", en: "Annual" }, + isPaid: true, + accrualMethod: EAccrualMethod.ANNUAL_ENTITLEMENT, + baseDaysPerYear: "16", + extraDaysPerPeriod: "1", + servicePeriodYears: 2, + maxDaysPerYear: null, + maxCarryOverDays: "6", + maxConsecutiveDays: null, + minServiceMonths: 0, + genderRestriction: EGenderRestriction.ANY, + countsWorkingDaysOnly: true, + allowsHalfDay: true, + requiresAttachmentAfter: null, + requiresApproval: true, + statuteReference: null, + sortOrder: 100, + isActive: true, + createdBy: null, + updatedBy: null, + ...overrides, + } as LeaveType; +} + +function makeProfile(overrides: Record = {}) { + return { + employeeId: "emp-1", + gender: "MALE", + hireDate: "2020-01-01", + managerEmployeeId: "mgr-1", + ...overrides, + }; +} + +function makeRequest(overrides: Partial = {}): LeaveRequest { + return { + id: "req-1", + organizationId: "org-1", + employeeId: "emp-2", + leaveTypeId: "type-1", + status: ELeaveRequestStatus.SUBMITTED, + startDate: "2026-09-14", + endDate: "2026-09-18", + isHalfDay: false, + workingDays: "4.00", + calendarDays: "5.00", + chargedDays: "4.00", + reason: null, + contactDuringLeave: null, + attachmentDocumentId: null, + approverEmployeeId: "mgr-1", + decidedByEmployeeId: null, + decidedAt: null, + decisionNote: null, + cancelledReason: null, + createdBy: "user-2", + updatedBy: null, + ...overrides, + } as LeaveRequest; +} + +function makeBalance(overrides: Record = {}) { + return { + leaveTypeId: "type-1", + code: "ANNUAL", + name: { am: "ዓመታዊ", en: "Annual" }, + leaveYearStart: "2026-01-01", + leaveYearEnd: "2026-12-31", + entitledDays: 16, + carriedOverDays: 0, + grantedDays: 16, + takenDays: 0, + adjustmentDays: 0, + availableDays: 10, + carryOverExpiresOn: null, + entitlementId: "ent-1", + ...overrides, + }; +} + +function makeSettings(overrides: Record = {}) { + return { + weekendDays: [0], + allowNegativeBalance: false, + carryOverDeadlineMonths: 6, + ...overrides, + }; +} + +function makeHarness() { + const manager = { + getRepository: jest.fn(), + }; + const dataSource = { + transaction: jest.fn().mockImplementation((cb: (m: unknown) => unknown) => cb(manager)), + manager, + }; + const requests = { + findOverlapping: jest.fn().mockResolvedValue([]), + create: jest.fn(), + findPage: jest.fn().mockResolvedValue([[], 0]), + findById: jest.fn(), + update: jest.fn(), + }; + const leaveTypes = { + findById: jest.fn(), + }; + const balances = { + balanceFor: jest.fn(), + post: jest.fn(), + entriesForSource: jest.fn().mockResolvedValue([]), + reverse: jest.fn(), + }; + const settingsService = { + resolve: jest.fn().mockResolvedValue(makeSettings()), + }; + const workingDays = { + countWorkingDays: jest.fn().mockResolvedValue(4), + holidayDates: jest.fn().mockResolvedValue(new Set()), + }; + const employees = { + findByEmployeeId: jest.fn().mockResolvedValue(makeProfile()), + }; + const iamDirectory = { + findLineManagerEmployeeId: jest.fn().mockResolvedValue("line-mgr-1"), + }; + + const service = new LeaveRequestsService( + dataSource as never, + requests as never, + leaveTypes as never, + balances as never, + settingsService as never, + workingDays as never, + employees as never, + iamDirectory as never, + ); + + leaveTypes.findById.mockResolvedValue(makeType()); + balances.balanceFor.mockResolvedValue(makeBalance()); + + return { + service, + dataSource, + manager, + requests, + leaveTypes, + balances, + settingsService, + workingDays, + employees, + iamDirectory, + }; +} + +// ──────────────────────────────────────────────────────────────────────── + +describe("LeaveRequestsService.quote", () => { + it("prices working-days-only types off countWorkingDays, not calendar span", async () => { + const h = makeHarness(); + h.workingDays.countWorkingDays.mockResolvedValue(4); // deliberately != calendar span (5) + h.balances.balanceFor.mockResolvedValue(makeBalance({ availableDays: 10 })); + + const result = await h.service.quote( + "emp-1", + "type-1", + "2026-09-14", + "2026-09-18", + false, + makeActor(), + ); + + expect(result.workingDays).toBe(4); + expect(result.calendarDays).toBe(5); + expect(result.chargedDays).toBe(4); + expect(result.countsWorkingDaysOnly).toBe(true); + expect(result.availableDays).toBe(10); + expect(result.remainingAfter).toBe(6); + }); + + it("prices calendar-day types off the full span, ignoring weekends/holidays", async () => { + const h = makeHarness(); + h.leaveTypes.findById.mockResolvedValue( + makeType({ countsWorkingDaysOnly: false, accrualMethod: EAccrualMethod.NONE }), + ); + h.workingDays.countWorkingDays.mockResolvedValue(2); // must be ignored + + const result = await h.service.quote( + "emp-1", + "type-1", + "2026-09-14", + "2026-09-18", + false, + makeActor(), + ); + + expect(result.chargedDays).toBe(5); + }); + + it("halves the charge for a half day", async () => { + const h = makeHarness(); + h.workingDays.countWorkingDays.mockResolvedValue(5); + + const result = await h.service.quote( + "emp-1", + "type-1", + "2026-09-14", + "2026-09-14", + true, + makeActor(), + ); + + expect(result.chargedDays).toBe(2.5); + }); + + it("flags the attachment requirement once charged days pass the threshold", async () => { + const h = makeHarness(); + h.leaveTypes.findById.mockResolvedValue(makeType({ requiresAttachmentAfter: "3" })); + h.workingDays.countWorkingDays.mockResolvedValue(3); + + const atThreshold = await h.service.quote( + "emp-1", + "type-1", + "2026-09-14", + "2026-09-18", + false, + makeActor(), + ); + expect(atThreshold.requiresAttachment).toBe(false); // 3 is not > 3 + + h.workingDays.countWorkingDays.mockResolvedValue(4); + const overThreshold = await h.service.quote( + "emp-1", + "type-1", + "2026-09-14", + "2026-09-18", + false, + makeActor(), + ); + expect(overThreshold.requiresAttachment).toBe(true); + }); + + it("swallows a balance-lookup failure into null rather than throwing", async () => { + const h = makeHarness(); + h.balances.balanceFor.mockRejectedValue(new BadRequestException("no balance")); + + const result = await h.service.quote( + "emp-1", + "type-1", + "2026-09-14", + "2026-09-18", + false, + makeActor(), + ); + + expect(result.availableDays).toBeNull(); + expect(result.remainingAfter).toBeNull(); + }); + + it("returns the holidays in range, sorted", async () => { + const h = makeHarness(); + h.workingDays.holidayDates.mockResolvedValue(new Set(["2026-09-16", "2026-09-15"])); + + const result = await h.service.quote( + "emp-1", + "type-1", + "2026-09-14", + "2026-09-18", + false, + makeActor(), + ); + + expect(result.holidaysInRange).toEqual(["2026-09-15", "2026-09-16"]); + }); +}); + +describe("LeaveRequestsService.create", () => { + function dto(overrides: Partial = {}): CreateLeaveRequestDto { + return { + leaveTypeId: "type-1", + startDate: "2026-09-14", + endDate: "2026-09-18", + isHalfDay: false, + ...overrides, + } as CreateLeaveRequestDto; + } + + it("uses actor.employeeId when dto.employeeId is omitted", async () => { + const h = makeHarness(); + h.requests.create.mockImplementation(async (data: Record) => ({ + id: "req-new", + ...data, + })); + + const result = await h.service.create(dto(), makeActor({ employeeId: "emp-1" })); + + expect(result).toMatchObject({ employeeId: "emp-1" }); + expect(h.employees.findByEmployeeId).toHaveBeenCalledWith("emp-1"); + }); + + it("refuses when neither dto.employeeId nor actor.employeeId is set", async () => { + const h = makeHarness(); + await expect( + h.service.create(dto(), makeActor({ employeeId: null })), + ).rejects.toBeInstanceOf(BadRequestException); + }); + + it("requires a staff account to file for someone else", async () => { + const h = makeHarness(); + await expect( + h.service.create( + dto({ employeeId: "emp-2" }), + makeActor({ employeeId: "emp-1", organizationId: "" as unknown as string }), + ), + ).rejects.toBeInstanceOf(ForbiddenException); + }); + + it("allows a staff account (has organizationId) to file for someone else", async () => { + const h = makeHarness(); + h.requests.create.mockImplementation(async (data: Record) => ({ + id: "req-new", + ...data, + })); + + const result = await h.service.create( + dto({ employeeId: "emp-2" }), + makeActor({ employeeId: "emp-1", organizationId: "org-1" }), + ); + + expect(result).toMatchObject({ employeeId: "emp-2" }); + }); + + it("rejects endDate before startDate", async () => { + const h = makeHarness(); + await expect( + h.service.create(dto({ startDate: "2026-09-18", endDate: "2026-09-14" }), makeActor()), + ).rejects.toBeInstanceOf(BadRequestException); + }); + + it("404s when the HR profile does not exist", async () => { + const h = makeHarness(); + h.employees.findByEmployeeId.mockResolvedValue(null); + await expect(h.service.create(dto(), makeActor())).rejects.toBeInstanceOf(NotFoundException); + }); + + it("enforces the gender restriction against the profile", async () => { + const h = makeHarness(); + h.leaveTypes.findById.mockResolvedValue( + makeType({ genderRestriction: EGenderRestriction.FEMALE }), + ); + h.employees.findByEmployeeId.mockResolvedValue(makeProfile({ gender: "MALE" })); + + await expect(h.service.create(dto(), makeActor())).rejects.toBeInstanceOf(BadRequestException); + }); + + it("enforces the gender restriction when no gender is recorded", async () => { + const h = makeHarness(); + h.leaveTypes.findById.mockResolvedValue( + makeType({ genderRestriction: EGenderRestriction.FEMALE }), + ); + h.employees.findByEmployeeId.mockResolvedValue(makeProfile({ gender: null })); + + await expect(h.service.create(dto(), makeActor())).rejects.toBeInstanceOf(BadRequestException); + }); + + it("refuses leave starting before the hire date", async () => { + const h = makeHarness(); + h.employees.findByEmployeeId.mockResolvedValue(makeProfile({ hireDate: "2027-01-01" })); + + await expect(h.service.create(dto(), makeActor())).rejects.toBeInstanceOf(BadRequestException); + }); + + it("refuses dates overlapping an existing live request", async () => { + const h = makeHarness(); + h.requests.findOverlapping.mockResolvedValue([ + makeRequest({ status: ELeaveRequestStatus.APPROVED, startDate: "2026-09-15", endDate: "2026-09-16" }), + ]); + + await expect(h.service.create(dto(), makeActor())).rejects.toThrow(/overlap/i); + }); + + it("refuses a half day the type does not allow", async () => { + const h = makeHarness(); + h.leaveTypes.findById.mockResolvedValue(makeType({ allowsHalfDay: false })); + + await expect( + h.service.create(dto({ isHalfDay: true }), makeActor()), + ).rejects.toBeInstanceOf(BadRequestException); + }); + + it("refuses a range that is entirely weekends/holidays (charged <= 0)", async () => { + const h = makeHarness(); + h.workingDays.countWorkingDays.mockResolvedValue(0); + + await expect(h.service.create(dto(), makeActor())).rejects.toThrow(/no working days/i); + expect(h.requests.create).not.toHaveBeenCalled(); + }); + + it("caps at maxConsecutiveDays", async () => { + const h = makeHarness(); + h.leaveTypes.findById.mockResolvedValue(makeType({ maxConsecutiveDays: "3" })); + h.workingDays.countWorkingDays.mockResolvedValue(4); + + await expect(h.service.create(dto(), makeActor())).rejects.toThrow(/limited to 3 days/i); + }); + + it("requires an attachment past the threshold", async () => { + const h = makeHarness(); + h.leaveTypes.findById.mockResolvedValue(makeType({ requiresAttachmentAfter: "2" })); + h.workingDays.countWorkingDays.mockResolvedValue(4); + + await expect(h.service.create(dto(), makeActor())).rejects.toThrow(/supporting evidence/i); + }); + + it("accepts an attachment past the threshold when supplied", async () => { + const h = makeHarness(); + h.leaveTypes.findById.mockResolvedValue(makeType({ requiresAttachmentAfter: "2" })); + h.workingDays.countWorkingDays.mockResolvedValue(4); + h.requests.create.mockImplementation(async (data: Record) => ({ + id: "req-new", + ...data, + })); + + await expect( + h.service.create(dto({ attachmentDocumentId: "doc-1" }), makeActor()), + ).resolves.toBeDefined(); + }); + + it("skips the balance check entirely for NONE-accrual types", async () => { + const h = makeHarness(); + h.leaveTypes.findById.mockResolvedValue(makeType({ accrualMethod: EAccrualMethod.NONE })); + h.requests.create.mockImplementation(async (data: Record) => ({ + id: "req-new", + ...data, + })); + + await h.service.create(dto(), makeActor()); + + expect(h.balances.balanceFor).not.toHaveBeenCalled(); + }); + + it("skips the balance check when the org allows a negative balance", async () => { + const h = makeHarness(); + h.settingsService.resolve.mockResolvedValue(makeSettings({ allowNegativeBalance: true })); + h.requests.create.mockImplementation(async (data: Record) => ({ + id: "req-new", + ...data, + })); + + await h.service.create(dto(), makeActor()); + + expect(h.balances.balanceFor).not.toHaveBeenCalled(); + }); + + it("counts other SUBMITTED requests as held against the balance", async () => { + const h = makeHarness(); + h.workingDays.countWorkingDays.mockResolvedValue(4); + h.balances.balanceFor.mockResolvedValue(makeBalance({ availableDays: 5 })); + h.requests.findPage.mockResolvedValue([ + [makeRequest({ id: "other-req", chargedDays: "2.00" })], + 1, + ]); + + // available 5 - held 2 = free 3, charged 4 > free 3 -> refused + await expect(h.service.create(dto(), makeActor())).rejects.toThrow(/already held by pending/i); + }); + + it("allows the request when enough balance remains after held requests", async () => { + const h = makeHarness(); + h.workingDays.countWorkingDays.mockResolvedValue(4); + h.balances.balanceFor.mockResolvedValue(makeBalance({ availableDays: 10 })); + h.requests.findPage.mockResolvedValue([ + [makeRequest({ id: "other-req", chargedDays: "2.00" })], + 1, + ]); + h.requests.create.mockImplementation(async (data: Record) => ({ + id: "req-new", + ...data, + })); + + await expect(h.service.create(dto(), makeActor())).resolves.toBeDefined(); + }); + + it("resolves the approver from the profile's managerEmployeeId without an IAM lookup", async () => { + const h = makeHarness(); + h.employees.findByEmployeeId.mockResolvedValue(makeProfile({ managerEmployeeId: "mgr-9" })); + h.requests.create.mockImplementation(async (data: Record) => ({ + id: "req-new", + ...data, + })); + + const result = await h.service.create(dto(), makeActor()); + + expect(result).toMatchObject({ approverEmployeeId: "mgr-9" }); + expect(h.iamDirectory.findLineManagerEmployeeId).not.toHaveBeenCalled(); + }); + + it("falls back to the IAM position tree when the profile has no manager", async () => { + const h = makeHarness(); + h.employees.findByEmployeeId.mockResolvedValue(makeProfile({ managerEmployeeId: null })); + h.iamDirectory.findLineManagerEmployeeId.mockResolvedValue("line-mgr-7"); + h.requests.create.mockImplementation(async (data: Record) => ({ + id: "req-new", + ...data, + })); + + const result = await h.service.create(dto(), makeActor()); + + expect(h.iamDirectory.findLineManagerEmployeeId).toHaveBeenCalledWith("emp-1"); + expect(result).toMatchObject({ approverEmployeeId: "line-mgr-7" }); + }); + + it("submits requiresApproval types as SUBMITTED with no ledger posting", async () => { + const h = makeHarness(); + h.requests.create.mockImplementation(async (data: Record) => ({ + id: "req-new", + ...data, + })); + + const result = await h.service.create(dto(), makeActor()); + + expect(result).toMatchObject({ + status: ELeaveRequestStatus.SUBMITTED, + decidedByEmployeeId: null, + decidedAt: null, + decisionNote: null, + }); + expect(h.balances.post).not.toHaveBeenCalled(); + }); + + it("auto-approves requiresApproval:false types and posts a DEDUCTION outside a transaction", async () => { + const h = makeHarness(); + h.leaveTypes.findById.mockResolvedValue(makeType({ requiresApproval: false, code: "BEREAVEMENT" })); + h.workingDays.countWorkingDays.mockResolvedValue(3); + h.requests.create.mockImplementation(async (data: Record) => ({ + id: "req-new", + ...data, + })); + h.balances.balanceFor.mockResolvedValue(makeBalance({ entitlementId: "ent-42" })); + + const result = await h.service.create(dto(), makeActor({ employeeId: "emp-1" })); + + expect(result).toMatchObject({ + status: ELeaveRequestStatus.APPROVED, + decidedByEmployeeId: "emp-1", + approverEmployeeId: null, + }); + expect(result.decidedAt).toBeInstanceOf(Date); + expect(result.decisionNote).toMatch(/does not require prior approval/i); + + expect(h.dataSource.transaction).not.toHaveBeenCalled(); + expect(h.balances.post).toHaveBeenCalledTimes(1); + const [entitlementId, entry, , manager] = h.balances.post.mock.calls[0]; + expect(entitlementId).toBe("ent-42"); + expect(entry).toMatchObject({ entryType: ELedgerEntryType.DEDUCTION, days: -3 }); + expect(manager).toBeUndefined(); + }); +}); + +describe("LeaveRequestsService.approve", () => { + it("404s when the request does not exist", async () => { + const h = makeHarness(); + h.requests.findById.mockResolvedValue(null); + await expect(h.service.approve("missing", undefined, makeActor())).rejects.toBeInstanceOf( + NotFoundException, + ); + }); + + it("only allows SUBMITTED to become APPROVED", async () => { + const h = makeHarness(); + h.requests.findById.mockResolvedValue( + makeRequest({ status: ELeaveRequestStatus.APPROVED }), + ); + await expect(h.service.approve("req-1", undefined, makeActor())).rejects.toBeInstanceOf( + BadRequestException, + ); + }); + + it("refuses self-approval", async () => { + const h = makeHarness(); + h.requests.findById.mockResolvedValue(makeRequest({ employeeId: "emp-1" })); + await expect( + h.service.approve("req-1", undefined, makeActor({ employeeId: "emp-1" })), + ).rejects.toBeInstanceOf(ForbiddenException); + }); + + it("404s when the leave type behind the request no longer exists", async () => { + const h = makeHarness(); + h.requests.findById.mockResolvedValue(makeRequest()); + h.leaveTypes.findById.mockResolvedValue(null); + await expect( + h.service.approve("req-1", undefined, makeActor({ employeeId: "mgr-1" })), + ).rejects.toBeInstanceOf(NotFoundException); + }); + + it("excludes the request's own pending entry from the held total when re-checking balance", async () => { + const h = makeHarness(); + // The request being approved is itself SUBMITTED and would otherwise show + // up in the pending-requests query — assertBalance must exclude it via + // excludeRequestId, or a solo request would always look self-blocking + // (held == charged, leaving zero free even though nothing else is pending). + const request = makeRequest({ chargedDays: "4.00" }); + h.requests.findById.mockResolvedValue(request); + h.balances.balanceFor.mockResolvedValue(makeBalance({ availableDays: 4, entitlementId: "ent-1" })); + h.requests.findPage.mockResolvedValue([[request], 1]); + const requestRepoInTx = { update: jest.fn().mockResolvedValue(undefined) }; + h.manager.getRepository.mockReturnValue(requestRepoInTx); + + await expect( + h.service.approve("req-1", undefined, makeActor({ employeeId: "mgr-1" })), + ).resolves.toBeDefined(); + }); + + it("refuses when another request has consumed the balance since submission", async () => { + const h = makeHarness(); + const request = makeRequest({ chargedDays: "4.00" }); + h.requests.findById.mockResolvedValue(request); + h.balances.balanceFor.mockResolvedValue(makeBalance({ availableDays: 3 })); + h.requests.findPage.mockResolvedValue([ + [request, makeRequest({ id: "other-req", chargedDays: "1.00" })], + 2, + ]); + + await expect( + h.service.approve("req-1", undefined, makeActor({ employeeId: "mgr-1" })), + ).rejects.toThrow(/not enough/i); + }); + + it("posts the DEDUCTION and flips status to APPROVED atomically in one transaction", async () => { + const h = makeHarness(); + const request = makeRequest({ chargedDays: "4.00" }); + h.requests.findById.mockResolvedValue(request); + h.balances.balanceFor.mockResolvedValue(makeBalance({ availableDays: 10, entitlementId: "ent-1" })); + h.requests.findPage.mockResolvedValue([[], 0]); + + const requestRepoInTx = { update: jest.fn().mockResolvedValue(undefined) }; + h.manager.getRepository.mockReturnValue(requestRepoInTx); + + const result = await h.service.approve("req-1", "looks good", makeActor({ employeeId: "mgr-1" })); + + expect(h.dataSource.transaction).toHaveBeenCalledTimes(1); + + // Both writes happened against the transaction's manager. + expect(h.balances.post).toHaveBeenCalledTimes(1); + const [entitlementId, entry, , manager] = h.balances.post.mock.calls[0]; + expect(entitlementId).toBe("ent-1"); + expect(entry).toMatchObject({ entryType: ELedgerEntryType.DEDUCTION, days: -4 }); + expect(manager).toBe(h.manager); + + expect(requestRepoInTx.update).toHaveBeenCalledWith( + "req-1", + expect.objectContaining({ + status: ELeaveRequestStatus.APPROVED, + decidedByEmployeeId: "mgr-1", + decisionNote: "looks good", + }), + ); + + expect(result.status).toBe(ELeaveRequestStatus.APPROVED); + }); +}); + +describe("LeaveRequestsService.reject", () => { + it("only allows SUBMITTED to become REJECTED", async () => { + const h = makeHarness(); + h.requests.findById.mockResolvedValue( + makeRequest({ status: ELeaveRequestStatus.CANCELLED }), + ); + await expect( + h.service.reject("req-1", "no", makeActor({ employeeId: "mgr-1" })), + ).rejects.toBeInstanceOf(BadRequestException); + }); + + it("refuses self-rejection", async () => { + const h = makeHarness(); + h.requests.findById.mockResolvedValue(makeRequest({ employeeId: "emp-1" })); + await expect( + h.service.reject("req-1", "no", makeActor({ employeeId: "emp-1" })), + ).rejects.toBeInstanceOf(ForbiddenException); + }); + + it("updates status without touching the ledger — nothing was deducted at submission", async () => { + const h = makeHarness(); + const request = makeRequest(); + h.requests.findById.mockResolvedValue(request); + h.requests.update.mockResolvedValue({ ...request, status: ELeaveRequestStatus.REJECTED }); + + const result = await h.service.reject("req-1", "not eligible", makeActor({ employeeId: "mgr-1" })); + + expect(h.requests.update).toHaveBeenCalledWith( + "req-1", + expect.objectContaining({ + status: ELeaveRequestStatus.REJECTED, + decidedByEmployeeId: "mgr-1", + decisionNote: "not eligible", + }), + ); + expect(h.balances.post).not.toHaveBeenCalled(); + expect(h.balances.reverse).not.toHaveBeenCalled(); + expect(h.dataSource.transaction).not.toHaveBeenCalled(); + expect(result.status).toBe(ELeaveRequestStatus.REJECTED); + }); +}); + +describe("LeaveRequestsService.cancel", () => { + it("only allows APPROVED to become CANCELLED", async () => { + const h = makeHarness(); + h.requests.findById.mockResolvedValue( + makeRequest({ status: ELeaveRequestStatus.SUBMITTED }), + ); + await expect( + h.service.cancel("req-1", "change of plans", makeActor({ employeeId: "emp-2" })), + ).rejects.toBeInstanceOf(BadRequestException); + }); + + it("requires a staff account to cancel someone else's leave", async () => { + const h = makeHarness(); + h.requests.findById.mockResolvedValue( + makeRequest({ status: ELeaveRequestStatus.APPROVED, employeeId: "emp-2" }), + ); + await expect( + h.service.cancel( + "req-1", + "change of plans", + makeActor({ employeeId: "emp-1", organizationId: "" as unknown as string }), + ), + ).rejects.toBeInstanceOf(ForbiddenException); + }); + + it("reverses every un-reversed DEDUCTION entry for the request, atomically", async () => { + const h = makeHarness(); + h.requests.findById.mockResolvedValue( + makeRequest({ status: ELeaveRequestStatus.APPROVED, employeeId: "emp-2" }), + ); + h.balances.entriesForSource.mockResolvedValue([ + { id: "entry-1", reversesId: null }, + ]); + const requestRepoInTx = { update: jest.fn().mockResolvedValue(undefined) }; + h.manager.getRepository.mockReturnValue(requestRepoInTx); + + const result = await h.service.cancel( + "req-1", + "change of plans", + makeActor({ employeeId: "emp-1", organizationId: "org-1" }), + ); + + expect(h.dataSource.transaction).toHaveBeenCalledTimes(1); + expect(h.balances.reverse).toHaveBeenCalledTimes(1); + expect(h.balances.reverse).toHaveBeenCalledWith( + "entry-1", + expect.stringContaining("change of plans"), + expect.anything(), + h.manager, + ); + expect(requestRepoInTx.update).toHaveBeenCalledWith( + "req-1", + expect.objectContaining({ + status: ELeaveRequestStatus.CANCELLED, + cancelledReason: "change of plans", + }), + ); + expect(result.status).toBe(ELeaveRequestStatus.CANCELLED); + }); + + it("idempotently skips an entry that has already been reversed", async () => { + const h = makeHarness(); + h.requests.findById.mockResolvedValue( + makeRequest({ status: ELeaveRequestStatus.APPROVED, employeeId: "emp-2" }), + ); + // entry-1 is the original DEDUCTION; entry-2 already reverses it. + h.balances.entriesForSource.mockResolvedValue([ + { id: "entry-1", reversesId: null }, + { id: "entry-2", reversesId: "entry-1" }, + ]); + const requestRepoInTx = { update: jest.fn().mockResolvedValue(undefined) }; + h.manager.getRepository.mockReturnValue(requestRepoInTx); + + await h.service.cancel( + "req-1", + "already reversed", + makeActor({ employeeId: "emp-1", organizationId: "org-1" }), + ); + + expect(h.balances.reverse).not.toHaveBeenCalled(); + expect(requestRepoInTx.update).toHaveBeenCalled(); + }); +}); diff --git a/apps/edr-hr-api/src/modules/payroll/services/payroll-calculator.service.spec.ts b/apps/edr-hr-api/src/modules/payroll/services/payroll-calculator.service.spec.ts new file mode 100644 index 000000000..d3a49ed9c --- /dev/null +++ b/apps/edr-hr-api/src/modules/payroll/services/payroll-calculator.service.spec.ts @@ -0,0 +1,521 @@ +import { + CalculationInput, + PayrollCalculatorService, +} from "./payroll-calculator.service"; +import { + EComponentCalculation, + EComponentType, + SalaryComponent, +} from "../entities/salary-component.entity"; +import { IncomeTaxBracket } from "../entities/statutory.entity"; +import { INCOME_TAX_BRACKETS_979_2016, RESERVED_CODES } from "../statutory-payroll"; + +/** + * The real national Schedule B bands (Proc. 979/2016 Art. 11), shaped as the + * entity rows `TaxService.computeTax` expects. Using the actual bands — not a + * simplified stand-in — is what makes the worked examples below real, checkable + * arithmetic rather than a tautology against a fake schedule. + */ +const BRACKETS = INCOME_TAX_BRACKETS_979_2016.bands.map((band) => ({ + lowerBound: band.lowerBound, + upperBound: band.upperBound, + rate: band.rate, + deduction: band.deduction, +})) as unknown as IncomeTaxBracket[]; + +function component(overrides: Partial = {}): SalaryComponent { + return { + id: "comp-1", + organizationId: "org-1", + code: "CODE", + name: { am: "test", en: "Test" }, + componentType: EComponentType.ALLOWANCE, + calculation: EComponentCalculation.FIXED, + defaultAmount: null, + defaultRate: null, + isTaxable: true, + isPensionable: false, + taxExemptAmount: null, + taxExemptRateOfBasic: null, + affectsNetPay: true, + statuteReference: null, + sortOrder: 100, + isActive: true, + createdBy: null, + updatedBy: null, + ...overrides, + } as SalaryComponent; +} + +function baseInput(overrides: Partial = {}): CalculationInput { + return { + basicSalary: 10000, + components: [], + brackets: BRACKETS, + pensionEmployeeRate: 0.07, + pensionEmployerRate: 0.11, + overtimePay: 0, + overtimeHours: 0, + absenceDeductionRate: 0, + isPensionEligible: true, + ...overrides, + }; +} + +describe("PayrollCalculatorService.calculate — worked examples", () => { + let service: PayrollCalculatorService; + + beforeEach(() => { + service = new PayrollCalculatorService(); + }); + + /** + * The single regression test this whole file exists for: income tax must be + * computed on taxable income NET of the employee's own pension deduction, not + * on gross taxable income. Basic-only, full attendance, pension-eligible: + * + * basic = 10,000.00 + * pension employee = 10,000.00 × 7% = 700.00 + * pension employer = 10,000.00 × 11% = 1,100.00 + * taxable income = 10,000.00 − 700.00 = 9,300.00 + * band = 7,800.01–10,900.00 (30%, ded. 955.00) + * income tax = 9,300.00 × 0.30 − 955.00 = 1,835.00 + * net pay = 10,000.00 − 700.00 − 1,835.00 = 7,465.00 + * + * Taxing GROSS taxable income (10,000.00, same band) instead would give + * 10,000 × 0.30 − 955 = 2,045.00 — a different, wrong, number. This test + * would fail if that ordering bug were reintroduced. + */ + it("computes income tax on taxable income net of the employee pension deduction", () => { + const result = service.calculate(baseInput()); + + expect(result.basicSalary).toBe(10000); + expect(result.grossPay).toBe(10000); + expect(result.pensionableIncome).toBe(10000); + expect(result.pensionEmployee).toBe(700); + expect(result.pensionEmployer).toBe(1100); + expect(result.taxableIncome).toBe(9300); + expect(result.incomeTax).toBe(1835); + expect(result.incomeTax).not.toBe(2045); // the gross-taxed wrong answer + expect(result.totalDeductions).toBe(700 + 1835); + expect(result.netPay).toBe(7465); + + // The employer's contribution is reported but plays no part in the deduction total. + const employerLine = result.lines.find((l) => l.code === RESERVED_CODES.pensionEmployer); + expect(employerLine?.affectsNetPay).toBe(false); + }); + + /** + * A full slip: unpaid-absence pro-ration, a capped allowance, an uncapped + * allowance, a percent-of-basic allowance, overtime, a plain other deduction, + * and both sides of pension — worked by hand end to end. + * + * basic (90% paid, 10% unpaid absence) = 10,000.00 × 0.90 = 9,000.00 + * transport allowance = 3,000.00 (exempt: min(2,200, 25% of + * unprorated basic 10,000=2,500) = 2,200 + * → taxable 800.00) + * housing allowance (no cap) = 1,500.00 (fully taxable) + * position allowance (5% of unprorated + * basic 10,000) = 500.00 (fully taxable) + * overtime (always fully taxable, never + * pensionable) = 150.00 + * ──────────────────────────────────────────────── + * gross pay = 9,000+3,000+1,500+500+150 = 14,150.00 + * pensionable = basic only (all allowances non-pensionable) = 9,000.00 + * taxable earnings = 9,000+800+1,500+500+150 = 11,950.00 + * pension employee = 9,000 × 7% = 630.00 + * pension employer = 9,000 × 11% = 990.00 + * taxable income = 11,950 − 630 = 11,320.00 + * band = 10,900.01+ (35%, ded. 1,500.00) + * income tax = 11,320 × 0.35 − 1,500 = 2,462.00 + * other deduction (loan) = 200.00 + * total deductions = 630 + 2,462 + 200 = 3,292.00 + * net pay = 14,150 − 3,292 = 10,858.00 + */ + it("computes a full payslip: absence pro-ration, capped/uncapped allowances, overtime, other deductions, both pension sides", () => { + const transport = component({ + id: "transport-1", + code: "TRANSPORT", + componentType: EComponentType.ALLOWANCE, + calculation: EComponentCalculation.FIXED, + isTaxable: true, + isPensionable: false, + taxExemptAmount: "2200.00", + taxExemptRateOfBasic: "0.2500", + sortOrder: 20, + }); + const housing = component({ + id: "housing-1", + code: "HOUSING", + componentType: EComponentType.ALLOWANCE, + calculation: EComponentCalculation.FIXED, + isTaxable: true, + isPensionable: false, + sortOrder: 30, + }); + const position = component({ + id: "position-1", + code: "POSITION", + componentType: EComponentType.ALLOWANCE, + calculation: EComponentCalculation.PERCENT_OF_BASIC, + isTaxable: true, + isPensionable: false, + sortOrder: 40, + }); + const loan = component({ + id: "loan-1", + code: "LOAN", + componentType: EComponentType.DEDUCTION, + calculation: EComponentCalculation.FIXED, + sortOrder: 90, + }); + + const result = service.calculate( + baseInput({ + basicSalary: 10000, + absenceDeductionRate: 0.1, + overtimePay: 150, + overtimeHours: 10, + components: [ + { component: transport, amount: "3000" }, + { component: housing, amount: "1500" }, + { component: position, rate: "0.05" }, + { component: loan, amount: "200" }, + ], + }), + ); + + expect(result.basicSalary).toBe(9000); + expect(result.grossPay).toBe(14150); + expect(result.pensionableIncome).toBe(9000); + expect(result.pensionEmployee).toBe(630); + expect(result.pensionEmployer).toBe(990); + expect(result.taxableIncome).toBe(11320); + expect(result.incomeTax).toBe(2462); + expect(result.totalDeductions).toBe(630 + 2462 + 200); + expect(result.netPay).toBe(10858); + + const transportLine = result.lines.find((l) => l.code === "TRANSPORT"); + expect(transportLine?.amount).toBe(3000); + expect(transportLine?.taxableAmount).toBe(800); + expect(transportLine?.basis).toBe("2200.00 exempt, 800.00 taxable"); + + const housingLine = result.lines.find((l) => l.code === "HOUSING"); + expect(housingLine?.amount).toBe(1500); + expect(housingLine?.taxableAmount).toBe(1500); + expect(housingLine?.basis).toBeNull(); + + const positionLine = result.lines.find((l) => l.code === "POSITION"); + expect(positionLine?.amount).toBe(500); + + const overtimeLine = result.lines.find((l) => l.code === RESERVED_CODES.overtime); + expect(overtimeLine?.amount).toBe(150); + expect(overtimeLine?.taxableAmount).toBe(150); + expect(overtimeLine?.isTaxable).toBe(true); + expect(overtimeLine?.isPensionable).toBe(false); + + const loanLine = result.lines.find((l) => l.code === "LOAN"); + expect(loanLine?.amount).toBe(200); + expect(loanLine?.taxableAmount).toBe(0); + expect(loanLine?.affectsNetPay).toBe(true); + + const basicLine = result.lines.find((l) => l.componentType === EComponentType.BASIC); + expect(basicLine?.basis).toBe("90% of 10000.00 — unpaid absence"); + }); +}); + +describe("PayrollCalculatorService.calculate — allowance taxable-portion cap", () => { + let service: PayrollCalculatorService; + + beforeEach(() => { + service = new PayrollCalculatorService(); + }); + + const allowanceLine = (input: CalculationInput) => + service.calculate(input).lines.find((l) => l.code === "ALLOW"); + + it("caps the exempt portion at a fixed amount when only taxExemptAmount is set", () => { + const allowance = component({ + code: "ALLOW", + componentType: EComponentType.ALLOWANCE, + taxExemptAmount: "1000.00", + taxExemptRateOfBasic: null, + }); + const line = allowanceLine( + baseInput({ components: [{ component: allowance, amount: "1500" }] }), + ); + expect(line?.taxableAmount).toBe(500); + expect(line?.basis).toBe("1000.00 exempt, 500.00 taxable"); + }); + + it("caps the exempt portion at a percentage of basic when only taxExemptRateOfBasic is set", () => { + const allowance = component({ + code: "ALLOW", + componentType: EComponentType.ALLOWANCE, + taxExemptAmount: null, + taxExemptRateOfBasic: "0.1000", + }); + const line = allowanceLine( + baseInput({ + basicSalary: 10000, + components: [{ component: allowance, amount: "1500" }], + }), + ); + // exempt = 10% of 10,000 = 1,000 → taxable 500 + expect(line?.taxableAmount).toBe(500); + }); + + it("takes the LESSER of the two caps — the transport-allowance rule — not the greater", () => { + const allowance = component({ + code: "ALLOW", + componentType: EComponentType.ALLOWANCE, + taxExemptAmount: "2200.00", + taxExemptRateOfBasic: "0.2500", + }); + // 25% of 10,000 = 2,500 > 2,200 fixed cap. The LOWER (2,200) must win. + const line = allowanceLine( + baseInput({ + basicSalary: 10000, + components: [{ component: allowance, amount: "3000" }], + }), + ); + expect(line?.taxableAmount).toBe(800); // NOT 500 (which the higher 2,500 cap would give) + }); + + it("is fully exempt when the allowance amount is below the cap", () => { + const allowance = component({ + code: "ALLOW", + componentType: EComponentType.ALLOWANCE, + taxExemptAmount: "2200.00", + }); + const line = allowanceLine( + baseInput({ components: [{ component: allowance, amount: "2000" }] }), + ); + expect(line?.taxableAmount).toBe(0); + expect(line?.basis).toBe("fully exempt (2000.00)"); + }); + + it("has no exempt cap at all when neither field is set — fully taxable", () => { + const allowance = component({ + code: "ALLOW", + componentType: EComponentType.ALLOWANCE, + taxExemptAmount: null, + taxExemptRateOfBasic: null, + }); + const line = allowanceLine( + baseInput({ components: [{ component: allowance, amount: "1500" }] }), + ); + expect(line?.taxableAmount).toBe(1500); + expect(line?.basis).toBeNull(); + }); + + it("is fully non-taxable, ignoring any cap, when the component itself is not taxable", () => { + const allowance = component({ + code: "ALLOW", + componentType: EComponentType.ALLOWANCE, + isTaxable: false, + taxExemptAmount: "100.00", + }); + const line = allowanceLine( + baseInput({ components: [{ component: allowance, amount: "1500" }] }), + ); + expect(line?.taxableAmount).toBe(0); + expect(line?.basis).toBe("not taxable"); + }); +}); + +describe("PayrollCalculatorService.calculate — pension", () => { + let service: PayrollCalculatorService; + + beforeEach(() => { + service = new PayrollCalculatorService(); + }); + + it("charges neither pension side and does not reduce taxable income when the employee is ineligible", () => { + const result = service.calculate(baseInput({ isPensionEligible: false })); + + expect(result.pensionEmployee).toBe(0); + expect(result.pensionEmployer).toBe(0); + expect(result.lines.find((l) => l.code === RESERVED_CODES.pensionEmployee)).toBeUndefined(); + expect(result.lines.find((l) => l.code === RESERVED_CODES.pensionEmployer)).toBeUndefined(); + // Taxable income equals taxable earnings verbatim — nothing was deducted first. + expect(result.taxableIncome).toBe(result.grossPay); + }); + + it("records the employer contribution with affectsNetPay:false and excludes it from totalDeductions", () => { + const result = service.calculate(baseInput()); + + const employerLine = result.lines.find((l) => l.code === RESERVED_CODES.pensionEmployer); + expect(employerLine).toBeDefined(); + expect(employerLine?.componentType).toBe(EComponentType.EMPLOYER_CONTRIBUTION); + expect(employerLine?.affectsNetPay).toBe(false); + expect(result.totalDeductions).toBe(result.pensionEmployee + result.incomeTax); + }); +}); + +describe("PayrollCalculatorService.calculate — other deductions", () => { + let service: PayrollCalculatorService; + + beforeEach(() => { + service = new PayrollCalculatorService(); + }); + + it("skips a DEDUCTION component whose calculation is STATUTORY — those are computed by the engine, not the structure", () => { + const fakeStatutory = component({ + code: "CUSTOM_STAT", + componentType: EComponentType.DEDUCTION, + calculation: EComponentCalculation.STATUTORY, + defaultAmount: "500.00", + }); + const result = service.calculate( + baseInput({ components: [{ component: fakeStatutory, amount: "500" }] }), + ); + expect(result.lines.find((l) => l.code === "CUSTOM_STAT")).toBeUndefined(); + }); + + it("prices a PERCENT_OF_GROSS deduction off the actual computed gross pay", () => { + const grossDeduction = component({ + code: "UNION_DUES", + componentType: EComponentType.DEDUCTION, + calculation: EComponentCalculation.PERCENT_OF_GROSS, + }); + const result = service.calculate( + baseInput({ + basicSalary: 10000, + components: [{ component: grossDeduction, rate: "0.02" }], + }), + ); + // gross pay is basic-only here (10,000) since no allowances were added. + const line = result.lines.find((l) => l.code === "UNION_DUES"); + expect(line?.amount).toBe(200); // 2% of 10,000 + }); + + it("drops an allowance or deduction whose computed amount is zero or negative — no line is emitted", () => { + const zeroAllowance = component({ + code: "ZERO", + componentType: EComponentType.ALLOWANCE, + calculation: EComponentCalculation.FIXED, + defaultAmount: null, + }); + const result = service.calculate( + baseInput({ components: [{ component: zeroAllowance }] }), + ); + expect(result.lines.find((l) => l.code === "ZERO")).toBeUndefined(); + }); + + it("falls back to the component's own defaultAmount/defaultRate when the structure line has no override", () => { + const fixedByDefault = component({ + code: "FIXED_DEFAULT", + componentType: EComponentType.ALLOWANCE, + calculation: EComponentCalculation.FIXED, + defaultAmount: "750.00", + }); + const percentByDefault = component({ + code: "PERCENT_DEFAULT", + componentType: EComponentType.ALLOWANCE, + calculation: EComponentCalculation.PERCENT_OF_BASIC, + defaultRate: "0.0500", + }); + const result = service.calculate( + baseInput({ + basicSalary: 10000, + components: [{ component: fixedByDefault }, { component: percentByDefault }], + }), + ); + expect(result.lines.find((l) => l.code === "FIXED_DEFAULT")?.amount).toBe(750); + expect(result.lines.find((l) => l.code === "PERCENT_DEFAULT")?.amount).toBe(500); + }); +}); + +describe("PayrollCalculatorService.calculate — line identity and ordering", () => { + let service: PayrollCalculatorService; + + beforeEach(() => { + service = new PayrollCalculatorService(); + }); + + it("uses the reserved default code/name for BASIC when no BASIC component is supplied", () => { + const result = service.calculate(baseInput()); + const basicLine = result.lines.find((l) => l.componentType === EComponentType.BASIC); + expect(basicLine?.code).toBe(RESERVED_CODES.basic); + expect(basicLine?.name.en).toBe("Basic salary"); + expect(basicLine?.salaryComponentId).toBeNull(); + expect(basicLine?.isPensionable).toBe(true); + }); + + it("uses the supplied BASIC component's own code/name/id/sortOrder/isPensionable when one is given", () => { + const basicComponent = component({ + id: "basic-custom", + code: "BASE", + name: { am: "custom", en: "Custom basic" }, + componentType: EComponentType.BASIC, + isPensionable: false, + sortOrder: 5, + }); + const result = service.calculate( + baseInput({ components: [{ component: basicComponent }] }), + ); + const basicLine = result.lines.find((l) => l.componentType === EComponentType.BASIC); + expect(basicLine?.code).toBe("BASE"); + expect(basicLine?.name.en).toBe("Custom basic"); + expect(basicLine?.salaryComponentId).toBe("basic-custom"); + expect(basicLine?.isPensionable).toBe(false); + expect(basicLine?.sortOrder).toBe(5); + }); + + it("uses the reserved default name for OVERTIME when no OVERTIME component is supplied", () => { + const result = service.calculate(baseInput({ overtimePay: 100, overtimeHours: 5 })); + const line = result.lines.find((l) => l.code === RESERVED_CODES.overtime); + expect(line?.name.en).toBe("Overtime"); + expect(line?.salaryComponentId).toBeNull(); + expect(line?.basis).toBe("5 hour(s) of approved overtime"); + }); + + it("omits the overtime line entirely when overtimePay is zero", () => { + const result = service.calculate(baseInput({ overtimePay: 0, overtimeHours: 0 })); + expect(result.lines.find((l) => l.code === RESERVED_CODES.overtime)).toBeUndefined(); + }); + + it("returns lines sorted by sortOrder ascending, regardless of push order", () => { + const lowSort = component({ + code: "LOW_SORT", + componentType: EComponentType.ALLOWANCE, + defaultAmount: "1", + sortOrder: 1, + }); + const result = service.calculate( + baseInput({ components: [{ component: lowSort }] }), + ); + const sortOrders = result.lines.map((l) => l.sortOrder); + const sorted = [...sortOrders].sort((a, b) => a - b); + expect(sortOrders).toEqual(sorted); + expect(result.lines[0].code).toBe("LOW_SORT"); + }); + + it("omits the income tax line when both incomeTax and taxableIncome are zero", () => { + const result = service.calculate(baseInput({ basicSalary: 0 })); + expect(result.incomeTax).toBe(0); + expect(result.taxableIncome).toBe(0); + expect(result.lines.find((l) => l.code === RESERVED_CODES.incomeTax)).toBeUndefined(); + }); +}); + +describe("PayrollCalculatorService.calculate — absence pro-ration clamping", () => { + let service: PayrollCalculatorService; + + beforeEach(() => { + service = new PayrollCalculatorService(); + }); + + it("clamps an absenceDeductionRate above 1 to a fully unpaid (zero) basic", () => { + const result = service.calculate(baseInput({ absenceDeductionRate: 1.5 })); + expect(result.basicSalary).toBe(0); + }); + + it("clamps a negative absenceDeductionRate to zero — full pay, no proration basis text", () => { + const result = service.calculate(baseInput({ absenceDeductionRate: -0.2 })); + expect(result.basicSalary).toBe(10000); + const basicLine = result.lines.find((l) => l.componentType === EComponentType.BASIC); + expect(basicLine?.basis).toBeNull(); + }); +}); diff --git a/apps/edr-hr-api/src/modules/payroll/services/payroll-runs.service.spec.ts b/apps/edr-hr-api/src/modules/payroll/services/payroll-runs.service.spec.ts new file mode 100644 index 000000000..26a222b21 --- /dev/null +++ b/apps/edr-hr-api/src/modules/payroll/services/payroll-runs.service.spec.ts @@ -0,0 +1,630 @@ +import { BadRequestException, ForbiddenException } from "@nestjs/common"; + +import type { ActorContext } from "../../employees/employees.service"; +import { EComponentType } from "../entities/salary-component.entity"; +import { + EPayrollRunStatus, + PayrollRun, + Payslip, + PayslipLine, +} from "../entities/payroll-run.entity"; +import { RESERVED_CODES } from "../statutory-payroll"; +import type { CalculationResult } from "./payroll-calculator.service"; +import { PayrollRunsService } from "./payroll-runs.service"; + +function actor(overrides: Partial = {}): ActorContext { + return { + employeeId: "actor-emp-1", + userId: "user-1", + organizationId: "org-1", + isSuperAdmin: false, + ...overrides, + }; +} + +function run(overrides: Partial = {}): PayrollRun { + return { + id: "run-1", + organizationId: "org-1", + periodStart: "2026-07-01", + periodEnd: "2026-07-30", + paymentDate: null, + status: EPayrollRunStatus.DRAFT, + note: null, + employeeCount: 0, + totalGross: "0.00", + totalDeductions: "0.00", + totalNet: "0.00", + totalIncomeTax: "0.00", + totalPensionEmployee: "0.00", + totalPensionEmployer: "0.00", + calculatedAt: null, + approvedByEmployeeId: null, + approvedAt: null, + createdBy: null, + updatedBy: null, + ...overrides, + } as PayrollRun; +} + +function calcResult(overrides: Partial = {}): CalculationResult { + return { + lines: [ + { + code: RESERVED_CODES.basic, + name: { am: "መሠረታዊ ደመወዝ", en: "Basic salary" }, + componentType: EComponentType.BASIC, + salaryComponentId: null, + amount: 10000, + taxableAmount: 10000, + isTaxable: true, + isPensionable: true, + affectsNetPay: true, + basis: null, + sortOrder: 10, + }, + ], + basicSalary: 10000, + grossPay: 10000, + taxableIncome: 9300, + pensionableIncome: 10000, + incomeTax: 1835, + pensionEmployee: 700, + pensionEmployer: 1100, + totalDeductions: 2535, + netPay: 7465, + ...overrides, + }; +} + +/** In-memory stand-in for the manager's `getRepository(Entity)` calls inside the transaction. */ +function makeManager(onRunUpdate: (patch: Record) => void) { + let payslipSeq = 0; + let lineSeq = 0; + const payslipRepo = { + delete: jest.fn().mockResolvedValue(undefined), + create: jest.fn((data: Record) => data), + save: jest.fn(async (data: Record) => ({ + id: `payslip-${++payslipSeq}`, + ...data, + })), + }; + const lineRepo = { + create: jest.fn((data: Record) => data), + save: jest.fn(async (data: Record) => ({ + id: `line-${++lineSeq}`, + ...data, + })), + }; + const runRepo = { + update: jest.fn(async (_id: string, patch: Record) => { + onRunUpdate(patch); + }), + }; + const getRepository = jest.fn((entity: unknown) => { + if (entity === Payslip) return payslipRepo; + if (entity === PayslipLine) return lineRepo; + if (entity === PayrollRun) return runRepo; + throw new Error("unexpected entity passed to manager.getRepository in test mock"); + }); + return { getRepository, payslipRepo, lineRepo, runRepo }; +} + +function build() { + let stored: PayrollRun | undefined; + const applyUpdate = (patch: Record) => { + stored = { ...(stored as PayrollRun), ...patch } as PayrollRun; + }; + + const manager = makeManager(applyUpdate); + const dataSource = { + transaction: jest.fn((cb: (m: unknown) => unknown) => cb(manager)), + }; + const runs = { + findOne: jest.fn(async () => stored ?? null), + update: jest.fn(async (_id: string, patch: Record) => { + applyUpdate(patch); + }), + save: jest.fn(), + create: jest.fn((d: Record) => d), + createQueryBuilder: jest.fn(), + }; + const payslips = { + find: jest.fn().mockResolvedValue([]), + createQueryBuilder: jest.fn(), + }; + const salaries = { findPayableOn: jest.fn().mockResolvedValue([]) }; + const components = { findActive: jest.fn().mockResolvedValue([]) }; + const structures = { findWithLines: jest.fn() }; + const calculator = { calculate: jest.fn() }; + const tax = { + bracketsOn: jest.fn().mockResolvedValue([]), + rateOn: jest.fn().mockResolvedValue(0.07), + }; + const employees = { findByEmployeeId: jest.fn().mockResolvedValue(null) }; + const overtime = { approvedTotals: jest.fn().mockResolvedValue([]) }; + const attendance = { findRange: jest.fn().mockResolvedValue([]) }; + const schedules = { resolveFor: jest.fn().mockResolvedValue(null) }; + const leaveSettings = { resolve: jest.fn().mockResolvedValue({ weekendDays: [0, 6] }) }; + const workingDays = { countWorkingDays: jest.fn().mockResolvedValue(22) }; + + const service = new PayrollRunsService( + dataSource as never, + runs as never, + payslips as never, + salaries as never, + components as never, + structures as never, + calculator as never, + tax as never, + employees as never, + overtime as never, + attendance as never, + schedules as never, + leaveSettings as never, + workingDays as never, + ); + + return { + service, + manager, + dataSource, + runs, + payslips, + salaries, + components, + structures, + calculator, + tax, + employees, + overtime, + attendance, + schedules, + leaveSettings, + workingDays, + use: (row: PayrollRun) => { + stored = row; + }, + getStored: () => stored, + }; +} + +const activeComponent = (over: Record = {}) => ({ + id: "comp-basic", + code: RESERVED_CODES.basic, + name: { am: "x", en: "Basic" }, + componentType: EComponentType.BASIC, + isActive: true, + ...over, +}); + +describe("PayrollRunsService.calculate", () => { + it.each([ + EPayrollRunStatus.APPROVED, + EPayrollRunStatus.PAID, + EPayrollRunStatus.CANCELLED, + ])("refuses to (re)calculate a %s run", async (status) => { + const { service, use } = build(); + use(run({ status })); + + await expect(service.calculate("run-1", actor())).rejects.toBeInstanceOf( + BadRequestException, + ); + }); + + it("rebuilds from scratch: deletes existing payslips before writing any new one, one payslip+lines per employee, aggregated totals, status CALCULATED", async () => { + const ctx = build(); + ctx.use(run({ status: EPayrollRunStatus.DRAFT })); + + ctx.salaries.findPayableOn.mockResolvedValue([ + { employeeId: "emp-1", basicSalary: "10000", salaryStructureId: "struct-1" }, + { employeeId: "emp-2", basicSalary: "8000", salaryStructureId: null }, + ]); + ctx.structures.findWithLines.mockResolvedValue({ + id: "struct-1", + lines: [{ salaryComponent: activeComponent(), amount: null, rate: null }], + }); + ctx.components.findActive.mockResolvedValue([activeComponent()]); + ctx.employees.findByEmployeeId.mockImplementation(async (employeeId: string) => ({ + employeeId, + employeeNumber: `E-${employeeId}`, + isPensionEligible: true, + salaryMode: "BANK", + bankAccountNumber: "123456", + })); + + const resultsByBasic: Record = { + 10000: calcResult({ + grossPay: 10000, + totalDeductions: 2535, + netPay: 7465, + incomeTax: 1835, + pensionEmployee: 700, + pensionEmployer: 1100, + }), + 8000: calcResult({ + basicSalary: 8000, + grossPay: 8000, + totalDeductions: 2000, + netPay: 6000, + incomeTax: 1300, + pensionEmployee: 560, + pensionEmployer: 880, + }), + }; + ctx.calculator.calculate.mockImplementation( + (input: { basicSalary: number }) => resultsByBasic[input.basicSalary], + ); + + await ctx.service.calculate("run-1", actor({ userId: "actor-user" })); + + // Delete-then-rebuild ordering. + expect(ctx.manager.payslipRepo.delete).toHaveBeenCalledWith({ payrollRunId: "run-1" }); + expect(ctx.manager.payslipRepo.delete.mock.invocationCallOrder[0]).toBeLessThan( + ctx.manager.payslipRepo.save.mock.invocationCallOrder[0], + ); + + expect(ctx.manager.payslipRepo.save).toHaveBeenCalledTimes(2); + expect(ctx.manager.lineRepo.save).toHaveBeenCalledTimes(2); // one line per employee's fixture result + + const savedLine = ctx.manager.lineRepo.save.mock.calls[0][0]; + expect(savedLine.code).toBe(RESERVED_CODES.basic); + expect(savedLine.amount).toBe("10000.00"); + + expect(ctx.manager.runRepo.update).toHaveBeenCalledWith( + "run-1", + expect.objectContaining({ + status: EPayrollRunStatus.CALCULATED, + employeeCount: 2, + totalGross: "18000.00", + totalDeductions: "4535.00", + totalNet: "13465.00", + totalIncomeTax: "3135.00", + totalPensionEmployee: "1260.00", + totalPensionEmployer: "1980.00", + calculatedAt: expect.any(Date), + updatedBy: "actor-user", + }), + ); + + // The applied update is visible on the next read of the run. + expect(ctx.getStored()?.status).toBe(EPayrollRunStatus.CALCULATED); + }); + + it("degrades to basic-plus-statutory only when an employee has no salary structure", async () => { + const ctx = build(); + ctx.use(run({ status: EPayrollRunStatus.CALCULATED })); // recalculation is allowed while CALCULATED + + ctx.salaries.findPayableOn.mockResolvedValue([ + { employeeId: "emp-2", basicSalary: "8000", salaryStructureId: null }, + ]); + ctx.components.findActive.mockResolvedValue([ + activeComponent({ id: "c-basic", code: RESERVED_CODES.basic, componentType: EComponentType.BASIC }), + activeComponent({ + id: "c-tax", + code: RESERVED_CODES.incomeTax, + componentType: EComponentType.DEDUCTION, + }), + activeComponent({ + id: "c-pe", + code: RESERVED_CODES.pensionEmployee, + componentType: EComponentType.DEDUCTION, + }), + activeComponent({ + id: "c-per", + code: RESERVED_CODES.pensionEmployer, + componentType: EComponentType.EMPLOYER_CONTRIBUTION, + }), + // Must be excluded: an ordinary allowance is neither BASIC nor one of the + // three reserved statutory codes. + activeComponent({ id: "c-transport", code: "TRANSPORT", componentType: EComponentType.ALLOWANCE }), + ]); + ctx.employees.findByEmployeeId.mockResolvedValue({ + employeeId: "emp-2", + employeeNumber: "E-emp-2", + isPensionEligible: true, + salaryMode: "BANK", + bankAccountNumber: null, + }); + ctx.calculator.calculate.mockReturnValue(calcResult({ basicSalary: 8000, grossPay: 8000 })); + + await ctx.service.calculate("run-1", actor()); + + const passedInput = ctx.calculator.calculate.mock.calls[0][0]; + const codes = passedInput.components.map((c: { component: { code: string } }) => c.component.code); + expect(codes).toEqual( + expect.arrayContaining([ + RESERVED_CODES.basic, + RESERVED_CODES.incomeTax, + RESERVED_CODES.pensionEmployee, + RESERVED_CODES.pensionEmployer, + ]), + ); + expect(codes).not.toContain("TRANSPORT"); + expect(passedInput.components.every((c: { amount: unknown; rate: unknown }) => c.amount === null && c.rate === null)).toBe(true); + }); + + it("skips an employee with no HR profile — no payslip is written and employeeCount reflects only the priced employees", async () => { + const ctx = build(); + ctx.use(run({ status: EPayrollRunStatus.DRAFT })); + ctx.salaries.findPayableOn.mockResolvedValue([ + { employeeId: "emp-1", basicSalary: "10000", salaryStructureId: null }, + { employeeId: "emp-ghost", basicSalary: "5000", salaryStructureId: null }, + ]); + ctx.components.findActive.mockResolvedValue([activeComponent()]); + ctx.employees.findByEmployeeId.mockImplementation(async (employeeId: string) => + employeeId === "emp-1" + ? { employeeId, employeeNumber: "E-1", isPensionEligible: true, salaryMode: "BANK" } + : null, + ); + ctx.calculator.calculate.mockReturnValue(calcResult()); + + await ctx.service.calculate("run-1", actor()); + + expect(ctx.calculator.calculate).toHaveBeenCalledTimes(1); + expect(ctx.manager.payslipRepo.save).toHaveBeenCalledTimes(1); + expect(ctx.manager.runRepo.update).toHaveBeenCalledWith( + "run-1", + expect.objectContaining({ employeeCount: 1 }), + ); + }); + + it("keeps only the first row per employee when findPayableOn returns overlapping history", async () => { + const ctx = build(); + ctx.use(run({ status: EPayrollRunStatus.DRAFT })); + ctx.salaries.findPayableOn.mockResolvedValue([ + { employeeId: "emp-1", basicSalary: "12000", salaryStructureId: null }, // newest — first in array + { employeeId: "emp-1", basicSalary: "9000", salaryStructureId: null }, // stale, must be ignored + ]); + ctx.components.findActive.mockResolvedValue([activeComponent()]); + ctx.employees.findByEmployeeId.mockResolvedValue({ + employeeId: "emp-1", + employeeNumber: "E-1", + isPensionEligible: true, + salaryMode: "BANK", + }); + ctx.calculator.calculate.mockReturnValue(calcResult()); + + await ctx.service.calculate("run-1", actor()); + + expect(ctx.calculator.calculate).toHaveBeenCalledTimes(1); + expect(ctx.calculator.calculate.mock.calls[0][0].basicSalary).toBe(12000); + }); + + it("computes absenceDeductionRate = absentDays / periodWorkingDays (HALF_DAY counts as 0.5 absent)", async () => { + const ctx = build(); + ctx.use(run({ status: EPayrollRunStatus.DRAFT })); + ctx.workingDays.countWorkingDays.mockResolvedValue(20); + ctx.salaries.findPayableOn.mockResolvedValue([ + { employeeId: "emp-1", basicSalary: "10000", salaryStructureId: null }, + ]); + ctx.components.findActive.mockResolvedValue([activeComponent()]); + ctx.employees.findByEmployeeId.mockResolvedValue({ + employeeId: "emp-1", + employeeNumber: "E-1", + isPensionEligible: true, + salaryMode: "BANK", + }); + ctx.attendance.findRange.mockResolvedValue([ + { status: "ABSENT" }, + { status: "ABSENT" }, + { status: "HALF_DAY" }, + { status: "PRESENT" }, + { status: "LATE" }, + ]); + ctx.calculator.calculate.mockReturnValue(calcResult()); + + await ctx.service.calculate("run-1", actor()); + + // absentDays = 2 + 0.5 = 2.5; rate = 2.5 / 20 = 0.125 + expect(ctx.calculator.calculate.mock.calls[0][0].absenceDeductionRate).toBeCloseTo(0.125); + }); + + it("guards against division by zero — zero working days in the period degrades to no absence deduction", async () => { + const ctx = build(); + ctx.use(run({ status: EPayrollRunStatus.DRAFT })); + ctx.workingDays.countWorkingDays.mockResolvedValue(0); + ctx.salaries.findPayableOn.mockResolvedValue([ + { employeeId: "emp-1", basicSalary: "10000", salaryStructureId: null }, + ]); + ctx.components.findActive.mockResolvedValue([activeComponent()]); + ctx.employees.findByEmployeeId.mockResolvedValue({ + employeeId: "emp-1", + employeeNumber: "E-1", + isPensionEligible: true, + salaryMode: "BANK", + }); + ctx.attendance.findRange.mockResolvedValue([{ status: "ABSENT" }, { status: "ABSENT" }]); + ctx.calculator.calculate.mockReturnValue(calcResult()); + + await ctx.service.calculate("run-1", actor()); + + expect(ctx.calculator.calculate.mock.calls[0][0].absenceDeductionRate).toBe(0); + }); +}); + +describe("PayrollRunsService.approve", () => { + it("signs off a CALCULATED, non-empty run, attributing it to the actor's employee id", async () => { + const ctx = build(); + ctx.use(run({ status: EPayrollRunStatus.CALCULATED, employeeCount: 3 })); + + await ctx.service.approve("run-1", actor({ employeeId: "approver-1", userId: "u-1" })); + + expect(ctx.runs.update).toHaveBeenCalledWith( + "run-1", + expect.objectContaining({ + status: EPayrollRunStatus.APPROVED, + approvedByEmployeeId: "approver-1", + approvedAt: expect.any(Date), + updatedBy: "u-1", + }), + ); + expect(ctx.getStored()?.status).toBe(EPayrollRunStatus.APPROVED); + }); + + it("refuses a run that is not CALCULATED", async () => { + const ctx = build(); + ctx.use(run({ status: EPayrollRunStatus.DRAFT, employeeCount: 3 })); + + await expect(ctx.service.approve("run-1", actor())).rejects.toBeInstanceOf( + BadRequestException, + ); + expect(ctx.runs.update).not.toHaveBeenCalled(); + }); + + it("refuses an empty run — approving it would record that nobody was paid", async () => { + const ctx = build(); + ctx.use(run({ status: EPayrollRunStatus.CALCULATED, employeeCount: 0 })); + + await expect(ctx.service.approve("run-1", actor())).rejects.toBeInstanceOf( + BadRequestException, + ); + expect(ctx.runs.update).not.toHaveBeenCalled(); + }); + + it("refuses when the actor has no employeeId — approval must be attributable", async () => { + const ctx = build(); + ctx.use(run({ status: EPayrollRunStatus.CALCULATED, employeeCount: 2 })); + + await expect( + ctx.service.approve("run-1", actor({ employeeId: null })), + ).rejects.toBeInstanceOf(ForbiddenException); + expect(ctx.runs.update).not.toHaveBeenCalled(); + }); + + it("refuses to recalculate a run once it has been approved", async () => { + const ctx = build(); + ctx.use(run({ status: EPayrollRunStatus.CALCULATED, employeeCount: 1 })); + + await ctx.service.approve("run-1", actor()); + expect(ctx.getStored()?.status).toBe(EPayrollRunStatus.APPROVED); + + await expect(ctx.service.calculate("run-1", actor())).rejects.toBeInstanceOf( + BadRequestException, + ); + // No transaction should even have been opened for the refused recalculation attempt. + expect(ctx.dataSource.transaction).not.toHaveBeenCalled(); + }); +}); + +describe("PayrollRunsService.markPaid", () => { + it("moves an APPROVED run to PAID with an explicit payment date", async () => { + const ctx = build(); + ctx.use(run({ status: EPayrollRunStatus.APPROVED })); + + await ctx.service.markPaid("run-1", "2026-08-05", actor({ userId: "u-2" })); + + expect(ctx.runs.update).toHaveBeenCalledWith("run-1", { + status: EPayrollRunStatus.PAID, + paymentDate: "2026-08-05", + updatedBy: "u-2", + }); + }); + + it("defaults the payment date to today when none is given", async () => { + const ctx = build(); + ctx.use(run({ status: EPayrollRunStatus.APPROVED })); + + await ctx.service.markPaid("run-1", undefined, actor()); + + const patch = ctx.runs.update.mock.calls[0][1]; + expect(patch.paymentDate).toMatch(/^\d{4}-\d{2}-\d{2}$/); + }); + + it.each([EPayrollRunStatus.DRAFT, EPayrollRunStatus.CALCULATED, EPayrollRunStatus.PAID])( + "refuses when the run is %s, not APPROVED", + async (status) => { + const ctx = build(); + ctx.use(run({ status })); + + await expect(ctx.service.markPaid("run-1", undefined, actor())).rejects.toBeInstanceOf( + BadRequestException, + ); + expect(ctx.runs.update).not.toHaveBeenCalled(); + }, + ); +}); + +describe("PayrollRunsService.payslipsForEmployee", () => { + /** + * A query-builder stand-in that actually filters a fixture set the way the + * real SQL would — this is what proves a DRAFT/CALCULATED run's payslip row + * stays invisible even though the row exists, not merely that the right + * arguments were passed to `.andWhere`. + */ + function makeFilteringPayslipsRepo( + rows: { employeeId: string; payrollRun: { status: EPayrollRunStatus; periodStart: string } }[], + ) { + interface FilteringQueryBuilder { + innerJoinAndSelect: jest.Mock; + where: jest.Mock; + andWhere: jest.Mock; + orderBy: jest.Mock; + getMany: jest.Mock; + } + + const createQueryBuilder = jest.fn(() => { + let employeeId: string | undefined; + let statuses: EPayrollRunStatus[] = []; + const qb: FilteringQueryBuilder = { + innerJoinAndSelect: jest.fn().mockReturnThis(), + where: jest.fn((_sql: string, params: Record) => { + employeeId = params.employeeId as string; + return qb; + }), + andWhere: jest.fn((_sql: string, params: Record) => { + if (params.statuses) statuses = params.statuses as EPayrollRunStatus[]; + return qb; + }), + orderBy: jest.fn().mockReturnThis(), + getMany: jest.fn(async () => + rows.filter( + (r) => r.employeeId === employeeId && statuses.includes(r.payrollRun.status), + ), + ), + }; + return qb; + }); + return { createQueryBuilder }; + } + + it("returns only payslips whose parent run is APPROVED or PAID — a DRAFT/CALCULATED row stays invisible", async () => { + const rows = [ + { employeeId: "emp-1", payrollRun: { status: EPayrollRunStatus.DRAFT, periodStart: "2026-05-01" } }, + { employeeId: "emp-1", payrollRun: { status: EPayrollRunStatus.CALCULATED, periodStart: "2026-06-01" } }, + { employeeId: "emp-1", payrollRun: { status: EPayrollRunStatus.APPROVED, periodStart: "2026-07-01" } }, + { employeeId: "emp-1", payrollRun: { status: EPayrollRunStatus.PAID, periodStart: "2026-04-01" } }, + // A different employee's APPROVED payslip must never leak into emp-1's list. + { employeeId: "emp-2", payrollRun: { status: EPayrollRunStatus.APPROVED, periodStart: "2026-07-01" } }, + ]; + const ctx = build(); + ctx.payslips.createQueryBuilder = makeFilteringPayslipsRepo(rows as never).createQueryBuilder; + + const result = await ctx.service.payslipsForEmployee("emp-1"); + + expect(result).toHaveLength(2); + expect( + result.every((r) => + [EPayrollRunStatus.APPROVED, EPayrollRunStatus.PAID].includes( + (r as unknown as { payrollRun: { status: EPayrollRunStatus } }).payrollRun.status, + ), + ), + ).toBe(true); + expect(result.every((r) => (r as unknown as { employeeId: string }).employeeId === "emp-1")).toBe( + true, + ); + }); + + it("returns nothing for an employee whose only runs are still DRAFT or CALCULATED", async () => { + const rows = [ + { employeeId: "emp-3", payrollRun: { status: EPayrollRunStatus.DRAFT, periodStart: "2026-05-01" } }, + { employeeId: "emp-3", payrollRun: { status: EPayrollRunStatus.CALCULATED, periodStart: "2026-06-01" } }, + ]; + const ctx = build(); + ctx.payslips.createQueryBuilder = makeFilteringPayslipsRepo(rows as never).createQueryBuilder; + + const result = await ctx.service.payslipsForEmployee("emp-3"); + + expect(result).toEqual([]); + }); +}); diff --git a/apps/edr-hr-api/src/modules/recruitment/services/recruitment.service.spec.ts b/apps/edr-hr-api/src/modules/recruitment/services/recruitment.service.spec.ts new file mode 100644 index 000000000..c5c6e3a65 --- /dev/null +++ b/apps/edr-hr-api/src/modules/recruitment/services/recruitment.service.spec.ts @@ -0,0 +1,358 @@ +import { BadRequestException, ConflictException } from "@nestjs/common"; + +import { RecruitmentService } from "./recruitment.service"; +import { ActorContext } from "../../employees/employees.service"; +import { + Applicant, + Application, + EApplicationStage, + EOfferStatus, + EOpeningStatus, + JobOffer, + JobOpening, +} from "../entities/recruitment.entity"; + +/** Tracks what a transaction wrote per-entity, keyed by the entity class name. */ +function makeManager() { + const calls: { + offerUpdates: { id: string; data: Record }[]; + applicationUpdates: { id: string; data: Record }[]; + openingUpdates: { id: string; data: Record }[]; + } = { offerUpdates: [], applicationUpdates: [], openingUpdates: [] }; + + const manager = { + getRepository: (entity: { name: string }) => { + const name = entity.name; + return { + update: jest.fn(async (id: string, data: Record) => { + if (name === "JobOffer") calls.offerUpdates.push({ id, data }); + if (name === "Application") calls.applicationUpdates.push({ id, data }); + if (name === "JobOpening") calls.openingUpdates.push({ id, data }); + return undefined; + }), + }; + }, + }; + + return { manager, calls }; +} + +const actorFixture = (over: Partial = {}): ActorContext => ({ + employeeId: "staff-1", + userId: "user-1", + organizationId: "org-1", + isSuperAdmin: false, + ...over, +}); + +const applicationFixture = (over: Partial = {}): Application => + ({ + id: "app-1", + organizationId: "org-1", + jobOpeningId: "opening-1", + applicantId: "applicant-1", + stage: EApplicationStage.APPLIED, + appliedOn: "2026-01-01", + ...over, + }) as Application; + +const openingFixture = (over: Partial = {}): JobOpening => + ({ + id: "opening-1", + organizationId: "org-1", + reference: "VAC-1", + title: { am: "", en: "Engineer" }, + positionId: "position-1", + jobTitleId: "job-title-1", + employmentType: "PERMANENT", + openings: 1, + filled: 0, + status: EOpeningStatus.OPEN, + ...over, + }) as JobOpening; + +const applicantFixture = (over: Partial = {}): Applicant => + ({ + id: "applicant-1", + organizationId: "org-1", + fullName: { am: "", en: "Jane Doe" }, + email: "jane@example.com", + phoneNumber: "0911000000", + source: "DIRECT", + ...over, + }) as Applicant; + +const offerFixture = (over: Partial = {}): JobOffer => + ({ + id: "offer-1", + organizationId: "org-1", + applicationId: "app-1", + offeredBasicSalary: "12000.00", + employmentType: "PERMANENT", + proposedStartDate: "2026-03-01", + status: EOfferStatus.ACCEPTED, + hiredEmployeeId: null, + ...over, + }) as JobOffer; + +describe("RecruitmentService", () => { + let dataSource: { transaction: jest.Mock }; + let openings: { findOne: jest.Mock }; + let applicants: { findOne: jest.Mock }; + let applications: { findOne: jest.Mock; update: jest.Mock }; + let interviews: { findOne: jest.Mock; count: jest.Mock }; + let offers: { findOne: jest.Mock }; + let iamDirectory: Record; + let orgExplorer: { hireIntoPosition: jest.Mock }; + let payrollConfig: { assignSalary: jest.Mock }; + let service: RecruitmentService; + let managerCalls: ReturnType["calls"]; + let storedApplication: Application; + + beforeEach(() => { + const built = makeManager(); + managerCalls = built.calls; + + dataSource = { + transaction: jest.fn(async (cb: (m: unknown) => unknown) => cb(built.manager)), + }; + openings = { findOne: jest.fn().mockResolvedValue(openingFixture()) }; + applicants = { findOne: jest.fn().mockResolvedValue(applicantFixture()) }; + + storedApplication = applicationFixture(); + applications = { + findOne: jest.fn().mockImplementation(async () => storedApplication), + update: jest.fn().mockImplementation(async (_id: string, patch: Record) => { + storedApplication = { ...storedApplication, ...patch } as Application; + }), + }; + interviews = { findOne: jest.fn(), count: jest.fn().mockResolvedValue(0) }; + offers = { findOne: jest.fn().mockResolvedValue(offerFixture()) }; + iamDirectory = { findPosition: jest.fn() }; + orgExplorer = { + hireIntoPosition: jest.fn().mockResolvedValue({ + employeeId: "emp-99", + positionId: "position-1", + profile: { id: "profile-99" }, + }), + }; + payrollConfig = { assignSalary: jest.fn().mockResolvedValue({ id: "salary-1" }) }; + + service = new RecruitmentService( + dataSource as never, + openings as never, + applicants as never, + applications as never, + interviews as never, + offers as never, + iamDirectory as never, + orgExplorer as never, + payrollConfig as never, + ); + }); + + // ── moveStage — the STAGE_TRANSITIONS state machine ─────────────────────── + + describe("moveStage", () => { + const validTransitions: [EApplicationStage, EApplicationStage][] = [ + [EApplicationStage.APPLIED, EApplicationStage.SCREENING], + [EApplicationStage.SCREENING, EApplicationStage.SHORTLISTED], + [EApplicationStage.SHORTLISTED, EApplicationStage.INTERVIEW], + [EApplicationStage.SHORTLISTED, EApplicationStage.OFFER], + [EApplicationStage.INTERVIEW, EApplicationStage.OFFER], + [EApplicationStage.OFFER, EApplicationStage.HIRED], + ]; + + it.each(validTransitions)("allows %s → %s", async (from, to) => { + storedApplication = applicationFixture({ stage: from }); + await expect( + service.moveStage("app-1", to, {}, actorFixture()), + ).resolves.toMatchObject({ stage: to }); + }); + + const rejectableFrom: EApplicationStage[] = [ + EApplicationStage.APPLIED, + EApplicationStage.SCREENING, + EApplicationStage.SHORTLISTED, + EApplicationStage.INTERVIEW, + EApplicationStage.OFFER, + ]; + + it.each(rejectableFrom)("allows REJECTED from %s (with a reason)", async (from) => { + storedApplication = applicationFixture({ stage: from }); + await expect( + service.moveStage( + "app-1", + EApplicationStage.REJECTED, + { reason: "Not a fit" }, + actorFixture(), + ), + ).resolves.toMatchObject({ stage: EApplicationStage.REJECTED }); + }); + + it.each(rejectableFrom)("allows WITHDRAWN from %s", async (from) => { + storedApplication = applicationFixture({ stage: from }); + await expect( + service.moveStage("app-1", EApplicationStage.WITHDRAWN, {}, actorFixture()), + ).resolves.toMatchObject({ stage: EApplicationStage.WITHDRAWN }); + }); + + const invalidTransitions: [EApplicationStage, EApplicationStage][] = [ + [EApplicationStage.APPLIED, EApplicationStage.SHORTLISTED], + [EApplicationStage.APPLIED, EApplicationStage.INTERVIEW], + [EApplicationStage.APPLIED, EApplicationStage.OFFER], + [EApplicationStage.APPLIED, EApplicationStage.HIRED], + [EApplicationStage.SCREENING, EApplicationStage.INTERVIEW], + [EApplicationStage.SCREENING, EApplicationStage.HIRED], + [EApplicationStage.SHORTLISTED, EApplicationStage.SCREENING], + [EApplicationStage.INTERVIEW, EApplicationStage.SCREENING], + [EApplicationStage.OFFER, EApplicationStage.INTERVIEW], + ]; + + it.each(invalidTransitions)("refuses %s → %s", async (from, to) => { + storedApplication = applicationFixture({ stage: from }); + await expect( + service.moveStage("app-1", to, {}, actorFixture()), + ).rejects.toBeInstanceOf(BadRequestException); + }); + + it.each([EApplicationStage.HIRED, EApplicationStage.REJECTED, EApplicationStage.WITHDRAWN])( + "refuses any move out of the terminal stage %s", + async (from) => { + storedApplication = applicationFixture({ stage: from }); + await expect( + service.moveStage( + "app-1", + EApplicationStage.SCREENING, + {}, + actorFixture(), + ), + ).rejects.toBeInstanceOf(BadRequestException); + }, + ); + + it("requires a reason to move to REJECTED", async () => { + storedApplication = applicationFixture({ stage: EApplicationStage.SCREENING }); + await expect( + service.moveStage("app-1", EApplicationStage.REJECTED, {}, actorFixture()), + ).rejects.toBeInstanceOf(BadRequestException); + }); + + it("records the rejection reason when one is given", async () => { + storedApplication = applicationFixture({ stage: EApplicationStage.SCREENING }); + await service.moveStage( + "app-1", + EApplicationStage.REJECTED, + { reason: "Salary expectations too high" }, + actorFixture(), + ); + expect(applications.update).toHaveBeenCalledWith( + "app-1", + expect.objectContaining({ rejectionReason: "Salary expectations too high" }), + ); + }); + }); + + // ── hireFromOffer ────────────────────────────────────────────────────────── + + describe("hireFromOffer", () => { + const hireDto = { username: "jane.doe", email: "jane@example.com" }; + + beforeEach(() => { + storedApplication = applicationFixture({ stage: EApplicationStage.OFFER }); + applications.findOne.mockImplementation(async () => ({ + ...storedApplication, + applicant: applicantFixture(), + jobOpening: openingFixture(), + })); + }); + + it("only turns an ACCEPTED offer into a hire", async () => { + offers.findOne.mockResolvedValue(offerFixture({ status: EOfferStatus.SENT })); + + await expect( + service.hireFromOffer("offer-1", hireDto, actorFixture(), {} as never), + ).rejects.toBeInstanceOf(BadRequestException); + expect(orgExplorer.hireIntoPosition).not.toHaveBeenCalled(); + }); + + it("refuses a SECOND hire off the same offer with a 409 — the re-hire guard", async () => { + offers.findOne.mockResolvedValue( + offerFixture({ status: EOfferStatus.ACCEPTED, hiredEmployeeId: "emp-already-hired" }), + ); + + await expect( + service.hireFromOffer("offer-1", hireDto, actorFixture(), {} as never), + ).rejects.toBeInstanceOf(ConflictException); + expect(orgExplorer.hireIntoPosition).not.toHaveBeenCalled(); + }); + + it("delegates to OrgExplorerService.hireIntoPosition with the offer/opening/applicant details", async () => { + await service.hireFromOffer("offer-1", hireDto, actorFixture(), {} as never); + + expect(orgExplorer.hireIntoPosition).toHaveBeenCalledWith( + "position-1", + expect.objectContaining({ + name: { am: "", en: "Jane Doe" }, + username: "jane.doe", + email: "jane@example.com", + employmentType: "PERMANENT", + hireDate: "2026-03-01", + jobTitleId: "job-title-1", + }), + expect.anything(), + expect.anything(), + ); + }); + + it("assigns salary AFTER and OUTSIDE the hire transaction, swallowing a failure without failing the hire", async () => { + payrollConfig.assignSalary.mockRejectedValue(new Error("payroll unreachable")); + + const result = await service.hireFromOffer( + "offer-1", + hireDto, + actorFixture(), + {} as never, + ); + + // The hire itself succeeded despite the salary assignment failing. + expect(result.employeeId).toBe("emp-99"); + expect(payrollConfig.assignSalary).toHaveBeenCalledTimes(1); + // The final bookkeeping transaction still ran to completion. + expect(managerCalls.offerUpdates).toHaveLength(1); + expect(managerCalls.offerUpdates[0].data.hiredEmployeeId).toBe("emp-99"); + }); + + it("updates offer, application and opening's filled count together in the final transaction", async () => { + await service.hireFromOffer("offer-1", hireDto, actorFixture(), {} as never); + + expect(managerCalls.offerUpdates).toEqual([ + { id: "offer-1", data: expect.objectContaining({ hiredEmployeeId: "emp-99" }) }, + ]); + expect(managerCalls.applicationUpdates).toEqual([ + { id: "app-1", data: expect.objectContaining({ stage: EApplicationStage.HIRED }) }, + ]); + expect(managerCalls.openingUpdates).toEqual([ + { + id: "opening-1", + data: expect.objectContaining({ filled: 1, status: EOpeningStatus.FILLED }), + }, + ]); + }); + + it("leaves the opening OPEN when there are still unfilled seats", async () => { + applications.findOne.mockImplementation(async () => ({ + ...storedApplication, + applicant: applicantFixture(), + jobOpening: openingFixture({ openings: 3, filled: 0, status: EOpeningStatus.OPEN }), + })); + + await service.hireFromOffer("offer-1", hireDto, actorFixture(), {} as never); + + expect(managerCalls.openingUpdates[0].data).toMatchObject({ + filled: 1, + status: EOpeningStatus.OPEN, + }); + }); + }); +}); diff --git a/apps/edr-hr-api/src/modules/reports/services/reports.service.spec.ts b/apps/edr-hr-api/src/modules/reports/services/reports.service.spec.ts new file mode 100644 index 000000000..f36b32c28 --- /dev/null +++ b/apps/edr-hr-api/src/modules/reports/services/reports.service.spec.ts @@ -0,0 +1,101 @@ +import { ReportsService } from "./reports.service"; +import { ActorContext } from "../../employees/employees.service"; + +const actorFixture = (over: Partial = {}): ActorContext => ({ + employeeId: null, + userId: "user-1", + organizationId: "org-1", + isSuperAdmin: false, + ...over, +}); + +describe("ReportsService.leaveLiability", () => { + let dataSource: { query: jest.Mock }; + let service: ReportsService; + + beforeEach(() => { + dataSource = { query: jest.fn().mockResolvedValue([]) }; + service = new ReportsService(dataSource as never); + }); + + it("values only leave types where is_paid=true AND max_carry_over_days>0 — excluding sick/bereavement/marriage/paternity-style entitlements", async () => { + await service.leaveLiability(actorFixture(), "2026-06-30"); + + const [sql] = dataSource.query.mock.calls[0]; + expect(sql).toContain('t."is_paid" = true'); + expect(sql).toContain('t."max_carry_over_days" > 0'); + }); + + it("computes the daily rate from the current effective basic salary via LEFT JOIN LATERAL, as of the given date", async () => { + await service.leaveLiability(actorFixture(), "2026-06-30"); + + const [sql] = dataSource.query.mock.calls[0]; + expect(sql).toContain("LEFT JOIN LATERAL"); + expect(sql).toContain('es."effective_from" <= $2::date'); + expect(sql).toContain( + '(es."effective_to" IS NULL OR es."effective_to" >= $2::date)', + ); + expect(sql).toContain('ORDER BY es."effective_from" DESC'); + expect(sql).toContain("/ 30.0"); + }); + + it("excludes TERMINATED/RETIRED employees", async () => { + await service.leaveLiability(actorFixture(), "2026-06-30"); + + const [sql] = dataSource.query.mock.calls[0]; + expect(sql).toContain("NOT IN ('TERMINATED','RETIRED')"); + }); + + it("drops zero balances via HAVING", async () => { + await service.leaveLiability(actorFixture(), "2026-06-30"); + + const [sql] = dataSource.query.mock.calls[0]; + expect(sql).toContain('HAVING COALESCE(SUM(l."days"), 0) > 0'); + }); + + it("passes [organizationId, asOf] as params when asOf is given", async () => { + await service.leaveLiability(actorFixture({ organizationId: "org-1" }), "2026-06-30"); + + const [, params] = dataSource.query.mock.calls[0]; + expect(params).toEqual(["org-1", "2026-06-30"]); + }); + + it("defaults asOf to today when omitted", async () => { + await service.leaveLiability(actorFixture()); + + const [, params] = dataSource.query.mock.calls[0]; + expect(params[0]).toBe("org-1"); + expect(params[1]).toMatch(/^\d{4}-\d{2}-\d{2}$/); + }); + + it("scopes by organization for a normal actor", async () => { + await service.leaveLiability(actorFixture({ organizationId: "org-1", isSuperAdmin: false })); + + const [, params] = dataSource.query.mock.calls[0]; + expect(params[0]).toBe("org-1"); + }); + + it("passes a null org scope for a super admin — no organization filter", async () => { + await service.leaveLiability(actorFixture({ isSuperAdmin: true })); + + const [, params] = dataSource.query.mock.calls[0]; + expect(params[0]).toBeNull(); + }); + + it("returns whatever the query resolves", async () => { + const rows = [ + { + employeeId: "emp-1", + employeeNumber: "EMP-00001", + leaveTypeCode: "ANNUAL", + balanceDays: "12", + dailyRate: "500.00", + liability: "6000.00", + }, + ]; + dataSource.query.mockResolvedValue(rows); + + const result = await service.leaveLiability(actorFixture(), "2026-06-30"); + expect(result).toEqual(rows); + }); +}); diff --git a/apps/edr-hr-api/src/modules/reports/services/reports.service.ts b/apps/edr-hr-api/src/modules/reports/services/reports.service.ts index cd668ccb7..4e8e5f0ca 100644 --- a/apps/edr-hr-api/src/modules/reports/services/reports.service.ts +++ b/apps/edr-hr-api/src/modules/reports/services/reports.service.ts @@ -58,7 +58,7 @@ export class ReportsService { * two. */ async headcount(actor: ActorContext): Promise { - const scope = orgScope(actor) ?? actor.organizationId ?? null; + const scope = orgScope(actor); return this.dataSource.query( `SELECT e."unit_id" AS "unitId", u."name" AS "unitName", @@ -104,7 +104,7 @@ export class ReportsService { turnoverRate: number; }> { ReportsService.assertRange(from, to); - const scope = orgScope(actor) ?? actor.organizationId ?? null; + const scope = orgScope(actor); const [row] = await this.dataSource.query< { @@ -155,7 +155,7 @@ export class ReportsService { payrollRunId: string, actor: ActorContext, ): Promise { - const scope = orgScope(actor) ?? actor.organizationId ?? null; + const scope = orgScope(actor); return this.dataSource.query( `SELECT s."employee_id" AS "employeeId", s."employee_number" AS "employeeNumber", @@ -198,7 +198,7 @@ export class ReportsService { }[] > { ReportsService.assertRange(from, to); - const scope = orgScope(actor) ?? actor.organizationId ?? null; + const scope = orgScope(actor); return this.dataSource.query( `SELECT s."employee_id" AS "employeeId", MAX(s."employee_number") AS "employeeNumber", @@ -234,7 +234,7 @@ export class ReportsService { }[] > { ReportsService.assertRange(from, to); - const scope = orgScope(actor) ?? actor.organizationId ?? null; + const scope = orgScope(actor); return this.dataSource.query( `SELECT s."employee_id" AS "employeeId", MAX(s."employee_number") AS "employeeNumber", @@ -289,7 +289,7 @@ export class ReportsService { liability: string; }[] > { - const scope = orgScope(actor) ?? actor.organizationId ?? null; + const scope = orgScope(actor); const on = asOf ?? new Date().toISOString().slice(0, 10); return this.dataSource.query( `SELECT ent."employee_id" AS "employeeId", @@ -345,7 +345,7 @@ export class ReportsService { }[] > { ReportsService.assertRange(from, to); - const scope = orgScope(actor) ?? actor.organizationId ?? null; + const scope = orgScope(actor); return this.dataSource.query( `SELECT e."unit_id" AS "unitId", u."name" AS "unitName", @@ -376,7 +376,7 @@ export class ReportsService { { leaveTypeCode: string; requests: number; days: string; employees: number }[] > { ReportsService.assertRange(from, to); - const scope = orgScope(actor) ?? actor.organizationId ?? null; + const scope = orgScope(actor); return this.dataSource.query( `SELECT t."code" AS "leaveTypeCode", COUNT(*)::int AS "requests", @@ -416,7 +416,7 @@ export class ReportsService { }[] > { ReportsService.assertRange(from, to); - const scope = orgScope(actor) ?? actor.organizationId ?? null; + const scope = orgScope(actor); return this.dataSource.query( `SELECT pl."code" AS "code", pl."component_type" AS "componentType", @@ -447,7 +447,7 @@ export class ReportsService { if (withinDays < 1 || withinDays > 365) { throw new BadRequestException("withinDays must be between 1 and 365."); } - const scope = orgScope(actor) ?? actor.organizationId ?? null; + const scope = orgScope(actor); const contracts = await this.dataSource.query( `SELECT p."employee_id" AS "employeeId", diff --git a/apps/edr-hr-web/src/features/appraisal/AppraisalReviewsPage.tsx b/apps/edr-hr-web/src/features/appraisal/AppraisalReviewsPage.tsx index f9946e5a5..0edc36cee 100644 --- a/apps/edr-hr-web/src/features/appraisal/AppraisalReviewsPage.tsx +++ b/apps/edr-hr-web/src/features/appraisal/AppraisalReviewsPage.tsx @@ -147,6 +147,7 @@ export function AppraisalReviewsPage() { showSelf isLoading={submit.isPending} submitLabel="Submit assessment" + submitTestId="appraisal-submit-manager" onSubmit={(scores, comment) => submit.mutate({ scores, comment })} /> diff --git a/apps/edr-hr-web/src/features/appraisal/MyAppraisalsPage.tsx b/apps/edr-hr-web/src/features/appraisal/MyAppraisalsPage.tsx index 96674d7e1..7ce46e4c3 100644 --- a/apps/edr-hr-web/src/features/appraisal/MyAppraisalsPage.tsx +++ b/apps/edr-hr-web/src/features/appraisal/MyAppraisalsPage.tsx @@ -180,6 +180,7 @@ export function MyAppraisalsPage() { ratings={detail.data.ratings ?? []} isLoading={self.isPending} submitLabel="Submit my assessment" + submitTestId="appraisal-submit-self" onSubmit={(scores, comment) => self.mutate({ scores, comment })} /> diff --git a/apps/edr-hr-web/src/features/appraisal/components/ScoringForm.tsx b/apps/edr-hr-web/src/features/appraisal/components/ScoringForm.tsx index 1332c120e..b6994a972 100644 --- a/apps/edr-hr-web/src/features/appraisal/components/ScoringForm.tsx +++ b/apps/edr-hr-web/src/features/appraisal/components/ScoringForm.tsx @@ -39,6 +39,7 @@ export function ScoringForm({ onSubmit, isLoading, submitLabel, + submitTestId, error, }: { ratings: AppraisalRating[]; @@ -46,6 +47,7 @@ export function ScoringForm({ onSubmit: (scores: ScoreInput[], comment: string) => void; isLoading?: boolean; submitLabel: string; + submitTestId?: string; error?: string | null; }) { const { i18n } = useTranslation(); @@ -80,7 +82,13 @@ export function ScoringForm({ {ratings.map((rating) => ( - + @@ -133,7 +141,7 @@ export function ScoringForm({ Running total - + {total}% @@ -155,6 +163,7 @@ export function ScoringForm({ @@ -235,6 +246,7 @@ function Actions({ leftSection={} loading={loading} onClick={onApprove} + data-testid={`attendance-regularization-approve-btn-${id}`} > Approve diff --git a/apps/edr-hr-web/src/features/attendance/MyAttendancePage.tsx b/apps/edr-hr-web/src/features/attendance/MyAttendancePage.tsx index e1c9d3d2e..8e17c2c74 100644 --- a/apps/edr-hr-web/src/features/attendance/MyAttendancePage.tsx +++ b/apps/edr-hr-web/src/features/attendance/MyAttendancePage.tsx @@ -193,7 +193,10 @@ export function MyAttendancePage() { {(records.data ?? []).map((record) => ( - + {record.workDate} {record.notes && ( @@ -281,7 +284,10 @@ export function MyAttendancePage() { {(regularizations.data?.items ?? []).map((request) => ( - + {request.workDate} diff --git a/apps/edr-hr-web/src/features/attendance/components/ClockWidget.tsx b/apps/edr-hr-web/src/features/attendance/components/ClockWidget.tsx index e4078ca9b..f15d583e8 100644 --- a/apps/edr-hr-web/src/features/attendance/components/ClockWidget.tsx +++ b/apps/edr-hr-web/src/features/attendance/components/ClockWidget.tsx @@ -91,6 +91,7 @@ export function ClockWidget() { } loading={punchIn.isPending || punchOut.isPending} onClick={() => (clockedIn ? punchOut.mutate() : punchIn.mutate())} + data-testid={clockedIn ? "attendance-clock-out" : "attendance-clock-in"} > {clockedIn ? "Clock out" : "Clock in"} diff --git a/apps/edr-hr-web/src/features/leave/LeaveApprovalsPage.tsx b/apps/edr-hr-web/src/features/leave/LeaveApprovalsPage.tsx index 68584a93b..daa06c85c 100644 --- a/apps/edr-hr-web/src/features/leave/LeaveApprovalsPage.tsx +++ b/apps/edr-hr-web/src/features/leave/LeaveApprovalsPage.tsx @@ -153,7 +153,7 @@ function ApprovalCard({ const after = balance ? Number((balance.availableDays - charged).toFixed(2)) : null; return ( - + @@ -203,6 +203,7 @@ function ApprovalCard({ color="red" leftSection={} onClick={onReject} + data-testid={`leave-reject-btn-${request.id}`} > Reject @@ -211,6 +212,7 @@ function ApprovalCard({ leftSection={} loading={isApproving} onClick={onApprove} + data-testid={`leave-approve-btn-${request.id}`} > Approve diff --git a/apps/edr-hr-web/src/features/leave/components/RequestLeaveModal.tsx b/apps/edr-hr-web/src/features/leave/components/RequestLeaveModal.tsx index 677982179..638c4ae6a 100644 --- a/apps/edr-hr-web/src/features/leave/components/RequestLeaveModal.tsx +++ b/apps/edr-hr-web/src/features/leave/components/RequestLeaveModal.tsx @@ -142,6 +142,7 @@ export function RequestLeaveModal({ )} ({ value: b.id, label: `${b.name} (${b.status})`, @@ -228,7 +235,10 @@ export function BudgetsPage() { {rows.map((r) => ( - + {r.accountCode} @@ -303,7 +313,7 @@ export function BudgetsPage() { {(budget.data?.lines ?? []).map((l) => ( - + {l.accountCode} {l.accountName?.en} diff --git a/apps/finance-web/src/features/cutover/CutoverPage.tsx b/apps/finance-web/src/features/cutover/CutoverPage.tsx index 2600e9ee1..319c4d9f3 100644 --- a/apps/finance-web/src/features/cutover/CutoverPage.tsx +++ b/apps/finance-web/src/features/cutover/CutoverPage.tsx @@ -121,7 +121,7 @@ export function CutoverPage() { description="Going live: the opening balances, and whether the books are ready to carry them." actions={ canManage ? ( - ) : undefined @@ -159,7 +159,12 @@ export function CutoverPage() { {(readiness.data?.checks ?? []).map((check) => { const Icon = CHECK_ICON[check.status]; return ( - + {parsed.length > 0 ? ( - <> +
{bad.length > 0 ? ( @@ -380,7 +386,7 @@ export function CutoverPage() { put a wrong plug in the ledger. ) : null} - +
) : null}
) : null} diff --git a/apps/finance-web/src/features/journals/JournalDetailPage.tsx b/apps/finance-web/src/features/journals/JournalDetailPage.tsx index fab6ab085..2e073e3ae 100644 --- a/apps/finance-web/src/features/journals/JournalDetailPage.tsx +++ b/apps/finance-web/src/features/journals/JournalDetailPage.tsx @@ -130,12 +130,18 @@ export function JournalDetailPage() { rather than left as an absence. */} {isDraft && (can(FINANCE_PERMS.journal.post) ? ( - ) : ( - + ))} diff --git a/apps/finance-web/src/features/journals/NewJournalPage.tsx b/apps/finance-web/src/features/journals/NewJournalPage.tsx index 43ed7a764..27fb20c23 100644 --- a/apps/finance-web/src/features/journals/NewJournalPage.tsx +++ b/apps/finance-web/src/features/journals/NewJournalPage.tsx @@ -186,7 +186,7 @@ export function NewJournalPage() { {lines.map((line) => ( - + `, not table rows. Testid is `budget-select` on that dropdown, plus `budget-approve-btn`, + `budget-variance-row--`, `budget-line-row-`. +- `assets/AssetsPage.tsx`: `asset-row-`, `run-depreciation-btn` (+ + `run-depreciation-confirm-btn` on the modal's Run button), `dispose-btn-`. +- `cutover/CutoverPage.tsx`: `cutover-readiness-check-`, `cutover-set-date-btn`, + `cutover-import-paste`, `cutover-import-preview` (required converting a conditional `<>...` + fragment to a `
` so the testid has a real node to land on). +- `reports/ReportsPage.tsx`: `report-trial-balance-row-`. **`report-balance-sheet-total` was + ambiguous** — the summary sentence holds three numbers inline, not one. Split into + `report-bs-total-assets`, `report-bs-total-liabilities`, `report-bs-total-equity`, each wrapping + just its number within the original sentence. --- diff --git a/e2e-hr-finance/run.sh b/e2e-hr-finance/run.sh index 33c039d8f..3f250fcf0 100755 --- a/e2e-hr-finance/run.sh +++ b/e2e-hr-finance/run.sh @@ -19,6 +19,13 @@ export DB_HOST="${DB_HOST:-localhost}" export DB_PORT="${DB_PORT:-5432}" export DB_USER="${DB_USER:-postgres}" +# Local convenience default — CHANGE THIS to your actual local Postgres +# password, or just export DB_PASSWORD in your shell instead and leave this +# alone. Either way: NEVER commit this file with a real password in it. If +# you edit the line below, `git diff` it before any commit and revert it, or +# keep the password out of git entirely by exporting DB_PASSWORD instead. +export DB_PASSWORD="${DB_PASSWORD:-TradingTria@2090}" + # A checked-in password reaching the production replica would be a real # incident, not an inconvenience. Refuse early and unmistakably. if [[ "$DB_NAME" == "smart_office_prod" ]]; then @@ -26,8 +33,8 @@ if [[ "$DB_NAME" == "smart_office_prod" ]]; then exit 1 fi -if [[ -z "${DB_PASSWORD:-}" ]]; then - echo "DB_PASSWORD is required (the seeders connect to $DB_NAME)" >&2 +if [[ -z "${DB_PASSWORD:-}" || "$DB_PASSWORD" == "CHANGE_ME_DB_PASSWORD" ]]; then + echo "DB_PASSWORD is required (the seeders connect to $DB_NAME) — edit the placeholder near the top of this script, or export DB_PASSWORD instead" >&2 exit 1 fi diff --git a/e2e-hr-finance/specs/finance/asset-migrated-depreciates.spec.ts b/e2e-hr-finance/specs/finance/asset-migrated-depreciates.spec.ts new file mode 100644 index 000000000..5734374a0 --- /dev/null +++ b/e2e-hr-finance/specs/finance/asset-migrated-depreciates.spec.ts @@ -0,0 +1,304 @@ +import { expect, test } from "@playwright/test"; + +import { ORG_ID, personaByKey } from "../../fixtures/personas"; +import { query } from "../../fixtures/db"; +import { storageFor } from "../../playwright.config"; + +/** + * FIN-04 — a migrated asset (accumulated depreciation carried in from + * cutover, no `depreciation_entries` rows of its own) still depreciates. + * + * The mechanism under test lives in `FixedAssetsRepository.findDepreciable` + * and `depreciationFor` (depreciation.calculator.ts): + * `periodsCharged = opening_periods_charged + COUNT(depreciation_entries)`. + * Without `openingPeriodsCharged` a migrated asset's count would read zero, + * the cumulative target would land BELOW what is already accumulated, the + * computed charge would be <= 0 and skipped — and a skip writes no row, so + * the count could never grow. The asset would silently never depreciate + * again. See the doc comments on `FixedAsset.openingPeriodsCharged` and on + * `findDepreciable` itself. + * + * A migrated asset is created via `POST /assets` with + * `openingAccumulatedDepreciation` + `openingPeriodsCharged` and NO + * `fundingAccountId` — the DTO's own doc comment says the ledger side comes + * from the opening balance entry, not from this call. This scenario supplies + * that missing ledger side itself (a balancing journal against Retained + * Earnings, never against 3900 — this file has nothing to do with + * cutover-suspense-draft-only.spec.ts and stays independent of it), because + * `runDepreciation` refuses to charge anything until the register and the + * ledger reconcile (`AssetsService.assertRegisterReconciles`). + */ +const FINANCE_API = process.env.FINANCE_API_URL ?? "http://localhost:3104"; + +const tokenFor = async (request: any, key: string): Promise => { + const persona = personaByKey(key); + const res = await request.post(`${FINANCE_API}/api/v1/auth/login`, { + data: { email: persona.email, password: persona.password }, + }); + return (await res.json()).token; +}; + +// Deliberately round and obviously synthetic — see money.ts / the cutover +// spec's own convention. base = 24000 - 2400 = 21600; base / life(60) = 360 +// exactly, so the expected charge has no rounding ambiguity to reason about. +const ASSET_CODE = "E2E-FIN04-MIGRATED-01"; +const CATEGORY_CODE = "E2E-CAT-FIN04"; +const ACQUISITION_COST = 24000; +const SALVAGE_VALUE = 2400; +const USEFUL_LIFE_MONTHS = 60; +const OPENING_PERIODS_CHARGED = 30; +const OPENING_ACCUMULATED = 10_800; // half of the 21,600 depreciable base +const EXPECTED_CHARGE = 360; // (21600 * 31/60) - 10800, exact — see above + +test.describe("FIN-04 · a migrated asset still depreciates", () => { + test.use({ storageState: storageFor("finance-manager") }); + + test("opening_periods_charged carries a migrated asset past its cutover count", async ({ + request, + }) => { + const token = await tokenFor(request, "finance-manager"); + const headers = { Authorization: `Bearer ${token}` }; + + // ── Guard: the register must already reconcile with the ledger org-wide, + // exactly what AssetsService.assertRegisterReconciles checks, replicated + // read-only here. If it does not, runDepreciation refuses for reasons that + // have nothing to do with this scenario, and this test should say so + // rather than fail confusingly. + const reconciliation = await query<{ + accountId: string; + ledgerAccumulated: string; + registerAccumulated: string; + }>( + `SELECT c.accumulated_account_id AS "accountId", + ROUND(COALESCE(SUM(l.credit - l.debit), 0), 2)::text AS "ledgerAccumulated", + ROUND(COALESCE(( + SELECT SUM(a2.accumulated_depreciation) + FROM finance.fixed_assets a2 + WHERE a2.asset_category_id IN ( + SELECT c2.id FROM finance.asset_categories c2 + WHERE c2.accumulated_account_id = c.accumulated_account_id + AND c2.organization_id = $1) + AND a2.deleted_at IS NULL + AND a2.status NOT IN ('DISPOSED','WRITTEN_OFF') + ), 0), 2)::text AS "registerAccumulated" + FROM finance.asset_categories c + LEFT JOIN finance.journal_lines l ON l.account_id = c.accumulated_account_id + LEFT JOIN finance.journal_entries e + ON e.id = l.journal_entry_id + AND e.status <> 'DRAFT' + AND e.deleted_at IS NULL + AND e.organization_id = $1 + WHERE c.organization_id = $1 AND c.deleted_at IS NULL + GROUP BY c.accumulated_account_id`, + [ORG_ID], + ); + const alreadyDrifted = reconciliation.some( + (row) => + Math.abs(Number(row.ledgerAccumulated) - Number(row.registerAccumulated)) >= 0.005, + ); + test.skip( + alreadyDrifted, + "the asset register and ledger already disagree in this environment (pre-existing, unrelated to this scenario) — runDepreciation would refuse regardless", + ); + + // ── Resolve the three chart-of-accounts rows the category needs. These + // are real, existing seeded accounts (chart-of-accounts.seed.ts), not + // invented ones. + const [assetAccount, accumulatedAccount, expenseAccount, retainedEarnings] = + await Promise.all([ + query<{ id: string }>( + `SELECT id FROM finance.accounts WHERE organization_id = $1 AND code = '1213' AND deleted_at IS NULL`, + [ORG_ID], + ), + query<{ id: string }>( + `SELECT id FROM finance.accounts WHERE organization_id = $1 AND code = '1290' AND deleted_at IS NULL`, + [ORG_ID], + ), + query<{ id: string }>( + `SELECT id FROM finance.accounts WHERE organization_id = $1 AND code = '5400' AND deleted_at IS NULL`, + [ORG_ID], + ), + query<{ id: string }>( + `SELECT id FROM finance.accounts WHERE organization_id = $1 AND code = '3200' AND deleted_at IS NULL`, + [ORG_ID], + ), + ]); + test.skip( + !assetAccount[0] || !accumulatedAccount[0] || !expenseAccount[0] || !retainedEarnings[0], + "the chart of accounts is missing one of 1213/1290/5400/3200 in this organization", + ); + + // ── Find-or-create the category (idempotent across reruns). + let categoryId: string; + const existingCategory = await query<{ id: string }>( + `SELECT id FROM finance.asset_categories WHERE organization_id = $1 AND code = $2 AND deleted_at IS NULL`, + [ORG_ID, CATEGORY_CODE], + ); + if (existingCategory[0]) { + categoryId = existingCategory[0].id; + } else { + const categoryRes = await request.post(`${FINANCE_API}/api/v1/assets/categories`, { + headers, + data: { + code: CATEGORY_CODE, + name: { en: "E2E FIN-04 category", am: "E2E FIN-04 ምድብ" }, + assetAccountId: assetAccount[0].id, + accumulatedAccountId: accumulatedAccount[0].id, + expenseAccountId: expenseAccount[0].id, + defaultLifeMonths: USEFUL_LIFE_MONTHS, + defaultSalvageRate: 0.1, + }, + }); + expect(categoryRes.status(), "creating the E2E-FIN04 asset category").toBeLessThan(300); + categoryId = (await categoryRes.json()).id; + } + + // ── Find-or-create the migrated asset itself (idempotent across reruns). + let assetId: string; + let assetIsFresh = false; + const existingAsset = await query<{ id: string }>( + `SELECT id FROM finance.fixed_assets WHERE organization_id = $1 AND asset_code = $2 AND deleted_at IS NULL`, + [ORG_ID, ASSET_CODE], + ); + if (existingAsset[0]) { + assetId = existingAsset[0].id; + } else { + assetIsFresh = true; + const assetRes = await request.post(`${FINANCE_API}/api/v1/assets`, { + headers, + data: { + assetCategoryId: categoryId, + assetCode: ASSET_CODE, + name: "E2E-FIN-04 migrated asset (opening depreciation)", + // Deliberately in the past and NOT tied to any fiscal period — + // createAsset performs no period check at all when fundingAccountId + // is omitted (see AssetsService.createAsset). + acquisitionDate: "2015-01-01", + inServiceDate: "2015-01-01", + acquisitionCost: ACQUISITION_COST, + salvageValue: SALVAGE_VALUE, + usefulLifeMonths: USEFUL_LIFE_MONTHS, + openingAccumulatedDepreciation: OPENING_ACCUMULATED, + openingPeriodsCharged: OPENING_PERIODS_CHARGED, + // No fundingAccountId — see the DTO's own doc comment. + }, + }); + expect(assetRes.status(), "creating the migrated asset").toBeLessThan(300); + assetId = (await assetRes.json()).id; + } + + // (DB) The asset arrived exactly as a migrated asset should: accumulated + // depreciation > 0, opening_periods_charged > 0, and zero rows of its own + // depreciation history — the precise shape the FIN-04 regression guards. + const registered = await query<{ + accumulatedDepreciation: string; + openingPeriodsCharged: string; + entryRows: string; + }>( + `SELECT a.accumulated_depreciation::text AS "accumulatedDepreciation", + a.opening_periods_charged::text AS "openingPeriodsCharged", + (SELECT count(*)::text FROM finance.depreciation_entries d + WHERE d.fixed_asset_id = a.id) AS "entryRows" + FROM finance.fixed_assets a + WHERE a.id = $1`, + [assetId], + ); + expect(Number(registered[0].accumulatedDepreciation)).toBeGreaterThan(0); + expect(Number(registered[0].openingPeriodsCharged)).toBeGreaterThan(0); + + // ── Find an OPEN period with no depreciation run against it yet. + const candidatePeriod = await query<{ id: string; startDate: string; endDate: string }>( + `SELECT p.id, p.start_date::text AS "startDate", p.end_date::text AS "endDate" + FROM finance.fiscal_periods p + WHERE p.organization_id = $1 + AND p.status = 'OPEN' + AND NOT EXISTS (SELECT 1 FROM finance.depreciation_runs r WHERE r.fiscal_period_id = p.id) + ORDER BY p.start_date ASC + LIMIT 1`, + [ORG_ID], + ); + test.skip( + candidatePeriod.length === 0, + "no OPEN fiscal period without an existing depreciation run is available in this environment", + ); + const period = candidatePeriod[0]; + + // ── Supply the ledger side of the migration, once, on first creation — + // the register/ledger reconciliation this run depends on. Both sides move + // by the SAME amount, so whatever the pre-existing drift was (checked + // above to be ~0), it is unchanged by this pair. + if (assetIsFresh) { + const catchUpMemo = `E2E-FIN-04 opening accumulated depreciation catch-up for ${ASSET_CODE}`; + const catchUp = await request.post(`${FINANCE_API}/api/v1/journals`, { + headers, + data: { + entryDate: period.startDate, + journalType: "OPENING", + memo: catchUpMemo, + lines: [ + { accountId: retainedEarnings[0].id, debit: OPENING_ACCUMULATED, credit: 0 }, + { accountId: accumulatedAccount[0].id, debit: 0, credit: OPENING_ACCUMULATED }, + ], + }, + }); + expect(catchUp.status(), "creating the catch-up draft").toBeLessThan(300); + const catchUpId = (await catchUp.json()).id; + const posted = await request.post( + `${FINANCE_API}/api/v1/journals/${catchUpId}/post`, + { headers }, + ); + expect(posted.status(), "posting the catch-up entry").toBeLessThan(300); + } + + // ── Act: run depreciation for the period. + const runRes = await request.post(`${FINANCE_API}/api/v1/assets/depreciation/run`, { + headers, + data: { fiscalPeriodId: period.id }, + }); + // (NET) Success — the whole register (every depreciable asset in the org, + // not only ours) was charged in one summarized entry. + expect(runRes.status(), "running depreciation for the period").toBeLessThan(300); + const runBody = await runRes.json(); + expect(runBody.assetCount).toBeGreaterThan(0); + + // (NET) The response, at the per-asset level, includes a non-zero charge + // for OUR migrated asset specifically — not just a nonzero org-wide total. + const entriesRes = await request.get( + `${FINANCE_API}/api/v1/assets/depreciation/runs/${runBody.runId}/entries`, + { headers }, + ); + expect(entriesRes.status()).toBe(200); + const entries: { amount: string | number; assetCode: string }[] = await entriesRes.json(); + const ourEntry = entries.find((e) => e.assetCode === ASSET_CODE); + expect(ourEntry, "the run must include a line for the migrated asset").toBeDefined(); + expect(Number(ourEntry!.amount)).toBe(EXPECTED_CHARGE); + + // (DB) A new row landed in finance.depreciation_entries for this asset. + const depreciationRows = await query<{ amount: string }>( + `SELECT amount::text FROM finance.depreciation_entries + WHERE fixed_asset_id = $1 AND depreciation_run_id = $2`, + [assetId, runBody.runId], + ); + expect(depreciationRows).toHaveLength(1); + expect(Number(depreciationRows[0].amount)).toBe(EXPECTED_CHARGE); + + // (DB) periodsCharged (opening_periods_charged + COUNT(depreciation_entries)) + // is now past the opening count — the asset moved, it did not stall. + const after = await query<{ + periodsCharged: string; + accumulatedDepreciation: string; + }>( + `SELECT (a.opening_periods_charged + + (SELECT COUNT(*)::int FROM finance.depreciation_entries d + WHERE d.fixed_asset_id = a.id))::text AS "periodsCharged", + a.accumulated_depreciation::text AS "accumulatedDepreciation" + FROM finance.fixed_assets a + WHERE a.id = $1`, + [assetId], + ); + expect(Number(after[0].periodsCharged)).toBeGreaterThan(OPENING_PERIODS_CHARGED); + expect(Number(after[0].accumulatedDepreciation)).toBeGreaterThan( + Number(registered[0].accumulatedDepreciation), + ); + }); +}); diff --git a/e2e-hr-finance/specs/finance/cutover-suspense-draft-only.spec.ts b/e2e-hr-finance/specs/finance/cutover-suspense-draft-only.spec.ts new file mode 100644 index 000000000..3c3cafcf8 --- /dev/null +++ b/e2e-hr-finance/specs/finance/cutover-suspense-draft-only.spec.ts @@ -0,0 +1,173 @@ +import { expect, test } from "@playwright/test"; + +import { ORG_ID, personaByKey } from "../../fixtures/personas"; +import { query } from "../../fixtures/db"; +import { storageFor } from "../../playwright.config"; + +/** + * FIN-05 — the cutover readiness "suspense" check counts POSTED lines only. + * + * `CutoverService.readiness` (apps/finance-api/src/modules/cutover/cutover.service.ts) + * sums 3900 Opening Balance Suspense with a LEFT JOIN whose posted-only filter + * sits on the JOIN's `ON` clause, not as a second `LEFT JOIN … WHERE` step — + * the comment right above that query names the exact regression this guards: + * written the other way, a DRAFT line still joins (the filter would only null + * the entry side, never exclude the line), and its amount would still be + * summed — so an unposted opening batch would read as a broken migration + * (FAIL) instead of "not started yet" (PENDING). + * + * `importOpeningBalances` (`POST /cutover/opening-balances`) creates a DRAFT + * `OPENING` entry ONLY — see its own doc comment: posting always goes through + * the normal `JournalsService.post` path, never here. So a batch that is + * never posted must leave the readiness suspense check UNCHANGED, whatever + * that check currently reads. + * + * The scenario does not assume a pristine environment (no prior cutover + * activity in 3900): it captures the check's exact status/detail BEFORE + * creating the DRAFT batch and asserts it is byte-identical AFTER — a + * regression that leaked a DRAFT line into the sum would change these numbers + * regardless of what they started at. Where the environment happens to be + * pristine (no posted line has ever touched 3900), the stronger, more literal + * assertion the plan asks for — PENDING, not FAIL — is also made directly. + */ +const FINANCE_API = process.env.FINANCE_API_URL ?? "http://localhost:3104"; + +const tokenFor = async (request: any, key: string): Promise => { + const persona = personaByKey(key); + const res = await request.post(`${FINANCE_API}/api/v1/auth/login`, { + data: { email: persona.email, password: persona.password }, + }); + return (await res.json()).token; +}; + +type ReadinessCheck = { key: string; status: "PASS" | "FAIL" | "PENDING"; detail: string }; + +/** The exact SQL readiness.suspense runs, replicated read-only for the DB axis. */ +const suspenseFromDb = () => + query<{ balance: string; lines: string }>( + `SELECT ROUND(COALESCE(SUM(l.debit - l.credit), 0), 2)::text AS balance, + COUNT(l.id)::text AS lines + FROM finance.accounts a + LEFT JOIN finance.journal_lines l + ON l.account_id = a.id + AND EXISTS (SELECT 1 + FROM finance.journal_entries e + WHERE e.id = l.journal_entry_id + AND e.status IN ('POSTED','REVERSED')) + WHERE a.organization_id = $1 + AND a.code = '3900' + AND a.deleted_at IS NULL`, + [ORG_ID], + ); + +test.describe("FIN-05 · the suspense readiness check counts posted lines only", () => { + test.use({ storageState: storageFor("finance-manager") }); + + test("a DRAFT-only opening-balances batch never moves the suspense check", async ({ + request, + }) => { + const token = await tokenFor(request, "finance-manager"); + const headers = { Authorization: `Bearer ${token}` }; + + const readinessBefore = await request.get(`${FINANCE_API}/api/v1/cutover/readiness`, { + headers, + }); + expect(readinessBefore.status()).toBe(200); + const beforeChecks: ReadinessCheck[] = (await readinessBefore.json()).checks; + const suspenseBefore = beforeChecks.find((c) => c.key === "suspense"); + expect(suspenseBefore, "the readiness payload must include a suspense check").toBeDefined(); + + test.skip( + suspenseBefore!.status === "FAIL", + "the org's 3900 suspense account already carries an unrelated posted imbalance in this environment — not this regression's concern", + ); + + const dbBefore = await suspenseFromDb(); + + const period = await query<{ startDate: string }>( + `SELECT start_date::text AS "startDate" + FROM finance.fiscal_periods + WHERE organization_id = $1 AND status = 'OPEN' + ORDER BY start_date ASC + LIMIT 1`, + [ORG_ID], + ); + test.skip(period.length === 0, "no OPEN fiscal period is available in this environment"); + + const [cash, capital] = await Promise.all([ + query<{ code: string }>( + `SELECT code FROM finance.accounts WHERE organization_id = $1 AND code = '1111' AND deleted_at IS NULL`, + [ORG_ID], + ), + query<{ code: string }>( + `SELECT code FROM finance.accounts WHERE organization_id = $1 AND code = '3100' AND deleted_at IS NULL`, + [ORG_ID], + ), + ]); + test.skip( + cash.length === 0 || capital.length === 0, + "the chart of accounts is missing 1111 Cash on Hand or 3100 Capital in this organization", + ); + + // Intentionally unbalanced-looking rows (debit 5000, credit 3000) so + // importOpeningBalances computes a nonzero plug — see the exact logic in + // CutoverService.importOpeningBalances: `plug = totalDebit - totalCredit`, + // credited to 3900 when positive. Round, obviously-synthetic amounts, the + // same convention cutover.service.spec.ts uses. + const memo = `E2E-FIN-05 draft-only opening balances (never posted, ${Date.now()})`; + const importRes = await request.post(`${FINANCE_API}/api/v1/cutover/opening-balances`, { + headers, + data: { + entryDate: period[0].startDate, + memo, + lines: [ + { accountCode: "1111", debit: 5000, description: "E2E-FIN-05 cash" }, + { accountCode: "3100", credit: 3000, description: "E2E-FIN-05 capital" }, + ], + }, + }); + expect(importRes.status(), "importing the draft opening-balances batch").toBeLessThan(300); + const importBody = await importRes.json(); + expect(importBody.suspensePlug).toBe(2000); + expect( + importBody.entry.status, + "importOpeningBalances must only ever produce a DRAFT", + ).toBe("DRAFT"); + + // Never post it — that omission IS the scenario. + + const readinessAfter = await request.get(`${FINANCE_API}/api/v1/cutover/readiness`, { + headers, + }); + expect(readinessAfter.status()).toBe(200); + const afterChecks: ReadinessCheck[] = (await readinessAfter.json()).checks; + const suspenseAfter = afterChecks.find((c) => c.key === "suspense"); + expect(suspenseAfter).toBeDefined(); + + // (NET) The headline assertion: a DRAFT-only batch must never turn the + // check into FAIL, and must leave its status exactly where it was. + expect( + suspenseAfter!.status, + "a DRAFT-only batch must never fail the suspense check", + ).not.toBe("FAIL"); + expect(suspenseAfter!.status).toBe(suspenseBefore!.status); + expect(suspenseAfter!.detail).toBe(suspenseBefore!.detail); + + // The most literal form of the plan's ask, made when the environment + // allows it: no prior posted line has ever touched 3900, so the check + // reads PENDING — and must still read PENDING, not FAIL, after our batch. + if (suspenseBefore!.status === "PENDING") { + expect(suspenseAfter!.status).toBe("PENDING"); + } + + // (DB) Independent of the API's own arithmetic: re-run the exact query + // readiness.suspense runs and confirm it is unchanged. Distinguishes + // "PENDING" (lines === '0') from "PASS"/"FAIL" (lines > '0') explicitly, + // rather than only checking truthiness of a balance. + const dbAfter = await suspenseFromDb(); + expect(dbAfter[0].lines, "no DRAFT line may ever be counted as a posted suspense line").toBe( + dbBefore[0].lines, + ); + expect(dbAfter[0].balance).toBe(dbBefore[0].balance); + }); +}); diff --git a/e2e-hr-finance/specs/finance/gating.spec.ts b/e2e-hr-finance/specs/finance/gating.spec.ts index c22a204ed..9a290af6a 100644 --- a/e2e-hr-finance/specs/finance/gating.spec.ts +++ b/e2e-hr-finance/specs/finance/gating.spec.ts @@ -1,7 +1,9 @@ +import type { APIRequestContext } from "@playwright/test"; import { expect, test } from "@playwright/test"; import { personaByKey } from "../../fixtures/personas"; import { query } from "../../fixtures/db"; +import { storageFor } from "../../playwright.config"; /** * FIN-01, FIN-03, FIN-07, FIN-08 — the Finance gates and two ledger invariants @@ -14,7 +16,7 @@ import { query } from "../../fixtures/db"; const FINANCE_API = process.env.FINANCE_API_URL ?? "http://localhost:3104"; -const tokenFor = async (request: any, key: string): Promise => { +const tokenFor = async (request: APIRequestContext, key: string): Promise => { const persona = personaByKey(key); const res = await request.post(`${FINANCE_API}/api/v1/auth/login`, { data: { email: persona.email, password: persona.password }, @@ -23,7 +25,7 @@ const tokenFor = async (request: any, key: string): Promise => { }; test.describe("FIN-08 · a cashier is refused what it must not do", () => { - test.use({ storageState: `${__dirname}/../../fixtures/storage/finance-cashier.json` }); + test.use({ storageState: storageFor("finance-cashier") }); /** * `cashier` is the narrowest Finance role: READ_ONLY_KEYS plus receivable @@ -81,7 +83,7 @@ test.describe("FIN-03 · separation of duties, approve vs pay", () => { */ const NO_SUCH_BILL = "00000000-0000-4000-8000-000000000000"; - test.use({ storageState: `${__dirname}/../../fixtures/storage/finance-cashier.json` }); + test.use({ storageState: storageFor("finance-cashier") }); test("a cashier may pay but may not approve", async ({ request }) => { const token = await tokenFor(request, "finance-cashier"); @@ -125,7 +127,7 @@ test.describe("FIN-03 · separation of duties, approve vs pay", () => { }); test.describe("FIN-01 / FIN-07 · ledger invariants", () => { - test.use({ storageState: `${__dirname}/../../fixtures/storage/finance-accountant.json` }); + test.use({ storageState: storageFor("finance-accountant") }); test("FIN-01 · an unbalanced journal entry is refused and writes nothing", async ({ request }) => { const token = await tokenFor(request, "finance-accountant"); @@ -157,9 +159,12 @@ test.describe("FIN-01 / FIN-07 · ledger invariants", () => { test("FIN-07 · the trial balance balances, computed independently", async ({ request }) => { const token = await tokenFor(request, "finance-accountant"); - const res = await request.get(`${FINANCE_API}/api/v1/reports/trial-balance`, { - headers: { Authorization: `Bearer ${token}` }, - }); + // dateFrom/dateTo are required (RangeQueryDto) — omitting them 400s before + // the report ever runs. A wide range covers whatever is actually posted. + const res = await request.get( + `${FINANCE_API}/api/v1/reports/trial-balance?dateFrom=2000-01-01&dateTo=2100-01-01`, + { headers: { Authorization: `Bearer ${token}` } }, + ); expect(res.status()).toBe(200); // Assert against the ledger itself rather than trusting the report to check diff --git a/e2e-hr-finance/specs/finance/journal-post-outside-period.spec.ts b/e2e-hr-finance/specs/finance/journal-post-outside-period.spec.ts new file mode 100644 index 000000000..20aae172d --- /dev/null +++ b/e2e-hr-finance/specs/finance/journal-post-outside-period.spec.ts @@ -0,0 +1,157 @@ +import { expect, test } from "@playwright/test"; + +import { ORG_ID, personaByKey } from "../../fixtures/personas"; +import { query } from "../../fixtures/db"; +import { storageFor } from "../../playwright.config"; + +/** + * FIN-02 — posting outside a fiscal period is refused, and never leaves a + * POSTED row behind. + * + * `JournalsService.create` and `JournalsService.post` both resolve the entry's + * period through the SAME gate — `PeriodsService.resolveOpenPeriodForDate` + * (apps/finance-api/src/modules/periods/periods.service.ts) — so a date that + * covers no fiscal period at all is refused at CREATE time, before a draft + * ever exists to post. That is a stronger guarantee than catching it only at + * post time, and this scenario is written to prove exactly that: the write + * path can never produce a DRAFT dated outside a period in the first place. + * + * The date is discovered, not invented: it is chosen to sit past the LATEST + * `finance.fiscal_periods` row this organization has, exactly as the plan + * instructs ("pick a date before the earliest open period or after the + * latest, whichever exists in fixture data"). + */ +const FINANCE_API = process.env.FINANCE_API_URL ?? "http://localhost:3104"; + +const tokenFor = async (request: any, key: string): Promise => { + const persona = personaByKey(key); + const res = await request.post(`${FINANCE_API}/api/v1/auth/login`, { + data: { email: persona.email, password: persona.password }, + }); + return (await res.json()).token; +}; + +test.describe("FIN-02 · posting outside a fiscal period is refused", () => { + // finance-manager holds BOTH can:create:journal_entry and + // can:post:journal_entry (accountant holds only the first — see + // finance-permissions.registry.ts). Using manager throughout means a + // non-2xx response can only be explained by the period gate, never by a + // permission gate the accountant would also trip. + test.use({ storageState: storageFor("finance-manager") }); + + test("a date covered by no fiscal period is refused at create — never becomes a postable draft, never becomes POSTED", async ({ + request, + }) => { + const token = await tokenFor(request, "finance-manager"); + const headers = { Authorization: `Bearer ${token}` }; + + // Cast every date column to text in SQL — node-postgres parses `date` to + // LOCAL midnight, and this org's periods must not round-trip through a JS + // Date (see the platform's known DATE trap). + const bounds = await query<{ + minStart: string | null; + maxEnd: string | null; + periodCount: string; + }>( + `SELECT MIN(start_date)::text AS "minStart", + MAX(end_date)::text AS "maxEnd", + count(*)::text AS "periodCount" + FROM finance.fiscal_periods + WHERE organization_id = $1`, + [ORG_ID], + ); + + test.skip( + bounds[0].periodCount === "0" || !bounds[0].maxEnd, + "no fiscal periods are seeded for this organization — nothing to sit outside of", + ); + + // Comfortably past the latest period this org has ever defined — not a + // CLOSED/LOCKED period (which create() also refuses, for a different + // reason), a genuine gap `findByDate` returns null for. + const latestYear = Number(bounds[0].maxEnd!.slice(0, 4)); + const gapDate = `${latestYear + 50}-01-15`; + + const memo = `E2E-FIN-02 outside-period draft attempt (${gapDate})`; + + // Two balanced, obviously-synthetic lines — the balance rule is not what + // this scenario is testing, so the amounts are trivial and round. + const [cash, capital] = await Promise.all([ + query<{ id: string }>( + `SELECT id FROM finance.accounts WHERE organization_id = $1 AND code = '1111' AND deleted_at IS NULL`, + [ORG_ID], + ), + query<{ id: string }>( + `SELECT id FROM finance.accounts WHERE organization_id = $1 AND code = '3100' AND deleted_at IS NULL`, + [ORG_ID], + ), + ]); + test.skip( + cash.length === 0 || capital.length === 0, + "the chart of accounts is missing 1111 Cash on Hand or 3100 Capital in this organization", + ); + + const before = await query<{ count: string }>( + `SELECT count(*)::text AS count FROM finance.journal_entries WHERE organization_id = $1 AND memo = $2`, + [ORG_ID, memo], + ); + expect(before[0].count).toBe("0"); + + const createRes = await request.post(`${FINANCE_API}/api/v1/journals`, { + headers, + data: { + entryDate: gapDate, + memo, + lines: [ + { accountId: cash[0].id, debit: 50, credit: 0 }, + { accountId: capital[0].id, debit: 0, credit: 50 }, + ], + }, + }); + + // (NET) The write is refused before a draft ever exists. + expect( + createRes.status(), + "a date no fiscal period covers must be refused at create, not merely at post", + ).toBeGreaterThanOrEqual(400); + expect(createRes.status()).toBeLessThan(500); + + const createBody = await createRes.json().catch(() => ({})); + expect(String(createBody?.message ?? "")).toMatch(/fiscal period/i); + + // (DB) Nothing was written — not a DRAFT, and certainly not POSTED. + const after = await query<{ count: string }>( + `SELECT count(*)::text AS count FROM finance.journal_entries WHERE organization_id = $1 AND memo = $2`, + [ORG_ID, memo], + ); + expect(after[0].count, "a refused create must leave no journal_entries row behind").toBe("0"); + + // Defense in depth: even with no id to post (the point above), the /post + // route itself refuses a fabricated id rather than answering 2xx. + const NO_SUCH_ENTRY = "00000000-0000-4000-8000-000000000000"; + const postRes = await request.post( + `${FINANCE_API}/api/v1/journals/${NO_SUCH_ENTRY}/post`, + { headers }, + ); + expect(postRes.status()).toBeGreaterThanOrEqual(400); + expect(postRes.status()).toBeLessThan(500); + + // (DB) The whole-ledger invariant this scenario protects, checked + // independently of our own attempt — same discipline as FIN-07 in + // gating.spec.ts: never trust the API to have checked its own arithmetic. + // No POSTED entry may EVER carry a date outside the period it names. + const outOfRange = await query<{ count: string }>( + `SELECT count(*)::text AS count + FROM finance.journal_entries e + JOIN finance.fiscal_periods p ON p.id = e.fiscal_period_id + WHERE e.organization_id = $1 + AND e.status = 'POSTED' + AND (e.entry_date < p.start_date OR e.entry_date > p.end_date)`, + [ORG_ID], + ); + expect( + outOfRange[0].count, + "no POSTED journal entry may ever carry a date outside its own fiscal period's range", + ).toBe("0"); + }); +}); diff --git a/e2e-hr-finance/specs/finance/payment-pre-cutover-skip.spec.ts b/e2e-hr-finance/specs/finance/payment-pre-cutover-skip.spec.ts new file mode 100644 index 000000000..3a8f2005f --- /dev/null +++ b/e2e-hr-finance/specs/finance/payment-pre-cutover-skip.spec.ts @@ -0,0 +1,179 @@ +import { createRequire } from "node:module"; +import * as path from "node:path"; + +import { expect, test } from "@playwright/test"; + +import { personaByKey } from "../../fixtures/personas"; +import { query } from "../../fixtures/db"; +import { storageFor } from "../../playwright.config"; + +/** + * FIN-06 — a payment settled BEFORE the organization's cutover date is + * recorded SKIPPED, with a reason, and no journal entry is ever created for + * it. + * + * The mechanism: `RevenuePostingService.postPaymentReceipt` + * (apps/finance-api/src/modules/revenue/revenue-posting.service.ts) checks the + * cutover boundary before anything else about the payment is judged. A + * payment paid before `cutoverDateFor(organizationId)` is already inside the + * receivable/cash figures the opening balances brought over — posting it + * again would count the same money twice — so it returns + * `{ outcome: "SKIPPED", reason: "…already carried by the opening + * balances…" }`. `PaymentEventsConsumer.handle` writes that outcome onto the + * claimed `finance.inbound_events` row and never calls `journals.createPosted` + * for a SKIPPED outcome. + * + * ── Why this can only run with a live broker ──────────────────────────────── + * `RevenueModule`'s `rabbitMQImport()` (revenue.module.ts) returns `[]` — no + * `RabbitMQModule`, no exchange, no queue — whenever `PAYMENT_RABBITMQ_URL` is + * unset. `PaymentEventsConsumer` is still registered as a provider either way, + * but with no `RabbitMQModule` its `@RabbitSubscribe` decorator is inert: it + * is never bound to anything and there is no exchange to publish onto. That is + * "very likely NOT configured" in this environment, exactly as the plan says, + * so the FIRST thing this test does is check for the same environment + * variable the app itself gates on and skip cleanly — never attempting a + * broker connection, and never even resolving the `amqplib` module — when it + * is absent. + */ +const FINANCE_API = process.env.FINANCE_API_URL ?? "http://localhost:3104"; +const PAYMENT_EVENTS_EXCHANGE = "payment.events"; // @edr/types PAYMENT_EVENTS_EXCHANGE + +const tokenFor = async (request: any, key: string): Promise => { + const persona = personaByKey(key); + const res = await request.post(`${FINANCE_API}/api/v1/auth/login`, { + data: { email: persona.email, password: persona.password }, + }); + return (await res.json()).token; +}; + +/** `"2026-07-08"` minus `n` days, as a pure string/date computation — never a + * round-trip of a value read back out of Postgres (see the platform's DATE + * trap: that only bites when node-postgres itself parses a `date` column). */ +const daysBefore = (iso: string, n: number): string => { + const d = new Date(`${iso}T00:00:00Z`); + d.setUTCDate(d.getUTCDate() - n); + return d.toISOString().slice(0, 10); +}; + +test.describe("FIN-06 · a pre-cutover payment is skipped, not double-posted", () => { + test.use({ storageState: storageFor("finance-manager") }); + + test("a payment.freight.succeeded message dated before cutover settles as SKIPPED with no journal entry", async ({ + request, + }) => { + // The runtime guard the plan requires — checked first, before anything + // else, exactly the way RevenueModule itself gates the broker. + if (!process.env.PAYMENT_RABBITMQ_URL) { + test.skip(true, "PAYMENT_RABBITMQ_URL not configured in this environment"); + } + + const token = await tokenFor(request, "finance-manager"); + const headers = { Authorization: `Bearer ${token}` }; + + // Discover the organization's real cutover date rather than inventing one + // — set() is not exercised here; a boundary this scenario depends on + // should already exist as a genuine fact about the org, or there is + // nothing "pre-cutover" to test. + const cutoverRes = await request.get(`${FINANCE_API}/api/v1/cutover`, { headers }); + expect(cutoverRes.status()).toBe(200); + const cutoverDate: string | null = (await cutoverRes.json())?.cutoverDate ?? null; + test.skip( + !cutoverDate, + "no cutover date is set for this organization — nothing is 'pre-cutover' to test", + ); + + // Borrowed, not a dependency of this directory — the same pattern + // fixtures/db.ts uses for `pg`. `amqplib` is a direct dependency of + // edr-freight-api (and edr-passenger-api), so it resolves from there. + // This line only runs once PAYMENT_RABBITMQ_URL is confirmed present, so + // an environment without amqplib installed anywhere never reaches it. + const requireFrom = createRequire( + path.join(__dirname, "..", "..", "..", "apps", "edr-freight-api", "package.json"), + ); + const amqp = requireFrom("amqplib") as { + connect: (url: string) => Promise<{ + createChannel: () => Promise<{ + assertExchange: ( + name: string, + type: string, + options: Record, + ) => Promise; + publish: ( + exchange: string, + routingKey: string, + content: Buffer, + options?: Record, + ) => boolean; + close: () => Promise; + }>; + close: () => Promise; + }>; + }; + + const eventId = `E2E-FIN-06-${Date.now()}`; + const paidBefore = daysBefore(cutoverDate as string, 10); + + // Shaped like PaymentSucceededEvent (@edr/types common/payments.ts). + // `amountMinor` is MAJOR units despite the name on this path — see + // apps/finance-api/src/common/money.ts. + const event = { + version: 1, + eventId, + eventType: "payment.succeeded", + occurredAt: `${paidBefore}T00:00:00.000Z`, + service: "FREIGHT", + intentId: `E2E-FIN-06-INTENT-${eventId}`, + referenceType: "SHIPMENT", + referenceId: `E2E-FIN-06-REF-${eventId}`, + merchantOrderId: `E2E-FIN-06-ORDER-${eventId}`, + provider: "CARD", + amountMinor: 1000, + currency: "ETB", + paidAt: `${paidBefore}T00:00:00.000Z`, + }; + + // `payment..` is three words on the wire — a `payment.*` + // binding matches nothing (AMQP `*` matches exactly one word); Finance's + // queue is bound `payment.#`, so `payment.freight.succeeded` is what a + // real publisher sends. See the platform's own known-traps table. + const routingKey = "payment.freight.succeeded"; + + const conn = await amqp.connect(process.env.PAYMENT_RABBITMQ_URL as string); + try { + const channel = await conn.createChannel(); + await channel.assertExchange(PAYMENT_EVENTS_EXCHANGE, "topic", { durable: true }); + channel.publish(PAYMENT_EVENTS_EXCHANGE, routingKey, Buffer.from(JSON.stringify(event)), { + contentType: "application/json", + persistent: true, + }); + await channel.close(); + } finally { + await conn.close(); + } + + // (DB) The consumer claims, judges and settles asynchronously — poll + // rather than assume it has landed by the time publish() returns. + await expect(async () => { + const rows = await query<{ status: string; error: string | null }>( + `SELECT status, error FROM finance.inbound_events WHERE event_id = $1`, + [eventId], + ); + expect(rows).toHaveLength(1); + expect(rows[0].status).toBe("SKIPPED"); + expect(rows[0].error ?? "").toMatch(/cutover/i); + }).toPass({ timeout: 15_000 }); + + // (DB) — and, just as importantly, nothing was posted for it: SKIPPED + // means no call to journals.createPosted was ever made for this source. + const journalRows = await query<{ count: string }>( + `SELECT count(*)::text AS count + FROM finance.journal_entries + WHERE source_module = 'payment' AND source_id = $1`, + [eventId], + ); + expect( + journalRows[0].count, + "a SKIPPED payment must never have a journal entry posted for it", + ).toBe("0"); + }); +}); diff --git a/e2e-hr-finance/specs/hr/appraisal-manager-score-final.spec.ts b/e2e-hr-finance/specs/hr/appraisal-manager-score-final.spec.ts new file mode 100644 index 000000000..bca979ab5 --- /dev/null +++ b/e2e-hr-finance/specs/hr/appraisal-manager-score-final.spec.ts @@ -0,0 +1,176 @@ +import { expect, test } from "@playwright/test"; + +import { ORG_ID, personaByKey } from "../../fixtures/personas"; +import { query, scalar } from "../../fixtures/db"; +import { storageFor } from "../../playwright.config"; + +/** + * HR-08 · the manager's score is final — it is never averaged with the + * employee's own. + * + * `AppraisalService.submitManager` writes `finalScore = managerScore.toFixed(2)` + * verbatim (apps/edr-hr-api/src/modules/appraisal/services/appraisal.service.ts). + * The self-assessment is recorded for comparison only and plays no part in the + * arithmetic — the code comment there says so explicitly: "Averaging it with + * the employee's own would let anyone raise their result by rating themselves + * highly." This scenario proves that end to end: submit a low self-score, then + * a materially different manager score, and check the number that lands in the + * response AND the database is the manager's weighted score, not any blend of + * the two. + * + * Fixtures: a template and cycle are created fresh each run — their codes + * carry a timestamp so reruns never collide — opened for the `hr-employee` + * persona, the one non-manager persona this suite already holds real + * self-service credentials for. `openCycle` silently skips any employeeId with + * no `hr.employee_profiles` row, and that persona is seeded at the IAM level + * only (fixtures/seed-personas.cjs), so + * `POST /employee-profiles/by-employee/:id/ensure` (idempotent, the product's + * own onboarding endpoint) guarantees one exists first. + */ +const HR_API = process.env.HR_API_URL ?? "http://localhost:3105"; + +const login = async (request: any, key: string): Promise => { + const persona = personaByKey(key); + const res = await request.post(`${HR_API}/api/v1/auth/login`, { + data: { email: persona.email, password: persona.password }, + }); + const { token } = await res.json(); + return token; +}; + +test.describe("HR-08 · manager score is final, not averaged with self", () => { + test.use({ storageState: storageFor("hr-manager") }); + + test("finalScore equals the manager's weighted score, in the response AND in hr.appraisals", async ({ + request, + }) => { + const managerToken = await login(request, "hr-manager"); + const managerAuth = { Authorization: `Bearer ${managerToken}` }; + + // hr-employee is a real iam.employees row (seeded by seed-personas.cjs), + // matched the same way that script matches one: by email, in the Active org. + const employeePersona = personaByKey("hr-employee"); + const employeeId = await scalar( + `SELECT e."id" + FROM iam.employees e + JOIN iam.users u ON u."id" = e."user_id" + WHERE u."email" = $1 AND e."organization_id" = $2 AND e."is_current" = true + LIMIT 1`, + [employeePersona.email, ORG_ID], + ); + expect(employeeId, "hr-employee must exist as a real iam.employees row").toBeTruthy(); + + const ensure = await request.post( + `${HR_API}/api/v1/employee-profiles/by-employee/${employeeId}/ensure`, + { headers: managerAuth }, + ); + expect( + ensure.ok(), + "an HR profile must exist before openCycle will create an appraisal for this employee", + ).toBeTruthy(); + + const ts = Date.now(); + const template = await request.post(`${HR_API}/api/v1/appraisal/templates`, { + headers: managerAuth, + data: { + code: `E2E-APR-${ts}`, + name: { am: "ኢ2ኢ ግምገማ ቅጽ", en: "E2E HR-08 Template" }, + maxScore: "10.00", + requiresSelfAssessment: true, + requiresAcknowledgement: true, + // Weights sum to exactly 100 — createTemplate refuses anything else. + criteria: [ + { code: "QUALITY", name: { am: "ጥራት", en: "Quality" }, weight: "60.00" }, + { code: "SPEED", name: { am: "ፍጥነት", en: "Speed" }, weight: "40.00" }, + ], + }, + }); + expect(template.status(), "weights sum to 100, so the template must be accepted").toBe(201); + const templateBody = await template.json(); + + const cycle = await request.post(`${HR_API}/api/v1/appraisal/cycles`, { + headers: managerAuth, + data: { + code: `E2E-CYC-${ts}`, + name: { am: "ኢ2ኢ ዑደት", en: "E2E HR-08 Cycle" }, + templateId: templateBody.id, + periodStart: "2026-01-01", + periodEnd: "2026-12-31", + }, + }); + expect(cycle.status()).toBe(201); + const cycleBody = await cycle.json(); + + const open = await request.post(`${HR_API}/api/v1/appraisal/cycles/${cycleBody.id}/open`, { + headers: managerAuth, + data: { employeeIds: [employeeId] }, + }); + expect(open.status()).toBe(201); + const openBody = await open.json(); + expect(openBody.created, "the target employee must not have been skipped").toBe(1); + + const appraisalId = await scalar( + `SELECT "id" FROM hr.appraisals WHERE cycle_id = $1 AND employee_id = $2`, + [cycleBody.id, employeeId], + ); + expect(appraisalId).toBeTruthy(); + + // Self scores low: weightedScore = (2/10)*60 + (2/10)*40 = 20.00 + const employeeToken = await login(request, "hr-employee"); + const self = await request.post(`${HR_API}/api/v1/appraisal/${appraisalId}/self`, { + headers: { Authorization: `Bearer ${employeeToken}` }, + data: { + ratings: [ + { code: "QUALITY", score: 2 }, + { code: "SPEED", score: 2 }, + ], + }, + }); + expect(self.status()).toBe(201); + const selfBody = await self.json(); + expect(selfBody.selfScore).toBe("20.00"); + expect(selfBody.status).toBe("PENDING_MANAGER"); + + // Manager scores much higher and DIFFERENT: (9/10)*60 + (8/10)*40 = 86.00. + // An average of 20.00 and 86.00 would be 53.00 — that is the number a + // regression to averaging would produce instead. + const manager = await request.post(`${HR_API}/api/v1/appraisal/${appraisalId}/manager`, { + headers: managerAuth, + data: { + ratings: [ + { code: "QUALITY", score: 9 }, + { code: "SPEED", score: 8 }, + ], + }, + }); + expect(manager.status()).toBe(201); + const managerBody = await manager.json(); + + expect(managerBody.managerScore).toBe("86.00"); + expect( + managerBody.finalScore, + "finalScore must equal the manager's weighted score", + ).toBe("86.00"); + expect(managerBody.finalScore).not.toBe("53.00"); + expect(managerBody.finalScore).not.toBe(managerBody.selfScore); + + const row = await query<{ + selfScore: string; + managerScore: string; + finalScore: string; + }>( + `SELECT "self_score" AS "selfScore", + "manager_score" AS "managerScore", + "final_score" AS "finalScore" + FROM hr.appraisals + WHERE id = $1`, + [appraisalId], + ); + expect(row[0].selfScore).toBe("20.00"); + expect(row[0].managerScore).toBe("86.00"); + expect( + row[0].finalScore, + "hr.appraisals.final_score must match the manager's score exactly, not an average", + ).toBe("86.00"); + }); +}); diff --git a/e2e-hr-finance/specs/hr/attendance-night-shift.spec.ts b/e2e-hr-finance/specs/hr/attendance-night-shift.spec.ts new file mode 100644 index 000000000..42f25b195 --- /dev/null +++ b/e2e-hr-finance/specs/hr/attendance-night-shift.spec.ts @@ -0,0 +1,206 @@ +import { expect, test } from "@playwright/test"; + +import { personaByKey } from "../../fixtures/personas"; +import { scalar } from "../../fixtures/db"; +import { storageFor } from "../../playwright.config"; + +/** + * HR-05 — a night shift stays on its start date. + * + * `AttendanceRecord.workDate` is a DATE, not a timestamp, and deliberately so + * (see the entity's own doc comment): a shift that clocks out at 03:00 belongs + * to the day it BEGAN. `AttendanceService.checkOut` proves this by looking for + * an open record on the check-out's own calendar day first, and — when the + * schedule crosses midnight — falling back to an open record from the + * immediately preceding day (`findOpenPreviousDay`). This scenario drives both + * punches through the real endpoints and checks the settled row lands on the + * check-in's date, not the check-out's. + * + * Naive `HH:MM` times in this codebase are wall-clock EAT (UTC+3) — + * `attendance-day.service.ts`'s `atTime()` helper parses a schedule's + * `startTime`/`endTime` the same way. Every instant sent here is written with + * an explicit `+03:00` offset for that reason, rather than left for whichever + * timezone happens to run this test to guess. + */ +const HR_API = process.env.HR_API_URL ?? "http://localhost:3105"; + +async function login( + request: import("@playwright/test").APIRequestContext, + personaKey: string, +): Promise<{ token: string; employeeId: string }> { + const persona = personaByKey(personaKey); + const res = await request.post(`${HR_API}/api/v1/auth/login`, { + data: { email: persona.email, password: persona.password }, + }); + const { token } = await res.json(); + + const me = await request.get(`${HR_API}/api/v1/me`, { + headers: { Authorization: `Bearer ${token}` }, + }); + const body = await me.json(); + const employee = Array.isArray(body.employee) ? body.employee[0] : body.employee; + const employeeId: string | undefined = employee?.id; + if (!employeeId) { + throw new Error(`[HR-05] persona "${personaKey}" has no employee id on /me`); + } + return { token, employeeId }; +} + +/** Calendar-day arithmetic done in UTC, so it never drifts with the local zone. */ +function addDaysISO(dateStr: string, days: number): string { + const d = new Date(`${dateStr}T00:00:00Z`); + d.setUTCDate(d.getUTCDate() + days); + return d.toISOString().slice(0, 10); +} + +test.describe("HR-05 · night shift stays on its start date", () => { + test.use({ storageState: storageFor("hr-employee") }); + + test("check-out after midnight settles on the check-in's calendar day", async ({ + request, + page, + }) => { + // Whole-org lookups elsewhere in this suite are heavy; this test only + // touches one employee, but give it room anyway. + test.setTimeout(90_000); + + const admin = await login(request, "hr-manager"); + const employee = await login(request, "hr-employee"); + const authAdmin = { Authorization: `Bearer ${admin.token}` }; + const authEmployee = { Authorization: `Bearer ${employee.token}` }; + + // ── 1. A real crossesMidnight schedule, or one we add ────────────────── + const schedulesRes = await request.get(`${HR_API}/api/v1/work-schedules`, { + headers: authAdmin, + }); + expect(schedulesRes.status(), "GET /work-schedules as hr-manager").toBe(200); + const schedules: Array<{ + id: string; + code: string; + crossesMidnight: boolean; + isActive: boolean; + }> = await schedulesRes.json(); + + let schedule = schedules.find((s) => s.crossesMidnight && s.isActive); + + if (!schedule) { + const create = await request.post(`${HR_API}/api/v1/work-schedules`, { + headers: authAdmin, + data: { + code: "E2E-NIGHT-SHIFT", + name: { am: "ኢ2ኢ የሌሊት ፈረቃ", en: "E2E Night Shift" }, + startTime: "22:00", + endTime: "06:00", + breakMinutes: 30, + crossesMidnight: true, + }, + }); + if (create.status() === 409) { + // Another pass of this suite created it a moment earlier — the config + // serializes workers, but not repeated invocations of the whole run. + const again = await request.get(`${HR_API}/api/v1/work-schedules`, { + headers: authAdmin, + }); + schedule = (await again.json()).find( + (s: { code: string }) => s.code === "E2E-NIGHT-SHIFT", + ); + } else { + expect(create.status(), "creating the E2E night-shift schedule").toBe(201); + schedule = await create.json(); + } + } + if (!schedule) { + throw new Error("[HR-05] could not find or create a crossesMidnight schedule"); + } + + // ── 2. Put the employee on it, well before any date this test picks ──── + const openOnThisSchedule = await scalar( + `SELECT a.id FROM hr.work_schedule_assignments a + WHERE a.employee_id = $1 AND a.work_schedule_id = $2 AND a.effective_to IS NULL`, + [employee.employeeId, schedule.id], + ); + if (!openOnThisSchedule) { + const assign = await request.post(`${HR_API}/api/v1/work-schedules/assign`, { + headers: authAdmin, + data: { + employeeId: employee.employeeId, + workScheduleId: schedule.id, + effectiveFrom: "2020-01-01", + }, + }); + expect(assign.status(), "assigning the E2E employee to the night shift").toBe(201); + } + + // ── 3. A shift date this employee has never punched — accumulate rather + // than delete, matching the suite's E2E- convention (doc §1.4) ───────── + const priorRuns = await scalar( + `SELECT count(*)::text FROM hr.attendance_records + WHERE employee_id = $1 AND notes LIKE 'E2E-HR-05%'`, + [employee.employeeId], + ); + // 3-day spacing keeps every run's [shiftDate, shiftDate+1] pair isolated + // from every other run's pair. + const offset = Number(priorRuns ?? "0") * 3; + const shiftDate = addDaysISO("2023-01-02", offset); // a Monday, safely in the past + const nextDate = addDaysISO(shiftDate, 1); + + // ── 4. Clock in at 22:00 EAT on shiftDate, clock out at 03:00 EAT the + // following calendar day ──────────────────────────────────────────────── + const checkIn = await request.post(`${HR_API}/api/v1/attendance/check-in`, { + headers: authEmployee, + data: { at: `${shiftDate}T22:00:00+03:00`, notes: "E2E-HR-05 night shift check-in" }, + }); + expect(checkIn.status(), "check-in").toBe(201); + const checkInBody = await checkIn.json(); + expect(checkInBody.workDate, "check-in lands on its own calendar day").toBe(shiftDate); + + const checkOut = await request.post(`${HR_API}/api/v1/attendance/check-out`, { + headers: authEmployee, + data: { + at: `${nextDate}T03:00:00+03:00`, + notes: "E2E-HR-05 night shift check-out", + }, + }); + expect(checkOut.status(), "check-out").toBe(201); + const checkOutBody = await checkOut.json(); + + // NET — the settled record still belongs to the day the shift BEGAN, not + // the day the check-out instant falls on. + expect( + checkOutBody.workDate, + "a night shift settles on its start date, not the check-out's calendar day", + ).toBe(shiftDate); + expect(checkOutBody.id, "check-out closes the SAME row check-in opened").toBe( + checkInBody.id, + ); + + // DB — cast the DATE column to text. node-postgres parses a bare DATE via + // this pool's default (non-TypeORM) type parser as LOCAL midnight, which + // reads back as the previous day east of UTC (see CLAUDE.md's DATE trap). + const dbWorkDate = await scalar( + `SELECT work_date::text FROM hr.attendance_records WHERE id = $1`, + [checkOutBody.id], + ); + expect(dbWorkDate).toBe(shiftDate); + + // No stray second row was created on the check-out's own calendar day. + const strayCount = await scalar( + `SELECT count(*)::text FROM hr.attendance_records + WHERE employee_id = $1 AND work_date = $2::date`, + [employee.employeeId, nextDate], + ); + expect(strayCount, "no separate row was filed under the check-out's date").toBe("0"); + + // DOM — the self-service employee's own attendance screen still renders. + // No feature-page testids exist yet (doc §4), so this proves the screen is + // reachable and error-free after the punch, not the exact cell contents. + const pageErrors: string[] = []; + page.on("pageerror", (e) => pageErrors.push(e.message)); + page.on("response", (r) => { + if (r.status() >= 500) pageErrors.push(`${r.status()} ${r.url()}`); + }); + await page.goto("/attendance", { waitUntil: "networkidle" }); + expect(page.url()).not.toContain("/forbidden"); + expect(pageErrors, "no page errors or 5xx responses").toEqual([]); + }); +}); diff --git a/e2e-hr-finance/specs/hr/gating.spec.ts b/e2e-hr-finance/specs/hr/gating.spec.ts index fb1b1192f..83db55921 100644 --- a/e2e-hr-finance/specs/hr/gating.spec.ts +++ b/e2e-hr-finance/specs/hr/gating.spec.ts @@ -2,6 +2,7 @@ import { expect, test } from "@playwright/test"; import { personaByKey } from "../../fixtures/personas"; import { query } from "../../fixtures/db"; +import { storageFor } from "../../playwright.config"; /** * HR-03, HR-10, HR-12 — permission gating, and the two regressions found by the @@ -17,7 +18,7 @@ import { query } from "../../fixtures/db"; const HR_API = process.env.HR_API_URL ?? "http://localhost:3105"; test.describe("HR-03 · leave approvals are reachable by an L2 holder", () => { - test.use({ storageState: `${__dirname}/../../fixtures/storage/hr-manager.json` }); + test.use({ storageState: storageFor("hr-manager") }); /** * The regression: nav gate, route gate, badge hook and three backend routes @@ -77,7 +78,7 @@ test.describe("HR-03 · leave approvals are reachable by an L2 holder", () => { }); test.describe("HR-10 · the org-filtered job positions list", () => { - test.use({ storageState: `${__dirname}/../../fixtures/storage/hr-manager.json` }); + test.use({ storageState: storageFor("hr-manager") }); /** * The regression: `JobPositionsRepository.findPage` filtered on @@ -118,7 +119,7 @@ test.describe("HR-10 · the org-filtered job positions list", () => { }); test.describe("HR-12 · a self-service employee is refused the manage screens", () => { - test.use({ storageState: `${__dirname}/../../fixtures/storage/hr-employee.json` }); + test.use({ storageState: storageFor("hr-employee") }); /** * `employee_self_service` is the narrowest HR role — the ten SELF_SERVICE_KEYS diff --git a/e2e-hr-finance/specs/hr/leave-approve.spec.ts b/e2e-hr-finance/specs/hr/leave-approve.spec.ts new file mode 100644 index 000000000..0b9d7e525 --- /dev/null +++ b/e2e-hr-finance/specs/hr/leave-approve.spec.ts @@ -0,0 +1,158 @@ +import { expect, test, type APIRequestContext } from "@playwright/test"; + +import { ORG_ID, personaByKey } from "../../fixtures/personas"; +import { query, scalar } from "../../fixtures/db"; +import { storageFor } from "../../playwright.config"; + +/** + * HR-02 — L1 approval deducts atomically. + * + * `LeaveRequestsService.approve()` posts the DEDUCTION and flips the status + * inside one `dataSource.transaction`, on purpose: "A request marked approved + * whose deduction failed gives away free leave; a deduction whose approval + * failed takes days for an absence nobody agreed to." This proves both halves + * landed together — exactly one DEDUCTION row, for this request, in the window + * the approval itself ran in. + * + * `hr-manager` (MANAGER_POSITION_ID, "Team Leader, Online Booking System") and + * `hr-employee` (REPORT_POSITION_ID, "Officer, Operation Data Management") are + * a real parent/child pair in the IAM position tree — see the doc comment on + * those constants in fixtures/personas.ts, written for exactly this scenario. + * Because of that, a request hr-employee submits resolves its approver (via + * `IamDirectoryService.findLineManagerEmployeeId`) to hr-manager for real, so + * the approval below is driven through the actual "awaiting-me" queue on + * `/leave/approvals` rather than an approve call aimed at an arbitrary id. + */ + +const HR_API = process.env.HR_API_URL ?? "http://localhost:3105"; + +// A different leave year (2032) from leave-submit.spec.ts's 2031 and +// leave-cancel.spec.ts's 2033 — each spec's MARRIAGE entitlement (3.00 +// days/year, uncapped by prior specs) stays independent, so the three files +// never contend for the same balance. +const START_DATE = "2032-06-14"; +const END_DATE = "2032-06-16"; + +async function loginAndMe( + request: APIRequestContext, + email: string, + password: string, +): Promise<{ token: string; employeeId: string }> { + const login = await request.post(`${HR_API}/api/v1/auth/login`, { + data: { email, password }, + }); + expect(login.ok(), `login failed for ${email}: ${await login.text()}`).toBeTruthy(); + const { token } = await login.json(); + + const meRes = await request.get(`${HR_API}/api/v1/me`, { + headers: { Authorization: `Bearer ${token}` }, + }); + expect(meRes.ok(), `/me failed for ${email}`).toBeTruthy(); + const me = await meRes.json(); + return { token, employeeId: me.employee?.id as string }; +} + +test.describe("HR-02 · L1 approval deducts atomically", () => { + test.use({ storageState: storageFor("hr-manager") }); + + test("approving posts exactly one DEDUCTION in the same window as the status flip", async ({ + request, + page, + }) => { + const manager = personaByKey("hr-manager"); + const employee = personaByKey("hr-employee"); + + const mgr = await loginAndMe(request, manager.email, manager.password); + const emp = await loginAndMe(request, employee.email, employee.password); + const employeeAuth = { Authorization: `Bearer ${emp.token}` }; + + // Setup, via API for speed — see leave-submit.spec.ts for why the profile + // must be ensured first. + const ensure = await request.post( + `${HR_API}/api/v1/employee-profiles/by-employee/${emp.employeeId}/ensure`, + { headers: { Authorization: `Bearer ${mgr.token}` } }, + ); + expect(ensure.ok(), `ensuring hr-employee's HR profile: ${await ensure.text()}`).toBeTruthy(); + + const leaveTypeId = await scalar( + `SELECT id FROM hr.leave_types + WHERE organization_id = $1 AND code = 'MARRIAGE' AND is_active = true + LIMIT 1`, + [ORG_ID], + ); + expect(leaveTypeId, "MARRIAGE leave type must exist for the Active org").not.toBeNull(); + + const createRes = await request.post(`${HR_API}/api/v1/leave-requests`, { + headers: employeeAuth, + data: { + leaveTypeId, + startDate: START_DATE, + endDate: END_DATE, + isHalfDay: false, + reason: "E2E-HR-02 leave-approve spec", + }, + }); + expect(createRes.status(), await createRes.text()).toBe(201); + const created = await createRes.json(); + expect(created.status).toBe("SUBMITTED"); + + // DOM: the request is really sitting in hr-manager's own approvals queue — + // not merely reachable via a direct approve call — which is the proof that + // the position pair above is genuinely related, not incidental. + await page.goto("/leave/approvals", { waitUntil: "networkidle" }); + const row = page.getByTestId(`leave-approval-row-${created.id}`); + await expect(row).toBeVisible(); + + const before = new Date(); + const [approveResponse] = await Promise.all([ + page.waitForResponse( + (res) => + res.url().includes(`/leave-requests/${created.id}/approve`) && + res.request().method() === "PATCH", + ), + page.getByTestId(`leave-approve-btn-${created.id}`).click(), + ]); + const after = new Date(); + + // NET: the response Approve actually returned. + expect(approveResponse.status(), await approveResponse.text()).toBe(200); + const approved = await approveResponse.json(); + expect(approved.status).toBe("APPROVED"); + + // DOM: decided requests leave the "awaiting-me" queue. + await expect(row).not.toBeVisible(); + + // DB: exactly one DEDUCTION for this request's source, sized to match the + // frozen chargedDays, posted inside the window this approval itself ran in. + const ledgerRows = await query<{ entry_type: string; days: string; created_at: string }>( + `SELECT entry_type, days, created_at FROM hr.leave_ledger_entries + WHERE source_type = 'LEAVE_REQUEST' AND source_id = $1`, + [created.id], + ); + const deductions = ledgerRows.filter((row) => row.entry_type === "DEDUCTION"); + expect(deductions.length, "exactly one deduction must exist for this request").toBe(1); + expect(Number(deductions[0].days)).toBe(-Number(created.chargedDays)); + + const postedAt = new Date(deductions[0].created_at).getTime(); + // A small tolerance either side for clock skew between the test runner and + // the database server — the point is "inside this test's run", not to the + // millisecond. + expect(postedAt).toBeGreaterThanOrEqual(before.getTime() - 2000); + expect(postedAt).toBeLessThanOrEqual(after.getTime() + 2000); + + const dbStatus = await scalar( + `SELECT status FROM hr.leave_requests WHERE id = $1`, + [created.id], + ); + expect(dbStatus).toBe("APPROVED"); + + // Teardown: cancel (reverses, does not delete — see leave-cancel.spec.ts) + // so these dates are free for the next run. An APPROVED row is "live" per + // LIVE_STATUSES and would otherwise trip a rerun's findOverlapping() check. + const cancelRes = await request.patch(`${HR_API}/api/v1/leave-requests/${created.id}/cancel`, { + headers: employeeAuth, + data: { reason: "E2E-HR-02 teardown" }, + }); + expect(cancelRes.status(), "teardown: cancel must succeed").toBe(200); + }); +}); diff --git a/e2e-hr-finance/specs/hr/leave-cancel.spec.ts b/e2e-hr-finance/specs/hr/leave-cancel.spec.ts new file mode 100644 index 000000000..0b2f47dee --- /dev/null +++ b/e2e-hr-finance/specs/hr/leave-cancel.spec.ts @@ -0,0 +1,180 @@ +import { expect, test, type APIRequestContext } from "@playwright/test"; + +import { ORG_ID, personaByKey } from "../../fixtures/personas"; +import { query, scalar } from "../../fixtures/db"; +import { storageFor } from "../../playwright.config"; + +/** + * HR-04 — cancelling approved leave reverses the deduction, it does not delete + * it. + * + * `LeaveRequestsService.cancel()` posts a REVERSAL against the original + * DEDUCTION rather than touching it — `hr.leave_ledger_entries` has no + * `updated_at`/`deleted_at` at all (see the entity's own doc comment: + * "append-only: never updated, never deleted"). This proves the ledger stays + * append-only across a real cancel, and that the unique-index-on-`reverses_id` + * guard actually refuses a second cancel of the same request. + */ + +const HR_API = process.env.HR_API_URL ?? "http://localhost:3105"; + +// A third leave year (2033) — independent of leave-submit.spec.ts's 2031 and +// leave-approve.spec.ts's 2032, so this file's MARRIAGE entitlement (3.00 +// days/year) is never contended by the other two. +const START_DATE = "2033-06-13"; +const END_DATE = "2033-06-15"; + +async function loginAndMe( + request: APIRequestContext, + email: string, + password: string, +): Promise<{ token: string; employeeId: string }> { + const login = await request.post(`${HR_API}/api/v1/auth/login`, { + data: { email, password }, + }); + expect(login.ok(), `login failed for ${email}: ${await login.text()}`).toBeTruthy(); + const { token } = await login.json(); + + const meRes = await request.get(`${HR_API}/api/v1/me`, { + headers: { Authorization: `Bearer ${token}` }, + }); + expect(meRes.ok(), `/me failed for ${email}`).toBeTruthy(); + const me = await meRes.json(); + return { token, employeeId: me.employee?.id as string }; +} + +test.describe("HR-04 · cancelling approved leave reverses, not deletes", () => { + test.use({ storageState: storageFor("hr-employee") }); + + test("cancel posts a REVERSAL, leaves the DEDUCTION untouched, and refuses a second cancel", async ({ + request, + page, + }) => { + const manager = personaByKey("hr-manager"); + const employee = personaByKey("hr-employee"); + + const mgr = await loginAndMe(request, manager.email, manager.password); + const emp = await loginAndMe(request, employee.email, employee.password); + const employeeAuth = { Authorization: `Bearer ${emp.token}` }; + const managerAuth = { Authorization: `Bearer ${mgr.token}` }; + + // Setup: an APPROVED request, via API for speed — the approve path itself + // is leave-approve.spec.ts's job, not this file's. + const ensure = await request.post( + `${HR_API}/api/v1/employee-profiles/by-employee/${emp.employeeId}/ensure`, + { headers: managerAuth }, + ); + expect(ensure.ok(), `ensuring hr-employee's HR profile: ${await ensure.text()}`).toBeTruthy(); + + const leaveTypeId = await scalar( + `SELECT id FROM hr.leave_types + WHERE organization_id = $1 AND code = 'MARRIAGE' AND is_active = true + LIMIT 1`, + [ORG_ID], + ); + expect(leaveTypeId, "MARRIAGE leave type must exist for the Active org").not.toBeNull(); + + const createRes = await request.post(`${HR_API}/api/v1/leave-requests`, { + headers: employeeAuth, + data: { + leaveTypeId, + startDate: START_DATE, + endDate: END_DATE, + isHalfDay: false, + reason: "E2E-HR-04 leave-cancel spec", + }, + }); + expect(createRes.status(), await createRes.text()).toBe(201); + const created = await createRes.json(); + + const approveRes = await request.patch(`${HR_API}/api/v1/leave-requests/${created.id}/approve`, { + headers: managerAuth, + data: {}, + }); + expect(approveRes.status(), await approveRes.text()).toBe(200); + + // The DEDUCTION posted by approve — captured whole, so "untouched" below + // can be a real equality check rather than a re-derived guess. + const beforeCancel = await query>( + `SELECT * FROM hr.leave_ledger_entries + WHERE source_type = 'LEAVE_REQUEST' AND source_id = $1 AND entry_type = 'DEDUCTION'`, + [created.id], + ); + expect(beforeCancel.length, "the approval must have posted one deduction").toBe(1); + const deduction = beforeCancel[0] as { id: string; days: string }; + + // The actual behaviour under test: the employee cancels their own + // approved leave. There is no data-testid for this action in MyLeavePage + // (only the leave-request-* and leave-approval-*/leave-approve-btn-*/ + // leave-reject-btn-* ids exist), so this is asserted via NET + DB rather + // than a UI click. + const cancelRes = await request.patch(`${HR_API}/api/v1/leave-requests/${created.id}/cancel`, { + headers: employeeAuth, + data: { reason: "E2E-HR-04 leave-cancel spec — plans changed" }, + }); + expect(cancelRes.status(), await cancelRes.text()).toBe(200); + const cancelled = await cancelRes.json(); + expect(cancelled.status).toBe("CANCELLED"); + + // DB: a REVERSAL row referencing the DEDUCTION by id, crediting back + // exactly what was deducted. + const reversalRows = await query<{ entry_type: string; days: string; reverses_id: string }>( + `SELECT entry_type, days, reverses_id FROM hr.leave_ledger_entries WHERE reverses_id = $1`, + [deduction.id], + ); + expect(reversalRows.length, "exactly one reversal must exist").toBe(1); + expect(reversalRows[0].entry_type).toBe("REVERSAL"); + expect(Number(reversalRows[0].days)).toBe(-Number(deduction.days)); + + // DB: the original DEDUCTION is byte-for-byte unchanged — not deleted, not + // updated. The ledger has no updated_at/deleted_at at all, so this is a + // straight equality against the row captured before cancel. + const afterDeduction = await query>( + `SELECT * FROM hr.leave_ledger_entries WHERE id = $1`, + [deduction.id], + ); + expect(afterDeduction.length).toBe(1); + expect(afterDeduction[0]).toEqual(deduction); + + const dbStatus = await scalar( + `SELECT status FROM hr.leave_requests WHERE id = $1`, + [created.id], + ); + expect(dbStatus).toBe("CANCELLED"); + + // The idempotent-double-cancel guard. cancel() re-runs assertTransition() + // first, and ALLOWED[CANCELLED] is empty ("that status is final") — so a + // second cancel is refused with 400 before it ever reaches the ledger's + // own unique-index-on-reverses_id guard in LeaveBalancesService.reverse() + // (a ConflictException/23505, reserved for two cancels racing inside the + // same still-APPROVED window; a sequential second call here never gets + // that far, because the first has already flipped the status). + const secondCancel = await request.patch(`${HR_API}/api/v1/leave-requests/${created.id}/cancel`, { + headers: employeeAuth, + data: { reason: "second cancel must be refused" }, + }); + expect( + secondCancel.status(), + `a cancelled request must refuse a second cancel: ${await secondCancel.text()}`, + ).toBe(400); + + // DB: the refused attempt wrote nothing — still exactly one reversal. + const reversalsAfter = await scalar( + `SELECT count(*)::text AS count FROM hr.leave_ledger_entries WHERE reverses_id = $1`, + [deduction.id], + ); + expect(reversalsAfter).toBe("1"); + + // DOM: hr-employee's own "My leave" table shows the cancelled status. + // A prior run's own (also CANCELLED) row can share this fixed START_DATE. + // The list sorts startDate DESC, createdAt DESC, so among same-date rows + // the one this test just created is always first. + await page.goto("/leave", { waitUntil: "networkidle" }); + const row = page.locator("tr", { hasText: START_DATE }).first(); + await expect(row).toContainText("Cancelled"); + + // No teardown needed: CANCELLED is not in LIVE_STATUSES, so + // findOverlapping() ignores this row and these dates are free for the + // next run without any extra cleanup. + }); +}); diff --git a/e2e-hr-finance/specs/hr/leave-submit.spec.ts b/e2e-hr-finance/specs/hr/leave-submit.spec.ts new file mode 100644 index 000000000..94fae4bc1 --- /dev/null +++ b/e2e-hr-finance/specs/hr/leave-submit.spec.ts @@ -0,0 +1,152 @@ +import { expect, test, type APIRequestContext } from "@playwright/test"; + +import { ORG_ID, personaByKey } from "../../fixtures/personas"; +import { query, scalar } from "../../fixtures/db"; +import { storageFor } from "../../playwright.config"; + +/** + * HR-01 — submitting a leave request freezes the charged-day count. + * + * `LeaveRequest.chargedDays` is written once, at submission, and nothing in + * the codebase ever recomputes it (see the entity's own doc comment: "The day + * counts are frozen at submission rather than derived on read"). This proves + * the freeze the only way that is actually observable: the number the server + * priced via `GET /leave-requests/quote` — before anything is committed — must + * equal both what `POST /leave-requests` returns AND what lands in + * `hr.leave_requests.charged_days`. + * + * A genuine "edit the holiday calendar after submission and reprice" scenario + * is not simulated here — there is no product endpoint that recomputes an + * already-submitted request, which is exactly the invariant being frozen: there + * is nothing to trigger. What this proves is the freeze AT submission time. + */ + +const HR_API = process.env.HR_API_URL ?? "http://localhost:3105"; + +// A Monday-to-Wednesday window five years out, in a leave year (2031) this +// employee has never touched. Far enough ahead to never collide with real +// production leave data; the teardown at the end of the test (withdraw) is +// what keeps a rerun from colliding with ITSELF. +const START_DATE = "2031-06-09"; +const END_DATE = "2031-06-11"; + +async function loginAndMe( + request: APIRequestContext, + email: string, + password: string, +): Promise<{ token: string; employeeId: string }> { + const login = await request.post(`${HR_API}/api/v1/auth/login`, { + data: { email, password }, + }); + expect(login.ok(), `login failed for ${email}: ${await login.text()}`).toBeTruthy(); + const { token } = await login.json(); + + const meRes = await request.get(`${HR_API}/api/v1/me`, { + headers: { Authorization: `Bearer ${token}` }, + }); + expect(meRes.ok(), `/me failed for ${email}`).toBeTruthy(); + const me = await meRes.json(); + return { token, employeeId: me.employee?.id as string }; +} + +test.describe("HR-01 · submitting leave freezes the charged-day count", () => { + test.use({ storageState: storageFor("hr-employee") }); + + test("chargedDays on the created request equals the pre-submit quote", async ({ + request, + page, + }) => { + const manager = personaByKey("hr-manager"); + const employee = personaByKey("hr-employee"); + + const mgr = await loginAndMe(request, manager.email, manager.password); + const emp = await loginAndMe(request, employee.email, employee.password); + const employeeAuth = { Authorization: `Bearer ${emp.token}` }; + + // hr-employee is seeded straight into iam.* by seed-personas.cjs and + // carries no hr.employee_profile — LeaveRequestsService.create() 404s + // without one ("Onboard them before they can request leave"). Provision + // it through the product's own idempotent onboarding endpoint rather than + // writing hr.employee_profiles ourselves — the suite's read-only-SQL + // doctrine (fixtures/db.ts) is exactly this: fixtures come from real + // endpoints, never raw INSERTs. + const ensure = await request.post( + `${HR_API}/api/v1/employee-profiles/by-employee/${emp.employeeId}/ensure`, + { headers: { Authorization: `Bearer ${mgr.token}` } }, + ); + expect(ensure.ok(), `ensuring hr-employee's HR profile: ${await ensure.text()}`).toBeTruthy(); + + // MARRIAGE is the one statutory type a same-day-provisioned profile is + // guaranteed eligible for: genderRestriction ANY, minServiceMonths 0 — and + // requiresApproval:true, unlike BEREAVEMENT, so this actually produces a + // SUBMITTED request rather than an auto-approved one. + const leaveTypeId = await scalar( + `SELECT id FROM hr.leave_types + WHERE organization_id = $1 AND code = 'MARRIAGE' AND is_active = true + LIMIT 1`, + [ORG_ID], + ); + expect(leaveTypeId, "MARRIAGE leave type must exist for the Active org").not.toBeNull(); + + const quoteRes = await request.get(`${HR_API}/api/v1/leave-requests/quote`, { + headers: employeeAuth, + params: { leaveTypeId: leaveTypeId!, startDate: START_DATE, endDate: END_DATE, isHalfDay: "false" }, + }); + expect(quoteRes.status(), await quoteRes.text()).toBe(200); + const quote = await quoteRes.json(); + expect(quote.chargedDays, "the quote must price something before submitting").toBeGreaterThan(0); + + const createRes = await request.post(`${HR_API}/api/v1/leave-requests`, { + headers: employeeAuth, + data: { + leaveTypeId, + startDate: START_DATE, + endDate: END_DATE, + isHalfDay: false, + reason: "E2E-HR-01 leave-submit spec", + }, + }); + expect(createRes.status(), await createRes.text()).toBe(201); + const created = await createRes.json(); + + // NET: the response is what the UI and everything downstream sees. + expect(created.status).toBe("SUBMITTED"); + expect(Number(created.chargedDays)).toBe(quote.chargedDays); + + // DB: the row actually written carries the same frozen number — not a + // value the client invented, and not one derived again on read. + const dbRows = await query<{ status: string; charged_days: string }>( + `SELECT status, charged_days FROM hr.leave_requests WHERE id = $1`, + [created.id], + ); + expect(dbRows.length).toBe(1); + expect(dbRows[0].status).toBe("SUBMITTED"); + expect(Number(dbRows[0].charged_days)).toBe(quote.chargedDays); + + // DOM: hr-employee's own "My leave" table, in the real browser session + // this describe block's storageState signs in — the frozen number and the + // SUBMITTED badge are both visible without touching the request-leave + // modal's Ethiopian/Gregorian date picker (already exercised by creating + // the request; reading a rendered table is the stable part to assert on). + await page.goto("/leave", { waitUntil: "networkidle" }); + // A prior run's already-terminal row can share this fixed START_DATE (the + // suite's convention is to leave a named row behind, not delete it). The + // list sorts startDate DESC, createdAt DESC, so among same-date rows the + // one this test just created is always first. + const row = page.locator("tr", { hasText: START_DATE }).first(); + await expect(row).toContainText("Awaiting decision"); + await expect(row).toContainText(String(quote.chargedDays)); + + // Teardown: withdraw rather than leave a SUBMITTED row behind. Nothing was + // deducted at submission (withdraw's own doc comment: "nothing was + // deducted, so nothing comes back"), so this is a clean no-op for the + // balance, and frees these dates so a rerun's findOverlapping() (which + // only looks at SUBMITTED/APPROVED — see LIVE_STATUSES) does not refuse + // the next run's create(). + const withdrawRes = await request.patch( + `${HR_API}/api/v1/leave-requests/${created.id}/withdraw`, + { headers: employeeAuth }, + ); + expect(withdrawRes.status(), "teardown: withdraw must succeed").toBe(200); + }); +}); diff --git a/e2e-hr-finance/specs/hr/payroll-no-recalc.spec.ts b/e2e-hr-finance/specs/hr/payroll-no-recalc.spec.ts new file mode 100644 index 000000000..a249f2b0b --- /dev/null +++ b/e2e-hr-finance/specs/hr/payroll-no-recalc.spec.ts @@ -0,0 +1,171 @@ +import { expect, test } from "@playwright/test"; + +import { ORG_ID, personaByKey } from "../../fixtures/personas"; +import { scalar } from "../../fixtures/db"; +import { storageFor } from "../../playwright.config"; + +/** + * HR-06 — recalculation is refused after approval. + * + * `PayrollRunsService.calculate()` is repeatable while a run is DRAFT or + * CALCULATED — that is how "recalculate after fixing a salary" works — and + * refused with a `BadRequestException` (HTTP 400) for anything else. Once + * APPROVED the payslips ARE the record: recomputing on read would silently + * restate figures already reported to tax and pension. + * + * Two roles are load-bearing here, and the split is structural + * (`hr-permissions.registry.ts`'s own comment names it): `run:payroll` belongs + * to `payroll_admin` and `approve:payroll` belongs to `hr_manager`. Neither + * role holds both, so this scenario genuinely needs the two personas already + * in the fixture roster — it is not possible to drive the whole lifecycle as + * one persona. + */ +const HR_API = process.env.HR_API_URL ?? "http://localhost:3105"; + +/** Tags the run this scenario owns, so a rerun can find and reuse it instead + * of piling up a fresh APPROVED run every pass — the suite's "named, findable + * row" convention (doc §1.4) rather than a per-run DELETE. */ +const NOTE = "E2E-HR-06 recalculation-refused"; + +async function login( + request: import("@playwright/test").APIRequestContext, + personaKey: string, +): Promise<{ token: string }> { + const persona = personaByKey(personaKey); + const res = await request.post(`${HR_API}/api/v1/auth/login`, { + data: { email: persona.email, password: persona.password }, + }); + const { token } = await res.json(); + return { token }; +} + +const firstOfMonth = (year: number, month0: number): string => + new Date(Date.UTC(year, month0, 1)).toISOString().slice(0, 10); + +const lastOfMonth = (year: number, month0: number): string => + new Date(Date.UTC(year, month0 + 1, 0)).toISOString().slice(0, 10); + +/** + * The nearest calendar month, starting at `fromYear`/`fromMonth0`, for which + * this org has no live (non-cancelled) payroll run. Kept close to "now" rather + * than picking an arbitrary future date, since the statutory tax brackets and + * rates a run depends on are seeded for the present, not for an arbitrary + * decade ahead. + */ +async function findFreePeriod( + fromYear: number, + fromMonth0: number, +): Promise<{ periodStart: string; periodEnd: string }> { + for (let i = 0; i < 36; i += 1) { + const y = fromYear + Math.floor((fromMonth0 + i) / 12); + const m = (fromMonth0 + i) % 12; + const periodStart = firstOfMonth(y, m); + const periodEnd = lastOfMonth(y, m); + // Mirrors the partial unique index (`uq_payroll_runs_org_period`), which + // excludes CANCELLED runs — a cancelled period is free to reuse. + const clash = await scalar( + `SELECT id FROM hr.payroll_runs + WHERE organization_id = $1 AND period_start = $2::date AND period_end = $3::date + AND status <> 'CANCELLED'`, + [ORG_ID, periodStart, periodEnd], + ); + if (!clash) return { periodStart, periodEnd }; + } + throw new Error("[HR-06] no free payroll period found in the next 36 months"); +} + +test.describe("HR-06 · recalculation refused after approval", () => { + test.use({ storageState: storageFor("hr-payroll-admin") }); + + test("a second POST /:id/calculate is refused once APPROVED, and nothing changes", async ({ + request, + }) => { + // Calculation sweeps every payable salary in the Active org (2,400+ + // employees) — give it real room rather than the config's 60s default. + test.setTimeout(180_000); + + const admin = await login(request, "hr-payroll-admin"); + const manager = await login(request, "hr-manager"); + const authAdmin = { Authorization: `Bearer ${admin.token}` }; + const authManager = { Authorization: `Bearer ${manager.token}` }; + + let runId = await scalar( + `SELECT id FROM hr.payroll_runs + WHERE organization_id = $1 AND note = $2 AND status = 'APPROVED' + LIMIT 1`, + [ORG_ID, NOTE], + ); + + if (!runId) { + const today = new Date(); + const { periodStart, periodEnd } = await findFreePeriod( + today.getUTCFullYear(), + today.getUTCMonth(), + ); + + const create = await request.post(`${HR_API}/api/v1/payroll-runs`, { + headers: authAdmin, + data: { periodStart, periodEnd, note: NOTE }, + }); + expect(create.status(), "open the run (payroll_admin holds run:payroll)").toBe(201); + const run = await create.json(); + expect(run.status).toBe("DRAFT"); + runId = run.id; + + const calc = await request.post(`${HR_API}/api/v1/payroll-runs/${runId}/calculate`, { + headers: authAdmin, + }); + expect(calc.status(), "first calculate — DRAFT is calculable").toBe(201); + const calculated = await calc.json(); + expect(calculated.status).toBe("CALCULATED"); + expect( + calculated.employeeCount, + "the Active org has payable salaries for this period", + ).toBeGreaterThan(0); + + // payroll_admin does NOT hold approve:payroll — that separation of duty + // is deliberate, so approval goes through hr-manager instead. + const approve = await request.patch( + `${HR_API}/api/v1/payroll-runs/${runId}/approve`, + { headers: authManager }, + ); + expect(approve.status(), "approve (hr_manager holds approve:payroll)").toBe(200); + expect((await approve.json()).status).toBe("APPROVED"); + } + + const payslipCountBefore = await scalar( + `SELECT count(*)::text FROM hr.payslips WHERE payroll_run_id = $1`, + [runId], + ); + + // NET — the run is APPROVED; PayrollRunsService.calculate()'s status guard + // throws a BadRequestException for anything but DRAFT/CALCULATED. + const second = await request.post(`${HR_API}/api/v1/payroll-runs/${runId}/calculate`, { + headers: authAdmin, + }); + expect( + second.status(), + "recalculating an APPROVED run must be refused with the exact 400 the service throws", + ).toBe(400); + + // DB — the status held, and the payslips were never touched. calculate() + // deletes-and-rebuilds them only inside the same call the status guard + // refused, so an unchanged count proves the guard ran before any write. + const statusAfter = await scalar( + `SELECT status FROM hr.payroll_runs WHERE id = $1`, + [runId], + ); + expect(statusAfter, "status must still be APPROVED after the refused call").toBe( + "APPROVED", + ); + + const payslipCountAfter = await scalar( + `SELECT count(*)::text FROM hr.payslips WHERE payroll_run_id = $1`, + [runId], + ); + expect( + payslipCountAfter, + "payslips must be byte-for-byte untouched by the refused recalculation", + ).toBe(payslipCountBefore); + }); +}); diff --git a/e2e-hr-finance/specs/hr/payroll-self-service-scoping.spec.ts b/e2e-hr-finance/specs/hr/payroll-self-service-scoping.spec.ts new file mode 100644 index 000000000..41ddae53f --- /dev/null +++ b/e2e-hr-finance/specs/hr/payroll-self-service-scoping.spec.ts @@ -0,0 +1,271 @@ +import { expect, test } from "@playwright/test"; + +import { ORG_ID, personaByKey } from "../../fixtures/personas"; +import { query, scalar } from "../../fixtures/db"; +import { storageFor } from "../../playwright.config"; + +/** + * HR-07 — self-service payroll is scoped. + * + * `PayrollRunsService.payslipsForEmployee` is the whole contract: + * + * .where("payslip.employee_id = :employeeId", { employeeId }) + * .andWhere("run.status IN (:...statuses)", { statuses: [APPROVED, PAID] }) + * + * Two things have to hold for `GET /payroll-runs/my-payslips` to be trustworthy: + * it must never return anyone else's payslip, and it must not publish a run's + * figures before they are approved. This scenario needs two employees on the + * SAME run to prove the first, and drives one run through CALCULATED before + * approving it to prove the second. + * + * `hr-employee` (self-service) and `hr-payroll-admin` are both real, distinct + * `iam.employees` rows in the Active org, and every HR role carries + * `view_own:payslip` (`SELF_SERVICE_KEYS`) — so both fixture personas already + * on the roster can stand in as "two employees" without inventing a third. + */ +const HR_API = process.env.HR_API_URL ?? "http://localhost:3105"; +const NOTE = "E2E-HR-07 self-service-scoping"; + +async function login( + request: import("@playwright/test").APIRequestContext, + personaKey: string, +): Promise<{ token: string; employeeId: string }> { + const persona = personaByKey(personaKey); + const res = await request.post(`${HR_API}/api/v1/auth/login`, { + data: { email: persona.email, password: persona.password }, + }); + const { token } = await res.json(); + + const me = await request.get(`${HR_API}/api/v1/me`, { + headers: { Authorization: `Bearer ${token}` }, + }); + const body = await me.json(); + const employee = Array.isArray(body.employee) ? body.employee[0] : body.employee; + const employeeId: string | undefined = employee?.id; + if (!employeeId) { + throw new Error(`[HR-07] persona "${personaKey}" has no employee id on /me`); + } + return { token, employeeId }; +} + +/** + * These fixture personas have no `hr.employee_profiles` row and no + * `hr.employee_salaries` row — `seed-personas.cjs` only ever writes `iam.*`. + * `PayrollRunsService.calculate()` silently skips anyone without an HR profile + * (see the service's own comment) and only prices a salary in force on the + * period end, so both are prerequisites for this employee to appear in a run + * at all. Created once, through the product's own endpoints, and reused on + * every rerun. + */ +async function ensurePayable( + request: import("@playwright/test").APIRequestContext, + authManager: Record, + employeeId: string, +): Promise { + const hasProfile = await scalar( + `SELECT id FROM hr.employee_profiles WHERE employee_id = $1`, + [employeeId], + ); + if (!hasProfile) { + const create = await request.post(`${HR_API}/api/v1/employee-profiles`, { + headers: authManager, + data: { employeeId, employmentType: "PERMANENT", hireDate: "2020-01-01" }, + }); + // A concurrent create from an earlier pass racing this one is the only + // expected non-201 — anything else is a real failure. + if (create.status() !== 409) { + expect(create.status(), `create HR profile for ${employeeId}`).toBe(201); + } + } + + const hasOpenSalary = await scalar( + `SELECT id FROM hr.employee_salaries WHERE employee_id = $1 AND effective_to IS NULL`, + [employeeId], + ); + if (!hasOpenSalary) { + const assign = await request.post(`${HR_API}/api/v1/payroll/salaries`, { + headers: authManager, + data: { + employeeId, + basicSalary: "8000.00", + effectiveFrom: "2020-01-01", + reason: "E2E-HR-07 fixture salary", + }, + }); + expect(assign.status(), `assign a salary to ${employeeId}`).toBe(201); + } +} + +const firstOfMonth = (year: number, month0: number): string => + new Date(Date.UTC(year, month0, 1)).toISOString().slice(0, 10); + +const lastOfMonth = (year: number, month0: number): string => + new Date(Date.UTC(year, month0 + 1, 0)).toISOString().slice(0, 10); + +async function findFreePeriod( + fromYear: number, + fromMonth0: number, +): Promise<{ periodStart: string; periodEnd: string }> { + for (let i = 0; i < 36; i += 1) { + const y = fromYear + Math.floor((fromMonth0 + i) / 12); + const m = (fromMonth0 + i) % 12; + const periodStart = firstOfMonth(y, m); + const periodEnd = lastOfMonth(y, m); + const clash = await scalar( + `SELECT id FROM hr.payroll_runs + WHERE organization_id = $1 AND period_start = $2::date AND period_end = $3::date + AND status <> 'CANCELLED'`, + [ORG_ID, periodStart, periodEnd], + ); + if (!clash) return { periodStart, periodEnd }; + } + throw new Error("[HR-07] no free payroll period found in the next 36 months"); +} + +test.describe("HR-07 · self-service is scoped", () => { + test.use({ storageState: storageFor("hr-employee") }); + + test("my-payslips returns only the caller's own, approved-or-paid payslips", async ({ + request, + }) => { + test.setTimeout(180_000); + + const self = await login(request, "hr-employee"); // employee A — the caller under test + const decoy = await login(request, "hr-payroll-admin"); // employee B — must never leak + const manager = await login(request, "hr-manager"); + const authManager = { Authorization: `Bearer ${manager.token}` }; + const authSelf = { Authorization: `Bearer ${self.token}` }; + + await ensurePayable(request, authManager, self.employeeId); + await ensurePayable(request, authManager, decoy.employeeId); + + let runId = await scalar( + `SELECT id FROM hr.payroll_runs + WHERE organization_id = $1 AND note = $2 AND status = 'APPROVED' + LIMIT 1`, + [ORG_ID, NOTE], + ); + + if (!runId) { + const admin = await login(request, "hr-payroll-admin"); + const authAdmin = { Authorization: `Bearer ${admin.token}` }; + + const today = new Date(); + // A different starting month than HR-06's own run, so the two scenarios + // never contend for the same free period. + const { periodStart, periodEnd } = await findFreePeriod( + today.getUTCFullYear(), + today.getUTCMonth() + 1, + ); + + const create = await request.post(`${HR_API}/api/v1/payroll-runs`, { + headers: authAdmin, + data: { periodStart, periodEnd, note: NOTE }, + }); + expect(create.status(), "open the run").toBe(201); + runId = (await create.json()).id; + + const calc = await request.post(`${HR_API}/api/v1/payroll-runs/${runId}/calculate`, { + headers: authAdmin, + }); + expect(calc.status(), "calculate").toBe(201); + expect((await calc.json()).employeeCount).toBeGreaterThan(0); + + // Both fixture employees are genuinely priced into this run before it is + // approved — sanity-check the setup, not just the scoping under test. + const selfPayslip = await scalar( + `SELECT id FROM hr.payslips WHERE payroll_run_id = $1 AND employee_id = $2`, + [runId, self.employeeId], + ); + const decoyPayslip = await scalar( + `SELECT id FROM hr.payslips WHERE payroll_run_id = $1 AND employee_id = $2`, + [runId, decoy.employeeId], + ); + expect(selfPayslip, "employee A must be priced into this run").not.toBeNull(); + expect(decoyPayslip, "employee B must be priced into this run").not.toBeNull(); + + // ── The state-transition half of the contract: CALCULATED is not + // published yet ────────────────────────────────────────────────────── + const beforeApproval = await request.get( + `${HR_API}/api/v1/payroll-runs/my-payslips`, + { headers: authSelf }, + ); + expect(beforeApproval.status()).toBe(200); + const beforeItems: Array<{ id: string; payrollRun?: { id: string } }> = + await beforeApproval.json(); + expect( + beforeItems.some((item) => item.id === selfPayslip), + "a CALCULATED run's payslip must not appear before approval", + ).toBe(false); + + const approve = await request.patch( + `${HR_API}/api/v1/payroll-runs/${runId}/approve`, + { headers: authManager }, + ); + expect(approve.status(), "approve").toBe(200); + } + + // The specific payslip id belonging to employee B (the decoy), fetched + // through the view-ALL route so we know exactly what must never leak into + // employee A's own-scoped response. + const decoyPayslipId = await scalar( + `SELECT id FROM hr.payslips WHERE payroll_run_id = $1 AND employee_id = $2`, + [runId, decoy.employeeId], + ); + expect(decoyPayslipId, "employee B's payslip must exist to test against").not.toBeNull(); + + // ── NET — employee A's own view, after approval ───────────────────────── + const mine = await request.get(`${HR_API}/api/v1/payroll-runs/my-payslips`, { + headers: authSelf, + }); + expect(mine.status()).toBe(200); + const items: Array<{ + id: string; + employeeId: string; + payrollRun?: { id: string; status: string }; + }> = await mine.json(); + + const ours = items.find((item) => item.payrollRun?.id === runId); + expect(ours, "employee A's own payslip from this run must appear").toBeTruthy(); + expect(ours?.employeeId).toBe(self.employeeId); + expect( + ours?.payrollRun?.status, + "only APPROVED/PAID runs are published — never DRAFT/CALCULATED", + ).toMatch(/^(APPROVED|PAID)$/); + + expect( + items.every((item) => item.employeeId === self.employeeId), + "every item returned must belong to the caller — never another employee", + ).toBe(true); + expect( + items.some((item) => item.id === decoyPayslipId), + "employee B's payslip must never appear in employee A's own-scoped response", + ).toBe(false); + + // ── DB — the response is exactly the set of A's approved-or-paid payslips, + // no more and no fewer ────────────────────────────────────────────────── + const dbOwnCount = await scalar( + `SELECT count(*)::text + FROM hr.payslips p + JOIN hr.payroll_runs r ON r.id = p.payroll_run_id + WHERE p.employee_id = $1 AND r.status IN ('APPROVED', 'PAID')`, + [self.employeeId], + ); + expect(items.length).toBe(Number(dbOwnCount)); + + const dbOthers = await query<{ id: string }>( + `SELECT p.id + FROM hr.payslips p + JOIN hr.payroll_runs r ON r.id = p.payroll_run_id + WHERE p.employee_id = $1 AND r.status IN ('APPROVED', 'PAID')`, + [decoy.employeeId], + ); + const returnedIds = new Set(items.map((item) => item.id)); + for (const row of dbOthers) { + expect( + returnedIds.has(row.id), + `decoy payslip ${row.id} must not be in employee A's response`, + ).toBe(false); + } + }); +}); diff --git a/e2e-hr-finance/specs/hr/recruitment-rehire-conflict.spec.ts b/e2e-hr-finance/specs/hr/recruitment-rehire-conflict.spec.ts new file mode 100644 index 000000000..c655a97fa --- /dev/null +++ b/e2e-hr-finance/specs/hr/recruitment-rehire-conflict.spec.ts @@ -0,0 +1,158 @@ +import { expect, test } from "@playwright/test"; + +import { personaByKey, STAFF_POSITION_ID } from "../../fixtures/personas"; +import { scalar } from "../../fixtures/db"; +import { storageFor } from "../../playwright.config"; + +/** + * HR-09 · hiring twice off the same offer is refused, and the vacancy's + * filled count does not move on the refused attempt. + * + * `RecruitmentService.hireFromOffer` guards on `offer.hiredEmployeeId`, not on + * a status transition: a hired offer stays ACCEPTED forever — only its + * `hiredEmployeeId` flips from null to the new employee's id. A second call + * throws `ConflictException` (409) BEFORE the transaction that increments + * `hr.job_openings.filled` ever runs + * (apps/edr-hr-api/src/modules/recruitment/services/recruitment.service.ts): + * + * if (offer.hiredEmployeeId) { + * throw new ConflictException( + * "This offer has already been hired — a second hire would create a " + + * "duplicate person.", + * ); + * } + * + * Fixture: a real hire creates a new IAM account, employee record and HR + * profile as a side effect — that is the behaviour under test, not incidental + * setup — so this scenario builds its own opening → applicant → application → + * offer chain through the product's own endpoints (STAGE_TRANSITIONS: APPLIED + * → SCREENING → SHORTLISTED → OFFER) rather than reusing a real employee's + * historical offer. Every identifying field carries an E2E- prefix or a + * fresh timestamp so reruns never collide. `STAFF_POSITION_ID` is reused + * deliberately — three other personas already hold it concurrently, which is + * the existing proof that this position accepts more than one holder. + */ +const HR_API = process.env.HR_API_URL ?? "http://localhost:3105"; + +test.describe("HR-09 · re-hiring an accepted offer is refused", () => { + test.use({ storageState: storageFor("hr-recruitment-officer") }); + + test("a second hire on the same offer returns 409 and the opening's filled count is unchanged", async ({ + request, + }) => { + const persona = personaByKey("hr-recruitment-officer"); + const loginRes = await request.post(`${HR_API}/api/v1/auth/login`, { + data: { email: persona.email, password: persona.password }, + }); + const { token } = await loginRes.json(); + const headers = { Authorization: `Bearer ${token}` }; + + const ts = Date.now(); + + const opening = await request.post(`${HR_API}/api/v1/recruitment/openings`, { + headers, + data: { + reference: `E2E-REHIRE-${ts}`, + title: { am: "ኢ2ኢ ምልመላ", en: "E2E HR-09 Opening" }, + positionId: STAFF_POSITION_ID, + openings: 1, + closesOn: "2027-01-01", + }, + }); + expect(opening.status()).toBe(201); + const openingBody = await opening.json(); + + const publish = await request.patch( + `${HR_API}/api/v1/recruitment/openings/${openingBody.id}/publish`, + { headers, data: {} }, + ); + expect(publish.status()).toBe(200); + + const applicant = await request.post(`${HR_API}/api/v1/recruitment/applicants`, { + headers, + data: { + fullName: { am: "ኢ2ኢ አመልካች", en: "E2E HR-09 Applicant" }, + phoneNumber: `E2E-${ts}`, + email: `e2e.hr09.${ts}@edr.local`, + }, + }); + expect(applicant.status()).toBe(201); + const applicantBody = await applicant.json(); + + const application = await request.post(`${HR_API}/api/v1/recruitment/applications`, { + headers, + data: { jobOpeningId: openingBody.id, applicantId: applicantBody.id }, + }); + expect(application.status()).toBe(201); + const applicationBody = await application.json(); + + for (const stage of ["SCREENING", "SHORTLISTED", "OFFER"]) { + const move = await request.patch( + `${HR_API}/api/v1/recruitment/applications/${applicationBody.id}/stage`, + { headers, data: { stage } }, + ); + expect(move.status(), `move to ${stage} must be accepted`).toBe(200); + } + + const offer = await request.post(`${HR_API}/api/v1/recruitment/offers`, { + headers, + data: { + applicationId: applicationBody.id, + offeredBasicSalary: "9000.00", + proposedStartDate: "2027-02-01", + }, + }); + expect(offer.status()).toBe(201); + const offerBody = await offer.json(); + + const send = await request.patch(`${HR_API}/api/v1/recruitment/offers/${offerBody.id}/send`, { + headers, + data: {}, + }); + expect(send.status()).toBe(200); + + const respond = await request.patch( + `${HR_API}/api/v1/recruitment/offers/${offerBody.id}/respond`, + { headers, data: { accepted: true } }, + ); + expect(respond.status()).toBe(200); + + const hireDto = { + username: `e2e.hr09.${ts}`, + email: `e2e.hr09.${ts}@edr.local`, + }; + + const firstHire = await request.post( + `${HR_API}/api/v1/recruitment/offers/${offerBody.id}/hire`, + { headers, data: hireDto }, + ); + expect( + firstHire.status(), + "the first hire off a freshly accepted offer must succeed", + ).toBe(201); + + const filledAfterFirst = await scalar( + `SELECT "filled" FROM hr.job_openings WHERE id = $1`, + [openingBody.id], + ); + expect(filledAfterFirst, "one seat filled after the real hire").toBe(1); + + const secondHire = await request.post( + `${HR_API}/api/v1/recruitment/offers/${offerBody.id}/hire`, + { headers, data: hireDto }, + ); + expect( + secondHire.status(), + "a second hire off the same already-hired offer must be refused with 409", + ).toBe(409); + + const filledAfterSecond = await scalar( + `SELECT "filled" FROM hr.job_openings WHERE id = $1`, + [openingBody.id], + ); + expect( + filledAfterSecond, + "the refused second attempt must not move the opening's filled count", + ).toBe(filledAfterFirst); + }); +}); diff --git a/e2e-hr-finance/specs/hr/reports-leave-liability-filter.spec.ts b/e2e-hr-finance/specs/hr/reports-leave-liability-filter.spec.ts new file mode 100644 index 000000000..9a74b4f78 --- /dev/null +++ b/e2e-hr-finance/specs/hr/reports-leave-liability-filter.spec.ts @@ -0,0 +1,141 @@ +import { expect, test } from "@playwright/test"; + +import { ORG_ID, personaByKey } from "../../fixtures/personas"; +import { query, scalar } from "../../fixtures/db"; +import { storageFor } from "../../playwright.config"; + +/** + * HR-11 · the leave-liability provision values only PAID leave that CARRIES + * OVER, never leave that lapses at year end. + * + * `ReportsService.leaveLiability` filters on + * `t."is_paid" = true AND t."max_carry_over_days" > 0` + * (apps/edr-hr-api/src/modules/reports/services/reports.service.ts). The + * service's own comment names the reason: sick, bereavement, marriage and + * paternity leave are entitlements that lapse at year end, not days the + * employee has banked, and valuing them would inflate the provision several + * times over. This scenario reads that exact predicate independently from + * `hr.leave_types` — rather than hardcoding assumed leave-type codes like + * "SICK" — and asserts the report's returned codes are exactly the ones that + * predicate allows. It then re-derives the total liability straight from the + * ledger (`hr.leave_entitlements` / `hr.leave_ledger_entries` / + * `hr.employee_salaries`) and checks it against what the endpoint returned — + * the same "don't trust the report to check its own arithmetic" pattern + * FIN-07 runs against the trial balance. + * + * `asOf` is pinned to a fixed date so the API call and the independent DB + * check are guaranteed to price the same day — leaving it to default to + * "today" on both sides would work in practice, but would make a mismatch + * from clock drift indistinguishable from a real regression. + */ +const HR_API = process.env.HR_API_URL ?? "http://localhost:3105"; +const AS_OF = "2026-06-30"; + +test.describe("HR-11 · leave liability values only carry-over leave types", () => { + test.use({ storageState: storageFor("hr-manager") }); + + test("excludes non-carry-over/unpaid types, and the total agrees with an independent ledger sum", async ({ + request, + }) => { + const persona = personaByKey("hr-manager"); + const loginRes = await request.post(`${HR_API}/api/v1/auth/login`, { + data: { email: persona.email, password: persona.password }, + }); + const { token } = await loginRes.json(); + + const res = await request.get(`${HR_API}/api/v1/reports/leave-liability?asOf=${AS_OF}`, { + headers: { Authorization: `Bearer ${token}` }, + }); + expect(res.status()).toBe(200); + const rows = (await res.json()) as { + employeeId: string; + employeeNumber: string | null; + leaveTypeCode: string; + balanceDays: string; + dailyRate: string; + liability: string; + }[]; + + // A 2,416-employee org with a real leave history should have SOME + // untaken carry-over balance — otherwise the assertions below would pass + // vacuously on an empty response. + expect( + rows.length, + "expected at least one carry-over liability row for the Active org", + ).toBeGreaterThan(0); + + const returnedCodes = new Set(rows.map((r) => r.leaveTypeCode)); + + // Types the SQL predicate must EXCLUDE — unpaid, or lapsing at year end. + const excluded = await query<{ code: string }>( + `SELECT DISTINCT "code" FROM hr.leave_types + WHERE organization_id = $1 AND deleted_at IS NULL + AND ("max_carry_over_days" <= 0 OR "is_paid" = false)`, + [ORG_ID], + ); + for (const { code } of excluded) { + expect( + returnedCodes.has(code), + `${code} does not carry over (or is unpaid) and must not appear in the liability report`, + ).toBe(false); + } + + // Every code the report DID return must be a paid, carry-over type — the + // other half of the same predicate, checked from the other direction. + const eligible = await query<{ code: string }>( + `SELECT DISTINCT "code" FROM hr.leave_types + WHERE organization_id = $1 AND deleted_at IS NULL + AND "max_carry_over_days" > 0 AND "is_paid" = true`, + [ORG_ID], + ); + const eligibleCodes = new Set(eligible.map((r) => r.code)); + for (const code of returnedCodes) { + expect( + eligibleCodes.has(code), + `${code} appeared in the report but is not a paid, carry-over leave type`, + ).toBe(true); + } + + // Independently re-derive the total liability from the ledger, using the + // same carry-over/paid filter, the same effective-dated salary lookup and + // the same per-row rounding — then compare the SUM to the endpoint's. + const independentTotal = await scalar( + `SELECT ROUND(SUM("rowLiability"), 2)::text AS "total" + FROM ( + SELECT ROUND( + COALESCE(SUM(l."days"), 0) * COALESCE(sal."basic_salary", 0) / 30.0, + 2 + ) AS "rowLiability" + FROM hr.leave_entitlements ent + JOIN hr.leave_types t ON t."id" = ent."leave_type_id" + JOIN hr.employee_profiles p ON p."employee_id" = ent."employee_id" + LEFT JOIN hr.leave_ledger_entries l ON l."entitlement_id" = ent."id" + LEFT JOIN LATERAL ( + SELECT es."basic_salary" + FROM hr.employee_salaries es + WHERE es."employee_id" = ent."employee_id" + AND es."deleted_at" IS NULL + AND es."effective_from" <= $2::date + AND (es."effective_to" IS NULL OR es."effective_to" >= $2::date) + ORDER BY es."effective_from" DESC + LIMIT 1 + ) sal ON true + WHERE ent."deleted_at" IS NULL + AND t."is_paid" = true + AND t."max_carry_over_days" > 0 + AND p."employment_state" NOT IN ('TERMINATED','RETIRED') + AND ent."organization_id" = $1::uuid + GROUP BY ent."employee_id", t."code", sal."basic_salary" + HAVING COALESCE(SUM(l."days"), 0) > 0 + ) "rowsByEmployeeAndType"`, + [ORG_ID, AS_OF], + ); + + const apiTotal = rows.reduce((sum, r) => sum + Number(r.liability), 0).toFixed(2); + + expect( + independentTotal, + "the report's total liability must match an independent re-derivation from the ledger", + ).toBe(apiTotal); + }); +}); diff --git a/package.json b/package.json index 116abbda1..39bc4b18a 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,8 @@ "test:e2e:passenger": "bash e2e/run.sh", "test:e2e:ui": "bash e2e-ui/run.sh", "test:e2e:ui:only": "playwright test -c e2e-ui/playwright.config.ts", + "test:e2e:hr-finance": "bash e2e-hr-finance/run.sh", + "test:e2e:hr-finance:only": "playwright test -c e2e-hr-finance/playwright.config.ts", "lint": "turbo run lint", "type-check": "turbo run type-check", "format": "prettier --write \"**/*.{ts,tsx,json,md}\"", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 25037a2ae..c7696d027 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -604,7 +604,7 @@ importers: version: 5.101.0(react@19.2.6) '@tria-plc/iamui': specifier: file:../../../local-packages/tria-plc-iamui-0.1.1.tgz - version: file:local-packages/tria-plc-iamui-0.1.1.tgz(a5d0bdee55164ae56064fb273b530e00) + version: file:local-packages/tria-plc-iamui-0.1.1.tgz(0ce39b7e349029277dcd938d06eeb0f7) '@vis.gl/react-google-maps': specifier: ^1.8.3 version: 1.8.3(react-dom@19.2.6(react@19.2.6))(react@19.2.6) @@ -776,6 +776,194 @@ importers: specifier: ^5.5.4 version: 5.9.3 + apps/edr-hr-api: + dependencies: + '@edr/api-common': + specifier: workspace:* + version: link:../../packages/api-common + '@edr/types': + specifier: workspace:* + version: link:../../packages/types + '@nestjs/common': + specifier: ^11.0.0 + version: 11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/config': + specifier: ^4.0.0 + version: 4.0.4(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(rxjs@7.8.2) + '@nestjs/core': + specifier: ^11.0.0 + version: 11.1.24(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/microservices@11.1.24)(@nestjs/platform-express@11.1.24)(@nestjs/websockets@11.1.27)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/jwt': + specifier: ^11.0.2 + version: 11.0.2(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2)) + '@nestjs/platform-express': + specifier: ^11.0.0 + version: 11.1.24(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24) + '@nestjs/swagger': + specifier: ^11.4.2 + version: 11.4.4(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2) + '@nestjs/typeorm': + specifier: ^11.0.1 + version: 11.0.1(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(reflect-metadata@0.2.2)(rxjs@7.8.2)(typeorm@0.3.30(babel-plugin-macros@3.1.0)(pg@8.21.0)(ts-node@10.9.2(@types/node@20.19.42)(typescript@5.9.3))) + '@tria-plc/api-common': + specifier: file:../../local-packages/tria-plc-api-common-1.6.0.tgz + version: file:local-packages/tria-plc-api-common-1.6.0.tgz(ae9a56cc1c6d93629dd85f7605ccd5b6) + '@tria-plc/iamapi-common': + specifier: file:../../local-packages/tria-plc-iamapi-common-1.3.4.tgz + version: file:local-packages/tria-plc-iamapi-common-1.3.4.tgz(81d9d3adb5cf581f5b3aa6a2c0425598) + class-transformer: + specifier: ^0.5.1 + version: 0.5.1 + class-validator: + specifier: ^0.14.1 + version: 0.14.4 + dotenv: + specifier: ^17.4.2 + version: 17.4.2 + pg: + specifier: ^8.13.0 + version: 8.21.0 + reflect-metadata: + specifier: ^0.2.2 + version: 0.2.2 + rxjs: + specifier: ^7.8.1 + version: 7.8.2 + typeorm: + specifier: 0.3.30 + version: 0.3.30(babel-plugin-macros@3.1.0)(pg@8.21.0)(ts-node@10.9.2(@types/node@20.19.42)(typescript@5.9.3)) + devDependencies: + '@edr/eslint-config': + specifier: workspace:* + version: link:../../packages/config/eslint-config + '@edr/tsconfig': + specifier: workspace:* + version: link:../../packages/config/tsconfig + '@nestjs/cli': + specifier: ^11.0.0 + version: 11.0.21(@types/node@20.19.42)(prettier@3.8.3) + '@nestjs/schematics': + specifier: ^11.0.0 + version: 11.1.0(chokidar@3.6.0)(prettier@3.8.3)(typescript@5.9.3) + '@nestjs/testing': + specifier: ^11.0.0 + version: 11.1.24(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(@nestjs/microservices@11.1.24)(@nestjs/platform-express@11.1.24) + '@types/express': + specifier: ^5.0.0 + version: 5.0.6 + '@types/jest': + specifier: ^29.5.13 + version: 29.5.14 + '@types/node': + specifier: ^20.14.0 + version: 20.19.42 + '@types/pg': + specifier: ^8.6.7 + version: 8.20.0 + jest: + specifier: ^29.7.0 + version: 29.7.0(@types/node@20.19.42)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@20.19.42)(typescript@5.9.3)) + ts-jest: + specifier: ^29.2.5 + version: 29.4.11(@babel/core@7.29.7)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.29.7))(jest-util@29.7.0)(jest@29.7.0(@types/node@20.19.42)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@20.19.42)(typescript@5.9.3)))(typescript@5.9.3) + ts-node: + specifier: ^10.9.2 + version: 10.9.2(@types/node@20.19.42)(typescript@5.9.3) + tsconfig-paths: + specifier: ^4.2.0 + version: 4.2.0 + typescript: + specifier: ^5.5.4 + version: 5.9.3 + + apps/edr-hr-web: + dependencies: + '@edr/ui-common': + specifier: workspace:* + version: link:../../packages/ui-common + '@hookform/resolvers': + specifier: ^5.4.0 + version: 5.6.0(@sinclair/typebox@0.27.10)(@standard-schema/spec@1.1.0)(ajv-formats@2.1.1(ajv@8.20.0))(ajv@8.20.0)(class-transformer@0.5.1)(class-validator@0.14.4)(effect@3.21.0)(react-hook-form@7.77.0(react@19.2.6))(zod@4.4.3) + '@mantine/core': + specifier: ^9.3.0 + version: 9.3.2(@mantine/hooks@9.3.2(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@mantine/dates': + specifier: ^9.3.0 + version: 9.3.2(@mantine/core@9.3.2(@mantine/hooks@9.3.2(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/hooks@9.3.2(react@19.2.6))(dayjs@1.11.21)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@mantine/hooks': + specifier: ^9.3.0 + version: 9.3.2(react@19.2.6) + '@mantine/notifications': + specifier: ^9.3.0 + version: 9.5.2(@mantine/core@9.3.2(@mantine/hooks@9.3.2(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/hooks@9.3.2(react@19.2.6))(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@mantine/spotlight': + specifier: ^9.5.2 + version: 9.5.2(@mantine/core@9.3.2(@mantine/hooks@9.3.2(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/hooks@9.3.2(react@19.2.6))(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@tabler/icons-react': + specifier: ^3.44.0 + version: 3.44.0(react@19.2.6) + '@tanstack/react-query': + specifier: ^5.62.0 + version: 5.101.0(react@19.2.6) + '@tanstack/react-table': + specifier: ^8.21.3 + version: 8.21.3(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + axios: + specifier: ^1.16.1 + version: 1.17.0 + dayjs: + specifier: ^1.11.13 + version: 1.11.21 + i18next: + specifier: ^26.3.5 + version: 26.3.5(typescript@5.9.3) + react: + specifier: 19.2.6 + version: 19.2.6 + react-dom: + specifier: 19.2.6 + version: 19.2.6(react@19.2.6) + react-hook-form: + specifier: ^7.77.0 + version: 7.77.0(react@19.2.6) + react-i18next: + specifier: ^17.0.8 + version: 17.0.8(i18next@26.3.5(typescript@5.9.3))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(typescript@5.9.3) + react-router-dom: + specifier: ^7.1.1 + version: 7.17.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + zod: + specifier: ^4.0.0 + version: 4.4.3 + devDependencies: + '@edr/tsconfig': + specifier: workspace:* + version: link:../../packages/config/tsconfig + '@tailwindcss/vite': + specifier: ^4.3.0 + version: 4.3.0(vite@6.4.3(@types/node@20.19.42)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0)) + '@types/node': + specifier: ^20.14.0 + version: 20.19.42 + '@types/react': + specifier: ^18.3.11 + version: 18.3.31 + '@types/react-dom': + specifier: ^18.3.0 + version: 18.3.7(@types/react@18.3.31) + '@vitejs/plugin-react': + specifier: ^4.3.4 + version: 4.7.0(vite@6.4.3(@types/node@20.19.42)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0)) + tailwindcss: + specifier: ^4.3.0 + version: 4.3.0 + typescript: + specifier: ^5.9.3 + version: 5.9.3 + vite: + specifier: ^6.0.7 + version: 6.4.3(@types/node@20.19.42)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0) + apps/edr-passenger-api: dependencies: '@edr/iam-seed': @@ -1245,6 +1433,197 @@ importers: specifier: ^5.5.4 version: 5.9.3 + apps/finance-api: + dependencies: + '@edr/api-common': + specifier: workspace:* + version: link:../../packages/api-common + '@edr/types': + specifier: workspace:* + version: link:../../packages/types + '@golevelup/nestjs-rabbitmq': + specifier: ^5.5.0 + version: 5.7.0(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/common': + specifier: ^11.0.0 + version: 11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/config': + specifier: ^4.0.0 + version: 4.0.4(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(rxjs@7.8.2) + '@nestjs/core': + specifier: ^11.0.0 + version: 11.1.24(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/microservices@11.1.24)(@nestjs/platform-express@11.1.24)(@nestjs/websockets@11.1.27)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/jwt': + specifier: ^11.0.2 + version: 11.0.2(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2)) + '@nestjs/platform-express': + specifier: ^11.0.0 + version: 11.1.24(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24) + '@nestjs/swagger': + specifier: ^11.4.2 + version: 11.4.4(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2) + '@nestjs/typeorm': + specifier: ^11.0.1 + version: 11.0.1(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(reflect-metadata@0.2.2)(rxjs@7.8.2)(typeorm@0.3.30(babel-plugin-macros@3.1.0)(pg@8.21.0)(ts-node@10.9.2(@types/node@20.19.42)(typescript@5.9.3))) + '@tria-plc/api-common': + specifier: file:../../local-packages/tria-plc-api-common-1.6.0.tgz + version: file:local-packages/tria-plc-api-common-1.6.0.tgz(ae9a56cc1c6d93629dd85f7605ccd5b6) + '@tria-plc/iamapi-common': + specifier: file:../../local-packages/tria-plc-iamapi-common-1.3.4.tgz + version: file:local-packages/tria-plc-iamapi-common-1.3.4.tgz(cad8c67eb144007f21458987b02127f7) + class-transformer: + specifier: ^0.5.1 + version: 0.5.1 + class-validator: + specifier: ^0.14.1 + version: 0.14.4 + dotenv: + specifier: ^17.4.2 + version: 17.4.2 + pg: + specifier: ^8.13.0 + version: 8.21.0 + reflect-metadata: + specifier: ^0.2.2 + version: 0.2.2 + rxjs: + specifier: ^7.8.1 + version: 7.8.2 + typeorm: + specifier: 0.3.30 + version: 0.3.30(babel-plugin-macros@3.1.0)(pg@8.21.0)(ts-node@10.9.2(@types/node@20.19.42)(typescript@5.9.3)) + devDependencies: + '@edr/eslint-config': + specifier: workspace:* + version: link:../../packages/config/eslint-config + '@edr/tsconfig': + specifier: workspace:* + version: link:../../packages/config/tsconfig + '@nestjs/cli': + specifier: ^11.0.0 + version: 11.0.21(@types/node@20.19.42)(cssnano@7.1.9(postcss@8.5.15))(postcss@8.5.15)(prettier@3.8.3) + '@nestjs/schematics': + specifier: ^11.0.0 + version: 11.1.0(chokidar@4.0.3)(prettier@3.8.3)(typescript@5.9.3) + '@nestjs/testing': + specifier: ^11.0.0 + version: 11.1.24(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(@nestjs/microservices@11.1.24)(@nestjs/platform-express@11.1.24) + '@types/express': + specifier: ^5.0.0 + version: 5.0.6 + '@types/jest': + specifier: ^29.5.13 + version: 29.5.14 + '@types/node': + specifier: ^20.14.0 + version: 20.19.42 + '@types/pg': + specifier: ^8.6.7 + version: 8.20.0 + jest: + specifier: ^29.7.0 + version: 29.7.0(@types/node@20.19.42)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@20.19.42)(typescript@5.9.3)) + ts-jest: + specifier: ^29.2.5 + version: 29.4.11(@babel/core@7.29.7)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.29.7))(jest-util@29.7.0)(jest@29.7.0(@types/node@20.19.42)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@types/node@20.19.42)(typescript@5.9.3)))(typescript@5.9.3) + ts-node: + specifier: ^10.9.2 + version: 10.9.2(@types/node@20.19.42)(typescript@5.9.3) + tsconfig-paths: + specifier: ^4.2.0 + version: 4.2.0 + typescript: + specifier: ^5.5.4 + version: 5.9.3 + + apps/finance-web: + dependencies: + '@edr/ui-common': + specifier: workspace:* + version: link:../../packages/ui-common + '@hookform/resolvers': + specifier: ^5.4.0 + version: 5.6.0(@sinclair/typebox@0.27.10)(@standard-schema/spec@1.1.0)(ajv-formats@2.1.1(ajv@8.20.0))(ajv@8.20.0)(class-transformer@0.5.1)(class-validator@0.14.4)(effect@3.21.0)(react-hook-form@7.77.0(react@19.2.6))(zod@4.4.3) + '@mantine/core': + specifier: ^9.3.0 + version: 9.3.2(@mantine/hooks@9.3.2(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@mantine/dates': + specifier: ^9.3.0 + version: 9.3.2(@mantine/core@9.3.2(@mantine/hooks@9.3.2(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/hooks@9.3.2(react@19.2.6))(dayjs@1.11.21)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@mantine/hooks': + specifier: ^9.3.0 + version: 9.3.2(react@19.2.6) + '@mantine/notifications': + specifier: ^9.3.0 + version: 9.5.2(@mantine/core@9.3.2(@mantine/hooks@9.3.2(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/hooks@9.3.2(react@19.2.6))(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@mantine/spotlight': + specifier: ^9.5.2 + version: 9.5.2(@mantine/core@9.3.2(@mantine/hooks@9.3.2(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/hooks@9.3.2(react@19.2.6))(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@tabler/icons-react': + specifier: ^3.44.0 + version: 3.44.0(react@19.2.6) + '@tanstack/react-query': + specifier: ^5.62.0 + version: 5.101.0(react@19.2.6) + '@tanstack/react-table': + specifier: ^8.21.3 + version: 8.21.3(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + axios: + specifier: ^1.16.1 + version: 1.17.0 + dayjs: + specifier: ^1.11.13 + version: 1.11.21 + i18next: + specifier: ^26.3.5 + version: 26.3.5(typescript@5.9.3) + react: + specifier: 19.2.6 + version: 19.2.6 + react-dom: + specifier: 19.2.6 + version: 19.2.6(react@19.2.6) + react-hook-form: + specifier: ^7.77.0 + version: 7.77.0(react@19.2.6) + react-i18next: + specifier: ^17.0.8 + version: 17.0.8(i18next@26.3.5(typescript@5.9.3))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(typescript@5.9.3) + react-router-dom: + specifier: ^7.1.1 + version: 7.17.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + zod: + specifier: ^4.0.0 + version: 4.4.3 + devDependencies: + '@edr/tsconfig': + specifier: workspace:* + version: link:../../packages/config/tsconfig + '@tailwindcss/vite': + specifier: ^4.3.0 + version: 4.3.0(vite@6.4.3(@types/node@20.19.42)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0)) + '@types/node': + specifier: ^20.14.0 + version: 20.19.42 + '@types/react': + specifier: ^18.3.11 + version: 18.3.31 + '@types/react-dom': + specifier: ^18.3.0 + version: 18.3.7(@types/react@18.3.31) + '@vitejs/plugin-react': + specifier: ^4.3.4 + version: 4.7.0(vite@6.4.3(@types/node@20.19.42)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0)) + tailwindcss: + specifier: ^4.3.0 + version: 4.3.0 + typescript: + specifier: ^5.9.3 + version: 5.9.3 + vite: + specifier: ^6.0.7 + version: 6.4.3(@types/node@20.19.42)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0) + e2e/freight: devDependencies: '@types/node': @@ -1852,6 +2231,9 @@ packages: '@codemirror/view@6.43.8': resolution: {integrity: sha512-qtItTDssZ/5GFfi94hrILu9j/VUeFPDPkhovEfmWFj2ipTxnzPB8DdHgfbb8HYTzLTYhrndKmyQxXUz/PDLenw==} + '@colordx/core@5.6.0': + resolution: {integrity: sha512-EDlcg/Hmlj1WuFh/Os9YhA+A2aasB2XlgwFfGePUDuW7fVHC07HBi5AFIMx0Ct1eM8kHM9NRyOtZ0MuVYa8xvg==} + '@colors/colors@1.5.0': resolution: {integrity: sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ==} engines: {node: '>=0.1.90'} @@ -1929,6 +2311,80 @@ packages: resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==} engines: {node: '>=12'} + '@css-inline/css-inline-android-arm-eabi@0.20.0': + resolution: {integrity: sha512-w1+cicyd2xGzuOcFYxL9Yp3E/KT6opGpdhaKo1vFAepyn8zHrpAR3c1DHH1RWSWK3ZVcD3ne1naqJXQa4k/9uw==} + engines: {node: '>= 10'} + cpu: [arm] + os: [android] + + '@css-inline/css-inline-android-arm64@0.20.0': + resolution: {integrity: sha512-V6ELV+DEjhYPqRcyCezUcMDSrXINMSCL4RBseEqWG18WhOZ9GoGd5XFpZzciQhHq/2MQS+06Jcc6RX59k7QS5g==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [android] + + '@css-inline/css-inline-darwin-arm64@0.20.0': + resolution: {integrity: sha512-hT8Mtwp4PJ9mMYFDG/xg7KCa7nPqseJdXaajxXVjXB8+6oemxgx+7TUO7HSfmeY4Ox7X2vaNfaIfti4ySgNvyg==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [darwin] + + '@css-inline/css-inline-darwin-x64@0.20.0': + resolution: {integrity: sha512-caFvVySabHZG4N1QUysbhWp2+VstMlYLysZYfAX0I6c9QfWcr+OZThoGOO2gC9lYy6QMCTBSD+9t+iceH9oBQw==} + engines: {node: '>= 10'} + cpu: [x64] + os: [darwin] + + '@css-inline/css-inline-linux-arm-gnueabihf@0.20.0': + resolution: {integrity: sha512-9/TdB6cHgJ285uL7Y+1XjdJGjIQOeF9/YNq47N+azf2SiYdA3ahGYBmZhCGtYG7aBTJly9iJCxjP9ZZN8+Lajw==} + engines: {node: '>= 10'} + cpu: [arm] + os: [linux] + + '@css-inline/css-inline-linux-arm64-gnu@0.20.0': + resolution: {integrity: sha512-umcG26teSSUxWGQm7sECI8KweUgBgq9Cqd6yNMTNzqC1K56ZhW3Iwi7QewVNGOpVPXoGsywIIwIuY9lyed1Jdw==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@css-inline/css-inline-linux-arm64-musl@0.20.0': + resolution: {integrity: sha512-wHQykZw3pX2R5Yp5VChOWzVXXO3XPdgWkQH1B9QBmft926EOkZpwxvubeAYy9I89qJzzdGNqiRl26+AUP88J3A==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@css-inline/css-inline-linux-x64-gnu@0.20.0': + resolution: {integrity: sha512-lHQZ+31CN3XJTn5MMFv1NYjyvuhAWEvul0fAYyppw4haA1TM+9UuA9jXdjqWXp26ItmainUnDMBlNLYQwMYt7g==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@css-inline/css-inline-linux-x64-musl@0.20.0': + resolution: {integrity: sha512-DO/cba/zndTY3s86nNNfeHx7DvrvLb+IxhkQWTr29IQeiUgbawCH9X3B/4Li2eo/sEx/imOOnYDdXYu8PeX2Fg==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + libc: [musl] + + '@css-inline/css-inline-win32-arm64-msvc@0.20.0': + resolution: {integrity: sha512-1o8xla5ljVHS7SguH6nTV6uoAIMRZv+2MrcxfLRObpkUtCO7oKXDeWnia7XlmnLhDX8Ncx5bDOenZb1N35kCiA==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [win32] + + '@css-inline/css-inline-win32-x64-msvc@0.20.0': + resolution: {integrity: sha512-pdO/QXLRwuq/RxO5PSFMhmWGJSQOvkiDyV4RrewEkE7SOCY8HAYNhx1T0y0pj2WzxuVdbNQbpwiCHAdQNplUJw==} + engines: {node: '>= 10'} + cpu: [x64] + os: [win32] + + '@css-inline/css-inline@0.20.0': + resolution: {integrity: sha512-WABsvMSBs/DDadwhAUDw6uXwUE6w4DC/bnC4E2Y2rqbCTw5E1PPfj6VksnSCALkv9ynZfYGYYO4+Rvhmn5kgpw==} + engines: {node: '>= 10'} + '@csstools/color-helpers@5.1.0': resolution: {integrity: sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==} engines: {node: '>=18'} @@ -2058,138 +2514,294 @@ packages: cpu: [ppc64] os: [aix] + '@esbuild/aix-ppc64@0.25.12': + resolution: {integrity: sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + '@esbuild/android-arm64@0.21.5': resolution: {integrity: sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==} engines: {node: '>=12'} cpu: [arm64] os: [android] + '@esbuild/android-arm64@0.25.12': + resolution: {integrity: sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + '@esbuild/android-arm@0.21.5': resolution: {integrity: sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==} engines: {node: '>=12'} cpu: [arm] os: [android] + '@esbuild/android-arm@0.25.12': + resolution: {integrity: sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + '@esbuild/android-x64@0.21.5': resolution: {integrity: sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==} engines: {node: '>=12'} cpu: [x64] os: [android] + '@esbuild/android-x64@0.25.12': + resolution: {integrity: sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + '@esbuild/darwin-arm64@0.21.5': resolution: {integrity: sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==} engines: {node: '>=12'} cpu: [arm64] os: [darwin] + '@esbuild/darwin-arm64@0.25.12': + resolution: {integrity: sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + '@esbuild/darwin-x64@0.21.5': resolution: {integrity: sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==} engines: {node: '>=12'} cpu: [x64] os: [darwin] + '@esbuild/darwin-x64@0.25.12': + resolution: {integrity: sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + '@esbuild/freebsd-arm64@0.21.5': resolution: {integrity: sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==} engines: {node: '>=12'} cpu: [arm64] os: [freebsd] + '@esbuild/freebsd-arm64@0.25.12': + resolution: {integrity: sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + '@esbuild/freebsd-x64@0.21.5': resolution: {integrity: sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==} engines: {node: '>=12'} cpu: [x64] os: [freebsd] + '@esbuild/freebsd-x64@0.25.12': + resolution: {integrity: sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + '@esbuild/linux-arm64@0.21.5': resolution: {integrity: sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==} engines: {node: '>=12'} cpu: [arm64] os: [linux] + '@esbuild/linux-arm64@0.25.12': + resolution: {integrity: sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + '@esbuild/linux-arm@0.21.5': resolution: {integrity: sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==} engines: {node: '>=12'} cpu: [arm] os: [linux] + '@esbuild/linux-arm@0.25.12': + resolution: {integrity: sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + '@esbuild/linux-ia32@0.21.5': resolution: {integrity: sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==} engines: {node: '>=12'} cpu: [ia32] os: [linux] + '@esbuild/linux-ia32@0.25.12': + resolution: {integrity: sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + '@esbuild/linux-loong64@0.21.5': resolution: {integrity: sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==} engines: {node: '>=12'} cpu: [loong64] os: [linux] + '@esbuild/linux-loong64@0.25.12': + resolution: {integrity: sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + '@esbuild/linux-mips64el@0.21.5': resolution: {integrity: sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==} engines: {node: '>=12'} cpu: [mips64el] os: [linux] + '@esbuild/linux-mips64el@0.25.12': + resolution: {integrity: sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + '@esbuild/linux-ppc64@0.21.5': resolution: {integrity: sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==} engines: {node: '>=12'} cpu: [ppc64] os: [linux] + '@esbuild/linux-ppc64@0.25.12': + resolution: {integrity: sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + '@esbuild/linux-riscv64@0.21.5': resolution: {integrity: sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==} engines: {node: '>=12'} cpu: [riscv64] os: [linux] + '@esbuild/linux-riscv64@0.25.12': + resolution: {integrity: sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + '@esbuild/linux-s390x@0.21.5': resolution: {integrity: sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==} engines: {node: '>=12'} cpu: [s390x] os: [linux] + '@esbuild/linux-s390x@0.25.12': + resolution: {integrity: sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + '@esbuild/linux-x64@0.21.5': resolution: {integrity: sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==} engines: {node: '>=12'} cpu: [x64] os: [linux] + '@esbuild/linux-x64@0.25.12': + resolution: {integrity: sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.25.12': + resolution: {integrity: sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + '@esbuild/netbsd-x64@0.21.5': resolution: {integrity: sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==} engines: {node: '>=12'} cpu: [x64] os: [netbsd] + '@esbuild/netbsd-x64@0.25.12': + resolution: {integrity: sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.25.12': + resolution: {integrity: sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + '@esbuild/openbsd-x64@0.21.5': resolution: {integrity: sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==} engines: {node: '>=12'} cpu: [x64] os: [openbsd] + '@esbuild/openbsd-x64@0.25.12': + resolution: {integrity: sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.25.12': + resolution: {integrity: sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + '@esbuild/sunos-x64@0.21.5': resolution: {integrity: sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==} engines: {node: '>=12'} cpu: [x64] os: [sunos] + '@esbuild/sunos-x64@0.25.12': + resolution: {integrity: sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + '@esbuild/win32-arm64@0.21.5': resolution: {integrity: sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==} engines: {node: '>=12'} cpu: [arm64] os: [win32] + '@esbuild/win32-arm64@0.25.12': + resolution: {integrity: sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + '@esbuild/win32-ia32@0.21.5': resolution: {integrity: sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==} engines: {node: '>=12'} cpu: [ia32] os: [win32] + '@esbuild/win32-ia32@0.25.12': + resolution: {integrity: sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + '@esbuild/win32-x64@0.21.5': resolution: {integrity: sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==} engines: {node: '>=12'} cpu: [x64] os: [win32] + '@esbuild/win32-x64@0.25.12': + resolution: {integrity: sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + '@eslint-community/eslint-utils@4.9.1': resolution: {integrity: sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} @@ -2563,6 +3175,10 @@ packages: resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} engines: {node: '>=12'} + '@isaacs/cliui@9.0.0': + resolution: {integrity: sha512-AokJm4tuBHillT+FpMtxQ60n8ObyXBatq7jD2/JA9dxbDDokKQm8KMht5ibGzLVU9IJDIKK4TPKgMHEYMn3lMg==} + engines: {node: '>=18'} + '@istanbuljs/load-nyc-config@1.1.0': resolution: {integrity: sha512-VjeHSlIzpv/NyD3N0YuHfXOPDIixcA1q2ZV98wsMqcYlPmv2n3Yb2lYP9XMElnaFVXg5A7YLTeLu6V84uQDjmQ==} engines: {node: '>=8'} @@ -2982,11 +3598,32 @@ packages: react: ^18.x || ^19.x react-dom: ^18.x || ^19.x + '@mantine/notifications@9.5.2': + resolution: {integrity: sha512-2DQW2i6BlTGI7Vqp1a8IN03Q2CI/nMd7joJu7RGMTDhnnD0rhAhQtu/NwGdmq5gl4vFCpzzwHtH359Do2MlRGg==} + peerDependencies: + '@mantine/core': 9.5.2 + '@mantine/hooks': 9.5.2 + react: ^19.2.0 + react-dom: ^19.2.0 + + '@mantine/spotlight@9.5.2': + resolution: {integrity: sha512-+YrHe0pqGrx+sTZKSV9v2K/OMyBow4u1Id5zI4jevTGlENKFqsKPn94CIx6Ryn48ODFzJyiiAAXYmvRT4Sw3xA==} + peerDependencies: + '@mantine/core': 9.5.2 + '@mantine/hooks': 9.5.2 + react: ^19.2.0 + react-dom: ^19.2.0 + '@mantine/store@7.17.8': resolution: {integrity: sha512-/FrB6PAVH4NEjQ1dsc9qOB+VvVlSuyjf4oOOlM9gscPuapDP/79Ryq7JkhHYfS55VWQ/YUlY24hDI2VV+VptXg==} peerDependencies: react: ^18.x || ^19.x + '@mantine/store@9.5.2': + resolution: {integrity: sha512-vpOS9QwqaJ1KOPr8MdQXYXFDIPkDg1KCr5O8FOGVZIGfsei1vvQsvvmMGGEKGmEE/4FCYwEfhCUWaAVw2psVzw==} + peerDependencies: + react: ^19.2.0 + '@marijn/find-cluster-break@1.0.3': resolution: {integrity: sha512-FY+MKLBoTsLNJF/eLWaOsXGdz6uh3Iu1axjPf6TUq92IYumcTcXWHoS747JARLkcdlJ/Waiaxc5wQfFO8jC6NA==} @@ -3252,6 +3889,23 @@ packages: '@emnapi/core': ^1.7.1 '@emnapi/runtime': ^1.7.1 + '@nestjs-modules/mailer@2.3.7': + resolution: {integrity: sha512-7prUdL8rt7Lz9m/RP/PFvZHwYgAewDexQHLwy4kyYueksoTGGxOzu7q4N/4Q+SU2+hKLakasbHdw8GrvQJi85w==} + peerDependencies: + '@nestjs/common': '>=7.0.9' + '@nestjs/core': '>=7.0.9' + '@nestjs/event-emitter': '>=2.0.0' + '@nestjs/terminus': '>=10.0.0' + bullmq: '>=4.0.0' + nodemailer: '>=8.0.5' + peerDependenciesMeta: + '@nestjs/event-emitter': + optional: true + '@nestjs/terminus': + optional: true + bullmq: + optional: true + '@nestjs/axios@4.0.1': resolution: {integrity: sha512-68pFJgu+/AZbWkGu65Z3r55bTsCPlgyKaV4BSG8yUAD72q1PPuyVRgUwFv6BxdnibTUHlyxm06FmYWNC+bjN7A==} peerDependencies: @@ -3588,6 +4242,9 @@ packages: engines: {node: ^14.18.0 || >=16.10.0, npm: '>=5.10.0'} hasBin: true + '@one-ini/wasm@0.1.1': + resolution: {integrity: sha512-XuySG1E38YScSJoMlqovLru4KTUNSjgVTIjyh7qMX6aNN5HY5Ct5LhRJdxO79JtTzKfzV/bnWpz+zquYrISsvw==} + '@open-draft/deferred-promise@2.2.0': resolution: {integrity: sha512-CecwLWx3rhxVQF6V4bAgPS5t+So2sTbPgAzafKkVizyi7tlwpcFpdFqq+wqF2OwNBmqFuu6tOyouTuxgpMfzmA==} @@ -4785,6 +5442,9 @@ packages: '@sec-ant/readable-stream@0.4.1': resolution: {integrity: sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==} + '@selderee/plugin-htmlparser2@0.11.0': + resolution: {integrity: sha512-P33hHGdldxGabLFjPPpaTxVolMrzrcegejx+0GxjrIb9Zv48D8yAIA/QTDR2dFl7Uz7urX8aX6+5bCZslr+gWQ==} + '@sendgrid/client@8.1.6': resolution: {integrity: sha512-/BHu0hqwXNHr2aLhcXU7RmmlVqrdfrbY9KpaNj00KZHlVOVoRxRVrpOCabIB+91ISXJ6+mLM9vpaVUhK6TwBWA==} engines: {node: '>=12.*'} @@ -5073,6 +5733,28 @@ packages: rxjs: ^7.8.0 typeorm: ^0.3.0 + '@tria-plc/iamapi-common@file:local-packages/tria-plc-iamapi-common-1.3.4.tgz': + resolution: {integrity: sha512-r0npmcfLgzVOe0dTZXFUg3O4S4pWRJiozLublyXYOyfjbYvVSkK4iiHGzji7aO+5ebjKTLt55f6ruVT6MxiiEw==, tarball: file:local-packages/tria-plc-iamapi-common-1.3.4.tgz} + version: 1.3.4 + engines: {node: '>=20'} + peerDependencies: + '@nestjs/axios': ^4.0.0 + '@nestjs/common': ^11.0.0 + '@nestjs/core': ^11.0.0 + '@nestjs/jwt': ^11.0.0 + '@nestjs/microservices': ^11.0.0 + '@nestjs/passport': ^11.0.0 + '@nestjs/swagger': ^11.0.0 + '@nestjs/throttler': ^6.0.0 + '@nestjs/typeorm': ^11.0.0 + '@tria-plc/api-common': '*' + axios: ^1.9.0 + class-transformer: ^0.5.1 + class-validator: ^0.14.1 + reflect-metadata: ^0.2.0 + rxjs: ^7.8.0 + typeorm: ^0.3.0 + '@tria-plc/iamui@file:local-packages/tria-plc-iamui-0.1.1.tgz': resolution: {integrity: sha512-FTihoH0lIqKV/0s+FTJZokQw8xX3XztXIqT8eEYEZgDM2xyzVSCHY8pHCUxw0MQtiR8R97zxZJ/o192eWt+E/g==, tarball: file:local-packages/tria-plc-iamui-0.1.1.tgz} version: 0.1.1 @@ -5199,6 +5881,9 @@ packages: resolution: {integrity: sha512-Fgg31wv9QbLDA0SpTOXO3MaxySc4DKGLi8sna4/Utjo4r3ZRPdCt4UQee8BWr+Q5z21yifghREPJGYaEOEIACg==} deprecated: This is a stub types definition. dompurify provides its own type definitions, so you do not need this installed. + '@types/ejs@3.1.5': + resolution: {integrity: sha512-nv+GSx77ZtXiJzwKdsASqi+YQ5Z7vwHsTP0JY2SiQgjGckkBRKZnk8nIM+7oUZ1VCtuTz0+By4qVR7fqzp/Dfg==} + '@types/eslint-scope@3.7.7': resolution: {integrity: sha512-MzMFlSLBqNF2gcHWO0G1vP/YQyfvrxZ0bF+u7mzUdZ1/xK4A4sru+nraZz5i3iEIk1l1uyicaDVTB4QbbEkAYg==} @@ -5285,6 +5970,12 @@ packages: '@types/mime@1.3.5': resolution: {integrity: sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==} + '@types/mjml-core@5.1.0': + resolution: {integrity: sha512-dYYmvGIHFi4wzsV+zX7UhP4C5QFSKeTjk53uQcadyIpTrrpJSs8JCZEKfSCrbKS7Hev3aR62b2dg58IeuwiR/w==} + + '@types/mjml@4.7.4': + resolution: {integrity: sha512-vyi1vzWgMzFMwZY7GSZYX0GU0dmtC8vLHwpgk+NWmwbwRSrlieVyJ9sn5elodwUfklJM7yGl0zQeet1brKTWaQ==} + '@types/ms@2.1.0': resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==} @@ -5315,6 +6006,9 @@ packages: '@types/prop-types@15.7.15': resolution: {integrity: sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==} + '@types/pug@2.0.10': + resolution: {integrity: sha512-Sk/uYFOBAB7mb74XcpizmH0KOR2Pv3D2Hmrh1Dmy5BmK3MpdSa5kqZcg6EKBdklU0bFXX9gCfzvpnyUehrPIuA==} + '@types/qrcode@1.5.6': resolution: {integrity: sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==} @@ -5340,6 +6034,9 @@ packages: '@types/react@18.3.31': resolution: {integrity: sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw==} + '@types/relateurl@0.2.33': + resolution: {integrity: sha512-bTQCKsVbIdzLqZhLkF5fcJQreE4y1ro4DIyVrlDNSCJRRwHhB8Z+4zXXa8jN6eDvc2HbRsEYgbvrnGvi54EpSw==} + '@types/send@0.17.6': resolution: {integrity: sha512-Uqt8rPBE8SY0RK8JB1EzVOIZ32uqy8HwdxCnoCOsYrvnswqmFZ/k+9Ikidlk/ImhsdvBsloHbAlewb2IEBV/Og==} @@ -5701,6 +6398,9 @@ packages: '@xtuc/long@4.2.2': resolution: {integrity: sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==} + '@zone-eu/mailsplit@5.4.8': + resolution: {integrity: sha512-eEyACj4JZ7sjzRvy26QhLgKEMWwQbsw1+QZnlLX+/gihcNH07lVPOcnwf5U6UAL7gkc//J3jVd76o/WS+taUiA==} + '@zxing/text-encoding@0.9.0': resolution: {integrity: sha512-U/4aVJ2mxI0aDNI8Uq0wEhMgY+u4CNtEb0om3+y3+niDAsoTCOB33UF0sxpzqzdqXLqmvc+vZyAt4O8pPdfkwA==} @@ -5708,6 +6408,13 @@ packages: resolution: {integrity: sha512-E+iruNOY8VV9s4JEbe1aNEm6MiszPRr/UfcHMz0TQh1BXSxHK+ASV1R6W4HpjBhSeS+54PIsAMCBmwD06LLsqQ==} hasBin: true + a-sync-waterfall@1.0.1: + resolution: {integrity: sha512-RYTOHHdWipFUliRFMCS4X2Yn2X8M87V/OpSqWzKKOGhzqyUxzyVmhHDH9sAvG+ZuQf/TAOFsLCpMw09I1ufUnA==} + + abbrev@2.0.0: + resolution: {integrity: sha512-6/mh1E2u2YgEsCHdY0Yx5oW+61gZU+1vXaoiHHrpKeuRNNgFvS+/jrwHiQhB5apAf5oB7UB7E19ol2R2LKH8hQ==} + engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} + abort-controller@3.0.0: resolution: {integrity: sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==} engines: {node: '>=6.5'} @@ -5738,6 +6445,11 @@ packages: resolution: {integrity: sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw==} engines: {node: '>=0.4.0'} + acorn@7.4.1: + resolution: {integrity: sha512-nQyp0o1/mNdbTO1PO6kHkwSrmgZ0MT/jCCpNiwbUjGoRN4dlBhqJtoQuCnEOKzgTVwg0ZWiCoQy6SxMebQVh8A==} + engines: {node: '>=0.4.0'} + hasBin: true + acorn@8.16.0: resolution: {integrity: sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==} engines: {node: '>=0.4.0'} @@ -5790,6 +6502,10 @@ packages: ajv@8.20.0: resolution: {integrity: sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==} + alce@1.2.0: + resolution: {integrity: sha512-XppPf2S42nO2WhvKzlwzlfcApcXHzjlod30pKmcWjRgLOtqoe5DMuqdiYoM6AgyXksc6A6pV4v1L/WW217e57w==} + engines: {node: '>=0.8.0'} + amqp-connection-manager@4.1.15: resolution: {integrity: sha512-YGi8MuO2K4u0qC4b1qAKVIK8CdBJF+PpEZpHKxmrL3x61pU1sAb2SHzQl7ypoqr6oECIfVqtG9DGdOZxaUq1Wg==} engines: {node: '>=10.0.0', npm: '>5.0.0'} @@ -6094,6 +6810,9 @@ packages: asn1@0.2.6: resolution: {integrity: sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ==} + assert-never@1.4.0: + resolution: {integrity: sha512-5oJg84os6NMQNl27T9LnZkvvqzvAnHu03ShCnoj6bsJwS7L8AO4lf+C/XjK/nvzEqQB744moC6V128RucQd1jA==} + assert-plus@1.0.0: resolution: {integrity: sha512-NfJ4UzBCcQGLDlQq7nHxH+tv3kyZ0hHQqF5BO6J7tNJeP5do1llPr8dZ8zHonfhAu0PHAdMkSo+8o0wxg9lZWw==} engines: {node: '>=0.8'} @@ -6220,6 +6939,10 @@ packages: babel-runtime@6.26.0: resolution: {integrity: sha512-ITKNuq2wKlW1fJg9sSW52eepoYgZBggvOAHC0u/CYu/qxQ9EVzThCgR69BnSXLHjy2f7SY5zaQ4yt7H9ZVxY2g==} + babel-walk@3.0.0-canary-5: + resolution: {integrity: sha512-GAwkz0AihzY5bkwIY5QDR+LvsRQgB/B+1foMPvi0FZPMl5fjD7ICiznUiBdLYMH1QYe6vqu4gWYytZOccLouFw==} + engines: {node: '>= 10.0.0'} + bail@2.0.2: resolution: {integrity: sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==} @@ -6474,6 +7197,9 @@ packages: camelize@1.0.1: resolution: {integrity: sha512-dU+Tx2fsypxTgtLoE36npi3UqcjSSMNYfkqgmoEhtZrraP5VWq0K7FkWVTYa8eMPtnU/G2txVsfdCJTn9uzpuQ==} + caniuse-api@3.0.0: + resolution: {integrity: sha512-bsTwuIg/BZZK/vreVTYYbSWoe2F+71P7K5QGEX+pT250DZbfU1MQ5prOKpPR+LL6uWKK3KMwMCAS74QB3Um1uw==} + caniuse-lite@1.0.30001797: resolution: {integrity: sha512-l8xKG+gwAIExZGl9FrF7KUwuOmk6wbEPC9Xoy/RtnWv1XG0Q4LFlagaLpUv3Kiza3W/wm27zy0yWJEieYKAP6w==} @@ -6502,6 +7228,10 @@ packages: resolution: {integrity: sha512-U3lRVLMSlsCfjqYPbLyVv11M9CPW4I728d6TCKMAOJueEeB9/8o+eSsMnxPJD+Q+K909sdESg7C+tIkoH6on1A==} engines: {node: '>=0.10.0'} + chalk@3.0.0: + resolution: {integrity: sha512-4D3B6Wf41KOYRFdszmDqMCGq5VV/uMAB273JILmO+3jAlh8X4qDtdtgCR3fxtbLEMzSx22QdhnDcJvu2u1fVwg==} + engines: {node: '>=8'} + chalk@4.1.2: resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} engines: {node: '>=10'} @@ -6526,6 +7256,9 @@ packages: character-entities@2.0.2: resolution: {integrity: sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==} + character-parser@2.2.0: + resolution: {integrity: sha512-+UqJQjFEFaTAs3bNsF2j2kEN1baG/zghZbdqoYEDxGZtJo9LBzl1A+m0D4n3qKx8N2FNv8/Xp6yV9mQmBuptaw==} + character-reference-invalid@2.0.1: resolution: {integrity: sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==} @@ -6536,6 +7269,13 @@ packages: resolution: {integrity: sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==} engines: {node: '>= 16'} + cheerio-select@2.1.0: + resolution: {integrity: sha512-9v9kG0LvzrlcungtnJtpGNxY+fzECQKhK4EGJX2vByejiMX84MFNQw4UxPJl3bFbTMw+Dfs37XaIkCwTZfLh4g==} + + cheerio@1.0.0: + resolution: {integrity: sha512-quS9HgjQpdaXOvsZz82Oz7uxtXiy6UIsIQcpBj7HRw2M63Skasm9qlDocAM7jNuaxdhpPU7c4kJN+gA5MCu4ww==} + engines: {node: '>=18.17'} + chokidar@3.6.0: resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==} engines: {node: '>= 8.10.0'} @@ -6719,10 +7459,18 @@ packages: comma-separated-tokens@2.0.3: resolution: {integrity: sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==} + commander@10.0.1: + resolution: {integrity: sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug==} + engines: {node: '>=14'} + commander@11.1.0: resolution: {integrity: sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ==} engines: {node: '>=16'} + commander@12.1.0: + resolution: {integrity: sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==} + engines: {node: '>=18'} + commander@13.1.0: resolution: {integrity: sha512-/rFeCpNJQbhSZjGVwO9RFV3xPqbnERS8MmIQzCtD/zl6gpJuV/bMLuN92oG3F7d8oDEHHRrujSXNUr8fpjntKw==} engines: {node: '>=18'} @@ -6731,6 +7479,10 @@ packages: resolution: {integrity: sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==} engines: {node: '>=20'} + commander@15.0.0: + resolution: {integrity: sha512-z67u4ZhzCL/Tydu1lJARtEZYWbWaN7oYLHbsuzocr6y4N6WZAagG3RQ4FW61V1/0+jImpj293XfrcYnd1qxtPg==} + engines: {node: '>=22.12.0'} + commander@2.20.3: resolution: {integrity: sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==} @@ -6738,6 +7490,10 @@ packages: resolution: {integrity: sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==} engines: {node: '>= 6'} + commander@5.1.0: + resolution: {integrity: sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg==} + engines: {node: '>= 6'} + commander@6.2.1: resolution: {integrity: sha512-U7VdrJFnJgo4xjrHpTzu0yrHPGImdsmD95ZlgYSEajAn2JKzDhDTPG9kBTefmObL2w/ngeZnilk+OV9CG3d7UA==} engines: {node: '>= 6'} @@ -6776,10 +7532,16 @@ packages: confbox@0.2.4: resolution: {integrity: sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==} + config-chain@1.1.13: + resolution: {integrity: sha512-qj+f8APARXHrM0hraqXYb2/bOVSV4PvJQlNZ/DVj0QrmNM2q2euizkeuVckQ57J+W0mRH6Hvi+k50M4Jul2VRQ==} + consola@3.4.2: resolution: {integrity: sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==} engines: {node: ^14.18.0 || >=16.10.0} + constantinople@4.0.1: + resolution: {integrity: sha512-vCrqcSIq4//Gx74TXXCGnHpulY1dskqLTFGDmhrGxzeXL8lF8kvXv6mpNWlJj1uD4DW23D4ljAqbY4RRaaUZIw==} + content-disposition@0.5.4: resolution: {integrity: sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==} engines: {node: '>= 0.6'} @@ -6931,12 +7693,21 @@ packages: resolution: {integrity: sha512-FyyrDHZKEjXDpNJYvVsV960FiqQyXc/LlYmsxl2BcdMb2WPx0OGRVgTg55rPSyLSNMqP52R9r8geSp7apN3Ofg==} engines: {node: '>=4'} + css-declaration-sorter@7.4.0: + resolution: {integrity: sha512-LTuzjPoyA2vMGKKcaOqKSp7Ub2eGrNfKiZH4LpezxpNrsICGCSFvsQOI29psISxNZtaXibkC2CXzrQ5enMeGGw==} + engines: {node: ^14 || ^16 || >=18} + peerDependencies: + postcss: ^8.0.9 + css-line-break@2.1.0: resolution: {integrity: sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w==} css-select@5.2.2: resolution: {integrity: sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==} + css-select@6.0.0: + resolution: {integrity: sha512-rZZVSLle8v0+EY8QAkDWrKhpgt6SA5OtHsgBnsj6ZaLb5dmDVOWUDtQitd9ydxxvEjhewNudS6eTVU7uOyzvXw==} + css-to-react-native@3.2.0: resolution: {integrity: sha512-e8RKaLXMOFii+02mOlqwjbD00KSEKqblnpO9e++1aXS1fPQOpS1YoqdVHBqPjHNoxeF2mimzVqawm2KCbEdtHQ==} @@ -6944,15 +7715,55 @@ packages: resolution: {integrity: sha512-tRpdppF7TRazZrjJ6v3stzv93qxRcSsFmW6cX0Zm2NVKpxE1WV1HblnghVv9TreireHkqI/VDEsfolRF1p6y7Q==} engines: {node: '>=8.0.0'} + css-tree@2.2.1: + resolution: {integrity: sha512-OA0mILzGc1kCOCSJerOeqDxDQ4HOh+G8NbOJFOTgOCzpw7fCBubk0fEyxp8AgOL/jvLgYA/uV0cMbe43ElF1JA==} + engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0, npm: '>=7.0.0'} + + css-tree@3.2.1: + resolution: {integrity: sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==} + engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0} + css-what@6.2.2: resolution: {integrity: sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==} engines: {node: '>= 6'} + css-what@7.0.0: + resolution: {integrity: sha512-wD5oz5xibMOPHzy13CyGmogB3phdvcDaB5t0W/Nr5Z2O/agcB8YwOz6e2Lsp10pNDzBoDO9nVa3RGs/2BttpHQ==} + engines: {node: '>= 6'} + cssesc@3.0.0: resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==} engines: {node: '>=4'} hasBin: true + cssnano-preset-default@7.0.17: + resolution: {integrity: sha512-11qO63A+czwguQFJCaTdICvbaxn0pJzz/XghLlv+OT7WyToDxAMR0Xb3/26/l0y0hQJywwNbj/SLSQlGBHE1OA==} + engines: {node: ^18.12.0 || ^20.9.0 || >=22.0} + peerDependencies: + postcss: ^8.5.13 + + cssnano-preset-lite@4.0.6: + resolution: {integrity: sha512-EI/VDoucl8SmVkXUZtWIux31cWoxgNUbF7njnpPxdz5ZbnKOjAd5DueLuCE1RKKLrOPQsEUaNfUgB1taohIIyQ==} + engines: {node: ^18.12.0 || ^20.9.0 || >=22.0} + peerDependencies: + postcss: ^8.5.13 + + cssnano-utils@5.0.3: + resolution: {integrity: sha512-ynIREMICLxkxm7e9bCR9sh75s4Q5drICi0ua1yxo5jH2XPBqSKkl4dOh4EbFqtUmnTMhRffHgYL0EKKkMjtJTg==} + engines: {node: ^18.12.0 || ^20.9.0 || >=22.0} + peerDependencies: + postcss: ^8.5.13 + + cssnano@7.1.9: + resolution: {integrity: sha512-uPR75+5Dk/WJ/YSPR1/YDHdwMM9c5FsaARljfKWgeCKLKOtJ0we21xy/RcCjn53fZnD/f6yYEIZ8pu18+GnbNQ==} + engines: {node: ^18.12.0 || ^20.9.0 || >=22.0} + peerDependencies: + postcss: ^8.5.13 + + csso@5.0.5: + resolution: {integrity: sha512-0LrrStPOdJj+SPCCrGhzryycLjwcgUSHBtxNA8aIDxf0GLsRh1cKYhB00Gd1lDOS4yGH69+SNn13+TWbVHETFQ==} + engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0, npm: '>=7.0.0'} + cssstyle@4.6.0: resolution: {integrity: sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==} engines: {node: '>=18'} @@ -7121,6 +7932,10 @@ packages: resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==} engines: {node: '>=6'} + deep-extend@0.6.0: + resolution: {integrity: sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==} + engines: {node: '>=4.0.0'} + deep-is@0.1.4: resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} @@ -7201,6 +8016,10 @@ packages: resolution: {integrity: sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==} engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16} + detect-indent@6.1.0: + resolution: {integrity: sha512-reYkTUJAZb9gUuZ2RvVCNhVHdg62RHnJ7WJl8ftMi4diZ6NWlciOzQN88pUhSELEwflJht4oQDv0F0BMlwaYtA==} + engines: {node: '>=8'} + detect-libc@2.1.2: resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} engines: {node: '>=8'} @@ -7212,6 +8031,9 @@ packages: detect-node-es@1.1.0: resolution: {integrity: sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==} + detect-node@2.1.0: + resolution: {integrity: sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g==} + devlop@1.1.0: resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==} @@ -7246,6 +8068,10 @@ packages: dijkstrajs@1.0.3: resolution: {integrity: sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==} + display-notification@3.0.0: + resolution: {integrity: sha512-/qAvqRy4zWP847zJc1GvXOc+AV1l9/ECKPA7APrLnqjur0o5liMM4bDJ/b1hnJo6Tyb5BfOHyyd4vn9lCh/NSg==} + engines: {node: '>=12'} + dlv@1.1.3: resolution: {integrity: sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==} @@ -7257,15 +8083,25 @@ packages: resolution: {integrity: sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==} engines: {node: '>=6.0.0'} + doctypes@1.1.0: + resolution: {integrity: sha512-LLBi6pEqS6Do3EKQ3J0NqHWV5hhb78Pi8vvESYwyOy2c31ZEZVdtitdzsQsKb7878PEERhzUk0ftqGhG6Mz+pQ==} + dom-helpers@5.2.1: resolution: {integrity: sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==} + dom-serializer@1.4.1: + resolution: {integrity: sha512-VHwB3KfrcOOkelEG2ZOfxqLZdfkil8PtJi4P8N2MMXucZq2yLp75ClViUlOVwyoHEDjYU433Aq+5zWP61+RGag==} + dom-serializer@2.0.0: resolution: {integrity: sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==} domelementtype@2.3.0: resolution: {integrity: sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==} + domhandler@4.3.1: + resolution: {integrity: sha512-GrwoxYN+uWlzO8uhUXRl0P+kHE4GtVPfYzVLcUxPL7KNdHKj66vvlhiweIHqYYXWlw+T8iLMp42Lm67ghw4WMQ==} + engines: {node: '>= 4'} + domhandler@5.0.3: resolution: {integrity: sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==} engines: {node: '>= 4'} @@ -7273,6 +8109,9 @@ packages: dompurify@3.4.8: resolution: {integrity: sha512-yb1cEmaOum7wFvOCSQxyfgVlv5D47Rc30iZWoMpbDIWTnJ6grDDQyu2KFJzB2k7u0pMuJcQ1zphH//fFnw2tjQ==} + domutils@2.8.0: + resolution: {integrity: sha512-w96Cjofp72M5IIhpjgobBimYEfoPjx1Vx0BSX9P30WBdZW2WIKU0T1Bd0kz2eNZ9ikjKgHbEyKx8BB6H1L3h3A==} + domutils@3.2.2: resolution: {integrity: sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==} @@ -7331,12 +8170,22 @@ packages: resolution: {integrity: sha512-wG99Zcfcys9fZux7Cft8BAX/YrOJLJSZ3jyYPfhZHqN2E+Ffx+QXBDsv3gubEgPtV6dTzJMSQUwk1H98/t/0wQ==} engines: {bun: '>=1', deno: '>=2', node: '>=16'} + editorconfig@1.0.7: + resolution: {integrity: sha512-e0GOtq/aTQhVdNyDU9e02+wz9oDDM+SIOQxWME2QRjzRX5yyLAuHDE+0aE8vHb9XRC8XD37eO2u57+F09JqFhw==} + engines: {node: '>=14'} + hasBin: true + ee-first@1.1.1: resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} effect@3.21.0: resolution: {integrity: sha512-PPN80qRokCd1f015IANNhrwOnLO7GrrMQfk4/lnZRE/8j7UPWrNNjPV0uBrZutI/nHzernbW+J0hdqQysHiSnQ==} + ejs@5.0.2: + resolution: {integrity: sha512-IpbUaI/CAW86l3f+T8zN0iggSc0LmMZLcIW5eRVStLVNCoTXkE0YlncbbH50fp8Cl6zHIky0sW2uUbhBqGw0Jw==} + engines: {node: '>=0.12.18'} + hasBin: true + electron-to-chromium@1.5.368: resolution: {integrity: sha512-7RckJJK4uESJF9PxvfMWd3TGqIiieUTG4HxnKaKuIpGbcr+r2ZEB3g2gAhCP3Fqm42vJSzLfgab9eva/C4/XVw==} @@ -7364,6 +8213,13 @@ packages: resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==} engines: {node: '>= 0.8'} + encoding-japanese@2.2.0: + resolution: {integrity: sha512-EuJWwlHPZ1LbADuKTClvHtwbaFn4rOD+dRAbWysqEOXRc2Uui0hJInNJrsdH0c+OhJA4nrCBdSkW4DD5YxAo6A==} + engines: {node: '>=8.10.0'} + + encoding-sniffer@0.2.1: + resolution: {integrity: sha512-5gvq20T6vfpekVtqrYQsSCFZ1wEg5+wW0/QaZMWkFr6BqD3NfKs0rLCx4rrVlSWJeZb5NBJgVLswK/w2MWU+Gw==} + end-of-stream@1.4.5: resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==} @@ -7390,6 +8246,13 @@ packages: resolution: {integrity: sha512-kKvD1tO6BM+oK9HzCPpUdRb4vKFQY/FPTFmurMvh6LlN68VMrdj77w8yp51/kDbpkFOS9J8w5W6zIzgM2H8/hw==} engines: {node: '>= 0.4'} + entities@2.2.0: + resolution: {integrity: sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A==} + + entities@3.0.1: + resolution: {integrity: sha512-WiyBqoomrwMdFG1e0kqvASYfnlb0lp8M5o5Fw2OFq1hNZxxcNk8Ik0Xm7LxzBhuidnZB/UtBqVCgUz3kBOP51Q==} + engines: {node: '>=0.12'} + entities@4.5.0: resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==} engines: {node: '>=0.12'} @@ -7398,6 +8261,10 @@ packages: resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==} engines: {node: '>=0.12'} + entities@7.0.1: + resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==} + engines: {node: '>=0.12'} + env-paths@2.2.1: resolution: {integrity: sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==} engines: {node: '>=6'} @@ -7463,13 +8330,26 @@ packages: engines: {node: '>=12'} hasBin: true + esbuild@0.25.12: + resolution: {integrity: sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==} + engines: {node: '>=18'} + hasBin: true + escalade@3.2.0: resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} engines: {node: '>=6'} + escape-goat@3.0.0: + resolution: {integrity: sha512-w3PwNZJwRxlp47QGzhuEBldEqVHHhh8/tIPcl6ecf2Bou99cdAt0knihBV0Ecc7CGxYduXVBDheH1K2oADRlvw==} + engines: {node: '>=10'} + escape-html@1.0.3: resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==} + escape-string-applescript@3.0.0: + resolution: {integrity: sha512-Wru0bY9XSICPNsy7KwbAZww9SLkoYjP9GtJkmnQOEqOy9U13KA0OJXoti7FaVMsiQ0mQfh916/xoByM6PqdW4g==} + engines: {node: '>=12'} + escape-string-regexp@1.0.5: resolution: {integrity: sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==} engines: {node: '>=0.8.0'} @@ -7602,6 +8482,11 @@ packages: resolution: {integrity: sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + esprima@1.2.5: + resolution: {integrity: sha512-S9VbPDU0adFErpDai3qDkjq8+G05ONtKzcyNrPKg/ZKa+tf879nX2KexNU95b31UoTJjRLInNBHHHjFPoCd7lQ==} + engines: {node: '>=0.4.0'} + hasBin: true + esprima@4.0.1: resolution: {integrity: sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==} engines: {node: '>=4'} @@ -7615,6 +8500,10 @@ packages: resolution: {integrity: sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==} engines: {node: '>=4.0'} + estraverse@1.9.3: + resolution: {integrity: sha512-25w1fMXQrGdoquWnScXZGckOv+Wes+JDnuN/+7ex3SauFRS72r2lFDec0EKPt2YD1wUJ/IrfEex+9yp4hfSOJA==} + engines: {node: '>=0.10.0'} + estraverse@4.3.0: resolution: {integrity: sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==} engines: {node: '>=4.0'} @@ -7742,6 +8631,9 @@ packages: exsolve@1.0.8: resolution: {integrity: sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==} + extend-object@1.0.0: + resolution: {integrity: sha512-0dHDIXC7y7LDmCh/lp1oYkmv73K25AMugQI07r8eFopkW6f7Ufn1q+ETMsJjnV9Am14SlElkqy3O92r6xEaxPw==} + extend-shallow@2.0.1: resolution: {integrity: sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug==} engines: {node: '>=0.10.0'} @@ -7913,6 +8805,10 @@ packages: resolution: {integrity: sha512-YyZM99iHrqLKjmt4LJDj58KI+fYyufRLBSYcqycxf//KpBk9FoewoGX0450m9nB44qrZnovzC2oeP5hUibxc/g==} engines: {node: '>=18'} + fixpack@4.0.0: + resolution: {integrity: sha512-5SM1+H2CcuJ3gGEwTiVo/+nd/hYpNj9Ch3iMDOQ58ndY+VGQ2QdvaUTkd3otjZvYnd/8LF/HkJ5cx7PBq0orCQ==} + hasBin: true + flat-cache@3.2.0: resolution: {integrity: sha512-CYcENa+FtcUKLmhhqyctpclsq7QF38pKjZHsGNiSQF5r4FtoKDWabFDl3hzaEQMvT1LHEysw5twgLvpYYb4vbw==} engines: {node: ^10.12.0 || >=12.0.0} @@ -8104,6 +9000,10 @@ packages: resolution: {integrity: sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==} engines: {node: '>=8.0.0'} + get-port@5.1.1: + resolution: {integrity: sha512-g/Q1aTSDOxFpchXC4i8ZWvxA1lnPqx/JHqcpIw0/LX9T8x/GBbi6YnlN5nhaKIFkT8oFsscUKgDJYxfwfS6QsQ==} + engines: {node: '>=8'} + get-proto@1.0.1: resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} engines: {node: '>= 0.4'} @@ -8174,6 +9074,12 @@ packages: 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 hasBin: true + glob@11.1.0: + resolution: {integrity: sha512-vuNwKSaKiqm7g0THUBu2x7ckSs3XJLXE+2ssL7/MfTGPLLcrJQ/4Uq1CjPTtO5cCIiRxqvN6Twy1qOwhL0Xjcw==} + engines: {node: 20 || >=22} + 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 + hasBin: true + glob@13.0.6: resolution: {integrity: sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==} engines: {node: 18 || 20 || >=22} @@ -8356,6 +9262,10 @@ packages: html-to-image@1.11.13: resolution: {integrity: sha512-cuOPoI7WApyhBElTTb9oqsawRvZ0rHhaHwghRLlTuffoD1B2aDemlCruLeZrUIIdvG7gs9xeELEPm6PhuASqrg==} + html-to-text@9.0.5: + resolution: {integrity: sha512-qY60FjREgVZL03vJU6IfMV4GDjGBIoOyvuFdpBDIX9yTlDw0TjxVBQp+P8NvpdIXNJvfWBTNul7fsAQJq2FNpg==} + engines: {node: '>=14'} + html-url-attributes@3.0.1: resolution: {integrity: sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==} @@ -8363,6 +9273,45 @@ packages: resolution: {integrity: sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA==} engines: {node: '>=8.0.0'} + htmlnano@3.4.0: + resolution: {integrity: sha512-5rgW9c/830dlDiWLlsT3ZLBs52UAupymGwkwr4rn2yyuzrNY0RZwgr6F4q2AkUqrRuuHb3kPelqYsUgtzQDtQw==} + hasBin: true + peerDependencies: + cssnano: ^7.0.0 || ^8.0.0 + postcss: ^8.3.11 + purgecss: ^8.0.0 + relateurl: ^0.2.7 + srcset: ^5.0.1 + svgo: ^4.0.0 + terser: ^5.21.0 + uncss: ^0.17.3 + peerDependenciesMeta: + cssnano: + optional: true + postcss: + optional: true + purgecss: + optional: true + relateurl: + optional: true + srcset: + optional: true + svgo: + optional: true + terser: + optional: true + uncss: + optional: true + + htmlparser2@7.2.0: + resolution: {integrity: sha512-H7MImA4MS6cw7nbyURtLPO1Tms7C5H602LRETv95z1MxO/7CP7rDVROehUYeYBUYEON94NXXDEPmZuq+hX4sog==} + + htmlparser2@8.0.2: + resolution: {integrity: sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA==} + + htmlparser2@9.1.0: + resolution: {integrity: sha512-5zfg6mHUoaer/97TxnGpxmbR7zJtPwIYFMZ/H5ucTlPZhKvtum05yiPK3Mgai3a0DyVxv7qYqoweaEd2nrYQzQ==} + http-errors@2.0.1: resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} engines: {node: '>= 0.8'} @@ -8492,6 +9441,9 @@ packages: inherits@2.0.4: resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + ini@1.3.8: + resolution: {integrity: sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==} + ini@2.0.0: resolution: {integrity: sha512-7PnF4oN3CvZF23ADhA5wRaYEQpJ8qygSkbtTXWBeXWXmEVRXK+1ITciHWwHhsjv1TmW0MgacIv6hEi5pX5NQdA==} engines: {node: '>=10'} @@ -8628,6 +9580,9 @@ packages: resolution: {integrity: sha512-LEhnkAdJqic4Dbqn58A0y52IXoHWlsueqQkKfMfdEnIYG8A1sm/GHidKkS6yvXlMoRrkM34csHnXQtOqcb+Jzg==} engines: {node: '>=0.10.0'} + is-expression@4.0.0: + resolution: {integrity: sha512-zMIXX63sxzG3XrkHkrAPvm/OVZVSCPNkwMHU8oTX7/U3AL78I0QXCEICXUM13BIa8TYGZ68PiTKfQz3yaTNr4A==} + is-extendable@0.1.1: resolution: {integrity: sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw==} engines: {node: '>=0.10.0'} @@ -8696,6 +9651,9 @@ packages: resolution: {integrity: sha512-qP1vozQRI+BMOPcjFzrjXuQvdak2pHNUMZoeG2eRbiSqyvbEf/wQtEOTOX1guk6E3t36RkaqiSt8A/6YElNxLQ==} engines: {node: '>=12'} + is-json@2.0.1: + resolution: {integrity: sha512-6BEnpVn1rcf3ngfmViLM6vjUjGErbdrL4rwlv+u1NO1XO8kqT4YGL8+19Q+Z/bas8tY90BTWMk2+fW1g6hQjbA==} + is-map@2.0.3: resolution: {integrity: sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==} engines: {node: '>= 0.4'} @@ -8754,6 +9712,9 @@ packages: is-potential-custom-element-name@1.0.1: resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==} + is-promise@2.2.2: + resolution: {integrity: sha512-+lP4/6lKUBfQjZ2pdxThZvLUAafmZb8OAxFb8XXtiQmS35INgr85hdOGoEs124ez1FCnZJt6jau/T+alh58QFQ==} + is-promise@4.0.0: resolution: {integrity: sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==} @@ -8917,6 +9878,10 @@ packages: jackspeak@3.4.3: resolution: {integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==} + jackspeak@4.2.3: + resolution: {integrity: sha512-ykkVRwrYvFm1nb2AJfKKYPr0emF6IiXDYUaFx4Zn9ZuIH7MrzEZ3sD5RlqGXNRpHtvUHJyOnCEFxOlNDtGo7wg==} + engines: {node: 20 || >=22} + jay-peg@1.1.1: resolution: {integrity: sha512-D62KEuBxz/ip2gQKOEhk/mx14o7eiFRaU+VNNSP4MOiIkwb/D6B3G1Mfas7C/Fit8EsSV2/IWjZElx/Gs6A4ww==} @@ -9081,12 +10046,20 @@ packages: jquery@3.7.1: resolution: {integrity: sha512-m4avr8yL8kmFN8psrbFFFmB/If14iN5o9nw/NgnnM+kybDJpRsAynV2BsfpTYrTRysYUdADVD7CkUUizgkpLfg==} + js-beautify@1.15.4: + resolution: {integrity: sha512-9/KXeZUKKJwqCXUdBxFJ3vPh467OCckSBmYDwSK/EtV090K+iMJ7zx2S3HLVDIWFQdqMIsZWbnaGiba18aWhaA==} + engines: {node: '>=14'} + hasBin: true + js-cookie@3.0.8: resolution: {integrity: sha512-yeJd4aNAdYZQjaon2bpD/Gb0B/omw7HQOsynXXcOiWVCacbBcPlgn8S/d1X6blFSaHao7ozqtW7NZW19xpCtIw==} js-md5@0.8.3: resolution: {integrity: sha512-qR0HB5uP6wCuRMrWPTrkMaev7MJZwJuuw4fnwAzRgP4J4/F8RwtodOKpGp4XpqsLBFzzgqIO42efFAyz2Et6KQ==} + js-stringify@1.0.2: + resolution: {integrity: sha512-rtS5ATOo2Q5k1G+DADISilDA6lv79zIiwFd6CcjuIxGKLFm5C+RLImRscVap9k55i+MOZwgliw+NejvkLuGD5g==} + js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} @@ -9192,6 +10165,9 @@ packages: resolution: {integrity: sha512-gqXddjPqQ6G40VdnI6T6yObEC+pDNvyP95wdQhkWkg7crHH3km5qP1FsOXEkzEQwnz6gz5qGTn1c2Y52wP3OyQ==} engines: {'0': node >=0.6.0} + jstransformer@1.0.0: + resolution: {integrity: sha512-C9YK3Rf8q6VAPDCCU9fnqo3mAfOH6vUGnMcP4AQAYIEpWtfGLpwOTmZ+igtdK5y+VvI2n3CyYSzy4Qh34eq24A==} + jsx-ast-utils@3.3.5: resolution: {integrity: sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==} engines: {node: '>=4.0'} @@ -9199,6 +10175,11 @@ packages: jszip@3.10.1: resolution: {integrity: sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==} + juice@11.1.1: + resolution: {integrity: sha512-4SBfZqKcc6DrIS+5b/WiGoWaZsdUPBH+e6SbRlNjJpaIRtfoBhYReAtobIEW6mcLeFFDXLBJMuZwkJLkBJjs2w==} + engines: {node: '>=18.17'} + hasBin: true + jwa@2.0.1: resolution: {integrity: sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==} @@ -9247,6 +10228,9 @@ packages: resolution: {integrity: sha512-b94GiNHQNy6JNTrt5w6zNyffMrNkXZb3KTkCZJb2V1xaEGCk093vkZ2jk3tpaeP33/OiXC+WvK9AxUebnf5nbw==} engines: {node: '>= 0.6.3'} + leac@0.6.0: + resolution: {integrity: sha512-y+SqErxb8h7nE/fiEX07jsbuhrpO9lL8eca7/Y1nuWV2moNlXhyd59iDGcRf6moVyDMbmTNzL40SUyrFU/yDpg==} + leven@3.1.0: resolution: {integrity: sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==} engines: {node: '>=6'} @@ -9268,9 +10252,21 @@ packages: engines: {node: '>=16'} hasBin: true + libbase64@1.3.0: + resolution: {integrity: sha512-GgOXd0Eo6phYgh0DJtjQ2tO8dc0IVINtZJeARPeiIJqge+HdsWSuaDTe8ztQ7j/cONByDZ3zeB325AHiv5O0dg==} + + libmime@5.3.7: + resolution: {integrity: sha512-FlDb3Wtha8P01kTL3P9M+ZDNDWPKPmKHWaU/cG/lg5pfuAwdflVpZE+wm9m7pKmC5ww6s+zTxBKS1p6yl3KpSw==} + + libmime@5.3.8: + resolution: {integrity: sha512-ZrCY+Q66mPvasAfjsQ/IgahzoBvfE1VdtGRpo1hwRB1oK3wJKxhKA3GOcd2a6j7AH5eMFccxK9fBoCpRZTf8ng==} + libphonenumber-js@1.13.6: resolution: {integrity: sha512-NdB6O6QvlGMCoG003m0YIKG2+Xw7DjmCZhmc1RH+K6HncADUbRf8TZeLegxBBN1VFyPHcNpPTKpIhYLXzJVy1Q==} + libqp@2.1.1: + resolution: {integrity: sha512-0Wd+GPz1O134cP62YU2GTOPNA7Qgl09XwCqM5zpBv87ERCXdfDtyKXvV7c9U22yWJh44QZqBocFnXN11K96qow==} + libreoffice-convert@1.8.1: resolution: {integrity: sha512-iZ1DD/EMTlPvol8G++QQ/0w4pVecSwRuhMLXRm7nRim/gcaSscSXuTO9Tgbkieyw5UdJg7UXD+lkFT8SCi51Dw==} engines: {node: '>=6'} @@ -9362,11 +10358,19 @@ packages: lines-and-columns@1.2.4: resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} + linkify-it@5.0.0: + resolution: {integrity: sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==} + lint-staged@15.5.2: resolution: {integrity: sha512-YUSOLq9VeRNAo/CTaVmhGDKG+LBtA8KF1X4K5+ykMSwWST1vDxJRB2kv2COgLb1fvpCo+A/y9A0G0znNVmdx4w==} engines: {node: '>=18.12.0'} hasBin: true + liquidjs@10.29.0: + resolution: {integrity: sha512-pCVOhs6FLAR8su3ItJ07diN26t6W5dHQRnmTMy8HPyTFuv1+oSCVJIGp5pGjfQyOZfh50KswvKtMTp6p4JEIdw==} + engines: {node: '>=16'} + hasBin: true + listenercount@1.0.1: resolution: {integrity: sha512-3mk/Zag0+IJxeDrxSgaDPy4zZ3w05PRZeJNnlWhzFz5OkX49J4krc+A8X2d2M69vGMBEX0uyl8M+W+8gH+kBqQ==} @@ -9594,6 +10598,9 @@ packages: magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + mailparser@3.9.8: + resolution: {integrity: sha512-7jSlFGXiianVnhnb6wdutJFloD34488nrHY7r6FNqwXAhZ7YiJDYrKKTxZJ0oSrXcAPHm8YoYnh97xyGtrBQ3w==} + make-cancellable-promise@2.0.0: resolution: {integrity: sha512-3SEQqTpV9oqVsIWqAcmDuaNeo7yBO3tqPtqGRcKkEo0lrzD3wqbKG9mkxO65KoOgXqj+zH2phJ2LiAsdzlogSw==} @@ -9686,6 +10693,12 @@ packages: mdn-data@2.0.14: resolution: {integrity: sha512-dn6wd0uw5GsdswPFfsgMp5NSB0/aDe6fK94YJV/AJDYXL6HVLWBsxeq7js7Ad+mU2K9LAlwpk6kN2D5mwCPVow==} + mdn-data@2.0.28: + resolution: {integrity: sha512-aylIc7Z9y4yzHYAJNuESG3hfhC+0Ibp/MAMiaOZgNv4pmEdFyfZhhhny4MNiAfWdBQ1RQ2mfDWmM1x8SvGyp8g==} + + mdn-data@2.27.1: + resolution: {integrity: sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==} + media-engine@1.0.3: resolution: {integrity: sha512-aa5tG6sDoK+k70B9iEX1NeyfT8ObCKhNDs6lJVpwF6r8vhUfuKMslIcirq6HIUYuuUYLefcEQOn9bSBOvawtwg==} @@ -9701,6 +10714,9 @@ packages: resolution: {integrity: sha512-UERzLsxzllchadvbPs5aolHh65ISpKpM+ccLbOJ8/vvpBKmAWf+la7dXFy7Mr0ySHbdHrFv5kGFCUHHe6GFEmw==} engines: {node: '>= 4.0.0'} + mensch@0.3.4: + resolution: {integrity: sha512-IAeFvcOnV9V0Yk+bFhYR07O3yNina9ANIN5MoXBKYJ/RLYPurd2d0yw14MDhpr9/momp0WofT1bPUh3hkzdi/g==} + meow@12.1.1: resolution: {integrity: sha512-BhXM0Au22RwUneMPwSCnyhTOizdWoIEPU9sp0Aqa1PnDMR5Wv2FGXYDjuzJEIX+Eo2Rb8xuYe5jrnm5QowQFkw==} engines: {node: '>=16.10'} @@ -9915,6 +10931,101 @@ packages: resolution: {integrity: sha512-WRoDn//mXBiJ1H40rqa3vH0toePwSsGb45iInWlTySa+Uu4k3tYUSxa2v1KqAiLtvlrSzaExqS1gtk96A9zvEA==} engines: {node: '>=0.10.0'} + mjml-accordion@5.4.0: + resolution: {integrity: sha512-yElB+84k5kZpTz8Ct3eRu63fkEeGc4mBZmbAfZrS5sZCb9DiamAfhozLee5WgO4Y3cwuf8cFsfhYGPuBw60XCw==} + + mjml-body@5.4.0: + resolution: {integrity: sha512-fPZLONKnRGR2NxkmfKPvnnr/ycVeyahi1ySX6Rk8lEO76ywXNFDWzTbzz/UQ2gzfnD8AhDbuqzd/UZRpW0vdOg==} + + mjml-button@5.4.0: + resolution: {integrity: sha512-HlecSMeio6xf21nh4vMaHIJF8bN24KatK3gwcR6ByxKfzTkQVR7jtWjJLUiyiuLOJcam9wCOVl7m5J6elrFpjg==} + + mjml-carousel@5.4.0: + resolution: {integrity: sha512-fuhOEETPC/+ZtISh5iOlc6uQqOKWwhwg1W8XTJrzWj5pfa46BzMdR7Nx0Z+8I8/HRqrZA3Ws4hiSTTgbpTIRjg==} + + mjml-cli@5.4.0: + resolution: {integrity: sha512-6HeOz0zadc9iOmMVg85ts1HhoW2CiLUNaTFfnC5YXfowPnjK0bWfMluDzxHcaAk12wlnDF06kROl8pKpZeiy6A==} + hasBin: true + + mjml-column@5.4.0: + resolution: {integrity: sha512-vnseCiUUKhtQXx5ZEoApxA3elu2AZZyyQkOhE2ntG5cPQuZd3EhRv/mkKKCS99Vi51Tcf7AQ3chwSD4AL+bDHg==} + + mjml-core@5.4.0: + resolution: {integrity: sha512-dhcbpBmxktzv/tV2Gcz7BJC15fKEP8LzXUlEYMhi0XDsNEkhHxPGXxvhMoc020jJ9Ypjobnh1q7ow+F8Xx72jA==} + + mjml-divider@5.4.0: + resolution: {integrity: sha512-8mk7J0tn0rX+FBO43cJkaKO3x0GCjUX4bdPI5txoh1UWyq1N6xgoEqTAR3luwR0f8juTmUXZv97GnQdRUsWtvQ==} + + mjml-group@5.4.0: + resolution: {integrity: sha512-gCCU0WV8Aytt68uczjId3xhsvUJ8qU1WDCL+b7I3wrI1ja5npRyXdATHM6Q2uXbm+an8Dxz5q77Ts9sodAHZIQ==} + + mjml-head-attributes@5.4.0: + resolution: {integrity: sha512-c2/Zi/2wCutEVChxY8RGbPIb2Wb0Ro3A9WVJ0DezyEeN9noTT1fRiMWYQIc2y3H5STfNtIFu6RbtDU+AK2p4rg==} + + mjml-head-breakpoint@5.4.0: + resolution: {integrity: sha512-qtJZ7uaMxVObrr13um5tbktxih8ycTStYAdcKMdSsgqLG3dTNEfVF9wg7AEHs6e3GB1cPnHgQwF9v7nxe+vocw==} + + mjml-head-font@5.4.0: + resolution: {integrity: sha512-c0shDE+Bt7tob9NNpNOk8pRpJMWztfgNuoyXAIkOc7VhILnsYzH5+4Ly70wlHOzQAspC3cODdkZxut8i3ApRcQ==} + + mjml-head-html-attributes@5.4.0: + resolution: {integrity: sha512-o9yEfrA1/5r3EbxXXUVBKf91wSh+vxBSNDYQFc0Do8/8gLg7MvczFVXH2Gq9PQdxPEO+AeBrP4sGP5ZxGJnSwg==} + + mjml-head-preview@5.4.0: + resolution: {integrity: sha512-jXbbRIGPn7IiAq6M/KZpQMX+RjRN9dW4Az4QTyqL4PObqsrn+rbug6vdZXdxXnCERUZiA7a7K3R4Z5BTOi+qFw==} + + mjml-head-style@5.4.0: + resolution: {integrity: sha512-wUPT4G8GjHlcy0Zq67taYT0E65pa6gwSxO43VJfjpU8Qa1QNmFYkOuDkzbb9oZN0QsETmbfG/RPK/mS4uOAfsg==} + + mjml-head-title@5.4.0: + resolution: {integrity: sha512-Tx4a6/CPDapUwq8NjGqfb3xBrzMXCxo3s1IGnd1Fnohl0uAZ5EySeBFf8c0uNSwrEhCDOTC5qrERm890Yselfw==} + + mjml-head@5.4.0: + resolution: {integrity: sha512-npWsul6ANzgxl2AZP0kG1yn9G5tOoX8iCgMhRwJkbIJ2rEX91SUvim9P/75s7HuqhccVsjO2L3OVHmnUEpWYng==} + + mjml-hero@5.4.0: + resolution: {integrity: sha512-j9bKjilTHqJMp3GIDtKUdP3JRnktAc0o7gHhv1NgThgv/z1DDQJ2zgtW/pme6wA5VWng5DTriPk9aoCLPuOlyQ==} + + mjml-image@5.4.0: + resolution: {integrity: sha512-6Y8aMIZbzIZ7SSpo0/o4n5E+JQx5d7OCwUoIIiXWPWGevfOE8JvjFt5MIa24CmSDbKWbhVuJEi1h4TUJhvAVbQ==} + + mjml-navbar@5.4.0: + resolution: {integrity: sha512-knEsmNN6uBLtEU3a9uwnRqUqAE38EeJcXYaGlI0ly75Zj+vyhKQGPjJzRN7XJqA2dFSgIm3dV9KU9vNW+jI3Zg==} + + mjml-parser-xml@5.4.0: + resolution: {integrity: sha512-A+KzRx+AeWIWxYN9z2KungvmVKMdW+NGLc5h+B9DeY93lSvcSpWH26nGDW9pcPi9B3fdwgYvqgOkYtX6EQATCA==} + + mjml-preset-core@5.4.0: + resolution: {integrity: sha512-rq22rNFCp4brsSAgpKrY4tXR/ZWeJeU/GyypihrzmDVu3dSFmOYbrJgW1eCo4g5rWMpEbnY8pn0t1SB8EB+ymQ==} + + mjml-raw@5.4.0: + resolution: {integrity: sha512-ewGXtauxkE35Xd9cJ3REjfD6vNSU82HfIm2pPi2RZF9eXrmfuSrrbSpXuPy13BP2lPpDJa6umUAuDsg5XhXr4A==} + + mjml-section@5.4.0: + resolution: {integrity: sha512-BetZqHS31bK5FS7rr5HlFo24m5OGDZi3yjkSn0aanF1SgRkmX1+LwnMQoDdT986SMys0oUYFeNSlz9lp8QgtrA==} + + mjml-social@5.4.0: + resolution: {integrity: sha512-7LUoIAOUzXzkLWfdErGrrqc8isxmDxaYQPcUNubQ1d9IXR48/S7Pi5s6PEiDxlaIgWHBcAJEoMszpLHA8+oCSQ==} + + mjml-spacer@5.4.0: + resolution: {integrity: sha512-v7P6InD4u7nyXTmNjKMOY0oQ2EaZzFzCcftexb6JdcqvFaZvO9vUSoOdfZUp2FzzFcXQaD7K5RRW3stJSdEJOQ==} + + mjml-table@5.4.0: + resolution: {integrity: sha512-e1Kq3AWzzVFv6rPgAy4eK1txA4rfMPivaavW9BrvCDJU3Wiz+fOo9FqtMDyUQXrsJJKSOi6MMA0h2lAESTsR5w==} + + mjml-text@5.4.0: + resolution: {integrity: sha512-QTLdNM6Fs6T4LlquEzJmM+i3lJ+toNmRLRSZyUXP1tjJQhlNmc9aT1UHRy1Gn5fb7XTAY4lo2LznVv/0Tb3qhw==} + + mjml-validator@5.4.0: + resolution: {integrity: sha512-IVsV3RxiEFfAed9U0C5DYmQA06e1JWDQQ8MqBinU7lQCYUkZIexTStohDACzHpN6RTIawi+U0GkT6PUHprv+3Q==} + + mjml-wrapper@5.4.0: + resolution: {integrity: sha512-niCuz5T7IfLKIKVv6G4BNu6sW07yl/kJ0o8oovd+CfG4lO9K+tXUwJQ+Iv3Y3tEQp0TfJE0aN1ysGrhAz6aB6Q==} + + mjml@5.4.0: + resolution: {integrity: sha512-nKeUbKsNtSLzqKcmOwGh3ELxLYHY1fdeSNLif+A33uQV4zBdHahNL7LLshvpTpG2yyu5LlZHQWogVs1dS/V30w==} + hasBin: true + mkdirp@0.5.6: resolution: {integrity: sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==} hasBin: true @@ -10090,6 +11201,19 @@ packages: resolution: {integrity: sha512-Uzmd6LXpouKo8EUK68IjH4+E01w/hXyV3R3g/geCJo+rXLNfh1xucB+LOzYEOQPSiUK3h/xZf0cQGcSsmyL2Og==} engines: {node: '>=18'} + nodemailer@8.0.5: + resolution: {integrity: sha512-0PF8Yb1yZuQfQbq+5/pZJrtF6WQcjTd5/S4JOHs9PGFxuTqoB/icwuB44pOdURHJbRKX1PPoJZtY7R4VUoCC8w==} + engines: {node: '>=6.0.0'} + + nodemailer@9.0.5: + resolution: {integrity: sha512-wvjiKvjczmsN7U/8006JOdXubgBk2XFAbioDMbT+sM7cPs0QrhJTa6KBRX7P5REGGkDcLUz/EarWidb8G8C1jQ==} + engines: {node: '>=6.0.0'} + + nopt@7.2.1: + resolution: {integrity: sha512-taM24ViiimT/XntxbPyJQzCG+p4EKOpgD3mxFwW38mGjVUrfERQOeY4EDHjdnptttfHuHQXFx+lTP08Q+mLa/w==} + engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} + hasBin: true + normalize-path@3.0.0: resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} engines: {node: '>=0.10.0'} @@ -10116,6 +11240,16 @@ packages: resolution: {integrity: sha512-4jbtZXNAsfZbAHiiqjLPBiCl16dES1zI4Hpzzxw61Tk+loF+sBDBKx1ICKKKwIqQ7M0mFn1TmkN7euSncWgHiQ==} engines: {node: '>=0.10.0'} + nunjucks@3.2.4: + resolution: {integrity: sha512-26XRV6BhkgK0VOxfbU5cQI+ICFUtMLixv1noZn1tGU38kQH5A5nmmbk/O45xdyBhD1esk47nKrY0mvQpZIhRjQ==} + engines: {node: '>= 6.9.0'} + hasBin: true + peerDependencies: + chokidar: ^3.3.0 + peerDependenciesMeta: + chokidar: + optional: true + nwsapi@2.2.24: resolution: {integrity: sha512-7YRhZ3jS45LwmSCT4b2sVFHt/WuovaktDU07QrtOBY2PXskss5a9jfmR9jptyumwXST+rFjrmppMY1KT/yn35A==} @@ -10206,6 +11340,10 @@ packages: resolution: {integrity: sha512-smsWv2LzFjP03xmvFoJ331ss6h+jixfA4UUV/Bsiyuu4YJPfN+FIQGOIiv4w9/+MoHkfkJ22UIaQWRVFRfH6Vw==} engines: {node: '>=20'} + open@7.4.2: + resolution: {integrity: sha512-MVHddDVweXZF3awtlAS+6pgKLlm/JgxZ90+/NBurBoQctVOOB/zDdVjcyPzQ+0laDGbsWgrRkflI65sQeOgT9Q==} + engines: {node: '>=8'} + open@8.4.2: resolution: {integrity: sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ==} engines: {node: '>=12'} @@ -10232,6 +11370,14 @@ packages: resolution: {integrity: sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==} engines: {node: '>= 0.4'} + p-event@4.2.0: + resolution: {integrity: sha512-KXatOjCRXXkSePPb1Nbi0p0m+gQAwdlbhi4wQKJPI1HsMQS9g+Sqp2o+QHziPr7eYJyOZet836KoHEVM1mwOrQ==} + engines: {node: '>=8'} + + p-finally@1.0.0: + resolution: {integrity: sha512-LICb2p9CB7FS+0eR1oqWnHhp0FljGLZCWBE9aix0Uye9W8LTQPwMTYVGWQWIw9RdQiDg4+epXQODwIYJtSJaow==} + engines: {node: '>=4'} + p-limit@2.3.0: resolution: {integrity: sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==} engines: {node: '>=6'} @@ -10256,10 +11402,18 @@ packages: resolution: {integrity: sha512-wPrq66Llhl7/4AGC6I+cqxT07LhXvWL08LNXz1fENOw0Ap4sRZZ/gZpTTJ5jpurzzzfS2W/Ge9BY3LgLjCShcw==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + p-timeout@3.2.0: + resolution: {integrity: sha512-rhIwUycgwwKcP9yTOOFK/AKsAopjjCakVqLHePO3CC6Mir1Z99xT+R63jZxAT5lFZLa2inS5h+ZS2GvR99/FBg==} + engines: {node: '>=8'} + p-try@2.2.0: resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==} engines: {node: '>=6'} + p-wait-for@3.2.0: + resolution: {integrity: sha512-wpgERjNkLrBiFmkMEjuZJEWKKDrNfHCKA1OhyN1wg1FrLkULbviEy6py1AyJUgZ72YWFbZ38FIpnqvVqAlDUwA==} + engines: {node: '>=8'} + pac-proxy-agent@7.2.0: resolution: {integrity: sha512-TEB8ESquiLMc0lV8vcd5Ql/JAKAoyzHFXaStwjkzpOpC5Yv+pIzLfHvjTSdf3vpa2bMiUQrg9i6276yn8666aA==} engines: {node: '>= 14'} @@ -10301,9 +11455,18 @@ packages: parse-svg-path@0.1.2: resolution: {integrity: sha512-JyPSBnkTJ0AI8GGJLfMXvKq42cj5c006fnLz6fXy6zfoVjJizi8BNTpu8on8ziI1cKy9d9DGNuY17Ce7wuejpQ==} + parse5-htmlparser2-tree-adapter@7.1.0: + resolution: {integrity: sha512-ruw5xyKs6lrpo9x9rCZqZZnIUntICjQAd0Wsmp396Ul9lN/h+ifgVV1x1gZHi8euej6wTfpqX8j+BFQxF0NS/g==} + + parse5-parser-stream@7.1.2: + resolution: {integrity: sha512-JyeQc9iwFLn5TbvvqACIF/VXG6abODeB3Fwmv/TGdLk2LfbWkaySGY72at4+Ty7EkPZj854u4CrICqNk2qIbow==} + parse5@7.3.0: resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==} + parseley@0.12.1: + resolution: {integrity: sha512-e6qHKe3a9HWr0oMRVDTRhKce+bRO8VGQR3NyVwcjwrbhMmFCX9KszEV35+rn4AdilFAq9VPxP/Fe1wC9Qjd2lw==} + parseurl@1.3.3: resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} engines: {node: '>= 0.8'} @@ -10399,6 +11562,9 @@ packages: resolution: {integrity: sha512-DlOzet0HO7OEnmUmB6wWGJrrdvbyJKftI1bhMitK7O2N8W2gc757yyYBbINy9IDafXAV9wmKr9t7xsTaNKRG5Q==} engines: {node: '>=20.16.0 || >=22.3.0'} + peberminta@0.9.0: + resolution: {integrity: sha512-XIxfHpEuSJbITd1H3EeQwpcZbTLHc+VVr8ANI9t5sit565tsI4/xK3KWTUFE2e6QiangUkh3B0jihzmGnNrRsQ==} + peek-readable@5.4.2: resolution: {integrity: sha512-peBp3qZyuS6cNIJ2akRNG1uo1WJ1d0wTxg/fxMdZ0BqCVhx242bSFHM9eNqflfJVS9SsgkzgT/1UgnsurBOTMg==} engines: {node: '>=14.16'} @@ -10510,6 +11676,48 @@ packages: resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==} engines: {node: '>= 0.4'} + postcss-calc@10.1.1: + resolution: {integrity: sha512-NYEsLHh8DgG/PRH2+G9BTuUdtf9ViS+vdoQ0YA5OQdGsfN4ztiwtDWNtBl9EKeqNMFnIu8IKZ0cLxEQ5r5KVMw==} + engines: {node: ^18.12 || ^20.9 || >=22.0} + peerDependencies: + postcss: ^8.4.38 + + postcss-colormin@7.0.10: + resolution: {integrity: sha512-yFr6JezOolHLta/buLE71VKPh2mXursp4saVe98/ol8ZnEWhL+racShqPKlvd/DKWLre/39B6HhcMXf7RZ3hxg==} + engines: {node: ^18.12.0 || ^20.9.0 || >=22.0} + peerDependencies: + postcss: ^8.5.13 + + postcss-convert-values@7.0.12: + resolution: {integrity: sha512-xurKu5qqk4viR3Cp3p4xBR4KfnZm4w4ys6+UBwBmeuBSNkH7+DtLnYOYnOffgtE4yx8sH9S1VZ6RAAvROXzP2Q==} + engines: {node: ^18.12.0 || ^20.9.0 || >=22.0} + peerDependencies: + postcss: ^8.5.13 + + postcss-discard-comments@7.0.8: + resolution: {integrity: sha512-CvvS5S9WrXblFXCEJ9nVo+4z+eA7zSC7Z88V1HEJuwlQhlFnYTIjg1xJY+BCUiG2bvICap2tXii4mP22BD108Q==} + engines: {node: ^18.12.0 || ^20.9.0 || >=22.0} + peerDependencies: + postcss: ^8.5.13 + + postcss-discard-duplicates@7.0.4: + resolution: {integrity: sha512-VBNn1+EuMZkeGVVtz0gRfbNGtx9IFgAsAV+E2pHtXPrp4qfGBkhTIiAuE/wrb+Y6Pakg9NewAlfTpYIFAWODtw==} + engines: {node: ^18.12.0 || ^20.9.0 || >=22.0} + peerDependencies: + postcss: ^8.5.13 + + postcss-discard-empty@7.0.3: + resolution: {integrity: sha512-M2pyjQCU+/7cMHVtL6bKTHjv0lZnPLMpicgr67Dlth7AbuV9gjVTtUqaRwn6Pp6BwSDspUzhz8SaUrRykJU5Dw==} + engines: {node: ^18.12.0 || ^20.9.0 || >=22.0} + peerDependencies: + postcss: ^8.5.13 + + postcss-discard-overridden@7.0.3: + resolution: {integrity: sha512-aNovXo9UsZuRNLzHJtp13lHIvinDPfiXBPePpXkSjCbgp++iU2FqE+YxvjIsg6EdyPZsASFbfu+JcBFVsErXIQ==} + engines: {node: ^18.12.0 || ^20.9.0 || >=22.0} + peerDependencies: + postcss: ^8.5.13 + postcss-import@15.1.0: resolution: {integrity: sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==} engines: {node: '>=14.0.0'} @@ -10540,12 +11748,120 @@ packages: yaml: optional: true + postcss-merge-longhand@7.0.7: + resolution: {integrity: sha512-b3mfYUxR388u5Pt0HPcVIUtUDn/k15UfTY9M+ORW+meCR6JLNxoZffiYvXyOYQoRYQNZyX/UFkMCM/mNHxe1qA==} + engines: {node: ^18.12.0 || ^20.9.0 || >=22.0} + peerDependencies: + postcss: ^8.5.13 + + postcss-merge-rules@7.0.11: + resolution: {integrity: sha512-SJUPM18g2BmPhf8BVlbwqWz4aK3pLu6u6xjfwEzra7xL6IBR10sUaiB++EzqcVfadPHrKBSMlNdP+XieykhI+Q==} + engines: {node: ^18.12.0 || ^20.9.0 || >=22.0} + peerDependencies: + postcss: ^8.5.13 + + postcss-minify-font-values@7.0.3: + resolution: {integrity: sha512-yilG/VOaNI74IylQvAQQxm3/wZVBkXyYUqNUAdxqwtbWUXPsbK1q8Ms0mL83v+f8YicgcyfYCRZtWACUdYajpA==} + engines: {node: ^18.12.0 || ^20.9.0 || >=22.0} + peerDependencies: + postcss: ^8.5.13 + + postcss-minify-gradients@7.0.5: + resolution: {integrity: sha512-YraROyQRg3BI1+Hg8E05B/JPdnTm8EDSVu4P2BxdM+CRiOyfmou809+chGIqo6fQqwjPGQ947nbGncSjmTU1WQ==} + engines: {node: ^18.12.0 || ^20.9.0 || >=22.0} + peerDependencies: + postcss: ^8.5.13 + + postcss-minify-params@7.0.9: + resolution: {integrity: sha512-R8itbB8BhlpoYyBm1ou0dD+vJnQ3F6adQipR4UnkCHUwlo+S9WXJaDRg1RHjC8YVAtIdrQzSWvJl40HnGDTKjA==} + engines: {node: ^18.12.0 || ^20.9.0 || >=22.0} + peerDependencies: + postcss: ^8.5.13 + + postcss-minify-selectors@7.1.2: + resolution: {integrity: sha512-aQtrEWKwqafNlExcKHQvPGsXR2+vlUqqJtf5XsCQcgsSb5PL4wlujWBYDJuWsP4UnQX1YHDHU8qRlD+1PzTQ+Q==} + engines: {node: ^18.12.0 || ^20.9.0 || >=22.0} + peerDependencies: + postcss: ^8.5.13 + postcss-nested@6.2.0: resolution: {integrity: sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==} engines: {node: '>=12.0'} peerDependencies: postcss: ^8.2.14 + postcss-normalize-charset@7.0.3: + resolution: {integrity: sha512-NoBfZu8PR4c2NlmjvrqQTzCzLY79hwcSRgNQ3ZiNK0ABzf9kYKloE/jNj+/8GQY1wsm8pRRgANk6ydLH8cwo0Q==} + engines: {node: ^18.12.0 || ^20.9.0 || >=22.0} + peerDependencies: + postcss: ^8.5.13 + + postcss-normalize-display-values@7.0.3: + resolution: {integrity: sha512-ldsCX0QIt05pKIOobZtVQ48wXJecr+czw4+e1/YjVhLMqslShgpVxgPtI2CefURR8oyVoYaU/l829MMwExDMLw==} + engines: {node: ^18.12.0 || ^20.9.0 || >=22.0} + peerDependencies: + postcss: ^8.5.13 + + postcss-normalize-positions@7.0.4: + resolution: {integrity: sha512-VEvlpeGd3Ju1Hqa/oN4jaP3+ms4laYwkEL9N9u+B6k54PZjXbW1n6wI+aVprf1BQXlCYpS5+1pl/7/vHiKgARg==} + engines: {node: ^18.12.0 || ^20.9.0 || >=22.0} + peerDependencies: + postcss: ^8.5.13 + + postcss-normalize-repeat-style@7.0.4: + resolution: {integrity: sha512-6mPKlY/8cSaDHxX502wERADarJsccwlky6yIrOapHH2ZgfoKAV94SbiTKfKEs4EEpdazuc3J72WsqeYk7hp9+Q==} + engines: {node: ^18.12.0 || ^20.9.0 || >=22.0} + peerDependencies: + postcss: ^8.5.13 + + postcss-normalize-string@7.0.3: + resolution: {integrity: sha512-HnEQPUchi1eznmDKEYrKUTqrprEq97SrpUYClgUkv7V2zRODD9DFoUsYU+m9ZOetmD5ku7fEMZB/lwy8IT6xVQ==} + engines: {node: ^18.12.0 || ^20.9.0 || >=22.0} + peerDependencies: + postcss: ^8.5.13 + + postcss-normalize-timing-functions@7.0.3: + resolution: {integrity: sha512-zmEzHdvpZBZu0OKlbJSfgASQvaayyAoVuWtvyr34IJ/LyS+DaOKvvR3EvFJ9RWWtNIx+CMvO125OVophaxNYew==} + engines: {node: ^18.12.0 || ^20.9.0 || >=22.0} + peerDependencies: + postcss: ^8.5.13 + + postcss-normalize-unicode@7.0.9: + resolution: {integrity: sha512-DRAdWfeh/TjmhLJsw91vdiWCnUod9iwvM7xyS02/nF/sLsCR3A8l3pztrSUrWG8DSBqfX7yEk9FM0USaVJ2mSg==} + engines: {node: ^18.12.0 || ^20.9.0 || >=22.0} + peerDependencies: + postcss: ^8.5.13 + + postcss-normalize-url@7.0.3: + resolution: {integrity: sha512-CL93wmloq5qsffmFv+bw24MIRbmhHrp53qoh1LDAb/5TtjWEXI/np4xcP/Gw9oWCb2XyWnqHYLDUwiKRoJBA1Q==} + engines: {node: ^18.12.0 || ^20.9.0 || >=22.0} + peerDependencies: + postcss: ^8.5.13 + + postcss-normalize-whitespace@7.0.3: + resolution: {integrity: sha512-FdHjjn+Ht5Z2ZRjNOmeCbNq6lq09sUYKpmlF/Aq0XjVNSLTL6fmHlA/3swN2wP2caY9GV/tjSDcIIyS7aN7W0A==} + engines: {node: ^18.12.0 || ^20.9.0 || >=22.0} + peerDependencies: + postcss: ^8.5.13 + + postcss-ordered-values@7.0.4: + resolution: {integrity: sha512-nubSi49hDHQk4E8KIj+IbLY8Bg+8OcSUEhgyolgM+atnOvXjV7EjaR6bac4YGZoFyPa9mWoAF3EaYbWdFkKqVg==} + engines: {node: ^18.12.0 || ^20.9.0 || >=22.0} + peerDependencies: + postcss: ^8.5.13 + + postcss-reduce-initial@7.0.9: + resolution: {integrity: sha512-ztTNPdIxXTxtBcG03E9u8v44M4ElXbMIRT7pf2onlquGula0Y83nKKxqM22FA/hMgkfCjN7ohevkVlaNwI8iOQ==} + engines: {node: ^18.12.0 || ^20.9.0 || >=22.0} + peerDependencies: + postcss: ^8.5.13 + + postcss-reduce-transforms@7.0.3: + resolution: {integrity: sha512-FXsnN9ZwcZTT8Yf8cAHA8qIGUXcX6WfLd9JoYhrdDfmvsVhhfqkkv7m4AC3rwFOfz+GzkUa87OCKF9dUcicd+g==} + engines: {node: ^18.12.0 || ^20.9.0 || >=22.0} + peerDependencies: + postcss: ^8.5.13 + postcss-selector-parser@6.1.2: resolution: {integrity: sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==} engines: {node: '>=4'} @@ -10554,6 +11870,18 @@ packages: resolution: {integrity: sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==} engines: {node: '>=4'} + postcss-svgo@7.1.3: + resolution: {integrity: sha512-2QfoFOYMcj8lwcVEf9WeTlkVIAm7u2QvOEhMzkQU3KUhhGX/l8hVV9EtjMv4iq3E9iI3OeeMN0YoMLbGusuigw==} + engines: {node: ^18.12.0 || ^20.9.0 || >= 18} + peerDependencies: + postcss: ^8.5.13 + + postcss-unique-selectors@7.0.7: + resolution: {integrity: sha512-d+sCkaRnSefghOUdH8CMJZV9yUQhj2ojpe8Nw/lA+LV1UOfeleGkLTl6XdCFFSai9UJ+DJPb69FFuqthXYsY8w==} + engines: {node: ^18.12.0 || ^20.9.0 || >=22.0} + peerDependencies: + postcss: ^8.5.13 + postcss-value-parser@4.2.0: resolution: {integrity: sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==} @@ -10584,6 +11912,18 @@ packages: posthog-js@1.400.1: resolution: {integrity: sha512-NGfzNwTu+VBw4FekgYs/aQbEkTFkvmpTFUKDGZw/9K6R/sG2WyuLsnnXySRcNH8RMki0Io8v9flNATrrTfmT+Q==} + posthtml-parser@0.11.0: + resolution: {integrity: sha512-QecJtfLekJbWVo/dMAA+OSwY79wpRmbqS5TeXvXSX+f0c6pW4/SE6inzZ2qkU7oAMCPqIDkZDvd/bQsSFUnKyw==} + engines: {node: '>=12'} + + posthtml-render@3.0.0: + resolution: {integrity: sha512-z+16RoxK3fUPgwaIgH9NGnK1HKY9XIDpydky5eQGgAFVXTCSezalv9U2jQuNV+Z9qV1fDWNzldcw4eK0SSbqKA==} + engines: {node: '>=12'} + + posthtml@0.16.7: + resolution: {integrity: sha512-7Hc+IvlQ7hlaIfQFZnxlRl0jnpWq2qwibORBhQYIb0QbNtuicc5ZxvKkVT71HJ4Py1wSZ/3VR1r8LfkCtoCzhw==} + engines: {node: '>=12.0.0'} + powershell-utils@0.1.0: resolution: {integrity: sha512-dM0jVuXJPsDN6DvRpea484tCUaMiXWjuCn++HGTqUWzGDjv5tZkEZldAJ/UMlqRYGFrD/etByo4/xOuC/snX2A==} engines: {node: '>=20'} @@ -10617,6 +11957,10 @@ packages: resolution: {integrity: sha512-gjVS5hOP+M3wMm5nmNOucbIrqudzs9v/57bWRHQWLYklXqoXKrVfYW2W9+glfGsqtPgpiz5WwyEEB+ksXIx3gQ==} engines: {node: '>=18'} + preview-email@3.3.0: + resolution: {integrity: sha512-9KxwH7IRm1hcn7GU7YNJFTIa/cMUTcphDfQdB1qZRMC5OOmMAb/heTQtztbTLyMGVdfy+I2u0Zznu0gUiwDLww==} + engines: {node: '>=14.17'} + prisma@6.19.3: resolution: {integrity: sha512-++ZJ0ijLrDJF6hNB4t4uxg2br3fC4H9Yc9tcbjr2fcNFP3rh/SBNrAgjhsqBU4Ght8JPrVofG/ZkXfnSfnYsFg==} engines: {node: '>=18.18'} @@ -10641,6 +11985,9 @@ packages: promise-breaker@6.0.0: resolution: {integrity: sha512-BthzO9yTPswGf7etOBiHCVuugs2N01/Q/94dIPls48z2zCmrnDptUUZzfIb+41xq0MnYZ/BzmOd6ikDR4ibNZA==} + promise@7.3.1: + resolution: {integrity: sha512-nolQXZ/4L+bP/UGlkfaIujX9BKxGwmQ9OT4mOt5yvy8iK1h3wqTEJCijzGANTCCl9nWjY41juyAn2K3Q1hLLTg==} + prompts@2.4.2: resolution: {integrity: sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==} engines: {node: '>= 6'} @@ -10651,6 +11998,9 @@ packages: property-information@7.2.0: resolution: {integrity: sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg==} + proto-list@1.2.4: + resolution: {integrity: sha512-vtK/94akxsTMhe0/cbfpR+syPuszcuwhqVjJq26CuNDgFGj682oRBXOP5MJpv2r7JtE8MsiepGIqvvOTBwn2vA==} + proxy-addr@2.0.7: resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} engines: {node: '>= 0.10'} @@ -10669,9 +12019,49 @@ packages: resolution: {integrity: sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==} engines: {node: '>=10'} + pug-attrs@3.0.0: + resolution: {integrity: sha512-azINV9dUtzPMFQktvTXciNAfAuVh/L/JCl0vtPCwvOA21uZrC08K/UnmrL+SXGEVc1FwzjW62+xw5S/uaLj6cA==} + + pug-code-gen@3.0.4: + resolution: {integrity: sha512-6okWYIKdasTyXICyEtvobmTZAVX57JkzgzIi4iRJlin8kmhG+Xry2dsus+Mun/nGCn6F2U49haHI5mkELXB14g==} + + pug-error@2.1.0: + resolution: {integrity: sha512-lv7sU9e5Jk8IeUheHata6/UThZ7RK2jnaaNztxfPYUY+VxZyk/ePVaNZ/vwmH8WqGvDz3LrNYt/+gA55NDg6Pg==} + + pug-filters@4.0.0: + resolution: {integrity: sha512-yeNFtq5Yxmfz0f9z2rMXGw/8/4i1cCFecw/Q7+D0V2DdtII5UvqE12VaZ2AY7ri6o5RNXiweGH79OCq+2RQU4A==} + + pug-lexer@5.0.1: + resolution: {integrity: sha512-0I6C62+keXlZPZkOJeVam9aBLVP2EnbeDw3An+k0/QlqdwH6rv8284nko14Na7c0TtqtogfWXcRoFE4O4Ff20w==} + + pug-linker@4.0.0: + resolution: {integrity: sha512-gjD1yzp0yxbQqnzBAdlhbgoJL5qIFJw78juN1NpTLt/mfPJ5VgC4BvkoD3G23qKzJtIIXBbcCt6FioLSFLOHdw==} + + pug-load@3.0.0: + resolution: {integrity: sha512-OCjTEnhLWZBvS4zni/WUMjH2YSUosnsmjGBB1An7CsKQarYSWQ0GCVyd4eQPMFJqZ8w9xgs01QdiZXKVjk92EQ==} + + pug-parser@6.0.0: + resolution: {integrity: sha512-ukiYM/9cH6Cml+AOl5kETtM9NR3WulyVP2y4HOU45DyMim1IeP/OOiyEWRr6qk5I5klpsBnbuHpwKmTx6WURnw==} + + pug-runtime@3.0.1: + resolution: {integrity: sha512-L50zbvrQ35TkpHwv0G6aLSuueDRwc/97XdY8kL3tOT0FmhgG7UypU3VztfV/LATAvmUfYi4wNxSajhSAeNN+Kg==} + + pug-strip-comments@2.0.0: + resolution: {integrity: sha512-zo8DsDpH7eTkPHCXFeAk1xZXJbyoTfdPlNR0bK7rpOMuhBYb0f5qUVCO1xlsitYd3w5FQTK7zpNVKb3rZoUrrQ==} + + pug-walk@2.0.0: + resolution: {integrity: sha512-yYELe9Q5q9IQhuvqsZNwA5hfPkMJ8u92bQLIMcsMxf/VADjNtEYptU+inlufAFYcWdHlwNfZOEnOOQrZrcyJCQ==} + + pug@3.0.4: + resolution: {integrity: sha512-kFfq5mMzrS7+wrl5pLJzZEzemx34OQ0w4SARfhy/3yxTlhbstsudDwJzhf1hP02yHzbjoVMSXUj/Sz6RNfMyXg==} + pump@3.0.4: resolution: {integrity: sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==} + punycode.js@2.3.1: + resolution: {integrity: sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==} + engines: {node: '>=6'} + punycode@1.4.1: resolution: {integrity: sha512-jmYNElW7yvO7TV33CjSmvSiE2yco3bV2czu/OzDKdMNVZQWfxCblURLhf+47syQRBntjfLdd/H0egrzIG+oaFQ==} @@ -10766,6 +12156,10 @@ packages: rc9@2.1.2: resolution: {integrity: sha512-btXCnMmRIBINM2LDZoEmOogIZU7Qe7zn4BpomSKZ/ykbLObuBdvG+mFq11DL6fjH1DRwHhrlgtYWG96bJiC7Cg==} + rc@1.2.8: + resolution: {integrity: sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==} + hasBin: true + react-cookie@8.1.2: resolution: {integrity: sha512-S45Z1y1dHyYfLEI4bFKQICuP+SwJqTPWbdc2ZpE6aQSdjSVJAjUDfwTPq8B7BWieIsgyyEWMb/QOrudtwJMjXA==} peerDependencies: @@ -11293,6 +12687,10 @@ packages: rrweb-cssom@0.8.0: resolution: {integrity: sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==} + run-applescript@5.0.0: + resolution: {integrity: sha512-XcT5rBksx1QdIhlFOCtgZkB99ZEouFZ1E2Kc2LHqNW13U3/74YGdkQRmThTwxy4QIyookibDKYZOPqX//6BlAg==} + engines: {node: '>=12'} + run-applescript@7.1.0: resolution: {integrity: sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q==} engines: {node: '>=18'} @@ -11344,6 +12742,10 @@ packages: resolution: {integrity: sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA==} engines: {node: '>=11.0.0'} + sax@1.6.1: + resolution: {integrity: sha512-42tBVwLWnaQvW5zc4HbZrTuWccECCZfBi92FDuwtqxasH+JbPB3/FOKb1m222K42R4WxuxzzMsTswfzgtSu64Q==} + engines: {node: '>=11.0.0'} + saxes@5.0.1: resolution: {integrity: sha512-5LBh1Tls8c9xgGjw3QrMwETmTMVk0oFgvrFSvWx62llR2hcEInrKNZ2GZCCuuy2lvWrdl5jhbpeqc5hRYKFOcw==} engines: {node: '>=10'} @@ -11369,6 +12771,9 @@ packages: resolution: {integrity: sha512-eflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/OUYdb48v4k4WWHQurA==} engines: {node: '>= 10.13.0'} + selderee@0.11.0: + resolution: {integrity: sha512-5TF+l7p4+OsnP8BCCvSyZiSPc4x4//p5uPwK8TCnVPJYRmU2aYKMpOXvw8zM5a5JvuuCGN1jmsMwuU2W02ukfA==} + self-closing-tags@1.0.1: resolution: {integrity: sha512-7t6hNbYMxM+VHXTgJmxwgZgLGktuXtVVD5AivWzNTdJBM4DBjnDKDzkf2SrNjihaArpeJYNjxkELBu1evI4lQA==} engines: {node: '>=0.12.0'} @@ -11501,6 +12906,9 @@ packages: resolution: {integrity: sha512-stxByr12oeeOyY2BlviTNQlYV5xOj47GirPr4yA1hE9JCtxfQN0+tVbkxwCtYDQWhEKWFHsEK48ORg5jrouCAg==} engines: {node: '>=20'} + slick@1.12.2: + resolution: {integrity: sha512-4qdtOGcBjral6YIBCWJ0ljFSKNLz9KkhbWtuGvUyRowl1kxfuE1x/Z/aJcaiilpb3do9bl5K7/1h9XC5wWpY/A==} + smart-buffer@4.2.0: resolution: {integrity: sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==} engines: {node: '>= 6.0.0', npm: '>= 3.0.0'} @@ -11761,6 +13169,10 @@ packages: resolution: {integrity: sha512-aulFJcD6YK8V1G7iRB5tigAP4TsHBZZrOV8pjV++zdUwmeV8uzbY7yn6h9MswN62adStNZFuCIx4haBnRuMDaw==} engines: {node: '>=18'} + strip-json-comments@2.0.1: + resolution: {integrity: sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==} + engines: {node: '>=0.10.0'} + strip-json-comments@3.1.1: resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} engines: {node: '>=8'} @@ -11812,6 +13224,12 @@ packages: babel-plugin-macros: optional: true + stylehacks@7.0.11: + resolution: {integrity: sha512-iODNfhXVLqc5LADs+Y6Oh5wJuK5ZcHbVng8aiK3y9pjMQdc5hLrBW0eFU6FtnpNrE6PoEg/MmFTU4waotj5WNg==} + engines: {node: ^18.12.0 || ^20.9.0 || >=22.0} + peerDependencies: + postcss: ^8.5.13 + stylis@4.2.0: resolution: {integrity: sha512-Orov6g6BB1sDfYgzWfTHDOxamtX1bE/zo104Dh9e6fqJ3PooipYyfJ0pUmrZO2wAvO8YbEyeFrkV91XTsGMSrw==} @@ -11859,6 +13277,11 @@ packages: resolution: {integrity: sha512-qsjeeq5YjBZ5eMdFuUa4ZosMLxgr5RZ+F+Y1OrDhuOCEInRMA3x74XdBtggJcj9kOeInz0WE+LgCPDkZFlBYJw==} engines: {node: '>=12.0.0'} + svgo@4.1.0: + resolution: {integrity: sha512-bkxnTg1kSU0guhIBmibA6UUhrQmPVA1XsQLN+ylCd+UWzbnLkySOcXpyk1mrl05f+pcaCx2eHb+sp6BgMZWX+Q==} + engines: {node: '>=16'} + hasBin: true + swagger-ui-dist@5.17.14: resolution: {integrity: sha512-CVbSfaLpstV65OnSjbXfVd6Sta3q3F7Cj/yYuvHMp1P90LztOLs6PfUnKEVAeiIVQt9u2SaPwv0LiH/OyMjHRw==} @@ -12058,6 +13481,10 @@ packages: resolution: {integrity: sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==} engines: {node: '>=14.0.0'} + tlds@1.261.0: + resolution: {integrity: sha512-QXqwfEl9ddlGBaRFXIvNKK6OhipSiLXuRuLJX5DErz0o0Q0rYxulWLdFryTkV5PkdZct5iMInwYEGe/eR++1AA==} + hasBin: true + tldts-core@6.1.86: resolution: {integrity: sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==} @@ -12107,6 +13534,9 @@ packages: resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} engines: {node: '>=0.6'} + token-stream@1.0.0: + resolution: {integrity: sha512-VSsyNPPW74RpHwR8Fc21uubwHY7wMDeJLys2IX5zJNih+OnAnaifKHo+1LHT7DAdloQ7apeaaWg8l7qnf/TnEg==} + token-types@5.0.1: resolution: {integrity: sha512-Y2fmSnZjQdDb9W4w4r1tswlMHylzWIeOKpx0aZH9BgGtACHhrk3OkT52AzwcuqTRBZtvvnTjDBh8eynMulu8Vg==} engines: {node: '>=14.16'} @@ -12367,6 +13797,9 @@ packages: engines: {node: '>=14.17'} hasBin: true + uc.micro@2.1.0: + resolution: {integrity: sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==} + uglify-js@3.19.3: resolution: {integrity: sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ==} engines: {node: '>=0.8.0'} @@ -12390,6 +13823,10 @@ packages: undici-types@7.18.2: resolution: {integrity: sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==} + undici@6.28.0: + resolution: {integrity: sha512-LIY910g9TI13YS95lrMFrs8Rm/u/irgHeTWoKCoteeJ04CUJ92eEfj0rVn+7VKMPBpUPiUoBKfhNyLI23EE/KA==} + engines: {node: '>=18.17'} + unicode-properties@1.4.1: resolution: {integrity: sha512-CLjCCLQ6UuMxWnbIylkisbRj31qxHPAurvena/0iwSVbQ2G1VY5/HjV0IRabOEbDHlzZlRdCrD4NhB0JtU40Pg==} @@ -12584,6 +14021,10 @@ packages: resolution: {integrity: sha512-kiGUalWN+rgBJ/1OHZsBtU4rXZOfj/7rKQxULKlIzwzQSvMJUUNgPwJEEh7gU6xEVxC0ahoOBvN2YI8GH6FNgA==} engines: {node: '>=10.12.0'} + valid-data-url@3.0.1: + resolution: {integrity: sha512-jOWVmzVceKlVVdwjNSenT4PbGghU0SBIizAev8ofZVgivk/TVHXSbNL8LP6M3spZvkR9/QolkyJavGSX5Cs0UA==} + engines: {node: '>=10'} + validate-npm-package-name@7.0.2: resolution: {integrity: sha512-hVDIBwsRruT73PbK7uP5ebUt+ezEtCmzZz3F59BSr2F6OVFnJ/6h8liuvdLrQ88Xmnk6/+xGGuq+pG9WwTuy3A==} engines: {node: ^20.17.0 || >=22.9.0} @@ -12658,6 +14099,46 @@ packages: terser: optional: true + vite@6.4.3: + resolution: {integrity: sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + hasBin: true + peerDependencies: + '@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0 + jiti: '>=1.21.0' + less: '*' + lightningcss: ^1.21.0 + sass: '*' + sass-embedded: '*' + stylus: '*' + sugarss: '*' + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + jiti: + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + vitest@2.1.9: resolution: {integrity: sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q==} engines: {node: ^18.0.0 || >=20.0.0} @@ -12718,6 +14199,10 @@ packages: web-encoding@1.1.5: resolution: {integrity: sha512-HYLeVCdJ0+lBYV2FvNZmv3HJ2Nt0QYXqZojk3d9FJOLkwnuhzM9tmamh8d7HPM8QqjKH8DeHkFTx+CFlWpZZDA==} + web-resource-inliner@8.0.0: + resolution: {integrity: sha512-Ezr98sqXW/+OCGoUEXuOKVR+oVFlSdn1tIySEEJdiSAw4IjrW8hQkwARSSBJTSB5Us5dnytDgL0ZDliAYBhaNA==} + engines: {node: '>=10.0.0'} + web-streams-polyfill@3.3.3: resolution: {integrity: sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==} engines: {node: '>= 8'} @@ -12797,6 +14282,10 @@ packages: engines: {node: '>=8'} hasBin: true + with@7.0.2: + resolution: {integrity: sha512-RNGKj82nUPg3g5ygxkQl0R937xLyho1J24ItRCBTr/m1YnZkzJy1hUiHUJrc/VlsDQzsCnInEGSg3bci0Lmd4w==} + engines: {node: '>= 10.0.0'} + wmf@1.0.2: resolution: {integrity: sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==} engines: {node: '>=0.8'} @@ -13023,6 +14512,17 @@ snapshots: '@alloc/quick-lru@5.2.0': {} + '@angular-devkit/core@19.2.24(chokidar@3.6.0)': + dependencies: + ajv: 8.18.0 + ajv-formats: 3.0.1(ajv@8.18.0) + jsonc-parser: 3.3.1 + picomatch: 4.0.4 + rxjs: 7.8.1 + source-map: 0.7.4 + optionalDependencies: + chokidar: 3.6.0 + '@angular-devkit/core@19.2.24(chokidar@4.0.3)': dependencies: ajv: 8.18.0 @@ -13046,6 +14546,16 @@ snapshots: - '@types/node' - chokidar + '@angular-devkit/schematics@19.2.24(chokidar@3.6.0)': + dependencies: + '@angular-devkit/core': 19.2.24(chokidar@3.6.0) + jsonc-parser: 3.3.1 + magic-string: 0.30.17 + ora: 5.4.1 + rxjs: 7.8.1 + transitivePeerDependencies: + - chokidar + '@angular-devkit/schematics@19.2.24(chokidar@4.0.3)': dependencies: '@angular-devkit/core': 19.2.24(chokidar@4.0.3) @@ -13081,11 +14591,11 @@ snapshots: '@babel/helpers': 7.29.7 '@babel/parser': 7.29.7 '@babel/template': 7.29.7 - '@babel/traverse': 7.29.7 + '@babel/traverse': 7.29.7(supports-color@5.5.0) '@babel/types': 7.29.7 '@jridgewell/remapping': 2.3.5 convert-source-map: 2.0.0 - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@5.5.0) gensync: 1.0.0-beta.2 json5: 2.2.3 semver: 6.3.1 @@ -13120,7 +14630,7 @@ snapshots: '@babel/helper-optimise-call-expression': 7.29.7 '@babel/helper-replace-supers': 7.29.7(@babel/core@7.29.7) '@babel/helper-skip-transparent-expression-wrappers': 7.29.7 - '@babel/traverse': 7.29.7 + '@babel/traverse': 7.29.7(supports-color@5.5.0) semver: 6.3.1 transitivePeerDependencies: - supports-color @@ -13129,14 +14639,7 @@ snapshots: '@babel/helper-member-expression-to-functions@7.29.7': dependencies: - '@babel/traverse': 7.29.7 - '@babel/types': 7.29.7 - transitivePeerDependencies: - - supports-color - - '@babel/helper-module-imports@7.29.7': - dependencies: - '@babel/traverse': 7.29.7 + '@babel/traverse': 7.29.7(supports-color@5.5.0) '@babel/types': 7.29.7 transitivePeerDependencies: - supports-color @@ -13151,9 +14654,9 @@ snapshots: '@babel/helper-module-transforms@7.29.7(@babel/core@7.29.7)': dependencies: '@babel/core': 7.29.7 - '@babel/helper-module-imports': 7.29.7 + '@babel/helper-module-imports': 7.29.7(supports-color@5.5.0) '@babel/helper-validator-identifier': 7.29.7 - '@babel/traverse': 7.29.7 + '@babel/traverse': 7.29.7(supports-color@5.5.0) transitivePeerDependencies: - supports-color @@ -13168,13 +14671,13 @@ snapshots: '@babel/core': 7.29.7 '@babel/helper-member-expression-to-functions': 7.29.7 '@babel/helper-optimise-call-expression': 7.29.7 - '@babel/traverse': 7.29.7 + '@babel/traverse': 7.29.7(supports-color@5.5.0) transitivePeerDependencies: - supports-color '@babel/helper-skip-transparent-expression-wrappers@7.29.7': dependencies: - '@babel/traverse': 7.29.7 + '@babel/traverse': 7.29.7(supports-color@5.5.0) '@babel/types': 7.29.7 transitivePeerDependencies: - supports-color @@ -13327,18 +14830,6 @@ snapshots: '@babel/parser': 7.29.7 '@babel/types': 7.29.7 - '@babel/traverse@7.29.7': - dependencies: - '@babel/code-frame': 7.29.7 - '@babel/generator': 7.29.7 - '@babel/helper-globals': 7.29.7 - '@babel/parser': 7.29.7 - '@babel/template': 7.29.7 - '@babel/types': 7.29.7 - debug: 4.4.3(supports-color@8.1.1) - transitivePeerDependencies: - - supports-color - '@babel/traverse@7.29.7(supports-color@5.5.0)': dependencies: '@babel/code-frame': 7.29.7 @@ -13619,6 +15110,9 @@ snapshots: style-mod: 4.1.3 w3c-keyname: 2.2.8 + '@colordx/core@5.6.0': + optional: true + '@colors/colors@1.5.0': optional: true @@ -13736,6 +15230,53 @@ snapshots: dependencies: '@jridgewell/trace-mapping': 0.3.9 + '@css-inline/css-inline-android-arm-eabi@0.20.0': + optional: true + + '@css-inline/css-inline-android-arm64@0.20.0': + optional: true + + '@css-inline/css-inline-darwin-arm64@0.20.0': + optional: true + + '@css-inline/css-inline-darwin-x64@0.20.0': + optional: true + + '@css-inline/css-inline-linux-arm-gnueabihf@0.20.0': + optional: true + + '@css-inline/css-inline-linux-arm64-gnu@0.20.0': + optional: true + + '@css-inline/css-inline-linux-arm64-musl@0.20.0': + optional: true + + '@css-inline/css-inline-linux-x64-gnu@0.20.0': + optional: true + + '@css-inline/css-inline-linux-x64-musl@0.20.0': + optional: true + + '@css-inline/css-inline-win32-arm64-msvc@0.20.0': + optional: true + + '@css-inline/css-inline-win32-x64-msvc@0.20.0': + optional: true + + '@css-inline/css-inline@0.20.0': + optionalDependencies: + '@css-inline/css-inline-android-arm-eabi': 0.20.0 + '@css-inline/css-inline-android-arm64': 0.20.0 + '@css-inline/css-inline-darwin-arm64': 0.20.0 + '@css-inline/css-inline-darwin-x64': 0.20.0 + '@css-inline/css-inline-linux-arm-gnueabihf': 0.20.0 + '@css-inline/css-inline-linux-arm64-gnu': 0.20.0 + '@css-inline/css-inline-linux-arm64-musl': 0.20.0 + '@css-inline/css-inline-linux-x64-gnu': 0.20.0 + '@css-inline/css-inline-linux-x64-musl': 0.20.0 + '@css-inline/css-inline-win32-arm64-msvc': 0.20.0 + '@css-inline/css-inline-win32-x64-msvc': 0.20.0 + '@csstools/color-helpers@5.1.0': {} '@csstools/css-calc@2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)': @@ -13823,7 +15364,7 @@ snapshots: '@emotion/babel-plugin@11.13.5': dependencies: - '@babel/helper-module-imports': 7.29.7 + '@babel/helper-module-imports': 7.29.7(supports-color@5.5.0) '@babel/runtime': 7.29.7 '@emotion/hash': 0.9.2 '@emotion/memoize': 0.9.0 @@ -13913,72 +15454,150 @@ snapshots: '@esbuild/aix-ppc64@0.21.5': optional: true + '@esbuild/aix-ppc64@0.25.12': + optional: true + '@esbuild/android-arm64@0.21.5': optional: true + '@esbuild/android-arm64@0.25.12': + optional: true + '@esbuild/android-arm@0.21.5': optional: true + '@esbuild/android-arm@0.25.12': + optional: true + '@esbuild/android-x64@0.21.5': optional: true + '@esbuild/android-x64@0.25.12': + optional: true + '@esbuild/darwin-arm64@0.21.5': optional: true + '@esbuild/darwin-arm64@0.25.12': + optional: true + '@esbuild/darwin-x64@0.21.5': optional: true + '@esbuild/darwin-x64@0.25.12': + optional: true + '@esbuild/freebsd-arm64@0.21.5': optional: true + '@esbuild/freebsd-arm64@0.25.12': + optional: true + '@esbuild/freebsd-x64@0.21.5': optional: true + '@esbuild/freebsd-x64@0.25.12': + optional: true + '@esbuild/linux-arm64@0.21.5': optional: true + '@esbuild/linux-arm64@0.25.12': + optional: true + '@esbuild/linux-arm@0.21.5': optional: true + '@esbuild/linux-arm@0.25.12': + optional: true + '@esbuild/linux-ia32@0.21.5': optional: true + '@esbuild/linux-ia32@0.25.12': + optional: true + '@esbuild/linux-loong64@0.21.5': optional: true + '@esbuild/linux-loong64@0.25.12': + optional: true + '@esbuild/linux-mips64el@0.21.5': optional: true + '@esbuild/linux-mips64el@0.25.12': + optional: true + '@esbuild/linux-ppc64@0.21.5': optional: true + '@esbuild/linux-ppc64@0.25.12': + optional: true + '@esbuild/linux-riscv64@0.21.5': optional: true + '@esbuild/linux-riscv64@0.25.12': + optional: true + '@esbuild/linux-s390x@0.21.5': optional: true + '@esbuild/linux-s390x@0.25.12': + optional: true + '@esbuild/linux-x64@0.21.5': optional: true + '@esbuild/linux-x64@0.25.12': + optional: true + + '@esbuild/netbsd-arm64@0.25.12': + optional: true + '@esbuild/netbsd-x64@0.21.5': optional: true + '@esbuild/netbsd-x64@0.25.12': + optional: true + + '@esbuild/openbsd-arm64@0.25.12': + optional: true + '@esbuild/openbsd-x64@0.21.5': optional: true + '@esbuild/openbsd-x64@0.25.12': + optional: true + + '@esbuild/openharmony-arm64@0.25.12': + optional: true + '@esbuild/sunos-x64@0.21.5': optional: true + '@esbuild/sunos-x64@0.25.12': + optional: true + '@esbuild/win32-arm64@0.21.5': optional: true + '@esbuild/win32-arm64@0.25.12': + optional: true + '@esbuild/win32-ia32@0.21.5': optional: true + '@esbuild/win32-ia32@0.25.12': + optional: true + '@esbuild/win32-x64@0.21.5': optional: true + '@esbuild/win32-x64@0.25.12': + optional: true + '@eslint-community/eslint-utils@4.9.1(eslint@8.57.1)': dependencies: eslint: 8.57.1 @@ -13989,12 +15608,12 @@ snapshots: '@eslint/eslintrc@2.1.4': dependencies: ajv: 6.15.0 - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@5.5.0) espree: 9.6.1 globals: 13.24.0 ignore: 5.3.2 import-fresh: 3.3.1 - js-yaml: 4.2.0 + js-yaml: 4.3.0 minimatch: 3.1.5 strip-json-comments: 3.1.1 transitivePeerDependencies: @@ -14149,7 +15768,7 @@ snapshots: '@humanwhocodes/config-array@0.13.0': dependencies: '@humanwhocodes/object-schema': 2.0.3 - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@5.5.0) minimatch: 3.1.5 transitivePeerDependencies: - supports-color @@ -14360,6 +15979,9 @@ snapshots: wrap-ansi: 8.1.0 wrap-ansi-cjs: wrap-ansi@7.0.0 + '@isaacs/cliui@9.0.0': + optional: true + '@istanbuljs/load-nyc-config@1.1.0': dependencies: camelcase: 5.3.1 @@ -15004,10 +16626,31 @@ snapshots: react-dom: 19.2.6(react@19.2.6) react-transition-group: 4.4.5(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@mantine/notifications@9.5.2(@mantine/core@9.3.2(@mantine/hooks@9.3.2(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/hooks@9.3.2(react@19.2.6))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + dependencies: + '@mantine/core': 9.3.2(@mantine/hooks@9.3.2(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@mantine/hooks': 9.3.2(react@19.2.6) + '@mantine/store': 9.5.2(react@19.2.6) + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) + react-transition-group: 4.4.5(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + + '@mantine/spotlight@9.5.2(@mantine/core@9.3.2(@mantine/hooks@9.3.2(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/hooks@9.3.2(react@19.2.6))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + dependencies: + '@mantine/core': 9.3.2(@mantine/hooks@9.3.2(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@mantine/hooks': 9.3.2(react@19.2.6) + '@mantine/store': 9.5.2(react@19.2.6) + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) + '@mantine/store@7.17.8(react@19.2.6)': dependencies: react: 19.2.6 + '@mantine/store@9.5.2(react@19.2.6)': + dependencies: + react: 19.2.6 + '@marijn/find-cluster-break@1.0.3': {} '@mdxeditor/editor@4.2.0(@codemirror/language@6.12.4)(@lezer/highlight@1.2.3)(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(typescript@5.9.3)(yjs@13.6.32)': @@ -15308,12 +16951,108 @@ snapshots: '@tybys/wasm-util': 0.10.2 optional: true + '@nestjs-modules/mailer@2.3.7(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(@nestjs/event-emitter@2.1.1(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24))(chokidar@3.6.0)(nodemailer@9.0.5)(svgo@4.1.0)(terser@5.48.0)(typescript@5.9.3)': + dependencies: + '@css-inline/css-inline': 0.20.0 + '@nestjs/common': 11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 11.1.24(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/microservices@11.1.24)(@nestjs/platform-express@11.1.24)(@nestjs/websockets@11.1.27)(reflect-metadata@0.2.2)(rxjs@7.8.2) + glob: 13.0.6 + nodemailer: 9.0.5 + tslib: 2.8.1 + optionalDependencies: + '@nestjs/event-emitter': 2.1.1(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24) + '@types/ejs': 3.1.5 + '@types/mjml': 4.7.4 + '@types/pug': 2.0.10 + ejs: 5.0.2 + handlebars: 4.7.9 + liquidjs: 10.29.0 + mjml: 5.4.0(svgo@4.1.0)(terser@5.48.0)(typescript@5.9.3) + nunjucks: 3.2.4(chokidar@3.6.0) + preview-email: 3.3.0 + pug: 3.0.4 + transitivePeerDependencies: + - chokidar + - purgecss + - relateurl + - srcset + - svgo + - terser + - typescript + - uncss + + '@nestjs-modules/mailer@2.3.7(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(@nestjs/event-emitter@2.1.1(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24))(chokidar@4.0.3)(nodemailer@9.0.5)(svgo@4.1.0)(terser@5.48.0)(typescript@5.9.3)': + dependencies: + '@css-inline/css-inline': 0.20.0 + '@nestjs/common': 11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 11.1.24(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/microservices@11.1.24)(@nestjs/platform-express@11.1.24)(@nestjs/websockets@11.1.27)(reflect-metadata@0.2.2)(rxjs@7.8.2) + glob: 13.0.6 + nodemailer: 9.0.5 + tslib: 2.8.1 + optionalDependencies: + '@nestjs/event-emitter': 2.1.1(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24) + '@types/ejs': 3.1.5 + '@types/mjml': 4.7.4 + '@types/pug': 2.0.10 + ejs: 5.0.2 + handlebars: 4.7.9 + liquidjs: 10.29.0 + mjml: 5.4.0(svgo@4.1.0)(terser@5.48.0)(typescript@5.9.3) + nunjucks: 3.2.4(chokidar@4.0.3) + preview-email: 3.3.0 + pug: 3.0.4 + transitivePeerDependencies: + - chokidar + - purgecss + - relateurl + - srcset + - svgo + - terser + - typescript + - uncss + '@nestjs/axios@4.0.1(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(axios@1.17.0)(rxjs@7.8.2)': dependencies: '@nestjs/common': 11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2) axios: 1.17.0 rxjs: 7.8.2 + '@nestjs/cli@11.0.21(@types/node@20.19.42)(cssnano@7.1.9(postcss@8.5.15))(postcss@8.5.15)(prettier@3.8.3)': + dependencies: + '@angular-devkit/core': 19.2.24(chokidar@4.0.3) + '@angular-devkit/schematics': 19.2.24(chokidar@4.0.3) + '@angular-devkit/schematics-cli': 19.2.24(@types/node@20.19.42)(chokidar@4.0.3) + '@inquirer/prompts': 7.10.1(@types/node@20.19.42) + '@nestjs/schematics': 11.1.0(chokidar@4.0.3)(prettier@3.8.3)(typescript@5.9.3) + 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(typescript@5.9.3)(webpack@5.106.0(cssnano@7.1.9(postcss@8.5.15))(postcss@8.5.15)) + 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(cssnano@7.1.9(postcss@8.5.15))(postcss@8.5.15) + webpack-node-externals: 3.0.0 + transitivePeerDependencies: + - '@minify-html/node' + - '@swc/css' + - '@swc/html' + - '@types/node' + - clean-css + - cssnano + - csso + - esbuild + - html-minifier-terser + - lightningcss + - postcss + - prettier + - uglify-js + - webpack-cli + '@nestjs/cli@11.0.21(@types/node@20.19.42)(prettier@3.8.3)': dependencies: '@angular-devkit/core': 19.2.24(chokidar@4.0.3) @@ -15385,7 +17124,7 @@ snapshots: tslib: 2.8.1 uid: 2.0.2 optionalDependencies: - '@nestjs/microservices': 11.1.24(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(@nestjs/websockets@11.1.27)(amqp-connection-manager@5.0.0(amqplib@2.0.1))(amqplib@2.0.1)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/microservices': 11.1.24(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(@nestjs/websockets@11.1.27)(amqp-connection-manager@5.0.0(amqplib@0.10.9))(amqplib@0.10.9)(reflect-metadata@0.2.2)(rxjs@7.8.2) '@nestjs/platform-express': 11.1.24(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24) '@nestjs/websockets': 11.1.27(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(@nestjs/platform-socket.io@11.1.27)(reflect-metadata@0.2.2)(rxjs@7.8.2) @@ -15417,6 +17156,19 @@ snapshots: class-transformer: 0.5.1 class-validator: 0.14.4 + '@nestjs/microservices@11.1.24(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(@nestjs/websockets@11.1.27)(amqp-connection-manager@5.0.0(amqplib@0.10.9))(amqplib@0.10.9)(reflect-metadata@0.2.2)(rxjs@7.8.2)': + dependencies: + '@nestjs/common': 11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 11.1.24(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/microservices@11.1.24)(@nestjs/platform-express@11.1.24)(@nestjs/websockets@11.1.27)(reflect-metadata@0.2.2)(rxjs@7.8.2) + iterare: 1.2.1 + reflect-metadata: 0.2.2 + rxjs: 7.8.2 + tslib: 2.8.1 + optionalDependencies: + '@nestjs/websockets': 11.1.27(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(@nestjs/platform-socket.io@11.1.27)(reflect-metadata@0.2.2)(rxjs@7.8.2) + amqp-connection-manager: 5.0.0(amqplib@0.10.9) + amqplib: 0.10.9 + '@nestjs/microservices@11.1.24(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(@nestjs/websockets@11.1.27)(amqp-connection-manager@5.0.0(amqplib@2.0.1))(amqplib@2.0.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)': dependencies: '@nestjs/common': 11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2) @@ -15465,6 +17217,19 @@ snapshots: '@nestjs/core': 11.1.24(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/microservices@11.1.24)(@nestjs/platform-express@11.1.24)(@nestjs/websockets@11.1.27)(reflect-metadata@0.2.2)(rxjs@7.8.2) cron: 4.4.0 + '@nestjs/schematics@11.1.0(chokidar@3.6.0)(prettier@3.8.3)(typescript@5.9.3)': + dependencies: + '@angular-devkit/core': 19.2.24(chokidar@3.6.0) + '@angular-devkit/schematics': 19.2.24(chokidar@3.6.0) + comment-json: 5.0.0 + jsonc-parser: 3.3.1 + pluralize: 8.0.0 + typescript: 5.9.3 + optionalDependencies: + prettier: 3.8.3 + transitivePeerDependencies: + - chokidar + '@nestjs/schematics@11.1.0(chokidar@4.0.3)(prettier@3.8.3)(typescript@5.9.3)': dependencies: '@angular-devkit/core': 19.2.24(chokidar@4.0.3) @@ -15514,7 +17279,7 @@ snapshots: '@nestjs/core': 11.1.24(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/microservices@11.1.24)(@nestjs/platform-express@11.1.24)(@nestjs/websockets@11.1.27)(reflect-metadata@0.2.2)(rxjs@7.8.2) tslib: 2.8.1 optionalDependencies: - '@nestjs/microservices': 11.1.24(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(@nestjs/websockets@11.1.27)(amqp-connection-manager@5.0.0(amqplib@2.0.1))(amqplib@2.0.1)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/microservices': 11.1.24(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(@nestjs/websockets@11.1.27)(amqp-connection-manager@5.0.0(amqplib@0.10.9))(amqplib@0.10.9)(reflect-metadata@0.2.2)(rxjs@7.8.2) '@nestjs/platform-express': 11.1.24(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24) '@nestjs/throttler@6.5.0(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(reflect-metadata@0.2.2)': @@ -15602,6 +17367,9 @@ snapshots: dependencies: consola: 3.4.2 + '@one-ini/wasm@0.1.1': + optional: true + '@open-draft/deferred-promise@2.2.0': {} '@open-draft/deferred-promise@3.0.0': {} @@ -15748,7 +17516,7 @@ snapshots: '@puppeteer/browsers@2.13.2': dependencies: - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@5.5.0) extract-zip: 2.0.1 progress: 2.0.3 proxy-agent: 6.5.0 @@ -17612,6 +19380,12 @@ snapshots: '@sec-ant/readable-stream@0.4.1': {} + '@selderee/plugin-htmlparser2@0.11.0': + dependencies: + domhandler: 5.0.3 + selderee: 0.11.0 + optional: true + '@sendgrid/client@8.1.6': dependencies: '@sendgrid/helpers': 8.0.0 @@ -17750,6 +19524,13 @@ snapshots: tailwindcss: 4.3.0 vite: 5.4.21(@types/node@24.13.1)(lightningcss@1.32.0)(terser@5.48.0) + '@tailwindcss/vite@4.3.0(vite@6.4.3(@types/node@20.19.42)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0))': + dependencies: + '@tailwindcss/node': 4.3.0 + '@tailwindcss/oxide': 4.3.0 + tailwindcss: 4.3.0 + vite: 6.4.3(@types/node@20.19.42)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0) + '@tanstack/match-sorter-utils@8.19.4': dependencies: remove-accents: 0.5.0 @@ -17822,7 +19603,7 @@ snapshots: '@tokenizer/inflate@0.4.1': dependencies: - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@5.5.0) token-types: 6.1.2 transitivePeerDependencies: - supports-color @@ -17880,7 +19661,7 @@ snapshots: '@nestjs/common': 11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2) '@nestjs/core': 11.1.24(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/microservices@11.1.24)(@nestjs/platform-express@11.1.24)(@nestjs/websockets@11.1.27)(reflect-metadata@0.2.2)(rxjs@7.8.2) '@nestjs/jwt': 11.0.2(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2)) - '@nestjs/microservices': 11.1.24(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(@nestjs/websockets@11.1.27)(amqp-connection-manager@5.0.0(amqplib@2.0.1))(amqplib@2.0.1)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/microservices': 11.1.24(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(@nestjs/websockets@11.1.27)(amqp-connection-manager@5.0.0(amqplib@0.10.9))(amqplib@0.10.9)(reflect-metadata@0.2.2)(rxjs@7.8.2) '@nestjs/passport': 10.0.3(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(passport@0.7.0) '@nestjs/swagger': 11.4.4(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2) '@nestjs/throttler': 6.5.0(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(reflect-metadata@0.2.2) @@ -17985,6 +19766,100 @@ snapshots: - '@faker-js/faker' - supports-color + '@tria-plc/iamapi-common@file:local-packages/tria-plc-iamapi-common-1.3.4.tgz(81d9d3adb5cf581f5b3aa6a2c0425598)': + dependencies: + '@nestjs-modules/mailer': 2.3.7(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(@nestjs/event-emitter@2.1.1(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24))(chokidar@3.6.0)(nodemailer@9.0.5)(svgo@4.1.0)(terser@5.48.0)(typescript@5.9.3) + '@nestjs/axios': 4.0.1(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(axios@1.17.0)(rxjs@7.8.2) + '@nestjs/common': 11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 11.1.24(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/microservices@11.1.24)(@nestjs/platform-express@11.1.24)(@nestjs/websockets@11.1.27)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/jwt': 11.0.2(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2)) + '@nestjs/microservices': 11.1.24(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(@nestjs/websockets@11.1.27)(amqp-connection-manager@5.0.0(amqplib@2.0.1))(amqplib@2.0.1)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/passport': 10.0.3(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(passport@0.7.0) + '@nestjs/swagger': 11.4.4(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2) + '@nestjs/throttler': 6.5.0(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(reflect-metadata@0.2.2) + '@nestjs/typeorm': 11.0.1(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(reflect-metadata@0.2.2)(rxjs@7.8.2)(typeorm@0.3.30(babel-plugin-macros@3.1.0)(pg@8.21.0)(ts-node@10.9.2(@types/node@20.19.42)(typescript@5.9.3))) + '@tria-plc/api-common': file:local-packages/tria-plc-api-common-1.6.0.tgz(ae9a56cc1c6d93629dd85f7605ccd5b6) + argon2: 0.43.1 + axios: 1.17.0 + class-transformer: 0.5.1 + class-validator: 0.14.4 + dotenv: 17.4.2 + ethiopian-date: 0.0.6 + file-type: 21.3.4 + jose: 5.10.0 + jsonwebtoken: 9.0.3 + libphonenumber-js: 1.13.6 + nestjs-minio-client: 2.2.0(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24) + nodemailer: 9.0.5 + passport-jwt: 4.0.1 + qrcode: 1.5.4 + reflect-metadata: 0.2.2 + rxjs: 7.8.2 + typeorm: 0.3.30(babel-plugin-macros@3.1.0)(pg@8.21.0)(ts-node@10.9.2(@types/node@20.19.42)(typescript@5.9.3)) + typeorm-extension: 3.9.0(@faker-js/faker@10.4.0)(typeorm@0.3.30(babel-plugin-macros@3.1.0)(pg@8.21.0)(ts-node@10.9.2(@types/node@20.19.42)(typescript@5.9.3))) + uuid: 11.1.1 + transitivePeerDependencies: + - '@faker-js/faker' + - '@nestjs/event-emitter' + - '@nestjs/terminus' + - bullmq + - chokidar + - purgecss + - relateurl + - srcset + - supports-color + - svgo + - terser + - typescript + - uncss + + '@tria-plc/iamapi-common@file:local-packages/tria-plc-iamapi-common-1.3.4.tgz(cad8c67eb144007f21458987b02127f7)': + dependencies: + '@nestjs-modules/mailer': 2.3.7(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(@nestjs/event-emitter@2.1.1(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24))(chokidar@4.0.3)(nodemailer@9.0.5)(svgo@4.1.0)(terser@5.48.0)(typescript@5.9.3) + '@nestjs/axios': 4.0.1(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(axios@1.17.0)(rxjs@7.8.2) + '@nestjs/common': 11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 11.1.24(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/microservices@11.1.24)(@nestjs/platform-express@11.1.24)(@nestjs/websockets@11.1.27)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/jwt': 11.0.2(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2)) + '@nestjs/microservices': 11.1.24(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(@nestjs/websockets@11.1.27)(amqp-connection-manager@5.0.0(amqplib@0.10.9))(amqplib@0.10.9)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/passport': 10.0.3(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(passport@0.7.0) + '@nestjs/swagger': 11.4.4(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2) + '@nestjs/throttler': 6.5.0(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(reflect-metadata@0.2.2) + '@nestjs/typeorm': 11.0.1(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24)(reflect-metadata@0.2.2)(rxjs@7.8.2)(typeorm@0.3.30(babel-plugin-macros@3.1.0)(pg@8.21.0)(ts-node@10.9.2(@types/node@20.19.42)(typescript@5.9.3))) + '@tria-plc/api-common': file:local-packages/tria-plc-api-common-1.6.0.tgz(ae9a56cc1c6d93629dd85f7605ccd5b6) + argon2: 0.43.1 + axios: 1.17.0 + class-transformer: 0.5.1 + class-validator: 0.14.4 + dotenv: 17.4.2 + ethiopian-date: 0.0.6 + file-type: 21.3.4 + jose: 5.10.0 + jsonwebtoken: 9.0.3 + libphonenumber-js: 1.13.6 + nestjs-minio-client: 2.2.0(@nestjs/common@11.1.24(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.24) + nodemailer: 9.0.5 + passport-jwt: 4.0.1 + qrcode: 1.5.4 + reflect-metadata: 0.2.2 + rxjs: 7.8.2 + typeorm: 0.3.30(babel-plugin-macros@3.1.0)(pg@8.21.0)(ts-node@10.9.2(@types/node@20.19.42)(typescript@5.9.3)) + typeorm-extension: 3.9.0(@faker-js/faker@10.4.0)(typeorm@0.3.30(babel-plugin-macros@3.1.0)(pg@8.21.0)(ts-node@10.9.2(@types/node@20.19.42)(typescript@5.9.3))) + uuid: 11.1.1 + transitivePeerDependencies: + - '@faker-js/faker' + - '@nestjs/event-emitter' + - '@nestjs/terminus' + - bullmq + - chokidar + - purgecss + - relateurl + - srcset + - supports-color + - svgo + - terser + - typescript + - uncss + '@tria-plc/iamui@file:local-packages/tria-plc-iamui-0.1.1.tgz(0ce39b7e349029277dcd938d06eeb0f7)': dependencies: '@emotion/react': 11.14.0(@types/react@18.3.31)(react@19.2.6) @@ -18109,130 +19984,6 @@ snapshots: - utf-8-validate - vite - '@tria-plc/iamui@file:local-packages/tria-plc-iamui-0.1.1.tgz(a5d0bdee55164ae56064fb273b530e00)': - dependencies: - '@emotion/react': 11.14.0(@types/react@18.3.31)(react@19.2.6) - '@emotion/styled': 11.14.1(@emotion/react@11.14.0(@types/react@18.3.31)(react@19.2.6))(@types/react@18.3.31)(react@19.2.6) - '@hookform/resolvers': 5.4.0(react-hook-form@7.77.0(react@19.2.6)) - '@lottiefiles/react-lottie-player': 3.6.0(react@19.2.6) - '@mantine/charts': 7.17.8(@mantine/core@7.17.8(@mantine/hooks@7.17.8(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/hooks@7.17.8(react@19.2.6))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(recharts@3.8.1(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react-is@19.2.7)(react@19.2.6)(redux@5.0.1)) - '@mantine/core': 7.17.8(@mantine/hooks@7.17.8(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@mantine/dates': 7.17.8(@mantine/core@7.17.8(@mantine/hooks@7.17.8(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/hooks@7.17.8(react@19.2.6))(dayjs@1.11.21)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@mantine/hooks': 7.17.8(react@19.2.6) - '@mantine/notifications': 7.17.8(@mantine/core@7.17.8(@mantine/hooks@7.17.8(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/hooks@7.17.8(react@19.2.6))(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@radix-ui/react-accordion': 1.2.13(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@radix-ui/react-alert-dialog': 1.1.16(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@radix-ui/react-avatar': 1.1.12(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@radix-ui/react-checkbox': 1.3.4(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@radix-ui/react-collapsible': 1.1.13(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@radix-ui/react-context-menu': 2.3.0(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@radix-ui/react-dialog': 1.1.16(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@radix-ui/react-dropdown-menu': 2.1.17(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@radix-ui/react-hover-card': 1.1.16(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@radix-ui/react-label': 2.1.9(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@radix-ui/react-navigation-menu': 1.2.15(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@radix-ui/react-popover': 1.1.16(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@radix-ui/react-progress': 1.1.9(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@radix-ui/react-radio-group': 1.4.0(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@radix-ui/react-scroll-area': 1.2.11(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@radix-ui/react-select': 2.3.0(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@radix-ui/react-separator': 1.1.9(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@radix-ui/react-slot': 1.2.5(@types/react@18.3.31)(react@19.2.6) - '@radix-ui/react-switch': 1.3.0(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@radix-ui/react-tabs': 1.1.14(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@radix-ui/react-toast': 1.2.16(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@radix-ui/react-tooltip': 1.2.9(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@react-pdf-viewer/core': 3.12.0(pdfjs-dist@5.4.296)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@react-pdf-viewer/default-layout': 3.12.0(pdfjs-dist@5.4.296)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@react-pdf/renderer': 4.5.1(react@19.2.6) - '@reduxjs/toolkit': 2.12.0(react-redux@9.3.0(@types/react@18.3.31)(react@19.2.6)(redux@5.0.1))(react@19.2.6) - '@tabler/icons-react': 3.44.0(react@19.2.6) - '@tailwindcss/vite': 4.3.0(vite@5.4.21(@types/node@24.13.1)(lightningcss@1.32.0)(terser@5.48.0)) - '@tanstack/react-query': 5.101.0(react@19.2.6) - '@tanstack/react-query-devtools': 5.101.0(@tanstack/react-query@5.101.0(react@19.2.6))(react@19.2.6) - '@tanstack/react-table': 8.21.3(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@tinymce/tinymce-react': 6.3.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(tinymce@7.9.3) - '@types/dompurify': 3.2.0 - '@types/node': 24.13.1 - '@types/tinymce': 4.6.9 - axios: 1.17.0 - class-variance-authority: 0.7.1 - clsx: 2.1.1 - cmdk: 1.1.1(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - date-fns: 3.6.0 - dayjs: 1.11.21 - dompurify: 3.4.8 - ethiopian-calendar-date-converter: 2.1.6 - ethiopian-calendar-new: 1.1.0 - file-type: 18.7.0 - framer-motion: 12.40.0(@emotion/is-prop-valid@1.4.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - html2canvas: 1.4.1 - i18next: 25.10.10(typescript@5.9.3) - i18next-browser-languagedetector: 8.2.1 - jquery: 3.7.1 - js-cookie: 3.0.8 - jspdf: 3.0.4 - lodash: 4.18.1 - lucide-react: 0.513.0(react@19.2.6) - mantine-react-table: 2.0.0-beta.9(@mantine/core@7.17.8(@mantine/hooks@7.17.8(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/dates@7.17.8(@mantine/core@7.17.8(@mantine/hooks@7.17.8(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/hooks@7.17.8(react@19.2.6))(dayjs@1.11.21)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/hooks@7.17.8(react@19.2.6))(@tabler/icons-react@3.44.0(react@19.2.6))(clsx@2.1.1)(dayjs@1.11.21)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - mui-ethiopian-datepicker: 0.3.2(4b3af212eafdf0059f009b005d7e343d) - next-themes: 0.4.6(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - path: 0.12.7 - pdf-lib: 1.17.1 - qs: 6.15.2 - react: 19.2.6 - react-cookie: 8.1.2(@types/react@18.3.31)(react@19.2.6) - react-css-nocode-editor: 1.0.13(react-dom@19.2.6(react@19.2.6))(react-is@19.2.7)(react@19.2.6) - react-day-picker: 8.10.2(date-fns@3.6.0)(react@19.2.6) - react-dom: 19.2.6(react@19.2.6) - react-dropzone: 14.4.1(react@19.2.6) - react-hook-form: 7.77.0(react@19.2.6) - react-i18next: 15.7.4(i18next@25.10.10(typescript@5.9.3))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(typescript@5.9.3) - react-icons: 5.6.0(react@19.2.6) - react-image-crop: 11.0.10(react@19.2.6) - react-intersection-observer: 9.16.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - react-pdf: 10.4.1(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - react-pdf-html: 2.1.5(@react-pdf/renderer@4.5.1(react@19.2.6))(react@19.2.6) - react-redux: 9.3.0(@types/react@18.3.31)(react@19.2.6)(redux@5.0.1) - react-resizable-panels: 3.0.6(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - react-router-dom: 7.17.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - react-signature-canvas: 1.1.0-alpha.2(@types/prop-types@15.7.15)(@types/react@18.3.31)(prop-types@15.8.1)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - recharts: 3.8.1(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react-is@19.2.7)(react@19.2.6)(redux@5.0.1) - rollup-plugin-visualizer: 7.0.1(rollup@4.61.1) - socket.io-client: 4.8.3 - sonner: 2.0.7(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - tailwind-merge: 3.6.0 - tailwind-scrollbar-hide: 4.0.0(tailwindcss@4.3.0) - tailwindcss: 4.3.0 - tailwindcss-animate: 1.0.7(tailwindcss@4.3.0) - tinymce: 7.9.3 - url: 0.11.4 - vaul: 1.1.2(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - xlsx: 0.18.5 - zod: 3.25.76 - transitivePeerDependencies: - - '@babel/core' - - '@emotion/is-prop-valid' - - '@mui/icons-material' - - '@mui/material' - - '@mui/x-date-pickers' - - '@types/prop-types' - - '@types/react' - - '@types/react-dom' - - bufferutil - - debug - - pdfjs-dist - - prop-types - - react-is - - react-native - - redux - - rolldown - - rollup - - supports-color - - typescript - - utf-8-validate - - vite - '@ts-morph/common@0.27.0': dependencies: fast-glob: 3.3.3 @@ -18352,6 +20103,9 @@ snapshots: dependencies: dompurify: 3.4.8 + '@types/ejs@3.1.5': + optional: true + '@types/eslint-scope@3.7.7': dependencies: '@types/eslint': 9.6.1 @@ -18456,6 +20210,14 @@ snapshots: '@types/mime@1.3.5': {} + '@types/mjml-core@5.1.0': + optional: true + + '@types/mjml@4.7.4': + dependencies: + '@types/mjml-core': 5.1.0 + optional: true + '@types/ms@2.1.0': {} '@types/multer@2.1.0': @@ -18488,6 +20250,9 @@ snapshots: '@types/prop-types@15.7.15': {} + '@types/pug@2.0.10': + optional: true + '@types/qrcode@1.5.6': dependencies: '@types/node': 20.19.42 @@ -18512,6 +20277,9 @@ snapshots: '@types/prop-types': 15.7.15 csstype: 3.2.3 + '@types/relateurl@0.2.33': + optional: true + '@types/send@0.17.6': dependencies: '@types/mime': 1.3.5 @@ -18630,7 +20398,7 @@ snapshots: '@typescript-eslint/types': 8.60.1 '@typescript-eslint/typescript-estree': 8.60.1(typescript@5.9.3) '@typescript-eslint/visitor-keys': 8.60.1 - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@5.5.0) eslint: 8.57.1 typescript: 5.9.3 transitivePeerDependencies: @@ -18640,7 +20408,7 @@ snapshots: dependencies: '@typescript-eslint/tsconfig-utils': 8.60.1(typescript@5.9.3) '@typescript-eslint/types': 8.60.1 - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@5.5.0) typescript: 5.9.3 transitivePeerDependencies: - supports-color @@ -18659,7 +20427,7 @@ snapshots: '@typescript-eslint/types': 8.60.1 '@typescript-eslint/typescript-estree': 8.60.1(typescript@5.9.3) '@typescript-eslint/utils': 8.60.1(eslint@8.57.1)(typescript@5.9.3) - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@5.5.0) eslint: 8.57.1 ts-api-utils: 2.5.0(typescript@5.9.3) typescript: 5.9.3 @@ -18674,7 +20442,7 @@ snapshots: '@typescript-eslint/tsconfig-utils': 8.60.1(typescript@5.9.3) '@typescript-eslint/types': 8.60.1 '@typescript-eslint/visitor-keys': 8.60.1 - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@5.5.0) minimatch: 10.2.5 semver: 7.8.2 tinyglobby: 0.2.17 @@ -18791,6 +20559,18 @@ snapshots: transitivePeerDependencies: - supports-color + '@vitejs/plugin-react@4.7.0(vite@6.4.3(@types/node@20.19.42)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0))': + dependencies: + '@babel/core': 7.29.7 + '@babel/plugin-transform-react-jsx-self': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-react-jsx-source': 7.29.7(@babel/core@7.29.7) + '@rolldown/pluginutils': 1.0.0-beta.27 + '@types/babel__core': 7.20.5 + react-refresh: 0.17.0 + vite: 6.4.3(@types/node@20.19.42)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0) + transitivePeerDependencies: + - supports-color + '@vitest/expect@2.1.9': dependencies: '@vitest/spy': 2.1.9 @@ -18921,6 +20701,13 @@ snapshots: '@xtuc/long@4.2.2': {} + '@zone-eu/mailsplit@5.4.8': + dependencies: + libbase64: 1.3.0 + libmime: 5.3.7 + libqp: 2.1.1 + optional: true + '@zxing/text-encoding@0.9.0': optional: true @@ -18929,6 +20716,12 @@ snapshots: jsonparse: 1.3.1 through: 2.3.8 + a-sync-waterfall@1.0.1: + optional: true + + abbrev@2.0.0: + optional: true + abort-controller@3.0.0: dependencies: event-target-shim: 5.0.1 @@ -18957,13 +20750,16 @@ snapshots: dependencies: acorn: 8.16.0 + acorn@7.4.1: + optional: true + acorn@8.16.0: {} adler-32@1.3.1: {} agent-base@6.0.2: dependencies: - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@5.5.0) transitivePeerDependencies: - supports-color @@ -19011,11 +20807,23 @@ snapshots: json-schema-traverse: 1.0.0 require-from-string: 2.0.2 + alce@1.2.0: + dependencies: + esprima: 1.2.5 + estraverse: 1.9.3 + optional: true + amqp-connection-manager@4.1.15(amqplib@0.10.9): dependencies: amqplib: 0.10.9 promise-breaker: 6.0.0 + amqp-connection-manager@5.0.0(amqplib@0.10.9): + dependencies: + amqplib: 0.10.9 + promise-breaker: 6.0.0 + optional: true + amqp-connection-manager@5.0.0(amqplib@2.0.1): dependencies: amqplib: 2.0.1 @@ -19360,6 +21168,9 @@ snapshots: dependencies: safer-buffer: 2.1.2 + assert-never@1.4.0: + optional: true + assert-plus@1.0.0: {} assertion-error@2.0.1: {} @@ -19472,16 +21283,6 @@ snapshots: transitivePeerDependencies: - supports-color - babel-plugin-styled-components@2.3.0(styled-components@5.3.11(react-dom@19.2.6(react@19.2.6))(react-is@19.2.7)(react@19.2.6))(supports-color@5.5.0): - dependencies: - '@babel/helper-annotate-as-pure': 7.29.7 - '@babel/helper-module-imports': 7.29.7(supports-color@5.5.0) - '@babel/plugin-syntax-jsx': 7.29.7(@babel/core@7.29.7) - picomatch: 4.0.4 - styled-components: 5.3.11(react-dom@19.2.6(react@19.2.6))(react-is@19.2.7)(react@19.2.6) - transitivePeerDependencies: - - supports-color - babel-polyfill@6.26.0: dependencies: babel-runtime: 6.26.0 @@ -19518,6 +21319,11 @@ snapshots: core-js: 2.6.12 regenerator-runtime: 0.11.1 + babel-walk@3.0.0-canary-5: + dependencies: + '@babel/types': 7.29.7 + optional: true + bail@2.0.2: {} balanced-match@1.0.2: {} @@ -19637,7 +21443,7 @@ snapshots: dependencies: bytes: 3.1.2 content-type: 1.0.5 - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@5.5.0) http-errors: 2.0.1 iconv-lite: 0.7.2 on-finished: 2.4.1 @@ -19797,6 +21603,14 @@ snapshots: camelize@1.0.1: {} + caniuse-api@3.0.0: + dependencies: + browserslist: 4.28.2 + caniuse-lite: 1.0.30001797 + lodash.memoize: 4.1.2 + lodash.uniq: 4.5.0 + optional: true + caniuse-lite@1.0.30001797: {} canvg@3.0.11: @@ -19840,6 +21654,12 @@ snapshots: strip-ansi: 3.0.1 supports-color: 2.0.0 + chalk@3.0.0: + dependencies: + ansi-styles: 4.3.0 + supports-color: 7.2.0 + optional: true + chalk@4.1.2: dependencies: ansi-styles: 4.3.0 @@ -19857,12 +21677,42 @@ snapshots: character-entities@2.0.2: {} + character-parser@2.2.0: + dependencies: + is-regex: 1.2.1 + optional: true + character-reference-invalid@2.0.1: {} chardet@2.1.1: {} check-error@2.1.3: {} + cheerio-select@2.1.0: + dependencies: + boolbase: 1.0.0 + css-select: 5.2.2 + css-what: 6.2.2 + domelementtype: 2.3.0 + domhandler: 5.0.3 + domutils: 3.2.2 + optional: true + + cheerio@1.0.0: + dependencies: + cheerio-select: 2.1.0 + dom-serializer: 2.0.0 + domhandler: 5.0.3 + domutils: 3.2.2 + encoding-sniffer: 0.2.1 + htmlparser2: 9.1.0 + parse5: 7.3.0 + parse5-htmlparser2-tree-adapter: 7.1.0 + parse5-parser-stream: 7.1.2 + undici: 6.28.0 + whatwg-mimetype: 4.0.0 + optional: true + chokidar@3.6.0: dependencies: anymatch: 3.1.3 @@ -20053,16 +21903,28 @@ snapshots: comma-separated-tokens@2.0.3: {} + commander@10.0.1: + optional: true + commander@11.1.0: {} + commander@12.1.0: + optional: true + commander@13.1.0: {} commander@14.0.3: {} + commander@15.0.0: + optional: true + commander@2.20.3: {} commander@4.1.1: {} + commander@5.1.0: + optional: true + commander@6.2.1: {} comment-json@5.0.0: @@ -20103,8 +21965,20 @@ snapshots: confbox@0.2.4: {} + config-chain@1.1.13: + dependencies: + ini: 1.3.8 + proto-list: 1.2.4 + optional: true + consola@3.4.2: {} + constantinople@4.0.1: + dependencies: + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 + optional: true + content-disposition@0.5.4: dependencies: safe-buffer: 5.2.1 @@ -20177,7 +22051,7 @@ snapshots: cosmiconfig@8.3.6(typescript@5.9.3): dependencies: import-fresh: 3.3.1 - js-yaml: 4.2.0 + js-yaml: 4.3.0 parse-json: 5.2.0 path-type: 4.0.0 optionalDependencies: @@ -20187,7 +22061,7 @@ snapshots: dependencies: env-paths: 2.2.1 import-fresh: 3.3.1 - js-yaml: 4.2.0 + js-yaml: 4.3.0 parse-json: 5.2.0 optionalDependencies: typescript: 5.9.3 @@ -20249,6 +22123,11 @@ snapshots: css-color-keywords@1.0.0: {} + css-declaration-sorter@7.4.0(postcss@8.5.15): + dependencies: + postcss: 8.5.15 + optional: true + css-line-break@2.1.0: dependencies: utrie: 1.0.2 @@ -20261,6 +22140,15 @@ snapshots: domutils: 3.2.2 nth-check: 2.1.1 + css-select@6.0.0: + dependencies: + boolbase: 1.0.0 + css-what: 7.0.0 + domhandler: 5.0.3 + domutils: 3.2.2 + nth-check: 2.1.1 + optional: true + css-to-react-native@3.2.0: dependencies: camelize: 1.0.1 @@ -20272,10 +22160,86 @@ snapshots: mdn-data: 2.0.14 source-map: 0.6.1 + css-tree@2.2.1: + dependencies: + mdn-data: 2.0.28 + source-map-js: 1.2.1 + optional: true + + css-tree@3.2.1: + dependencies: + mdn-data: 2.27.1 + source-map-js: 1.2.1 + optional: true + css-what@6.2.2: {} + css-what@7.0.0: + optional: true + cssesc@3.0.0: {} + cssnano-preset-default@7.0.17(postcss@8.5.15): + dependencies: + browserslist: 4.28.2 + css-declaration-sorter: 7.4.0(postcss@8.5.15) + cssnano-utils: 5.0.3(postcss@8.5.15) + postcss: 8.5.15 + postcss-calc: 10.1.1(postcss@8.5.15) + postcss-colormin: 7.0.10(postcss@8.5.15) + postcss-convert-values: 7.0.12(postcss@8.5.15) + postcss-discard-comments: 7.0.8(postcss@8.5.15) + postcss-discard-duplicates: 7.0.4(postcss@8.5.15) + postcss-discard-empty: 7.0.3(postcss@8.5.15) + postcss-discard-overridden: 7.0.3(postcss@8.5.15) + postcss-merge-longhand: 7.0.7(postcss@8.5.15) + postcss-merge-rules: 7.0.11(postcss@8.5.15) + postcss-minify-font-values: 7.0.3(postcss@8.5.15) + postcss-minify-gradients: 7.0.5(postcss@8.5.15) + postcss-minify-params: 7.0.9(postcss@8.5.15) + postcss-minify-selectors: 7.1.2(postcss@8.5.15) + postcss-normalize-charset: 7.0.3(postcss@8.5.15) + postcss-normalize-display-values: 7.0.3(postcss@8.5.15) + postcss-normalize-positions: 7.0.4(postcss@8.5.15) + postcss-normalize-repeat-style: 7.0.4(postcss@8.5.15) + postcss-normalize-string: 7.0.3(postcss@8.5.15) + postcss-normalize-timing-functions: 7.0.3(postcss@8.5.15) + postcss-normalize-unicode: 7.0.9(postcss@8.5.15) + postcss-normalize-url: 7.0.3(postcss@8.5.15) + postcss-normalize-whitespace: 7.0.3(postcss@8.5.15) + postcss-ordered-values: 7.0.4(postcss@8.5.15) + postcss-reduce-initial: 7.0.9(postcss@8.5.15) + postcss-reduce-transforms: 7.0.3(postcss@8.5.15) + postcss-svgo: 7.1.3(postcss@8.5.15) + postcss-unique-selectors: 7.0.7(postcss@8.5.15) + optional: true + + cssnano-preset-lite@4.0.6(postcss@8.5.15): + dependencies: + cssnano-utils: 5.0.3(postcss@8.5.15) + postcss: 8.5.15 + postcss-discard-comments: 7.0.8(postcss@8.5.15) + postcss-discard-empty: 7.0.3(postcss@8.5.15) + postcss-normalize-whitespace: 7.0.3(postcss@8.5.15) + optional: true + + cssnano-utils@5.0.3(postcss@8.5.15): + dependencies: + postcss: 8.5.15 + optional: true + + cssnano@7.1.9(postcss@8.5.15): + dependencies: + cssnano-preset-default: 7.0.17(postcss@8.5.15) + lilconfig: 3.1.3 + postcss: 8.5.15 + optional: true + + csso@5.0.5: + dependencies: + css-tree: 2.2.1 + optional: true + cssstyle@4.6.0: dependencies: '@asamuzakjp/css-color': 3.2.0 @@ -20456,6 +22420,9 @@ snapshots: deep-eql@5.0.2: {} + deep-extend@0.6.0: + optional: true + deep-is@0.1.4: {} deepmerge-ts@7.1.5: {} @@ -20524,12 +22491,18 @@ snapshots: destroy@1.2.0: {} + detect-indent@6.1.0: + optional: true + detect-libc@2.1.2: {} detect-newline@3.1.0: {} detect-node-es@1.1.0: {} + detect-node@2.1.0: + optional: true + devlop@1.1.0: dependencies: dequal: 2.0.3 @@ -20555,6 +22528,12 @@ snapshots: dijkstrajs@1.0.3: {} + display-notification@3.0.0: + dependencies: + escape-string-applescript: 3.0.0 + run-applescript: 5.0.0 + optional: true + dlv@1.1.3: {} doctrine@2.1.0: @@ -20565,11 +22544,21 @@ snapshots: dependencies: esutils: 2.0.3 + doctypes@1.1.0: + optional: true + dom-helpers@5.2.1: dependencies: '@babel/runtime': 7.29.7 csstype: 3.2.3 + dom-serializer@1.4.1: + dependencies: + domelementtype: 2.3.0 + domhandler: 4.3.1 + entities: 2.2.0 + optional: true + dom-serializer@2.0.0: dependencies: domelementtype: 2.3.0 @@ -20578,6 +22567,11 @@ snapshots: domelementtype@2.3.0: {} + domhandler@4.3.1: + dependencies: + domelementtype: 2.3.0 + optional: true + domhandler@5.0.3: dependencies: domelementtype: 2.3.0 @@ -20586,6 +22580,13 @@ snapshots: optionalDependencies: '@types/trusted-types': 2.0.7 + domutils@2.8.0: + dependencies: + dom-serializer: 1.4.1 + domelementtype: 2.3.0 + domhandler: 4.3.1 + optional: true + domutils@3.2.2: dependencies: dom-serializer: 2.0.0 @@ -20656,6 +22657,14 @@ snapshots: '@noble/curves': 1.9.7 '@noble/hashes': 1.8.0 + editorconfig@1.0.7: + dependencies: + '@one-ini/wasm': 0.1.1 + commander: 10.0.1 + minimatch: 9.0.9 + semver: 7.8.2 + optional: true + ee-first@1.1.1: {} effect@3.21.0: @@ -20663,6 +22672,9 @@ snapshots: '@standard-schema/spec': 1.1.0 fast-check: 3.23.2 + ejs@5.0.2: + optional: true + electron-to-chromium@1.5.368: {} emittery@0.13.1: {} @@ -20679,6 +22691,15 @@ snapshots: encodeurl@2.0.0: {} + encoding-japanese@2.2.0: + optional: true + + encoding-sniffer@0.2.1: + dependencies: + iconv-lite: 0.6.3 + whatwg-encoding: 3.1.1 + optional: true + end-of-stream@1.4.5: dependencies: once: 1.4.0 @@ -20686,7 +22707,7 @@ snapshots: engine.io-client@6.6.5: dependencies: '@socket.io/component-emitter': 3.1.2 - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@5.5.0) engine.io-parser: 5.2.3 ws: 8.20.1 xmlhttprequest-ssl: 2.1.2 @@ -20706,7 +22727,7 @@ snapshots: base64id: 2.0.0 cookie: 0.7.2 cors: 2.8.6 - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@5.5.0) engine.io-parser: 5.2.3 ws: 8.21.0 transitivePeerDependencies: @@ -20731,10 +22752,19 @@ snapshots: punycode: 1.4.1 safe-regex-test: 1.1.0 + entities@2.2.0: + optional: true + + entities@3.0.1: + optional: true + entities@4.5.0: {} entities@6.0.1: {} + entities@7.0.1: + optional: true + env-paths@2.2.1: {} environment@1.1.0: {} @@ -20882,10 +22912,45 @@ snapshots: '@esbuild/win32-ia32': 0.21.5 '@esbuild/win32-x64': 0.21.5 + esbuild@0.25.12: + optionalDependencies: + '@esbuild/aix-ppc64': 0.25.12 + '@esbuild/android-arm': 0.25.12 + '@esbuild/android-arm64': 0.25.12 + '@esbuild/android-x64': 0.25.12 + '@esbuild/darwin-arm64': 0.25.12 + '@esbuild/darwin-x64': 0.25.12 + '@esbuild/freebsd-arm64': 0.25.12 + '@esbuild/freebsd-x64': 0.25.12 + '@esbuild/linux-arm': 0.25.12 + '@esbuild/linux-arm64': 0.25.12 + '@esbuild/linux-ia32': 0.25.12 + '@esbuild/linux-loong64': 0.25.12 + '@esbuild/linux-mips64el': 0.25.12 + '@esbuild/linux-ppc64': 0.25.12 + '@esbuild/linux-riscv64': 0.25.12 + '@esbuild/linux-s390x': 0.25.12 + '@esbuild/linux-x64': 0.25.12 + '@esbuild/netbsd-arm64': 0.25.12 + '@esbuild/netbsd-x64': 0.25.12 + '@esbuild/openbsd-arm64': 0.25.12 + '@esbuild/openbsd-x64': 0.25.12 + '@esbuild/openharmony-arm64': 0.25.12 + '@esbuild/sunos-x64': 0.25.12 + '@esbuild/win32-arm64': 0.25.12 + '@esbuild/win32-ia32': 0.25.12 + '@esbuild/win32-x64': 0.25.12 + escalade@3.2.0: {} + escape-goat@3.0.0: + optional: true + escape-html@1.0.3: {} + escape-string-applescript@3.0.0: + optional: true + escape-string-regexp@1.0.5: {} escape-string-regexp@2.0.0: {} @@ -20910,7 +22975,7 @@ snapshots: '@typescript-eslint/parser': 8.60.1(eslint@8.57.1)(typescript@5.9.3) eslint: 8.57.1 eslint-import-resolver-node: 0.3.10 - eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.60.1(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1))(eslint@8.57.1) + eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@8.57.1) eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.60.1(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.1) eslint-plugin-jsx-a11y: 6.10.2(eslint@8.57.1) eslint-plugin-react: 7.37.5(eslint@8.57.1) @@ -20934,10 +22999,10 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.60.1(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1))(eslint@8.57.1): + eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0)(eslint@8.57.1): dependencies: '@nolyfill/is-core-module': 1.0.39 - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@5.5.0) eslint: 8.57.1 get-tsconfig: 4.14.0 is-bun-module: 2.0.0 @@ -20949,14 +23014,14 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-module-utils@2.13.0(@typescript-eslint/parser@8.60.1(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.60.1(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1): + eslint-module-utils@2.13.0(@typescript-eslint/parser@8.60.1(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.1): dependencies: debug: 3.2.7(supports-color@8.1.1) optionalDependencies: '@typescript-eslint/parser': 8.60.1(eslint@8.57.1)(typescript@5.9.3) eslint: 8.57.1 eslint-import-resolver-node: 0.3.10 - eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.60.1(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1))(eslint@8.57.1) + eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@8.57.1) transitivePeerDependencies: - supports-color @@ -20971,7 +23036,7 @@ snapshots: doctrine: 2.1.0 eslint: 8.57.1 eslint-import-resolver-node: 0.3.10 - eslint-module-utils: 2.13.0(@typescript-eslint/parser@8.60.1(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.60.1(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1))(eslint@8.57.1))(eslint@8.57.1) + eslint-module-utils: 2.13.0(@typescript-eslint/parser@8.60.1(eslint@8.57.1)(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1)(eslint@8.57.1) hasown: 2.0.4 is-core-module: 2.16.2 is-glob: 4.0.3 @@ -21065,7 +23130,7 @@ snapshots: ajv: 6.15.0 chalk: 4.1.2 cross-spawn: 7.0.6 - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@5.5.0) doctrine: 3.0.0 escape-string-regexp: 4.0.0 eslint-scope: 7.2.2 @@ -21101,6 +23166,9 @@ snapshots: acorn-jsx: 5.3.2(acorn@8.16.0) eslint-visitor-keys: 3.4.3 + esprima@1.2.5: + optional: true + esprima@4.0.1: {} esquery@1.7.0: @@ -21111,6 +23179,9 @@ snapshots: dependencies: estraverse: 5.3.0 + estraverse@1.9.3: + optional: true + estraverse@4.3.0: {} estraverse@5.3.0: {} @@ -21302,7 +23373,7 @@ snapshots: content-type: 1.0.5 cookie: 0.7.2 cookie-signature: 1.2.2 - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@5.5.0) depd: 2.0.0 encodeurl: 2.0.0 escape-html: 1.0.3 @@ -21329,6 +23400,9 @@ snapshots: exsolve@1.0.8: {} + extend-object@1.0.0: + optional: true + extend-shallow@2.0.1: dependencies: is-extendable: 0.1.1 @@ -21355,7 +23429,7 @@ snapshots: extract-zip@2.0.1: dependencies: - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@5.5.0) get-stream: 5.2.0 yauzl: 2.10.0 optionalDependencies: @@ -21510,7 +23584,7 @@ snapshots: finalhandler@2.1.1: dependencies: - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@5.5.0) encodeurl: 2.0.0 escape-html: 1.0.3 on-finished: 2.4.1 @@ -21537,6 +23611,16 @@ snapshots: path-exists: 5.0.0 unicorn-magic: 0.1.0 + fixpack@4.0.0: + dependencies: + alce: 1.2.0 + chalk: 3.0.0 + detect-indent: 6.1.0 + detect-newline: 3.1.0 + extend-object: 1.0.0 + rc: 1.2.8 + optional: true + flat-cache@3.2.0: dependencies: flatted: 3.4.2 @@ -21578,6 +23662,23 @@ snapshots: forever-agent@0.6.1: {} + fork-ts-checker-webpack-plugin@9.1.0(typescript@5.9.3)(webpack@5.106.0(cssnano@7.1.9(postcss@8.5.15))(postcss@8.5.15)): + dependencies: + '@babel/code-frame': 7.29.7 + chalk: 4.1.2 + chokidar: 4.0.3 + cosmiconfig: 8.3.6(typescript@5.9.3) + deepmerge: 4.3.1 + fs-extra: 10.1.0 + memfs: 3.5.3 + minimatch: 3.1.5 + node-abort-controller: 3.1.1 + schema-utils: 3.3.0 + semver: 7.8.2 + tapable: 2.3.3 + typescript: 5.9.3 + webpack: 5.106.0(cssnano@7.1.9(postcss@8.5.15))(postcss@8.5.15) + fork-ts-checker-webpack-plugin@9.1.0(typescript@5.9.3)(webpack@5.106.0): dependencies: '@babel/code-frame': 7.29.7 @@ -21726,6 +23827,9 @@ snapshots: get-package-type@0.1.0: {} + get-port@5.1.1: + optional: true + get-proto@1.0.1: dependencies: dunder-proto: 1.0.1 @@ -21758,7 +23862,7 @@ snapshots: dependencies: basic-ftp: 5.3.1 data-uri-to-buffer: 6.0.2 - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@5.5.0) transitivePeerDependencies: - supports-color @@ -21810,6 +23914,16 @@ snapshots: package-json-from-dist: 1.0.1 path-scurry: 1.11.1 + glob@11.1.0: + dependencies: + foreground-child: 3.3.1 + jackspeak: 4.2.3 + minimatch: 10.2.5 + minipass: 7.1.3 + package-json-from-dist: 1.0.1 + path-scurry: 2.0.2 + optional: true + glob@13.0.6: dependencies: minimatch: 10.2.5 @@ -22049,6 +24163,15 @@ snapshots: html-to-image@1.11.13: {} + html-to-text@9.0.5: + dependencies: + '@selderee/plugin-htmlparser2': 0.11.0 + deepmerge: 4.3.1 + dom-serializer: 2.0.0 + htmlparser2: 8.0.2 + selderee: 0.11.0 + optional: true + html-url-attributes@3.0.1: {} html2canvas@1.4.1: @@ -22056,6 +24179,46 @@ snapshots: css-line-break: 2.1.0 text-segmentation: 1.0.3 + htmlnano@3.4.0(cssnano@7.1.9(postcss@8.5.15))(postcss@8.5.15)(svgo@4.1.0)(terser@5.48.0)(typescript@5.9.3): + dependencies: + '@types/relateurl': 0.2.33 + commander: 15.0.0 + cosmiconfig: 9.0.1(typescript@5.9.3) + posthtml: 0.16.7 + tinyglobby: 0.2.17 + optionalDependencies: + cssnano: 7.1.9(postcss@8.5.15) + postcss: 8.5.15 + svgo: 4.1.0 + terser: 5.48.0 + transitivePeerDependencies: + - typescript + optional: true + + htmlparser2@7.2.0: + dependencies: + domelementtype: 2.3.0 + domhandler: 4.3.1 + domutils: 2.8.0 + entities: 3.0.1 + optional: true + + htmlparser2@8.0.2: + dependencies: + domelementtype: 2.3.0 + domhandler: 5.0.3 + domutils: 3.2.2 + entities: 4.5.0 + optional: true + + htmlparser2@9.1.0: + dependencies: + domelementtype: 2.3.0 + domhandler: 5.0.3 + domutils: 3.2.2 + entities: 4.5.0 + optional: true + http-errors@2.0.1: dependencies: depd: 2.0.0 @@ -22067,7 +24230,7 @@ snapshots: http-proxy-agent@7.0.2: dependencies: agent-base: 7.1.4 - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@5.5.0) transitivePeerDependencies: - supports-color @@ -22080,14 +24243,14 @@ snapshots: https-proxy-agent@5.0.1: dependencies: agent-base: 6.0.2 - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@5.5.0) transitivePeerDependencies: - supports-color https-proxy-agent@7.0.6: dependencies: agent-base: 7.1.4 - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@5.5.0) transitivePeerDependencies: - supports-color @@ -22168,6 +24331,9 @@ snapshots: inherits@2.0.4: {} + ini@1.3.8: + optional: true + ini@2.0.0: {} ini@4.1.1: {} @@ -22304,6 +24470,12 @@ snapshots: dependencies: is-odd: 0.1.2 + is-expression@4.0.0: + dependencies: + acorn: 7.4.1 + object-assign: 4.1.1 + optional: true + is-extendable@0.1.1: {} is-extendable@1.0.1: @@ -22359,6 +24531,9 @@ snapshots: is-interactive@2.0.0: {} + is-json@2.0.1: + optional: true + is-map@2.0.3: {} is-negative-zero@2.0.3: {} @@ -22400,6 +24575,9 @@ snapshots: is-potential-custom-element-name@1.0.1: {} + is-promise@2.2.2: + optional: true + is-promise@4.0.0: {} is-regex@1.2.1: @@ -22527,7 +24705,7 @@ snapshots: istanbul-lib-source-maps@4.0.1: dependencies: - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@5.5.0) istanbul-lib-coverage: 3.2.2 source-map: 0.6.1 transitivePeerDependencies: @@ -22561,6 +24739,11 @@ snapshots: optionalDependencies: '@pkgjs/parseargs': 0.11.0 + jackspeak@4.2.3: + dependencies: + '@isaacs/cliui': 9.0.0 + optional: true + jay-peg@1.1.1: dependencies: restructure: 3.0.2 @@ -22899,10 +25082,22 @@ snapshots: jquery@3.7.1: {} + js-beautify@1.15.4: + dependencies: + config-chain: 1.1.13 + editorconfig: 1.0.7 + glob: 10.5.0 + js-cookie: 3.0.8 + nopt: 7.2.1 + optional: true + js-cookie@3.0.8: {} js-md5@0.8.3: {} + js-stringify@1.0.2: + optional: true + js-tokens@4.0.0: {} js-yaml@3.14.2: @@ -23038,6 +25233,12 @@ snapshots: json-schema: 0.4.0 verror: 1.10.0 + jstransformer@1.0.0: + dependencies: + is-promise: 2.2.2 + promise: 7.3.1 + optional: true + jsx-ast-utils@3.3.5: dependencies: array-includes: 3.1.9 @@ -23052,6 +25253,16 @@ snapshots: readable-stream: 2.3.8 setimmediate: 1.0.5 + juice@11.1.1: + dependencies: + cheerio: 1.0.0 + commander: 12.1.0 + entities: 7.0.1 + mensch: 0.3.4 + slick: 1.12.2 + web-resource-inliner: 8.0.0 + optional: true + jwa@2.0.1: dependencies: buffer-equal-constant-time: 1.0.1 @@ -23097,6 +25308,9 @@ snapshots: dependencies: readable-stream: 2.3.8 + leac@0.6.0: + optional: true + leven@3.1.0: {} levn@0.4.1: @@ -23114,8 +25328,30 @@ snapshots: dependencies: isomorphic.js: 0.2.5 + libbase64@1.3.0: + optional: true + + libmime@5.3.7: + dependencies: + encoding-japanese: 2.2.0 + iconv-lite: 0.6.3 + libbase64: 1.3.0 + libqp: 2.1.1 + optional: true + + libmime@5.3.8: + dependencies: + encoding-japanese: 2.2.0 + iconv-lite: 0.7.2 + libbase64: 1.3.0 + libqp: 2.1.1 + optional: true + libphonenumber-js@1.13.6: {} + libqp@2.1.1: + optional: true + libreoffice-convert@1.8.1: dependencies: async: 3.2.6 @@ -23183,11 +25419,16 @@ snapshots: lines-and-columns@1.2.4: {} + linkify-it@5.0.0: + dependencies: + uc.micro: 2.1.0 + optional: true + lint-staged@15.5.2: dependencies: chalk: 5.6.2 commander: 13.1.0 - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@5.5.0) execa: 8.0.1 lilconfig: 3.1.3 listr2: 8.3.3 @@ -23198,6 +25439,11 @@ snapshots: transitivePeerDependencies: - supports-color + liquidjs@10.29.0: + dependencies: + commander: 10.0.1 + optional: true + listenercount@1.0.1: {} listr2@8.3.3: @@ -23413,6 +25659,20 @@ snapshots: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 + mailparser@3.9.8: + dependencies: + '@zone-eu/mailsplit': 5.4.8 + encoding-japanese: 2.2.0 + he: 1.2.0 + html-to-text: 9.0.5 + iconv-lite: 0.7.2 + libmime: 5.3.8 + linkify-it: 5.0.0 + nodemailer: 8.0.5 + punycode.js: 2.3.1 + tlds: 1.261.0 + optional: true + make-cancellable-promise@2.0.0: {} make-dir@4.0.0: @@ -23608,6 +25868,12 @@ snapshots: mdn-data@2.0.14: {} + mdn-data@2.0.28: + optional: true + + mdn-data@2.27.1: + optional: true + media-engine@1.0.3: {} media-typer@0.3.0: {} @@ -23618,6 +25884,9 @@ snapshots: dependencies: fs-monkey: 1.1.0 + mensch@0.3.4: + optional: true + meow@12.1.1: {} merge-descriptors@1.0.3: {} @@ -23874,7 +26143,7 @@ snapshots: micromark@4.0.2: dependencies: '@types/debug': 4.1.13 - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@5.5.0) decode-named-character-reference: 1.3.0 devlop: 1.1.0 micromark-core-commonmark: 2.0.3 @@ -23982,6 +26251,496 @@ snapshots: for-in: 1.0.2 is-extendable: 1.0.1 + mjml-accordion@5.4.0(svgo@4.1.0)(terser@5.48.0)(typescript@5.9.3): + dependencies: + '@babel/runtime': 7.29.7 + lodash: 4.18.1 + mjml-core: 5.4.0(svgo@4.1.0)(terser@5.48.0)(typescript@5.9.3) + transitivePeerDependencies: + - purgecss + - relateurl + - srcset + - svgo + - terser + - typescript + - uncss + optional: true + + mjml-body@5.4.0(svgo@4.1.0)(terser@5.48.0)(typescript@5.9.3): + dependencies: + '@babel/runtime': 7.29.7 + lodash: 4.18.1 + mjml-core: 5.4.0(svgo@4.1.0)(terser@5.48.0)(typescript@5.9.3) + transitivePeerDependencies: + - purgecss + - relateurl + - srcset + - svgo + - terser + - typescript + - uncss + optional: true + + mjml-button@5.4.0(svgo@4.1.0)(terser@5.48.0)(typescript@5.9.3): + dependencies: + '@babel/runtime': 7.29.7 + lodash: 4.18.1 + mjml-core: 5.4.0(svgo@4.1.0)(terser@5.48.0)(typescript@5.9.3) + transitivePeerDependencies: + - purgecss + - relateurl + - srcset + - svgo + - terser + - typescript + - uncss + optional: true + + mjml-carousel@5.4.0(svgo@4.1.0)(terser@5.48.0)(typescript@5.9.3): + dependencies: + '@babel/runtime': 7.29.7 + lodash: 4.18.1 + mjml-core: 5.4.0(svgo@4.1.0)(terser@5.48.0)(typescript@5.9.3) + transitivePeerDependencies: + - purgecss + - relateurl + - srcset + - svgo + - terser + - typescript + - uncss + optional: true + + mjml-cli@5.4.0(svgo@4.1.0)(terser@5.48.0)(typescript@5.9.3): + dependencies: + '@babel/runtime': 7.29.7 + chokidar: 4.0.3 + glob: 11.1.0 + lodash: 4.18.1 + minimatch: 10.2.5 + mjml-core: 5.4.0(svgo@4.1.0)(terser@5.48.0)(typescript@5.9.3) + mjml-parser-xml: 5.4.0 + mjml-preset-core: 5.4.0(svgo@4.1.0)(terser@5.48.0)(typescript@5.9.3) + mjml-validator: 5.4.0 + yargs: 17.7.2 + transitivePeerDependencies: + - purgecss + - relateurl + - srcset + - svgo + - terser + - typescript + - uncss + optional: true + + mjml-column@5.4.0(svgo@4.1.0)(terser@5.48.0)(typescript@5.9.3): + dependencies: + '@babel/runtime': 7.29.7 + lodash: 4.18.1 + mjml-core: 5.4.0(svgo@4.1.0)(terser@5.48.0)(typescript@5.9.3) + transitivePeerDependencies: + - purgecss + - relateurl + - srcset + - svgo + - terser + - typescript + - uncss + optional: true + + mjml-core@5.4.0(svgo@4.1.0)(terser@5.48.0)(typescript@5.9.3): + dependencies: + '@babel/runtime': 7.29.7 + cheerio: 1.0.0 + cssnano: 7.1.9(postcss@8.5.15) + cssnano-preset-lite: 4.0.6(postcss@8.5.15) + detect-node: 2.1.0 + htmlnano: 3.4.0(cssnano@7.1.9(postcss@8.5.15))(postcss@8.5.15)(svgo@4.1.0)(terser@5.48.0)(typescript@5.9.3) + js-beautify: 1.15.4 + juice: 11.1.1 + lodash: 4.18.1 + mjml-parser-xml: 5.4.0 + mjml-validator: 5.4.0 + postcss: 8.5.15 + transitivePeerDependencies: + - purgecss + - relateurl + - srcset + - svgo + - terser + - typescript + - uncss + optional: true + + mjml-divider@5.4.0(svgo@4.1.0)(terser@5.48.0)(typescript@5.9.3): + dependencies: + '@babel/runtime': 7.29.7 + lodash: 4.18.1 + mjml-core: 5.4.0(svgo@4.1.0)(terser@5.48.0)(typescript@5.9.3) + transitivePeerDependencies: + - purgecss + - relateurl + - srcset + - svgo + - terser + - typescript + - uncss + optional: true + + mjml-group@5.4.0(svgo@4.1.0)(terser@5.48.0)(typescript@5.9.3): + dependencies: + '@babel/runtime': 7.29.7 + lodash: 4.18.1 + mjml-core: 5.4.0(svgo@4.1.0)(terser@5.48.0)(typescript@5.9.3) + transitivePeerDependencies: + - purgecss + - relateurl + - srcset + - svgo + - terser + - typescript + - uncss + optional: true + + mjml-head-attributes@5.4.0(svgo@4.1.0)(terser@5.48.0)(typescript@5.9.3): + dependencies: + '@babel/runtime': 7.29.7 + lodash: 4.18.1 + mjml-core: 5.4.0(svgo@4.1.0)(terser@5.48.0)(typescript@5.9.3) + transitivePeerDependencies: + - purgecss + - relateurl + - srcset + - svgo + - terser + - typescript + - uncss + optional: true + + mjml-head-breakpoint@5.4.0(svgo@4.1.0)(terser@5.48.0)(typescript@5.9.3): + dependencies: + '@babel/runtime': 7.29.7 + lodash: 4.18.1 + mjml-core: 5.4.0(svgo@4.1.0)(terser@5.48.0)(typescript@5.9.3) + transitivePeerDependencies: + - purgecss + - relateurl + - srcset + - svgo + - terser + - typescript + - uncss + optional: true + + mjml-head-font@5.4.0(svgo@4.1.0)(terser@5.48.0)(typescript@5.9.3): + dependencies: + '@babel/runtime': 7.29.7 + lodash: 4.18.1 + mjml-core: 5.4.0(svgo@4.1.0)(terser@5.48.0)(typescript@5.9.3) + transitivePeerDependencies: + - purgecss + - relateurl + - srcset + - svgo + - terser + - typescript + - uncss + optional: true + + mjml-head-html-attributes@5.4.0(svgo@4.1.0)(terser@5.48.0)(typescript@5.9.3): + dependencies: + '@babel/runtime': 7.29.7 + lodash: 4.18.1 + mjml-core: 5.4.0(svgo@4.1.0)(terser@5.48.0)(typescript@5.9.3) + transitivePeerDependencies: + - purgecss + - relateurl + - srcset + - svgo + - terser + - typescript + - uncss + optional: true + + mjml-head-preview@5.4.0(svgo@4.1.0)(terser@5.48.0)(typescript@5.9.3): + dependencies: + '@babel/runtime': 7.29.7 + lodash: 4.18.1 + mjml-core: 5.4.0(svgo@4.1.0)(terser@5.48.0)(typescript@5.9.3) + transitivePeerDependencies: + - purgecss + - relateurl + - srcset + - svgo + - terser + - typescript + - uncss + optional: true + + mjml-head-style@5.4.0(svgo@4.1.0)(terser@5.48.0)(typescript@5.9.3): + dependencies: + '@babel/runtime': 7.29.7 + lodash: 4.18.1 + mjml-core: 5.4.0(svgo@4.1.0)(terser@5.48.0)(typescript@5.9.3) + transitivePeerDependencies: + - purgecss + - relateurl + - srcset + - svgo + - terser + - typescript + - uncss + optional: true + + mjml-head-title@5.4.0(svgo@4.1.0)(terser@5.48.0)(typescript@5.9.3): + dependencies: + '@babel/runtime': 7.29.7 + lodash: 4.18.1 + mjml-core: 5.4.0(svgo@4.1.0)(terser@5.48.0)(typescript@5.9.3) + transitivePeerDependencies: + - purgecss + - relateurl + - srcset + - svgo + - terser + - typescript + - uncss + optional: true + + mjml-head@5.4.0(svgo@4.1.0)(terser@5.48.0)(typescript@5.9.3): + dependencies: + '@babel/runtime': 7.29.7 + lodash: 4.18.1 + mjml-core: 5.4.0(svgo@4.1.0)(terser@5.48.0)(typescript@5.9.3) + transitivePeerDependencies: + - purgecss + - relateurl + - srcset + - svgo + - terser + - typescript + - uncss + optional: true + + mjml-hero@5.4.0(svgo@4.1.0)(terser@5.48.0)(typescript@5.9.3): + dependencies: + '@babel/runtime': 7.29.7 + lodash: 4.18.1 + mjml-core: 5.4.0(svgo@4.1.0)(terser@5.48.0)(typescript@5.9.3) + transitivePeerDependencies: + - purgecss + - relateurl + - srcset + - svgo + - terser + - typescript + - uncss + optional: true + + mjml-image@5.4.0(svgo@4.1.0)(terser@5.48.0)(typescript@5.9.3): + dependencies: + '@babel/runtime': 7.29.7 + lodash: 4.18.1 + mjml-core: 5.4.0(svgo@4.1.0)(terser@5.48.0)(typescript@5.9.3) + transitivePeerDependencies: + - purgecss + - relateurl + - srcset + - svgo + - terser + - typescript + - uncss + optional: true + + mjml-navbar@5.4.0(svgo@4.1.0)(terser@5.48.0)(typescript@5.9.3): + dependencies: + '@babel/runtime': 7.29.7 + lodash: 4.18.1 + mjml-core: 5.4.0(svgo@4.1.0)(terser@5.48.0)(typescript@5.9.3) + transitivePeerDependencies: + - purgecss + - relateurl + - srcset + - svgo + - terser + - typescript + - uncss + optional: true + + mjml-parser-xml@5.4.0: + dependencies: + '@babel/runtime': 7.29.7 + detect-node: 2.1.0 + htmlparser2: 9.1.0 + lodash: 4.18.1 + optional: true + + mjml-preset-core@5.4.0(svgo@4.1.0)(terser@5.48.0)(typescript@5.9.3): + dependencies: + '@babel/runtime': 7.29.7 + mjml-accordion: 5.4.0(svgo@4.1.0)(terser@5.48.0)(typescript@5.9.3) + mjml-body: 5.4.0(svgo@4.1.0)(terser@5.48.0)(typescript@5.9.3) + mjml-button: 5.4.0(svgo@4.1.0)(terser@5.48.0)(typescript@5.9.3) + mjml-carousel: 5.4.0(svgo@4.1.0)(terser@5.48.0)(typescript@5.9.3) + mjml-column: 5.4.0(svgo@4.1.0)(terser@5.48.0)(typescript@5.9.3) + mjml-divider: 5.4.0(svgo@4.1.0)(terser@5.48.0)(typescript@5.9.3) + mjml-group: 5.4.0(svgo@4.1.0)(terser@5.48.0)(typescript@5.9.3) + mjml-head: 5.4.0(svgo@4.1.0)(terser@5.48.0)(typescript@5.9.3) + mjml-head-attributes: 5.4.0(svgo@4.1.0)(terser@5.48.0)(typescript@5.9.3) + mjml-head-breakpoint: 5.4.0(svgo@4.1.0)(terser@5.48.0)(typescript@5.9.3) + mjml-head-font: 5.4.0(svgo@4.1.0)(terser@5.48.0)(typescript@5.9.3) + mjml-head-html-attributes: 5.4.0(svgo@4.1.0)(terser@5.48.0)(typescript@5.9.3) + mjml-head-preview: 5.4.0(svgo@4.1.0)(terser@5.48.0)(typescript@5.9.3) + mjml-head-style: 5.4.0(svgo@4.1.0)(terser@5.48.0)(typescript@5.9.3) + mjml-head-title: 5.4.0(svgo@4.1.0)(terser@5.48.0)(typescript@5.9.3) + mjml-hero: 5.4.0(svgo@4.1.0)(terser@5.48.0)(typescript@5.9.3) + mjml-image: 5.4.0(svgo@4.1.0)(terser@5.48.0)(typescript@5.9.3) + mjml-navbar: 5.4.0(svgo@4.1.0)(terser@5.48.0)(typescript@5.9.3) + mjml-raw: 5.4.0(svgo@4.1.0)(terser@5.48.0)(typescript@5.9.3) + mjml-section: 5.4.0(svgo@4.1.0)(terser@5.48.0)(typescript@5.9.3) + mjml-social: 5.4.0(svgo@4.1.0)(terser@5.48.0)(typescript@5.9.3) + mjml-spacer: 5.4.0(svgo@4.1.0)(terser@5.48.0)(typescript@5.9.3) + mjml-table: 5.4.0(svgo@4.1.0)(terser@5.48.0)(typescript@5.9.3) + mjml-text: 5.4.0(svgo@4.1.0)(terser@5.48.0)(typescript@5.9.3) + mjml-wrapper: 5.4.0(svgo@4.1.0)(terser@5.48.0)(typescript@5.9.3) + transitivePeerDependencies: + - purgecss + - relateurl + - srcset + - svgo + - terser + - typescript + - uncss + optional: true + + mjml-raw@5.4.0(svgo@4.1.0)(terser@5.48.0)(typescript@5.9.3): + dependencies: + '@babel/runtime': 7.29.7 + lodash: 4.18.1 + mjml-core: 5.4.0(svgo@4.1.0)(terser@5.48.0)(typescript@5.9.3) + transitivePeerDependencies: + - purgecss + - relateurl + - srcset + - svgo + - terser + - typescript + - uncss + optional: true + + mjml-section@5.4.0(svgo@4.1.0)(terser@5.48.0)(typescript@5.9.3): + dependencies: + '@babel/runtime': 7.29.7 + lodash: 4.18.1 + mjml-core: 5.4.0(svgo@4.1.0)(terser@5.48.0)(typescript@5.9.3) + transitivePeerDependencies: + - purgecss + - relateurl + - srcset + - svgo + - terser + - typescript + - uncss + optional: true + + mjml-social@5.4.0(svgo@4.1.0)(terser@5.48.0)(typescript@5.9.3): + dependencies: + '@babel/runtime': 7.29.7 + lodash: 4.18.1 + mjml-core: 5.4.0(svgo@4.1.0)(terser@5.48.0)(typescript@5.9.3) + transitivePeerDependencies: + - purgecss + - relateurl + - srcset + - svgo + - terser + - typescript + - uncss + optional: true + + mjml-spacer@5.4.0(svgo@4.1.0)(terser@5.48.0)(typescript@5.9.3): + dependencies: + '@babel/runtime': 7.29.7 + lodash: 4.18.1 + mjml-core: 5.4.0(svgo@4.1.0)(terser@5.48.0)(typescript@5.9.3) + transitivePeerDependencies: + - purgecss + - relateurl + - srcset + - svgo + - terser + - typescript + - uncss + optional: true + + mjml-table@5.4.0(svgo@4.1.0)(terser@5.48.0)(typescript@5.9.3): + dependencies: + '@babel/runtime': 7.29.7 + lodash: 4.18.1 + mjml-core: 5.4.0(svgo@4.1.0)(terser@5.48.0)(typescript@5.9.3) + transitivePeerDependencies: + - purgecss + - relateurl + - srcset + - svgo + - terser + - typescript + - uncss + optional: true + + mjml-text@5.4.0(svgo@4.1.0)(terser@5.48.0)(typescript@5.9.3): + dependencies: + '@babel/runtime': 7.29.7 + lodash: 4.18.1 + mjml-core: 5.4.0(svgo@4.1.0)(terser@5.48.0)(typescript@5.9.3) + transitivePeerDependencies: + - purgecss + - relateurl + - srcset + - svgo + - terser + - typescript + - uncss + optional: true + + mjml-validator@5.4.0: + dependencies: + '@babel/runtime': 7.29.7 + optional: true + + mjml-wrapper@5.4.0(svgo@4.1.0)(terser@5.48.0)(typescript@5.9.3): + dependencies: + '@babel/runtime': 7.29.7 + lodash: 4.18.1 + mjml-core: 5.4.0(svgo@4.1.0)(terser@5.48.0)(typescript@5.9.3) + mjml-section: 5.4.0(svgo@4.1.0)(terser@5.48.0)(typescript@5.9.3) + transitivePeerDependencies: + - purgecss + - relateurl + - srcset + - svgo + - terser + - typescript + - uncss + optional: true + + mjml@5.4.0(svgo@4.1.0)(terser@5.48.0)(typescript@5.9.3): + dependencies: + '@babel/runtime': 7.29.7 + mjml-cli: 5.4.0(svgo@4.1.0)(terser@5.48.0)(typescript@5.9.3) + mjml-core: 5.4.0(svgo@4.1.0)(terser@5.48.0)(typescript@5.9.3) + mjml-preset-core: 5.4.0(svgo@4.1.0)(terser@5.48.0)(typescript@5.9.3) + mjml-validator: 5.4.0 + transitivePeerDependencies: + - purgecss + - relateurl + - srcset + - svgo + - terser + - typescript + - uncss + optional: true + mkdirp@0.5.6: dependencies: minimist: 1.2.8 @@ -24195,6 +26954,16 @@ snapshots: node-releases@2.0.47: {} + nodemailer@8.0.5: + optional: true + + nodemailer@9.0.5: {} + + nopt@7.2.1: + dependencies: + abbrev: 2.0.0 + optional: true + normalize-path@3.0.0: {} normalize-svg-path@1.1.0: @@ -24220,6 +26989,24 @@ snapshots: number-is-nan@1.0.1: {} + nunjucks@3.2.4(chokidar@3.6.0): + dependencies: + a-sync-waterfall: 1.0.1 + asap: 2.0.6 + commander: 5.1.0 + optionalDependencies: + chokidar: 3.6.0 + optional: true + + nunjucks@3.2.4(chokidar@4.0.3): + dependencies: + a-sync-waterfall: 1.0.1 + asap: 2.0.6 + commander: 5.1.0 + optionalDependencies: + chokidar: 4.0.3 + optional: true + nwsapi@2.2.24: {} nypm@0.6.6: @@ -24321,6 +27108,12 @@ snapshots: powershell-utils: 0.1.0 wsl-utils: 0.3.1 + open@7.4.2: + dependencies: + is-docker: 2.2.1 + is-wsl: 2.2.0 + optional: true + open@8.4.2: dependencies: define-lazy-prop: 2.0.0 @@ -24370,6 +27163,14 @@ snapshots: object-keys: 1.1.1 safe-push-apply: 1.0.0 + p-event@4.2.0: + dependencies: + p-timeout: 3.2.0 + optional: true + + p-finally@1.0.0: + optional: true + p-limit@2.3.0: dependencies: p-try: 2.2.0 @@ -24394,13 +27195,23 @@ snapshots: dependencies: p-limit: 4.0.0 + p-timeout@3.2.0: + dependencies: + p-finally: 1.0.0 + optional: true + p-try@2.2.0: {} + p-wait-for@3.2.0: + dependencies: + p-timeout: 3.2.0 + optional: true + pac-proxy-agent@7.2.0: dependencies: '@tootallnate/quickjs-emscripten': 0.23.0 agent-base: 7.1.4 - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@5.5.0) get-uri: 6.0.5 http-proxy-agent: 7.0.2 https-proxy-agent: 7.0.6 @@ -24449,10 +27260,27 @@ snapshots: parse-svg-path@0.1.2: {} + parse5-htmlparser2-tree-adapter@7.1.0: + dependencies: + domhandler: 5.0.3 + parse5: 7.3.0 + optional: true + + parse5-parser-stream@7.1.2: + dependencies: + parse5: 7.3.0 + optional: true + parse5@7.3.0: dependencies: entities: 6.0.1 + parseley@0.12.1: + dependencies: + leac: 0.6.0 + peberminta: 0.9.0 + optional: true + parseurl@1.3.3: {} pascal-case@3.1.2: @@ -24533,6 +27361,9 @@ snapshots: optionalDependencies: '@napi-rs/canvas': 0.1.100 + peberminta@0.9.0: + optional: true + peek-readable@5.4.2: {} pend@1.2.0: {} @@ -24620,6 +27451,50 @@ snapshots: possible-typed-array-names@1.1.0: {} + postcss-calc@10.1.1(postcss@8.5.15): + dependencies: + postcss: 8.5.15 + postcss-selector-parser: 7.1.1 + postcss-value-parser: 4.2.0 + optional: true + + postcss-colormin@7.0.10(postcss@8.5.15): + dependencies: + '@colordx/core': 5.6.0 + browserslist: 4.28.2 + caniuse-api: 3.0.0 + postcss: 8.5.15 + postcss-value-parser: 4.2.0 + optional: true + + postcss-convert-values@7.0.12(postcss@8.5.15): + dependencies: + browserslist: 4.28.2 + postcss: 8.5.15 + postcss-value-parser: 4.2.0 + optional: true + + postcss-discard-comments@7.0.8(postcss@8.5.15): + dependencies: + postcss: 8.5.15 + postcss-selector-parser: 7.1.1 + optional: true + + postcss-discard-duplicates@7.0.4(postcss@8.5.15): + dependencies: + postcss: 8.5.15 + optional: true + + postcss-discard-empty@7.0.3(postcss@8.5.15): + dependencies: + postcss: 8.5.15 + optional: true + + postcss-discard-overridden@7.0.3(postcss@8.5.15): + dependencies: + postcss: 8.5.15 + optional: true + postcss-import@15.1.0(postcss@8.5.15): dependencies: postcss: 8.5.15 @@ -24640,11 +27515,132 @@ snapshots: postcss: 8.5.15 yaml: 2.9.0 + postcss-merge-longhand@7.0.7(postcss@8.5.15): + dependencies: + postcss: 8.5.15 + postcss-value-parser: 4.2.0 + stylehacks: 7.0.11(postcss@8.5.15) + optional: true + + postcss-merge-rules@7.0.11(postcss@8.5.15): + dependencies: + browserslist: 4.28.2 + caniuse-api: 3.0.0 + cssnano-utils: 5.0.3(postcss@8.5.15) + postcss: 8.5.15 + postcss-selector-parser: 7.1.1 + optional: true + + postcss-minify-font-values@7.0.3(postcss@8.5.15): + dependencies: + postcss: 8.5.15 + postcss-value-parser: 4.2.0 + optional: true + + postcss-minify-gradients@7.0.5(postcss@8.5.15): + dependencies: + '@colordx/core': 5.6.0 + cssnano-utils: 5.0.3(postcss@8.5.15) + postcss: 8.5.15 + postcss-value-parser: 4.2.0 + optional: true + + postcss-minify-params@7.0.9(postcss@8.5.15): + dependencies: + browserslist: 4.28.2 + cssnano-utils: 5.0.3(postcss@8.5.15) + postcss: 8.5.15 + postcss-value-parser: 4.2.0 + optional: true + + postcss-minify-selectors@7.1.2(postcss@8.5.15): + dependencies: + browserslist: 4.28.2 + caniuse-api: 3.0.0 + cssesc: 3.0.0 + postcss: 8.5.15 + postcss-selector-parser: 7.1.1 + optional: true + postcss-nested@6.2.0(postcss@8.5.15): dependencies: postcss: 8.5.15 postcss-selector-parser: 6.1.2 + postcss-normalize-charset@7.0.3(postcss@8.5.15): + dependencies: + postcss: 8.5.15 + optional: true + + postcss-normalize-display-values@7.0.3(postcss@8.5.15): + dependencies: + postcss: 8.5.15 + postcss-value-parser: 4.2.0 + optional: true + + postcss-normalize-positions@7.0.4(postcss@8.5.15): + dependencies: + postcss: 8.5.15 + postcss-value-parser: 4.2.0 + optional: true + + postcss-normalize-repeat-style@7.0.4(postcss@8.5.15): + dependencies: + postcss: 8.5.15 + postcss-value-parser: 4.2.0 + optional: true + + postcss-normalize-string@7.0.3(postcss@8.5.15): + dependencies: + postcss: 8.5.15 + postcss-value-parser: 4.2.0 + optional: true + + postcss-normalize-timing-functions@7.0.3(postcss@8.5.15): + dependencies: + postcss: 8.5.15 + postcss-value-parser: 4.2.0 + optional: true + + postcss-normalize-unicode@7.0.9(postcss@8.5.15): + dependencies: + browserslist: 4.28.2 + postcss: 8.5.15 + postcss-value-parser: 4.2.0 + optional: true + + postcss-normalize-url@7.0.3(postcss@8.5.15): + dependencies: + postcss: 8.5.15 + postcss-value-parser: 4.2.0 + optional: true + + postcss-normalize-whitespace@7.0.3(postcss@8.5.15): + dependencies: + postcss: 8.5.15 + postcss-value-parser: 4.2.0 + optional: true + + postcss-ordered-values@7.0.4(postcss@8.5.15): + dependencies: + cssnano-utils: 5.0.3(postcss@8.5.15) + postcss: 8.5.15 + postcss-value-parser: 4.2.0 + optional: true + + postcss-reduce-initial@7.0.9(postcss@8.5.15): + dependencies: + browserslist: 4.28.2 + caniuse-api: 3.0.0 + postcss: 8.5.15 + optional: true + + postcss-reduce-transforms@7.0.3(postcss@8.5.15): + dependencies: + postcss: 8.5.15 + postcss-value-parser: 4.2.0 + optional: true + postcss-selector-parser@6.1.2: dependencies: cssesc: 3.0.0 @@ -24655,6 +27651,19 @@ snapshots: cssesc: 3.0.0 util-deprecate: 1.0.2 + postcss-svgo@7.1.3(postcss@8.5.15): + dependencies: + postcss: 8.5.15 + postcss-value-parser: 4.2.0 + svgo: 4.1.0 + optional: true + + postcss-unique-selectors@7.0.7(postcss@8.5.15): + dependencies: + postcss: 8.5.15 + postcss-selector-parser: 7.1.1 + optional: true + postcss-value-parser@4.2.0: {} postcss@8.4.31: @@ -24692,6 +27701,22 @@ snapshots: transitivePeerDependencies: - preact-render-to-string + posthtml-parser@0.11.0: + dependencies: + htmlparser2: 7.2.0 + optional: true + + posthtml-render@3.0.0: + dependencies: + is-json: 2.0.1 + optional: true + + posthtml@0.16.7: + dependencies: + posthtml-parser: 0.11.0 + posthtml-render: 3.0.0 + optional: true + powershell-utils@0.1.0: {} preact@10.29.7: {} @@ -24712,6 +27737,20 @@ snapshots: dependencies: parse-ms: 4.0.0 + preview-email@3.3.0: + dependencies: + ci-info: 3.9.0 + display-notification: 3.0.0 + fixpack: 4.0.0 + get-port: 5.1.1 + mailparser: 3.9.8 + nodemailer: 9.0.5 + open: 7.4.2 + p-event: 4.2.0 + p-wait-for: 3.2.0 + pug: 3.0.4 + optional: true + prisma@6.19.3(typescript@5.9.3): dependencies: '@prisma/config': 6.19.3 @@ -24729,6 +27768,11 @@ snapshots: promise-breaker@6.0.0: {} + promise@7.3.1: + dependencies: + asap: 2.0.6 + optional: true + prompts@2.4.2: dependencies: kleur: 3.0.3 @@ -24742,6 +27786,9 @@ snapshots: property-information@7.2.0: {} + proto-list@1.2.4: + optional: true + proxy-addr@2.0.7: dependencies: forwarded: 0.2.0 @@ -24750,7 +27797,7 @@ snapshots: proxy-agent@6.5.0: dependencies: agent-base: 7.1.4 - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@5.5.0) http-proxy-agent: 7.0.2 https-proxy-agent: 7.0.6 lru-cache: 7.18.3 @@ -24766,11 +27813,93 @@ snapshots: proxy-from-env@2.1.0: {} + pug-attrs@3.0.0: + dependencies: + constantinople: 4.0.1 + js-stringify: 1.0.2 + pug-runtime: 3.0.1 + optional: true + + pug-code-gen@3.0.4: + dependencies: + constantinople: 4.0.1 + doctypes: 1.1.0 + js-stringify: 1.0.2 + pug-attrs: 3.0.0 + pug-error: 2.1.0 + pug-runtime: 3.0.1 + void-elements: 3.1.0 + with: 7.0.2 + optional: true + + pug-error@2.1.0: + optional: true + + pug-filters@4.0.0: + dependencies: + constantinople: 4.0.1 + jstransformer: 1.0.0 + pug-error: 2.1.0 + pug-walk: 2.0.0 + resolve: 1.22.12 + optional: true + + pug-lexer@5.0.1: + dependencies: + character-parser: 2.2.0 + is-expression: 4.0.0 + pug-error: 2.1.0 + optional: true + + pug-linker@4.0.0: + dependencies: + pug-error: 2.1.0 + pug-walk: 2.0.0 + optional: true + + pug-load@3.0.0: + dependencies: + object-assign: 4.1.1 + pug-walk: 2.0.0 + optional: true + + pug-parser@6.0.0: + dependencies: + pug-error: 2.1.0 + token-stream: 1.0.0 + optional: true + + pug-runtime@3.0.1: + optional: true + + pug-strip-comments@2.0.0: + dependencies: + pug-error: 2.1.0 + optional: true + + pug-walk@2.0.0: + optional: true + + pug@3.0.4: + dependencies: + pug-code-gen: 3.0.4 + pug-filters: 4.0.0 + pug-lexer: 5.0.1 + pug-linker: 4.0.0 + pug-load: 3.0.0 + pug-parser: 6.0.0 + pug-runtime: 3.0.1 + pug-strip-comments: 2.0.0 + optional: true + pump@3.0.4: dependencies: end-of-stream: 1.4.5 once: 1.4.0 + punycode.js@2.3.1: + optional: true + punycode@1.4.1: {} punycode@2.3.1: {} @@ -24779,7 +27908,7 @@ snapshots: dependencies: '@puppeteer/browsers': 2.13.2 chromium-bidi: 14.0.0(devtools-protocol@0.0.1608973) - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@5.5.0) devtools-protocol: 0.0.1608973 typed-query-selector: 2.12.2 webdriver-bidi-protocol: 0.4.1 @@ -25014,6 +28143,14 @@ snapshots: defu: 6.1.7 destr: 2.0.5 + rc@1.2.8: + dependencies: + deep-extend: 0.6.0 + ini: 1.3.8 + minimist: 1.2.8 + strip-json-comments: 2.0.1 + optional: true + react-cookie@8.1.2(@types/react@18.3.31)(react@19.2.6): dependencies: '@types/hoist-non-react-statics': 3.3.7(@types/react@18.3.31) @@ -25032,15 +28169,6 @@ snapshots: - '@babel/core' - react-is - react-css-nocode-editor@1.0.13(react-dom@19.2.6(react@19.2.6))(react-is@19.2.7)(react@19.2.6): - dependencies: - react: 19.2.6 - react-dom: 19.2.6(react@19.2.6) - styled-components: 5.3.11(react-dom@19.2.6(react@19.2.6))(react-is@19.2.7)(react@19.2.6) - transitivePeerDependencies: - - '@babel/core' - - react-is - react-day-picker@8.10.2(date-fns@3.6.0)(react@19.2.6): dependencies: date-fns: 3.6.0 @@ -25651,7 +28779,7 @@ snapshots: router@2.2.0: dependencies: - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@5.5.0) depd: 2.0.0 is-promise: 4.0.0 parseurl: 1.3.3 @@ -25663,6 +28791,11 @@ snapshots: rrweb-cssom@0.8.0: {} + run-applescript@5.0.0: + dependencies: + execa: 5.1.1 + optional: true + run-applescript@7.1.0: {} run-async@0.1.0: @@ -25718,6 +28851,9 @@ snapshots: sax@1.6.0: {} + sax@1.6.1: + optional: true + saxes@5.0.1: dependencies: xmlchars: 2.2.0 @@ -25747,6 +28883,11 @@ snapshots: ajv-formats: 2.1.1(ajv@8.20.0) ajv-keywords: 5.1.0(ajv@8.20.0) + selderee@0.11.0: + dependencies: + parseley: 0.12.1 + optional: true + self-closing-tags@1.0.1: {} semver@6.3.1: {} @@ -25773,7 +28914,7 @@ snapshots: send@1.2.1: dependencies: - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@5.5.0) encodeurl: 2.0.0 escape-html: 1.0.3 etag: 1.8.1 @@ -25960,6 +29101,9 @@ snapshots: ansi-styles: 6.2.3 is-fullwidth-code-point: 5.1.0 + slick@1.12.2: + optional: true + smart-buffer@4.2.0: {} smob@1.6.2: {} @@ -25989,7 +29133,7 @@ snapshots: socket.io-adapter@2.5.8: dependencies: - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@5.5.0) ws: 8.21.0 transitivePeerDependencies: - bufferutil @@ -25999,7 +29143,7 @@ snapshots: socket.io-client@4.8.3: dependencies: '@socket.io/component-emitter': 3.1.2 - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@5.5.0) engine.io-client: 6.6.5 socket.io-parser: 4.2.6 transitivePeerDependencies: @@ -26010,7 +29154,7 @@ snapshots: socket.io-parser@4.2.6: dependencies: '@socket.io/component-emitter': 3.1.2 - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@5.5.0) transitivePeerDependencies: - supports-color @@ -26019,7 +29163,7 @@ snapshots: accepts: 1.3.8 base64id: 2.0.0 cors: 2.8.6 - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@5.5.0) engine.io: 6.6.9 socket.io-adapter: 2.5.8 socket.io-parser: 4.2.6 @@ -26031,7 +29175,7 @@ snapshots: socks-proxy-agent@8.0.5: dependencies: agent-base: 7.1.4 - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@5.5.0) socks: 2.8.9 transitivePeerDependencies: - supports-color @@ -26281,6 +29425,9 @@ snapshots: strip-final-newline@4.0.0: {} + strip-json-comments@2.0.1: + optional: true + strip-json-comments@3.1.1: {} striptags@3.2.0: {} @@ -26326,24 +29473,6 @@ snapshots: transitivePeerDependencies: - '@babel/core' - styled-components@5.3.11(react-dom@19.2.6(react@19.2.6))(react-is@19.2.7)(react@19.2.6): - dependencies: - '@babel/helper-module-imports': 7.29.7(supports-color@5.5.0) - '@babel/traverse': 7.29.7(supports-color@5.5.0) - '@emotion/is-prop-valid': 1.4.0 - '@emotion/stylis': 0.8.5 - '@emotion/unitless': 0.7.5 - babel-plugin-styled-components: 2.3.0(styled-components@5.3.11(react-dom@19.2.6(react@19.2.6))(react-is@19.2.7)(react@19.2.6))(supports-color@5.5.0) - css-to-react-native: 3.2.0 - hoist-non-react-statics: 3.3.2 - react: 19.2.6 - react-dom: 19.2.6(react@19.2.6) - react-is: 19.2.7 - shallowequal: 1.1.0 - supports-color: 5.5.0 - transitivePeerDependencies: - - '@babel/core' - styled-jsx@5.1.1(babel-plugin-macros@3.1.0)(react@18.3.1): dependencies: client-only: 0.0.1 @@ -26351,6 +29480,13 @@ snapshots: optionalDependencies: babel-plugin-macros: 3.1.0 + stylehacks@7.0.11(postcss@8.5.15): + dependencies: + browserslist: 4.28.2 + postcss: 8.5.15 + postcss-selector-parser: 7.1.1 + optional: true + stylis@4.2.0: {} success-symbol@0.1.0: {} @@ -26369,7 +29505,7 @@ snapshots: dependencies: component-emitter: 1.3.1 cookiejar: 2.1.4 - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@5.5.0) fast-safe-stringify: 2.1.1 form-data: 4.0.5 formidable: 3.5.4 @@ -26408,6 +29544,17 @@ snapshots: svg-pathdata@6.0.3: optional: true + svgo@4.1.0: + dependencies: + commander: 11.1.0 + css-select: 6.0.0 + css-tree: 3.2.1 + css-what: 7.0.0 + csso: 5.0.5 + picocolors: 1.1.1 + sax: 1.6.1 + optional: true + swagger-ui-dist@5.17.14: {} swagger-ui-dist@5.32.6: @@ -26509,6 +29656,17 @@ snapshots: - bare-abort-controller - react-native-b4a + terser-webpack-plugin@5.6.1(cssnano@7.1.9(postcss@8.5.15))(postcss@8.5.15)(webpack@5.106.0(cssnano@7.1.9(postcss@8.5.15))(postcss@8.5.15)): + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + jest-worker: 27.5.1 + schema-utils: 4.3.3 + terser: 5.48.0 + webpack: 5.106.0(cssnano@7.1.9(postcss@8.5.15))(postcss@8.5.15) + optionalDependencies: + cssnano: 7.1.9(postcss@8.5.15) + postcss: 8.5.15 + terser-webpack-plugin@5.6.1(webpack@5.106.0): dependencies: '@jridgewell/trace-mapping': 0.3.31 @@ -26594,6 +29752,9 @@ snapshots: tinyspy@3.0.2: {} + tlds@1.261.0: + optional: true + tldts-core@6.1.86: {} tldts-core@7.4.2: {} @@ -26640,6 +29801,9 @@ snapshots: toidentifier@1.0.1: {} + token-stream@1.0.0: + optional: true + token-types@5.0.1: dependencies: '@tokenizer/token': 0.3.0 @@ -26882,7 +30046,7 @@ snapshots: app-root-path: 3.1.0 buffer: 6.0.3 dayjs: 1.11.21 - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@5.5.0) dedent: 1.7.2(babel-plugin-macros@3.1.0) dotenv: 16.6.1 glob: 10.5.0 @@ -26906,7 +30070,7 @@ snapshots: app-root-path: 3.1.0 buffer: 6.0.3 dayjs: 1.11.21 - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@5.5.0) dedent: 1.7.2(babel-plugin-macros@3.1.0) dotenv: 16.6.1 glob: 10.5.0 @@ -26925,6 +30089,9 @@ snapshots: typescript@5.9.3: {} + uc.micro@2.1.0: + optional: true + uglify-js@3.19.3: optional: true @@ -26945,6 +30112,9 @@ snapshots: undici-types@7.18.2: {} + undici@6.28.0: + optional: true + unicode-properties@1.4.1: dependencies: base64-js: 1.5.1 @@ -27193,6 +30363,9 @@ snapshots: '@types/istanbul-lib-coverage': 2.0.6 convert-source-map: 2.0.0 + valid-data-url@3.0.1: + optional: true + validate-npm-package-name@7.0.2: {} validator@13.15.35: {} @@ -27267,7 +30440,7 @@ snapshots: vite-node@2.1.9(@types/node@22.20.1)(lightningcss@1.32.0)(terser@5.48.0): dependencies: cac: 6.7.14 - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@5.5.0) es-module-lexer: 1.7.0 pathe: 1.1.2 vite: 5.4.21(@types/node@22.20.1)(lightningcss@1.32.0)(terser@5.48.0) @@ -27285,7 +30458,7 @@ snapshots: vite-node@2.1.9(@types/node@24.13.1)(lightningcss@1.32.0)(terser@5.48.0): dependencies: cac: 6.7.14 - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@5.5.0) es-module-lexer: 1.7.0 pathe: 1.1.2 vite: 5.4.21(@types/node@24.13.1)(lightningcss@1.32.0)(terser@5.48.0) @@ -27322,6 +30495,22 @@ snapshots: lightningcss: 1.32.0 terser: 5.48.0 + vite@6.4.3(@types/node@20.19.42)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.48.0)(yaml@2.9.0): + dependencies: + esbuild: 0.25.12 + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 + postcss: 8.5.15 + rollup: 4.61.1 + tinyglobby: 0.2.17 + optionalDependencies: + '@types/node': 20.19.42 + fsevents: 2.3.3 + jiti: 2.7.0 + lightningcss: 1.32.0 + terser: 5.48.0 + yaml: 2.9.0 + vitest@2.1.9(@types/node@22.20.1)(jsdom@25.0.1)(lightningcss@1.32.0)(msw@2.14.6(@types/node@22.20.1)(typescript@5.9.3))(terser@5.48.0): dependencies: '@vitest/expect': 2.1.9 @@ -27332,7 +30521,7 @@ snapshots: '@vitest/spy': 2.1.9 '@vitest/utils': 2.1.9 chai: 5.3.3 - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@5.5.0) expect-type: 1.3.0 magic-string: 0.30.21 pathe: 1.1.2 @@ -27368,7 +30557,7 @@ snapshots: '@vitest/spy': 2.1.9 '@vitest/utils': 2.1.9 chai: 5.3.3 - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3(supports-color@5.5.0) expect-type: 1.3.0 magic-string: 0.30.21 pathe: 1.1.2 @@ -27440,6 +30629,15 @@ snapshots: optionalDependencies: '@zxing/text-encoding': 0.9.0 + web-resource-inliner@8.0.0: + dependencies: + ansi-colors: 4.1.3 + escape-goat: 3.0.0 + htmlparser2: 9.1.0 + mime: 2.6.0 + valid-data-url: 3.0.1 + optional: true + web-streams-polyfill@3.3.3: {} web-vitals@5.3.0: {} @@ -27493,6 +30691,47 @@ snapshots: - postcss - uglify-js + webpack@5.106.0(cssnano@7.1.9(postcss@8.5.15))(postcss@8.5.15): + dependencies: + '@types/eslint-scope': 3.7.7 + '@types/estree': 1.0.9 + '@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.4(acorn@8.16.0) + browserslist: 4.28.2 + chrome-trace-event: 1.0.4 + enhanced-resolve: 5.23.0 + es-module-lexer: 2.1.0 + eslint-scope: 5.1.1 + events: 3.3.0 + glob-to-regexp: 0.4.1 + graceful-fs: 4.2.11 + json-parse-even-better-errors: 2.3.1 + loader-runner: 4.3.2 + mime-types: 2.1.35 + neo-async: 2.6.2 + schema-utils: 4.3.3 + tapable: 2.3.3 + terser-webpack-plugin: 5.6.1(cssnano@7.1.9(postcss@8.5.15))(postcss@8.5.15)(webpack@5.106.0(cssnano@7.1.9(postcss@8.5.15))(postcss@8.5.15)) + watchpack: 2.5.1 + webpack-sources: 3.5.0 + transitivePeerDependencies: + - '@minify-html/node' + - '@swc/core' + - '@swc/css' + - '@swc/html' + - clean-css + - cssnano + - csso + - esbuild + - html-minifier-terser + - lightningcss + - postcss + - uglify-js + whatwg-encoding@3.1.1: dependencies: iconv-lite: 0.6.3 @@ -27560,6 +30799,14 @@ snapshots: siginfo: 2.0.0 stackback: 0.0.2 + with@7.0.2: + dependencies: + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 + assert-never: 1.4.0 + babel-walk: 3.0.0-canary-5 + optional: true + wmf@1.0.2: {} word-wrap@1.2.5: {}