diff --git a/prisma/migrations/20260305000851_add_technician_profiles/migration.sql b/prisma/migrations/20260305000851_add_technician_profiles/migration.sql new file mode 100644 index 0000000..873f550 --- /dev/null +++ b/prisma/migrations/20260305000851_add_technician_profiles/migration.sql @@ -0,0 +1,51 @@ +-- CreateEnum +CREATE TYPE "CompensationModel" AS ENUM ('PER_JOB', 'SALARY', 'HYBRID'); + +-- CreateTable +CREATE TABLE "TechnicianProfile" ( + "id" TEXT NOT NULL, + "tenantId" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "phone" TEXT, + "skills" TEXT[], + "zoneId" TEXT, + "compensationModel" "CompensationModel" NOT NULL DEFAULT 'PER_JOB', + "monthlySalary" DECIMAL(10,2), + "isActive" BOOLEAN NOT NULL DEFAULT true, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "TechnicianProfile_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "JobTypeRate" ( + "id" TEXT NOT NULL, + "tenantId" TEXT NOT NULL, + "jobType" TEXT NOT NULL, + "rate" DECIMAL(10,2) NOT NULL, + "description" TEXT, + "isActive" BOOLEAN NOT NULL DEFAULT true, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "JobTypeRate_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "TechnicianProfile_tenantId_idx" ON "TechnicianProfile"("tenantId"); + +-- CreateIndex +CREATE UNIQUE INDEX "TechnicianProfile_tenantId_userId_key" ON "TechnicianProfile"("tenantId", "userId"); + +-- CreateIndex +CREATE INDEX "JobTypeRate_tenantId_idx" ON "JobTypeRate"("tenantId"); + +-- CreateIndex +CREATE UNIQUE INDEX "JobTypeRate_tenantId_jobType_key" ON "JobTypeRate"("tenantId", "jobType"); + +-- AddForeignKey +ALTER TABLE "TechnicianProfile" ADD CONSTRAINT "TechnicianProfile_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "TechnicianProfile" ADD CONSTRAINT "TechnicianProfile_zoneId_fkey" FOREIGN KEY ("zoneId") REFERENCES "Zone"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index d579dfb..68427d7 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -136,6 +136,12 @@ enum JobOrderStatus { CANCELLED } +enum CompensationModel { + PER_JOB + SALARY + HYBRID +} + // ============================================================================= // MODELS // ============================================================================= @@ -318,8 +324,9 @@ model Zone { createdAt DateTime @default(now()) updatedAt DateTime @updatedAt - subscribers Subscriber[] - assignments ZoneAssignment[] + subscribers Subscriber[] + assignments ZoneAssignment[] + technicianProfiles TechnicianProfile[] /// Zone names must be unique within a tenant @@unique([tenantId, name]) @@ -392,6 +399,8 @@ model User { assignedJobOrders JobOrder[] @relation("JobOrderAssignedTo") /// Job orders created by this user (staff) createdJobOrders JobOrder[] @relation("JobOrderCreatedBy") + /// Technician profiles for this user (one per tenant, enforced by @@unique([tenantId, userId])) + technicianProfiles TechnicianProfile[] createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@ -795,3 +804,54 @@ model InvoiceLine { @@index([invoiceId]) @@index([tenantId]) } + +/// A TechnicianProfile holds ISP-specific attributes for a technician user. +/// Compensation model determines how the technician is paid: +/// PER_JOB: sum of job type rates for completed jobs +/// SALARY: fixed monthly salary only +/// HYBRID: monthly salary + per-job bonuses +model TechnicianProfile { + id String @id @default(uuid()) + tenantId String + /// The technician user (FK to User) — one profile per user per tenant + userId String + user User @relation(fields: [userId], references: [id]) + phone String? + /// PostgreSQL array of skill tags (e.g., ["fiber", "wireless", "installation"]) + skills String[] + /// Zone the technician is primarily assigned to (optional) + zoneId String? + zone Zone? @relation(fields: [zoneId], references: [id]) + compensationModel CompensationModel @default(PER_JOB) + /// Fixed monthly salary component (used for SALARY and HYBRID models) + monthlySalary Decimal? @db.Decimal(10, 2) + isActive Boolean @default(true) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + /// One profile per user per tenant + @@unique([tenantId, userId]) + /// RLS-ready index — always present on tenant-scoped models + @@index([tenantId]) +} + +/// A JobTypeRate defines the per-job bonus rate for a specific job type at tenant level. +/// Used to calculate per-job compensation for technicians (PER_JOB and HYBRID models). +/// Missing rates default to 0 bonus (not an error). +model JobTypeRate { + id String @id @default(uuid()) + tenantId String + /// The job type identifier (e.g., "Installation", "Repair", "Maintenance") + jobType String + /// Per-job bonus rate for this job type + rate Decimal @db.Decimal(10, 2) + description String? + isActive Boolean @default(true) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + /// One rate per job type per tenant + @@unique([tenantId, jobType]) + /// RLS-ready index — always present on tenant-scoped models + @@index([tenantId]) +} diff --git a/src/lib/prisma-tenant.ts b/src/lib/prisma-tenant.ts index 50065c1..94019fb 100644 --- a/src/lib/prisma-tenant.ts +++ b/src/lib/prisma-tenant.ts @@ -30,7 +30,7 @@ import { prisma } from "@/lib/prisma"; * Extend this list as new models are added in later phases: * e.g., "subscriber", "invoice", "servicePlan", "payment" */ -export const TENANT_SCOPED_MODELS = ["user", "account", "accountingPeriod", "subscriber", "servicePlan", "tenantSettings", "journalEntry", "journalEntryLine", "invoice", "invoiceLine", "payment", "paymentAllocation", "zone", "zoneAssignment", "ticket", "ticketCategory", "collection", "collectionAllocation", "remittance", "jobOrder"] as const; +export const TENANT_SCOPED_MODELS = ["user", "account", "accountingPeriod", "subscriber", "servicePlan", "tenantSettings", "journalEntry", "journalEntryLine", "invoice", "invoiceLine", "payment", "paymentAllocation", "zone", "zoneAssignment", "ticket", "ticketCategory", "collection", "collectionAllocation", "remittance", "jobOrder", "technicianProfile", "jobTypeRate"] as const; export type TenantScopedModel = (typeof TENANT_SCOPED_MODELS)[number]; @@ -1704,6 +1704,198 @@ export function withTenantContext(tenantId: string) { return query(args); }, }, + + technicianProfile: { + async findMany({ args, query }) { + args.where = { ...args.where, tenantId }; + return query(args); + }, + + async findFirst({ args, query }) { + args.where = { ...args.where, tenantId }; + return query(args); + }, + + async findFirstOrThrow({ args, query }) { + args.where = { ...args.where, tenantId }; + return query(args); + }, + + async findUnique({ args, query }) { + if (args.where && "id" in args.where && !("tenantId" in (args.where as object))) { + return prisma.technicianProfile.findFirst({ + ...args, + where: { ...args.where, tenantId }, + }); + } + return query(args); + }, + + async findUniqueOrThrow({ args, query }) { + if (args.where && "id" in args.where && !("tenantId" in (args.where as object))) { + const result = await prisma.technicianProfile.findFirst({ + ...args, + where: { ...args.where, tenantId }, + }); + if (!result) { + throw new Error("Record not found"); + } + return result; + } + return query(args); + }, + + async create({ args, query }) { + args.data = { ...args.data, tenantId } as typeof args.data; + return query(args); + }, + + async createMany({ args, query }) { + if (Array.isArray(args.data)) { + args.data = args.data.map((item) => ({ ...item, tenantId })) as typeof args.data; + } else { + args.data = { ...args.data, tenantId } as typeof args.data; + } + return query(args); + }, + + async update({ args, query }) { + args.where = { ...args.where, tenantId }; + return query(args); + }, + + async updateMany({ args, query }) { + args.where = { ...args.where, tenantId }; + return query(args); + }, + + async delete({ args, query }) { + args.where = { ...args.where, tenantId }; + return query(args); + }, + + async deleteMany({ args, query }) { + args.where = { ...args.where, tenantId }; + return query(args); + }, + + async upsert({ args, query }) { + args.where = { ...args.where, tenantId } as typeof args.where; + args.create = { ...args.create, tenantId } as typeof args.create; + return query(args); + }, + + async count({ args, query }) { + args.where = { ...args.where, tenantId }; + return query(args); + }, + + async aggregate({ args, query }) { + args.where = { ...args.where, tenantId }; + return query(args); + }, + + async groupBy({ args, query }) { + args.where = { ...args.where, tenantId }; + return query(args); + }, + }, + + jobTypeRate: { + async findMany({ args, query }) { + args.where = { ...args.where, tenantId }; + return query(args); + }, + + async findFirst({ args, query }) { + args.where = { ...args.where, tenantId }; + return query(args); + }, + + async findFirstOrThrow({ args, query }) { + args.where = { ...args.where, tenantId }; + return query(args); + }, + + async findUnique({ args, query }) { + if (args.where && "id" in args.where && !("tenantId" in (args.where as object))) { + return prisma.jobTypeRate.findFirst({ + ...args, + where: { ...args.where, tenantId }, + }); + } + return query(args); + }, + + async findUniqueOrThrow({ args, query }) { + if (args.where && "id" in args.where && !("tenantId" in (args.where as object))) { + const result = await prisma.jobTypeRate.findFirst({ + ...args, + where: { ...args.where, tenantId }, + }); + if (!result) { + throw new Error("Record not found"); + } + return result; + } + return query(args); + }, + + async create({ args, query }) { + args.data = { ...args.data, tenantId } as typeof args.data; + return query(args); + }, + + async createMany({ args, query }) { + if (Array.isArray(args.data)) { + args.data = args.data.map((item) => ({ ...item, tenantId })) as typeof args.data; + } else { + args.data = { ...args.data, tenantId } as typeof args.data; + } + return query(args); + }, + + async update({ args, query }) { + args.where = { ...args.where, tenantId }; + return query(args); + }, + + async updateMany({ args, query }) { + args.where = { ...args.where, tenantId }; + return query(args); + }, + + async delete({ args, query }) { + args.where = { ...args.where, tenantId }; + return query(args); + }, + + async deleteMany({ args, query }) { + args.where = { ...args.where, tenantId }; + return query(args); + }, + + async upsert({ args, query }) { + args.where = { ...args.where, tenantId } as typeof args.where; + args.create = { ...args.create, tenantId } as typeof args.create; + return query(args); + }, + + async count({ args, query }) { + args.where = { ...args.where, tenantId }; + return query(args); + }, + + async aggregate({ args, query }) { + args.where = { ...args.where, tenantId }; + return query(args); + }, + + async groupBy({ args, query }) { + args.where = { ...args.where, tenantId }; + return query(args); + }, + }, }, }); }