From 56f5d071c448f7747cb0ca82709f290e1e76dcc7 Mon Sep 17 00:00:00 2001 From: kevin-asprec Date: Thu, 5 Mar 2026 07:24:58 +0800 Subject: [PATCH] feat(03-01): Zone schema, migration, and tenant scoping - Add Zone model (id, tenantId, name, description, isActive) - Add ZoneAssignment model (collector-to-zone join table) - Replace Subscriber.zone String? with Subscriber.zoneId FK to Zone - Add Zone + ZoneAssignment to TENANT_SCOPED_MODELS with full operation blocks - Add "Zone" to AppSubjects in types.ts - Grant OFFICE_STAFF manage Zone, COLLECTOR read Zone in permissions.ts - Migration 20260305000000_add_zones applied to DB --- .../20260305000000_add_zones/migration.sql | 57 +++++ prisma/schema.prisma | 49 ++++- src/lib/casl/permissions.ts | 4 + src/lib/casl/types.ts | 1 + src/lib/prisma-tenant.ts | 194 +++++++++++++++++- 5 files changed, 302 insertions(+), 3 deletions(-) create mode 100644 prisma/migrations/20260305000000_add_zones/migration.sql diff --git a/prisma/migrations/20260305000000_add_zones/migration.sql b/prisma/migrations/20260305000000_add_zones/migration.sql new file mode 100644 index 0000000..39107a2 --- /dev/null +++ b/prisma/migrations/20260305000000_add_zones/migration.sql @@ -0,0 +1,57 @@ +-- AlterTable +ALTER TABLE "Subscriber" DROP COLUMN "zone", +ADD COLUMN "zoneId" TEXT; + +-- CreateTable +CREATE TABLE "Zone" ( + "id" TEXT NOT NULL, + "tenantId" TEXT NOT NULL, + "name" TEXT NOT NULL, + "description" TEXT, + "isActive" BOOLEAN NOT NULL DEFAULT true, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Zone_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "ZoneAssignment" ( + "id" TEXT NOT NULL, + "tenantId" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "zoneId" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "ZoneAssignment_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "Zone_tenantId_idx" ON "Zone"("tenantId"); + +-- CreateIndex +CREATE UNIQUE INDEX "Zone_tenantId_name_key" ON "Zone"("tenantId", "name"); + +-- CreateIndex +CREATE INDEX "ZoneAssignment_tenantId_idx" ON "ZoneAssignment"("tenantId"); + +-- CreateIndex +CREATE INDEX "ZoneAssignment_userId_idx" ON "ZoneAssignment"("userId"); + +-- CreateIndex +CREATE INDEX "ZoneAssignment_zoneId_idx" ON "ZoneAssignment"("zoneId"); + +-- CreateIndex +CREATE UNIQUE INDEX "ZoneAssignment_tenantId_userId_zoneId_key" ON "ZoneAssignment"("tenantId", "userId", "zoneId"); + +-- CreateIndex +CREATE INDEX "Subscriber_tenantId_zoneId_idx" ON "Subscriber"("tenantId", "zoneId"); + +-- AddForeignKey +ALTER TABLE "Subscriber" ADD CONSTRAINT "Subscriber_zoneId_fkey" FOREIGN KEY ("zoneId") REFERENCES "Zone"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "ZoneAssignment" ADD CONSTRAINT "ZoneAssignment_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "ZoneAssignment" ADD CONSTRAINT "ZoneAssignment_zoneId_fkey" FOREIGN KEY ("zoneId") REFERENCES "Zone"("id") ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index cc360d0..016e60c 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -235,8 +235,9 @@ model Subscriber { email String? phone String? address String - /// Zone for collector routing (Phase 3) - zone String? + /// Zone FK for collector routing (Phase 3) — replaces old String? zone field + zoneId String? + zone Zone? @relation(fields: [zoneId], references: [id]) servicePlanId String servicePlan ServicePlan @relation(fields: [servicePlanId], references: [id]) status SubscriberStatus @default(ACTIVE) @@ -264,6 +265,48 @@ model Subscriber { @@index([tenantId]) @@index([tenantId, status]) @@index([tenantId, servicePlanId]) + @@index([tenantId, zoneId]) +} + +/// A Zone groups subscribers geographically for collector routing. +/// Collectors are assigned to zones and can only collect from subscribers in those zones. +/// This is a security boundary enforced at the data layer. +model Zone { + id String @id @default(uuid()) + tenantId String + name String + description String? + isActive Boolean @default(true) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + subscribers Subscriber[] + assignments ZoneAssignment[] + + /// Zone names must be unique within a tenant + @@unique([tenantId, name]) + /// RLS-ready index — always present on tenant-scoped models + @@index([tenantId]) +} + +/// A ZoneAssignment links a collector user to a zone. +/// Collectors can be assigned to multiple zones; zones can have multiple collectors. +model ZoneAssignment { + id String @id @default(uuid()) + tenantId String + /// The collector user assigned to this zone + userId String + user User @relation(fields: [userId], references: [id]) + zoneId String + zone Zone @relation(fields: [zoneId], references: [id]) + createdAt DateTime @default(now()) + + /// One assignment per collector per zone per tenant + @@unique([tenantId, userId, zoneId]) + /// RLS-ready index — always present on tenant-scoped models + @@index([tenantId]) + @@index([userId]) + @@index([zoneId]) } /// A User belongs to a Tenant (or is a super-admin with no tenant). @@ -295,6 +338,8 @@ model User { approvedJournalEntries JournalEntry[] @relation("JournalEntryApprovedBy") /// Payments this user recorded recordedPayments Payment[] @relation("PaymentRecordedBy") + /// Zone assignments for collector role (which zones this user can collect from) + zoneAssignments ZoneAssignment[] createdAt DateTime @default(now()) updatedAt DateTime @updatedAt diff --git a/src/lib/casl/permissions.ts b/src/lib/casl/permissions.ts index f878620..08d1b9a 100644 --- a/src/lib/casl/permissions.ts +++ b/src/lib/casl/permissions.ts @@ -51,6 +51,8 @@ export function definePermissionsFor( can("manage", "Invoice"); // Record payments can("manage", "Payment"); + // Zone management (assign subscribers and collectors to zones) + can("manage", "Zone"); // Ticketing can("manage", "Ticket"); // Job order management @@ -69,6 +71,8 @@ export function definePermissionsFor( case Role.COLLECTOR: { // Can view all subscribers for context (zone filtering done at data layer) can("read", "Subscriber"); + // Can read zones they are assigned to (data layer enforces which zones) + can("read", "Zone"); // Can record payments (zone filtering done at data layer) can("create", "Payment"); // View payment history diff --git a/src/lib/casl/types.ts b/src/lib/casl/types.ts index d9697af..79f338e 100644 --- a/src/lib/casl/types.ts +++ b/src/lib/casl/types.ts @@ -13,6 +13,7 @@ export type AppSubjects = | "Subscriber" | "Invoice" | "Payment" + | "Zone" | "Ticket" | "JobOrder" | "Inventory" diff --git a/src/lib/prisma-tenant.ts b/src/lib/prisma-tenant.ts index 0774f8f..b241944 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"] as const; +export const TENANT_SCOPED_MODELS = ["user", "account", "accountingPeriod", "subscriber", "servicePlan", "tenantSettings", "journalEntry", "journalEntryLine", "invoice", "invoiceLine", "payment", "paymentAllocation", "zone", "zoneAssignment"] as const; export type TenantScopedModel = (typeof TENANT_SCOPED_MODELS)[number]; @@ -936,6 +936,198 @@ export function withTenantContext(tenantId: string) { return query(args); }, }, + + zone: { + 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.zone.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.zone.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); + }, + }, + + zoneAssignment: { + 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.zoneAssignment.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.zoneAssignment.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); + }, + }, }, }); }