--- phase: 03-operational-modules plan: "05" subsystem: api tags: [prisma, postgresql, technician, compensation, per-job, salary, hybrid, job-type-rates, tenant-isolation] # Dependency graph requires: - phase: 03-04 provides: JobOrder model (COMPLETED status used for per-job compensation calculation), job type string on each order - phase: 03-01 provides: Zone model (technicianProfile.zoneId FK) - phase: 01-04 provides: CASL AppSubjects/AppActions types that TechnicianProfile and JobTypeRate are added to provides: - TechnicianProfile model with CompensationModel enum (PER_JOB/SALARY/HYBRID), skills[], zoneId, monthlySalary - JobTypeRate model for tenant-level per-job bonus rates by jobType string - technician-service.ts: createTechnicianProfile, updateTechnicianProfile, getTechnicianProfile, getTechnicianProfileByUserId, listTechnicians - compensation-service.ts: getCompensationSummary (per-technician totals for all 3 models), getTechnicianCompensationDetail (job-by-job) - 6 API routes: GET/POST /api/technicians, GET/PUT /api/technicians/[id], GET /api/technicians/[id]/compensation, GET/POST /api/job-type-rates, PUT /api/job-type-rates/[id], GET /api/reports/compensation - 27 integration tests: all 3 compensation models, edge cases, cross-tenant isolation affects: - Phase 4 (inventory may link to technicianProfiles for equipment checkout) - Phase 5 (reporting dashboards may aggregate compensation data) # Tech tracking tech-stack: added: [] patterns: - "Missing rate = 0: rateMap.get(jobType) ?? new Prisma.Decimal(0) — no error on unknown job types" - "CompensationModel switch in service: PER_JOB baseSalary=0, SALARY jobBonus=0, HYBRID both" - "Cross-model validation at service layer: monthlySalary required check before profile creation" - "Prisma one-to-many for User.technicianProfiles[]: compound @@unique([tenantId, userId]) enforces one-per-tenant at DB level" key-files: created: - prisma/migrations/20260305000851_add_technician_profiles/migration.sql - src/lib/services/technician-service.ts - src/lib/services/compensation-service.ts - src/app/api/technicians/route.ts - src/app/api/technicians/[id]/route.ts - src/app/api/technicians/[id]/compensation/route.ts - src/app/api/job-type-rates/route.ts - src/app/api/job-type-rates/[id]/route.ts - src/app/api/reports/compensation/route.ts - src/lib/__tests__/compensation-service.test.ts modified: - prisma/schema.prisma - src/lib/prisma-tenant.ts - src/lib/casl/types.ts - src/lib/casl/permissions.ts key-decisions: - "User.technicianProfiles as one-to-many (not one-to-one): Prisma one-to-one requires @unique on FK field, incompatible with compound @@unique([tenantId,userId]); one-to-many + findFirst enforces same logical constraint" - "Missing job type rate defaults to 0: rateMap.get() ?? Decimal(0) — no throws on unknown job types per plan spec" - "SALARY model detail includes job list with rate=0 per job: preserves consistent detail shape across all models" - "TechnicianProfile and JobTypeRate added to CASL AppSubjects: ADMIN gets manage-all, OFFICE_STAFF gets read+update on TechnicianProfile and read on JobTypeRate" - "Cleanup order in tests: jobOrders -> tickets -> ticketCategories -> jobTypeRates -> technicianProfiles -> zoneAssignments -> subscribers -> zones -> servicePlans -> users -> tenant" patterns-established: - "Compensation calculation: load profiles, load all rates into Map, iterate profiles with per-job filter by completedAt range" - "Rate lookup: buildRateMap() returns Map; missing key = 0 (not error)" - "All 3 models handled by model-switch logic in service: if PER_JOB|HYBRID -> jobBonus else 0; if SALARY|HYBRID -> baseSalary else 0" # Metrics duration: 9min completed: 2026-03-05 --- # Phase 3 Plan 05: Technician Management Summary **TechnicianProfile/JobTypeRate models with hybrid compensation engine (PER_JOB/SALARY/HYBRID), missing-rate-defaults-to-0 edge case, 6 API routes, and 27 passing integration tests — Phase 3 complete** ## Performance - **Duration:** 9 min - **Started:** 2026-03-05T00:07:21Z - **Completed:** 2026-03-05T00:17:08Z - **Tasks:** 2 - **Files modified:** 14 ## Accomplishments - TechnicianProfile model with CompensationModel enum (PER_JOB/SALARY/HYBRID), skills array, zone FK, monthlySalary; JobTypeRate model with @@unique([tenantId,jobType]); migration applied - CompensationService calculates all 3 models: PER_JOB (job bonuses only), SALARY (base only), HYBRID (base + bonuses); missing rates default to 0 (not error); only COMPLETED jobs in date range count - 6 API routes: full CRUD for technician profiles, job type rates CRUD, compensation detail per technician, compensation summary report - 27 integration tests covering all 3 models, missing rate edge case, CANCELLED/PENDING exclusion, date range filtering, summary + detail breakdown, cross-tenant isolation — all green on first run ## Task Commits Each task was committed atomically: 1. **Task 1: TechnicianProfile/JobTypeRate schema, migration, tenant scoping** - `230b0ec` (feat) 2. **Task 2: Technician service, compensation service, APIs, and 27 integration tests** - `fba2ba4` (feat) **Plan metadata:** (docs commit below) ## Files Created/Modified - `prisma/schema.prisma` - Added CompensationModel enum, TechnicianProfile model, JobTypeRate model, reverse relations on User and Zone - `prisma/migrations/20260305000851_add_technician_profiles/migration.sql` - Migration adding technician_profiles and job_type_rates tables - `src/lib/prisma-tenant.ts` - Added technicianProfile and jobTypeRate to TENANT_SCOPED_MODELS with full 12-operation extension blocks - `src/lib/casl/types.ts` - Added TechnicianProfile and JobTypeRate to AppSubjects union - `src/lib/casl/permissions.ts` - OFFICE_STAFF: read+update TechnicianProfile, read JobTypeRate - `src/lib/services/technician-service.ts` - Full CRUD: createTechnicianProfile (validates TECHNICIAN role), updateTechnicianProfile, getTechnicianProfile, getTechnicianProfileByUserId, listTechnicians - `src/lib/services/compensation-service.ts` - getCompensationSummary, getTechnicianCompensationDetail with all 3 models and missing-rate-defaults-to-0 - `src/app/api/technicians/route.ts` - GET (list), POST (create) - `src/app/api/technicians/[id]/route.ts` - GET (single), PUT (update) - `src/app/api/technicians/[id]/compensation/route.ts` - GET (job-by-job detail for period) - `src/app/api/job-type-rates/route.ts` - GET (list), POST (create) - `src/app/api/job-type-rates/[id]/route.ts` - PUT (update rate/description/isActive) - `src/app/api/reports/compensation/route.ts` - GET (summary report with optional technician filter) - `src/lib/__tests__/compensation-service.test.ts` - 27 integration tests ## Decisions Made - User.technicianProfiles as one-to-many (not one-to-one): Prisma requires @unique on the FK field for one-to-one, incompatible with compound @@unique([tenantId,userId]); one-to-many + findFirst provides the same logical guarantee at the application layer - Missing job type rate = 0 bonus: rateMap.get(jobType) ?? Decimal(0) — plan spec requires this to be a non-error default - SALARY model detail returns jobs with rate=0 per job: preserves consistent API shape across all 3 models (detail always has a jobs array) - TechnicianProfile and JobTypeRate added to CASL AppSubjects: enables withPermission() middleware to gate these endpoints; ADMIN gets manage-all via existing rule ## Deviations from Plan ### Auto-fixed Issues **1. [Rule 1 - Bug] Prisma one-to-one relation validation failure for User.technicianProfile** - **Found during:** Task 1 (migration) - **Issue:** Plan specified `TechnicianProfile?` on User (one-to-one) but Prisma validation requires `@unique` on the FK field (`userId`) for one-to-one relations. The compound `@@unique([tenantId, userId])` does not satisfy Prisma's check. - **Fix:** Changed User side to `technicianProfiles TechnicianProfile[]` (one-to-many); application code uses `findFirst` to enforce the one-per-tenant constraint; database @@unique([tenantId, userId]) enforces the constraint at DB level - **Files modified:** prisma/schema.prisma - **Verification:** Migration applied successfully; tsc --noEmit passes - **Committed in:** 230b0ec (Task 1 commit) --- **Total deviations:** 1 auto-fixed (1 bug — Prisma schema validation) **Impact on plan:** Minimal. The logical constraint (one profile per user per tenant) is preserved at the DB level. No scope creep. ## Issues Encountered None — all 27 tests passed on the first run. ## User Setup Required None - no external service configuration required. ## Next Phase Readiness - Phase 3 (Operational Modules) is now 100% complete: Zones, Collections, Ticketing, Job Orders, Technician Management all done - TechnicianProfile is ready for Phase 4 inventory integration (equipment checkout by technician) - Compensation report is ready for Phase 5 dashboard/reporting integration - CASL permission matrix is extended with TechnicianProfile and JobTypeRate for Phase 4/5 use --- *Phase: 03-operational-modules* *Completed: 2026-03-05*