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:
57
prisma/migrations/20260305000000_add_zones/migration.sql
Normal file
57
prisma/migrations/20260305000000_add_zones/migration.sql
Normal 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;
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ export type AppSubjects =
|
|||||||
| "Subscriber"
|
| "Subscriber"
|
||||||
| "Invoice"
|
| "Invoice"
|
||||||
| "Payment"
|
| "Payment"
|
||||||
|
| "Zone"
|
||||||
| "Ticket"
|
| "Ticket"
|
||||||
| "JobOrder"
|
| "JobOrder"
|
||||||
| "Inventory"
|
| "Inventory"
|
||||||
|
|||||||
@@ -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);
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user