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
This commit is contained in:
kevin-asprec
2026-03-05 07:24:58 +08:00
parent d54b517e2e
commit 56f5d071c4
5 changed files with 302 additions and 3 deletions

View File

@@ -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;

View File

@@ -235,8 +235,9 @@ model Subscriber {
email String? email String?
phone String? phone String?
address String address String
/// Zone for collector routing (Phase 3) /// Zone FK for collector routing (Phase 3) — replaces old String? zone field
zone String? zoneId String?
zone Zone? @relation(fields: [zoneId], references: [id])
servicePlanId String servicePlanId String
servicePlan ServicePlan @relation(fields: [servicePlanId], references: [id]) servicePlan ServicePlan @relation(fields: [servicePlanId], references: [id])
status SubscriberStatus @default(ACTIVE) status SubscriberStatus @default(ACTIVE)
@@ -264,6 +265,48 @@ model Subscriber {
@@index([tenantId]) @@index([tenantId])
@@index([tenantId, status]) @@index([tenantId, status])
@@index([tenantId, servicePlanId]) @@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). /// A User belongs to a Tenant (or is a super-admin with no tenant).
@@ -295,6 +338,8 @@ model User {
approvedJournalEntries JournalEntry[] @relation("JournalEntryApprovedBy") approvedJournalEntries JournalEntry[] @relation("JournalEntryApprovedBy")
/// Payments this user recorded /// Payments this user recorded
recordedPayments Payment[] @relation("PaymentRecordedBy") recordedPayments Payment[] @relation("PaymentRecordedBy")
/// Zone assignments for collector role (which zones this user can collect from)
zoneAssignments ZoneAssignment[]
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt

View File

@@ -51,6 +51,8 @@ export function definePermissionsFor(
can("manage", "Invoice"); can("manage", "Invoice");
// Record payments // Record payments
can("manage", "Payment"); can("manage", "Payment");
// Zone management (assign subscribers and collectors to zones)
can("manage", "Zone");
// Ticketing // Ticketing
can("manage", "Ticket"); can("manage", "Ticket");
// Job order management // Job order management
@@ -69,6 +71,8 @@ export function definePermissionsFor(
case Role.COLLECTOR: { case Role.COLLECTOR: {
// Can view all subscribers for context (zone filtering done at data layer) // Can view all subscribers for context (zone filtering done at data layer)
can("read", "Subscriber"); 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 record payments (zone filtering done at data layer)
can("create", "Payment"); can("create", "Payment");
// View payment history // View payment history

View File

@@ -13,6 +13,7 @@ export type AppSubjects =
| "Subscriber" | "Subscriber"
| "Invoice" | "Invoice"
| "Payment" | "Payment"
| "Zone"
| "Ticket" | "Ticket"
| "JobOrder" | "JobOrder"
| "Inventory" | "Inventory"

View File

@@ -30,7 +30,7 @@ import { prisma } from "@/lib/prisma";
* Extend this list as new models are added in later phases: * Extend this list as new models are added in later phases:
* e.g., "subscriber", "invoice", "servicePlan", "payment" * 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]; export type TenantScopedModel = (typeof TENANT_SCOPED_MODELS)[number];
@@ -936,6 +936,198 @@ export function withTenantContext(tenantId: string) {
return query(args); 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);
},
},
}, },
}); });
} }