feat(03-05): technician service, compensation service, APIs, and 27 passing tests

- technician-service.ts: createTechnicianProfile, updateTechnicianProfile, getTechnicianProfile, getTechnicianProfileByUserId, listTechnicians
- compensation-service.ts: getCompensationSummary (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
- Added TechnicianProfile and JobTypeRate to CASL AppSubjects; OFFICE_STAFF read access
- 27 integration tests: all 3 models, missing rate defaults to 0, only COMPLETED count, date range filter, summary+detail reports, cross-tenant isolation — all green
This commit is contained in:
kevin-asprec
2026-03-05 08:17:03 +08:00
parent 230b0ec683
commit fba2ba4bf9
11 changed files with 2001 additions and 0 deletions

View File

@@ -0,0 +1,75 @@
import { NextRequest, NextResponse } from "next/server";
import { withPermission } from "@/lib/middleware/authorize";
import { withTenantContext } from "@/lib/prisma-tenant";
/**
* PUT /api/job-type-rates/[id]
*
* Update a job type rate.
*
* Accepts: { rate?, description?, isActive? }
* Note: jobType is immutable (it's part of the unique key).
*
* Requires: update on JobTypeRate subject (ADMIN only).
*
* Response:
* 200 OK — updated job type rate
* 400 Bad Request — validation error
* 404 Not Found — rate not found in tenant scope
* 401 Unauthorized — no session
* 403 Forbidden — insufficient role
*/
export function PUT(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
return withPermission("update", "JobTypeRate")(
async (req: NextRequest, { user }) => {
if (!user.tenantId) {
return NextResponse.json(
{ error: "No tenant context — super-admins must use the admin API" },
{ status: 400 }
);
}
const { id } = await params;
let body: unknown;
try {
body = await req.json();
} catch {
return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 });
}
const { rate, description, isActive } = body as Record<string, unknown>;
const data: Record<string, unknown> = {};
if (rate !== undefined) {
if (isNaN(Number(rate)) || Number(rate) < 0) {
return NextResponse.json({ error: "rate must be a valid non-negative number" }, { status: 400 });
}
data.rate = Number(rate);
}
if (description !== undefined) data.description = description ? String(description).trim() : null;
if (isActive !== undefined) data.isActive = Boolean(isActive);
const tenantPrisma = withTenantContext(user.tenantId);
// Verify exists
const existing = await tenantPrisma.jobTypeRate.findFirst({ where: { id } });
if (!existing) {
return NextResponse.json({ error: "Job type rate not found" }, { status: 404 });
}
try {
const updated = await tenantPrisma.jobTypeRate.update({
where: { id },
data,
});
return NextResponse.json(updated);
} catch (err) {
const message = err instanceof Error ? err.message : "Failed to update job type rate";
return NextResponse.json({ error: message }, { status: 400 });
}
}
)(req);
}

View File

@@ -0,0 +1,121 @@
import { NextRequest, NextResponse } from "next/server";
import { withPermission } from "@/lib/middleware/authorize";
import { withTenantContext } from "@/lib/prisma-tenant";
/**
* GET /api/job-type-rates
*
* List all job type rates for the current tenant.
*
* Query params: activeOnly? (boolean)
*
* Requires: read on JobTypeRate subject.
*
* Response:
* 200 OK — { rates, total }
* 401 Unauthorized — no session
* 403 Forbidden — insufficient role
*/
export function GET(req: NextRequest) {
return withPermission("read", "JobTypeRate")(
async (req: NextRequest, { user }) => {
if (!user.tenantId) {
return NextResponse.json(
{ error: "No tenant context — super-admins must use the admin API" },
{ status: 400 }
);
}
const url = new URL(req.url);
const activeOnly = url.searchParams.get("activeOnly") === "true";
const tenantPrisma = withTenantContext(user.tenantId);
const where: Record<string, unknown> = {};
if (activeOnly) where.isActive = true;
const [rates, total] = await Promise.all([
tenantPrisma.jobTypeRate.findMany({
where,
orderBy: { jobType: "asc" },
}),
tenantPrisma.jobTypeRate.count({ where }),
]);
return NextResponse.json({ rates, total });
}
)(req);
}
/**
* POST /api/job-type-rates
*
* Create a new job type rate.
*
* Accepts: { jobType, rate, description? }
*
* Requires: create on JobTypeRate subject (ADMIN only).
*
* Response:
* 201 Created — created job type rate
* 400 Bad Request — validation error or duplicate jobType
* 401 Unauthorized — no session
* 403 Forbidden — insufficient role
*/
export function POST(req: NextRequest) {
return withPermission("create", "JobTypeRate")(
async (req: NextRequest, { user }) => {
if (!user.tenantId) {
return NextResponse.json(
{ error: "No tenant context — super-admins must use the admin API" },
{ status: 400 }
);
}
let body: unknown;
try {
body = await req.json();
} catch {
return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 });
}
const { jobType, rate, description } = body as Record<string, unknown>;
if (!jobType || typeof jobType !== "string" || !jobType.trim()) {
return NextResponse.json({ error: "jobType is required" }, { status: 400 });
}
if (rate === undefined || rate === null || isNaN(Number(rate))) {
return NextResponse.json({ error: "rate must be a valid number" }, { status: 400 });
}
if (Number(rate) < 0) {
return NextResponse.json({ error: "rate must be >= 0" }, { status: 400 });
}
const tenantPrisma = withTenantContext(user.tenantId);
try {
const created = await tenantPrisma.jobTypeRate.create({
data: {
tenantId: user.tenantId,
jobType: (jobType as string).trim(),
rate: Number(rate),
description: description ? String(description).trim() : null,
isActive: true,
},
});
return NextResponse.json(created, { status: 201 });
} catch (err) {
const message = err instanceof Error ? err.message : "Failed to create job type rate";
if (message.includes("Unique constraint") || message.includes("P2002")) {
return NextResponse.json(
{ error: `A rate for job type "${jobType}" already exists` },
{ status: 400 }
);
}
return NextResponse.json({ error: message }, { status: 400 });
}
}
)(req);
}

View File

@@ -0,0 +1,73 @@
import { NextRequest, NextResponse } from "next/server";
import { withPermission } from "@/lib/middleware/authorize";
import { withTenantContext } from "@/lib/prisma-tenant";
import { getCompensationSummary } from "@/lib/services/compensation-service";
/**
* GET /api/reports/compensation
*
* Get compensation summary report for all technicians in a period.
*
* Query params:
* periodStart (required) — ISO date string (inclusive)
* periodEnd (required) — ISO date string (inclusive)
* technicianProfileId? — filter to a single technician
*
* Requires: read on TechnicianProfile subject (ADMIN, OFFICE_STAFF).
*
* Response:
* 200 OK — { periodStart, periodEnd, technicians, grandTotal }
* Each technician: { technicianProfileId, userId, firstName, lastName,
* compensationModel, baseSalary, jobBonusTotal, totalCompensation, completedJobCount }
* 400 Bad Request — missing/invalid period params
* 401 Unauthorized — no session
* 403 Forbidden — insufficient role
*/
export function GET(req: NextRequest) {
return withPermission("read", "TechnicianProfile")(
async (req: NextRequest, { user }) => {
if (!user.tenantId) {
return NextResponse.json(
{ error: "No tenant context — super-admins must use the admin API" },
{ status: 400 }
);
}
const url = new URL(req.url);
const periodStartParam = url.searchParams.get("periodStart");
const periodEndParam = url.searchParams.get("periodEnd");
const technicianProfileId = url.searchParams.get("technicianProfileId") ?? undefined;
if (!periodStartParam || !periodEndParam) {
return NextResponse.json(
{ error: "periodStart and periodEnd query params are required" },
{ status: 400 }
);
}
const periodStart = new Date(periodStartParam);
const periodEnd = new Date(periodEndParam);
if (isNaN(periodStart.getTime()) || isNaN(periodEnd.getTime())) {
return NextResponse.json(
{ error: "periodStart and periodEnd must be valid ISO date strings" },
{ status: 400 }
);
}
const tenantPrisma = withTenantContext(user.tenantId);
try {
const summary = await getCompensationSummary(tenantPrisma, {
periodStart,
periodEnd,
technicianProfileId,
});
return NextResponse.json(summary);
} catch (err) {
const message = err instanceof Error ? err.message : "Failed to generate compensation report";
return NextResponse.json({ error: message }, { status: 400 });
}
}
)(req);
}

View File

@@ -0,0 +1,75 @@
import { NextRequest, NextResponse } from "next/server";
import { withPermission } from "@/lib/middleware/authorize";
import { withTenantContext } from "@/lib/prisma-tenant";
import { getTechnicianCompensationDetail } from "@/lib/services/compensation-service";
/**
* GET /api/technicians/[id]/compensation
*
* Get compensation detail (job-by-job breakdown) for a specific technician.
*
* Query params:
* periodStart (required) — ISO date string (inclusive)
* periodEnd (required) — ISO date string (inclusive)
*
* Requires: read on TechnicianProfile subject.
*
* Response:
* 200 OK — { technicianProfileId, userId, firstName, lastName, compensationModel,
* periodStart, periodEnd, baseSalary, jobs, jobBonusTotal, totalCompensation }
* 400 Bad Request — missing/invalid period params
* 404 Not Found — profile not found in tenant scope
* 401 Unauthorized — no session
* 403 Forbidden — insufficient role
*/
export function GET(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
return withPermission("read", "TechnicianProfile")(
async (req: NextRequest, { user }) => {
if (!user.tenantId) {
return NextResponse.json(
{ error: "No tenant context — super-admins must use the admin API" },
{ status: 400 }
);
}
const { id } = await params;
const url = new URL(req.url);
const periodStartParam = url.searchParams.get("periodStart");
const periodEndParam = url.searchParams.get("periodEnd");
if (!periodStartParam || !periodEndParam) {
return NextResponse.json(
{ error: "periodStart and periodEnd query params are required" },
{ status: 400 }
);
}
const periodStart = new Date(periodStartParam);
const periodEnd = new Date(periodEndParam);
if (isNaN(periodStart.getTime()) || isNaN(periodEnd.getTime())) {
return NextResponse.json(
{ error: "periodStart and periodEnd must be valid ISO date strings" },
{ status: 400 }
);
}
const tenantPrisma = withTenantContext(user.tenantId);
try {
const detail = await getTechnicianCompensationDetail(tenantPrisma, {
technicianProfileId: id,
periodStart,
periodEnd,
});
return NextResponse.json(detail);
} catch (err) {
const message = err instanceof Error ? err.message : "Failed to get compensation detail";
if (message.includes("not found")) {
return NextResponse.json({ error: message }, { status: 404 });
}
return NextResponse.json({ error: message }, { status: 400 });
}
}
)(req);
}

View File

@@ -0,0 +1,110 @@
import { NextRequest, NextResponse } from "next/server";
import { withPermission } from "@/lib/middleware/authorize";
import { withTenantContext } from "@/lib/prisma-tenant";
import {
getTechnicianProfile,
updateTechnicianProfile,
} from "@/lib/services/technician-service";
import { CompensationModel } from "@prisma/client";
/**
* GET /api/technicians/[id]
*
* Get a single technician profile by ID.
*
* Requires: read on TechnicianProfile subject.
*
* Response:
* 200 OK — technician profile with user and zone
* 404 Not Found — profile not found in tenant scope
* 401 Unauthorized — no session
* 403 Forbidden — insufficient role
*/
export function GET(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
return withPermission("read", "TechnicianProfile")(
async (_req: NextRequest, { user }) => {
if (!user.tenantId) {
return NextResponse.json(
{ error: "No tenant context — super-admins must use the admin API" },
{ status: 400 }
);
}
const { id } = await params;
const tenantPrisma = withTenantContext(user.tenantId);
const profile = await getTechnicianProfile(tenantPrisma, id);
if (!profile) {
return NextResponse.json({ error: "Technician profile not found" }, { status: 404 });
}
return NextResponse.json(profile);
}
)(req);
}
/**
* PUT /api/technicians/[id]
*
* Update a technician profile.
*
* Accepts: { phone?, skills?, zoneId?, compensationModel?, monthlySalary?, isActive? }
*
* Requires: update on TechnicianProfile subject (ADMIN only).
*
* Response:
* 200 OK — updated technician profile
* 400 Bad Request — validation error
* 404 Not Found — profile not found
* 401 Unauthorized — no session
* 403 Forbidden — insufficient role
*/
export function PUT(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
return withPermission("update", "TechnicianProfile")(
async (req: NextRequest, { user }) => {
if (!user.tenantId) {
return NextResponse.json(
{ error: "No tenant context — super-admins must use the admin API" },
{ status: 400 }
);
}
const { id } = await params;
let body: unknown;
try {
body = await req.json();
} catch {
return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 });
}
const { phone, skills, zoneId, compensationModel, monthlySalary, isActive } =
body as Record<string, unknown>;
const tenantPrisma = withTenantContext(user.tenantId);
try {
const profile = await updateTechnicianProfile(tenantPrisma, id, {
phone: phone as string | undefined,
skills: Array.isArray(skills) ? (skills as string[]) : undefined,
zoneId: zoneId === null ? null : (zoneId as string | undefined),
compensationModel: compensationModel as CompensationModel | undefined,
monthlySalary:
monthlySalary === null
? null
: monthlySalary !== undefined
? Number(monthlySalary)
: undefined,
isActive: isActive !== undefined ? Boolean(isActive) : undefined,
});
return NextResponse.json(profile);
} catch (err) {
const message = err instanceof Error ? err.message : "Failed to update technician profile";
if (message.includes("not found")) {
return NextResponse.json({ error: message }, { status: 404 });
}
return NextResponse.json({ error: message }, { status: 400 });
}
}
)(req);
}

View File

@@ -0,0 +1,104 @@
import { NextRequest, NextResponse } from "next/server";
import { withPermission } from "@/lib/middleware/authorize";
import { withTenantContext } from "@/lib/prisma-tenant";
import {
listTechnicians,
createTechnicianProfile,
} from "@/lib/services/technician-service";
import { CompensationModel } from "@prisma/client";
/**
* GET /api/technicians
*
* List technician profiles for the current tenant.
*
* Query params: activeOnly? (boolean), zoneId? (string)
*
* Requires: read on TechnicianProfile subject (ADMIN, OFFICE_STAFF).
*
* Response:
* 200 OK — { profiles, total }
* 401 Unauthorized — no session
* 403 Forbidden — insufficient role
*/
export function GET(req: NextRequest) {
return withPermission("read", "TechnicianProfile")(
async (req: NextRequest, { user }) => {
if (!user.tenantId) {
return NextResponse.json(
{ error: "No tenant context — super-admins must use the admin API" },
{ status: 400 }
);
}
const url = new URL(req.url);
const activeOnly = url.searchParams.get("activeOnly") === "true";
const zoneId = url.searchParams.get("zoneId") ?? undefined;
const tenantPrisma = withTenantContext(user.tenantId);
const result = await listTechnicians(tenantPrisma, { activeOnly, zoneId });
return NextResponse.json(result);
}
)(req);
}
/**
* POST /api/technicians
*
* Create a new technician profile.
*
* Accepts: { userId, phone?, skills?, zoneId?, compensationModel?, monthlySalary? }
*
* Requires: create on TechnicianProfile subject (ADMIN only).
*
* Response:
* 201 Created — created technician profile
* 400 Bad Request — validation error
* 401 Unauthorized — no session
* 403 Forbidden — insufficient role
*/
export function POST(req: NextRequest) {
return withPermission("create", "TechnicianProfile")(
async (req: NextRequest, { user }) => {
if (!user.tenantId) {
return NextResponse.json(
{ error: "No tenant context — super-admins must use the admin API" },
{ status: 400 }
);
}
let body: unknown;
try {
body = await req.json();
} catch {
return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 });
}
const { userId, phone, skills, zoneId, compensationModel, monthlySalary } =
body as Record<string, unknown>;
if (!userId || typeof userId !== "string") {
return NextResponse.json({ error: "userId is required" }, { status: 400 });
}
const tenantPrisma = withTenantContext(user.tenantId);
try {
const profile = await createTechnicianProfile(tenantPrisma, user.tenantId, {
userId: userId as string,
phone: phone as string | undefined,
skills: Array.isArray(skills) ? (skills as string[]) : undefined,
zoneId: zoneId as string | undefined,
compensationModel: compensationModel as CompensationModel | undefined,
monthlySalary:
monthlySalary !== undefined ? Number(monthlySalary) : undefined,
});
return NextResponse.json(profile, { status: 201 });
} catch (err) {
const message = err instanceof Error ? err.message : "Failed to create technician profile";
return NextResponse.json({ error: message }, { status: 400 });
}
}
)(req);
}