Files
NetForge/src/app/api/technicians/route.ts
kevin-asprec fba2ba4bf9 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
2026-03-05 08:17:03 +08:00

105 lines
3.3 KiB
TypeScript

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);
}