feat(03-01): Zone service, API routes, and 25 integration tests

- zone-service.ts: createZone, updateZone, listZones, getZone,
  assignSubscriberToZone, removeSubscriberFromZone, assignCollectorToZone,
  removeCollectorFromZone, getCollectorZones, getCollectorSubscribers
- API routes: GET/POST /api/zones, GET/PUT /api/zones/[id],
  POST/DELETE /api/zones/[id]/subscribers, GET /api/collectors/[id]/subscribers
- 25 integration tests: zone CRUD, subscriber assignment, collector scoping,
  no-zone-assignment security boundary, cross-tenant isolation
- Fix subscriber-service.ts: zone String? -> zoneId FK (deviation Rule 1)
- Fix subscriber API routes: zone -> zoneId (deviation Rule 1)
This commit is contained in:
kevin-asprec
2026-03-05 07:30:34 +08:00
parent 56f5d071c4
commit 214df6cd92
10 changed files with 1360 additions and 11 deletions

View File

@@ -0,0 +1,61 @@
import { NextRequest, NextResponse } from "next/server";
import { withPermission } from "@/lib/middleware/authorize";
import { withTenantContext } from "@/lib/prisma-tenant";
import { getCollectorSubscribers } from "@/lib/services/zone-service";
import { Role } from "@prisma/client";
/**
* GET /api/collectors/[id]/subscribers
*
* Get all subscribers in the zones assigned to a collector.
*
* SECURITY:
* - A COLLECTOR can only query their own subscriber list (id must match their userId).
* - An ADMIN or OFFICE_STAFF can query any collector's subscriber list.
* - If the collector has no zone assignments, an error is returned (security boundary).
*
* Requires: read on Subscriber subject.
*
* Response:
* 200 OK — array of subscribers with zone info
* 400 Bad Request — collector has no zone assignments
* 403 Forbidden — collector trying to query another collector's list
* 404 Not Found — collector user not found
* 401 Unauthorized — no session
*/
export function GET(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
return withPermission("read", "Subscriber")(
async (_req: NextRequest, { user }) => {
if (!user.tenantId) {
return NextResponse.json(
{ error: "No tenant context — super-admins must use the admin API" },
{ status: 400 }
);
}
const { id: collectorId } = await params;
// Collectors can only query their own subscriber list
const isAdmin = user.roles?.includes(Role.ADMIN) || user.roles?.includes(Role.OFFICE_STAFF);
if (!isAdmin && user.id !== collectorId) {
return NextResponse.json(
{ error: "Collectors can only query their own subscriber list" },
{ status: 403 }
);
}
const tenantPrisma = withTenantContext(user.tenantId);
try {
const subscribers = await getCollectorSubscribers(tenantPrisma, user.tenantId, collectorId);
return NextResponse.json(subscribers);
} catch (err) {
const message = err instanceof Error ? err.message : "Failed to get collector subscribers";
if (message.includes("no zone assignments")) {
return NextResponse.json({ error: message }, { status: 400 });
}
return NextResponse.json({ error: message }, { status: 500 });
}
}
)(req);
}

View File

@@ -48,7 +48,7 @@ export function GET(req: NextRequest, { params }: { params: Promise<{ id: string
*
* Update subscriber profile fields.
* Status changes must use PATCH /api/subscribers/[id]/status.
* Accepts: { firstName?, lastName?, email?, phone?, address?, zone?, servicePlanId?, notes? }
* Accepts: { firstName?, lastName?, email?, phone?, address?, zoneId?, servicePlanId?, notes? }
*
* Requires: manage on Subscriber subject.
*
@@ -89,7 +89,7 @@ export function PUT(req: NextRequest, { params }: { params: Promise<{ id: string
email: updates.email as string | undefined,
phone: updates.phone as string | undefined,
address: updates.address as string | undefined,
zone: updates.zone as string | undefined,
zoneId: updates.zoneId as string | undefined,
servicePlanId: updates.servicePlanId as string | undefined,
notes: updates.notes as string | undefined,
autoSuspendDays: updates.autoSuspendDays as number | null | undefined,

View File

@@ -61,7 +61,7 @@ export const GET = withPermission("read", "Subscriber")(
* POST /api/subscribers
*
* Register a new subscriber.
* Accepts: { firstName, lastName, email?, phone?, address, zone?, servicePlanId, notes? }
* Accepts: { firstName, lastName, email?, phone?, address, zoneId?, servicePlanId, notes? }
*
* Requires: manage on Subscriber subject.
*
@@ -93,7 +93,7 @@ export const POST = withPermission("manage", "Subscriber")(
email,
phone,
address,
zone,
zoneId,
servicePlanId,
notes,
} = body as Record<string, unknown>;
@@ -120,7 +120,7 @@ export const POST = withPermission("manage", "Subscriber")(
email: email as string | undefined,
phone: phone as string | undefined,
address,
zone: zone as string | undefined,
zoneId: zoneId as string | undefined,
servicePlanId,
notes: notes as string | undefined,
});

View File

@@ -0,0 +1,97 @@
import { NextRequest, NextResponse } from "next/server";
import { withPermission } from "@/lib/middleware/authorize";
import { withTenantContext } from "@/lib/prisma-tenant";
import { getZone, updateZone } from "@/lib/services/zone-service";
/**
* GET /api/zones/[id]
*
* Get a single zone with its subscribers and assigned collectors.
*
* Requires: read on Zone subject.
*
* Response:
* 200 OK — zone with relations
* 404 Not Found — zone not found in tenant scope
* 401 Unauthorized — no session
* 403 Forbidden — insufficient role
*/
export function GET(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
return withPermission("read", "Zone")(
async (_req: NextRequest, { user }) => {
if (!user.tenantId) {
return NextResponse.json(
{ error: "No tenant context — super-admins must use the admin API" },
{ status: 400 }
);
}
const { id } = await params;
const tenantPrisma = withTenantContext(user.tenantId);
const zone = await getZone(tenantPrisma, id);
if (!zone) {
return NextResponse.json({ error: "Zone not found" }, { status: 404 });
}
return NextResponse.json(zone);
}
)(req);
}
/**
* PUT /api/zones/[id]
*
* Update a zone's name, description, or isActive flag.
* Accepts: { name?, description?, isActive? }
*
* Requires: update on Zone subject.
*
* Response:
* 200 OK — updated zone
* 400 Bad Request — validation error
* 404 Not Found — zone not found
* 401 Unauthorized — no session
* 403 Forbidden — insufficient role
*/
export function PUT(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
return withPermission("update", "Zone")(
async (req: NextRequest, { user }) => {
if (!user.tenantId) {
return NextResponse.json(
{ error: "No tenant context — super-admins must use the admin API" },
{ status: 400 }
);
}
const { id } = await params;
let body: unknown;
try {
body = await req.json();
} catch {
return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 });
}
const updates = body as Record<string, unknown>;
const tenantPrisma = withTenantContext(user.tenantId);
try {
const zone = await updateZone(tenantPrisma, id, {
name: updates.name as string | undefined,
description: updates.description as string | undefined,
isActive: updates.isActive as boolean | undefined,
});
return NextResponse.json(zone);
} catch (err) {
const message = err instanceof Error ? err.message : "Failed to update zone";
if (message.includes("Record to update not found") || message.includes("P2025")) {
return NextResponse.json({ error: "Zone not found" }, { status: 404 });
}
return NextResponse.json({ error: message }, { status: 400 });
}
}
)(req);
}

View File

@@ -0,0 +1,115 @@
import { NextRequest, NextResponse } from "next/server";
import { withPermission } from "@/lib/middleware/authorize";
import { withTenantContext } from "@/lib/prisma-tenant";
import { assignSubscriberToZone, removeSubscriberFromZone } from "@/lib/services/zone-service";
/**
* POST /api/zones/[id]/subscribers
*
* Assign a subscriber to this zone.
* Accepts: { subscriberId }
*
* Requires: update on Zone subject (managing zone membership).
*
* Response:
* 200 OK — updated subscriber with zoneId
* 400 Bad Request — missing subscriberId or zone not found
* 401 Unauthorized — no session
* 403 Forbidden — insufficient role
*/
export function POST(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
return withPermission("update", "Zone")(
async (req: NextRequest, { user }) => {
if (!user.tenantId) {
return NextResponse.json(
{ error: "No tenant context — super-admins must use the admin API" },
{ status: 400 }
);
}
const { id: zoneId } = await params;
let body: unknown;
try {
body = await req.json();
} catch {
return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 });
}
const { subscriberId } = body as Record<string, unknown>;
if (!subscriberId || typeof subscriberId !== "string") {
return NextResponse.json({ error: "subscriberId is required" }, { status: 400 });
}
const tenantPrisma = withTenantContext(user.tenantId);
try {
const subscriber = await assignSubscriberToZone(tenantPrisma, subscriberId, zoneId);
return NextResponse.json(subscriber);
} catch (err) {
const message = err instanceof Error ? err.message : "Failed to assign subscriber to zone";
if (message.includes("Record to update not found") || message.includes("P2025")) {
return NextResponse.json({ error: "Subscriber not found" }, { status: 404 });
}
return NextResponse.json({ error: message }, { status: 400 });
}
}
)(req);
}
/**
* DELETE /api/zones/[id]/subscribers
*
* Remove a subscriber from this zone (set zoneId to null).
* Accepts: { subscriberId }
*
* Requires: update on Zone subject (managing zone membership).
*
* Response:
* 200 OK — updated subscriber with zoneId null
* 400 Bad Request — missing subscriberId
* 401 Unauthorized — no session
* 403 Forbidden — insufficient role
*/
export function DELETE(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
return withPermission("update", "Zone")(
async (req: NextRequest, { user }) => {
if (!user.tenantId) {
return NextResponse.json(
{ error: "No tenant context — super-admins must use the admin API" },
{ status: 400 }
);
}
// params not used here (removing from any zone, not just this one)
await params;
let body: unknown;
try {
body = await req.json();
} catch {
return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 });
}
const { subscriberId } = body as Record<string, unknown>;
if (!subscriberId || typeof subscriberId !== "string") {
return NextResponse.json({ error: "subscriberId is required" }, { status: 400 });
}
const tenantPrisma = withTenantContext(user.tenantId);
try {
const subscriber = await removeSubscriberFromZone(tenantPrisma, subscriberId);
return NextResponse.json(subscriber);
} catch (err) {
const message = err instanceof Error ? err.message : "Failed to remove subscriber from zone";
if (message.includes("Record to update not found") || message.includes("P2025")) {
return NextResponse.json({ error: "Subscriber not found" }, { status: 404 });
}
return NextResponse.json({ error: message }, { status: 400 });
}
}
)(req);
}

View File

@@ -0,0 +1,83 @@
import { NextRequest, NextResponse } from "next/server";
import { withPermission } from "@/lib/middleware/authorize";
import { withTenantContext } from "@/lib/prisma-tenant";
import { createZone, listZones } from "@/lib/services/zone-service";
/**
* GET /api/zones
*
* List all zones for the authenticated tenant.
* Returns zones with subscriber count and collector count.
*
* Requires: read on Zone subject.
*
* Response:
* 200 OK — array of zones with counts
* 401 Unauthorized — no session
* 403 Forbidden — insufficient role
*/
export const GET = withPermission("read", "Zone")(
async (_req: NextRequest, { user }) => {
if (!user.tenantId) {
return NextResponse.json(
{ error: "No tenant context — super-admins must use the admin API" },
{ status: 400 }
);
}
const tenantPrisma = withTenantContext(user.tenantId);
const zones = await listZones(tenantPrisma);
return NextResponse.json(zones);
}
);
/**
* POST /api/zones
*
* Create a new zone.
* Accepts: { name, description? }
*
* Requires: create on Zone subject.
*
* Response:
* 201 Created — created zone
* 400 Bad Request — validation error or duplicate name
* 401 Unauthorized — no session
* 403 Forbidden — insufficient role
*/
export const POST = withPermission("create", "Zone")(
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 { name, description } = body as Record<string, unknown>;
if (!name || typeof name !== "string") {
return NextResponse.json({ error: "name is required" }, { status: 400 });
}
const tenantPrisma = withTenantContext(user.tenantId);
try {
const zone = await createZone(tenantPrisma, user.tenantId, {
name: name as string,
description: description as string | undefined,
});
return NextResponse.json(zone, { status: 201 });
} catch (err) {
const message = err instanceof Error ? err.message : "Failed to create zone";
return NextResponse.json({ error: message }, { status: 400 });
}
}
);

View File

@@ -256,7 +256,7 @@ describe("Subscriber CRUD", () => {
email: `alice-${TEST_TIMESTAMP}@example.com`,
phone: "+1-555-0100",
address: "123 Main St, Springfield",
zone: "Zone A",
// zone field removed — use zoneId FK (zone assignment via zone-service)
servicePlanId: planId,
notes: "Test subscriber",
});

View File

@@ -0,0 +1,618 @@
/**
* Zone Service Integration Tests
*
* Tests the full zone management lifecycle:
* - Zone CRUD (create, update, list, get)
* - Duplicate zone name prevention
* - Subscriber assignment to zone
* - Collector assignment to zone (with COLLECTOR role validation)
* - getCollectorSubscribers: zone-scoped subscriber list
* - getCollectorSubscribers: throws when no zones assigned
* - Cross-tenant isolation: Tenant A zones invisible to Tenant B
*
* These tests require a live PostgreSQL database connection.
*
* CLEANUP ORDER:
* zoneAssignments -> subscribers -> servicePlans -> zones -> users -> tenant
*/
import { prisma } from "@/lib/prisma";
import { withTenantContext } from "@/lib/prisma-tenant";
import { seedChartOfAccounts } from "@/lib/accounting/seed-coa";
import {
createZone,
updateZone,
listZones,
getZone,
assignSubscriberToZone,
removeSubscriberFromZone,
assignCollectorToZone,
removeCollectorFromZone,
getCollectorSubscribers,
getCollectorZones,
} from "@/lib/services/zone-service";
import { BillingType, TenantStatus } from "@prisma/client";
// ---------------------------------------------------------------------------
// Shared test state
// ---------------------------------------------------------------------------
const TS = Date.now();
let tenantAId: string;
let tenantBId: string;
let adminUserId: string;
let collectorUserId: string;
let nonCollectorUserId: string;
// Service plans
let planAId: string;
let planBId: string;
// Subscribers
let subAId: string;
let subBId: string;
let subCId: string;
// Zones
let zoneNorthId: string;
let zoneSouthId: string;
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function tA() {
return withTenantContext(tenantAId);
}
function tB() {
return withTenantContext(tenantBId);
}
let subCounter = 0;
async function createTestSubscriber(tenantId: string, servicePlanId: string) {
subCounter++;
const suffix = `${TS}-${subCounter}`;
return prisma.subscriber.create({
data: {
tenantId,
accountNumber: `ZON-SUB-${suffix}`,
firstName: "Zone",
lastName: `Subscriber-${suffix}`,
address: `${subCounter} Zone Test St`,
servicePlanId,
status: "ACTIVE",
billingDay: 15,
creditBalance: 0,
activatedAt: new Date(),
},
});
}
// ---------------------------------------------------------------------------
// Setup / Teardown
// ---------------------------------------------------------------------------
beforeAll(async () => {
// Create Tenant A
const tenantA = await prisma.tenant.create({
data: {
name: `Zone Test Tenant A ${TS}`,
slug: `zone-a-${TS}`,
ownerEmail: `zone-a-${TS}@test.example`,
status: TenantStatus.ACTIVE,
},
});
tenantAId = tenantA.id;
// Create Tenant B (for isolation tests)
const tenantB = await prisma.tenant.create({
data: {
name: `Zone Test Tenant B ${TS}`,
slug: `zone-b-${TS}`,
ownerEmail: `zone-b-${TS}@test.example`,
status: TenantStatus.ACTIVE,
},
});
tenantBId = tenantB.id;
// Seed COA for both tenants (required for full Prisma health)
await prisma.$transaction(async (tx) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
await seedChartOfAccounts(tx as any, tenantAId);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
await seedChartOfAccounts(tx as any, tenantBId);
});
// Create admin user (ADMIN role)
const admin = await prisma.user.create({
data: {
email: `zone-admin-${TS}@test.example`,
passwordHash: "hashed",
firstName: "Zone",
lastName: "Admin",
tenantId: tenantAId,
roles: ["ADMIN"],
isActive: true,
},
});
adminUserId = admin.id;
// Create collector user (COLLECTOR role)
const collector = await prisma.user.create({
data: {
email: `zone-collector-${TS}@test.example`,
passwordHash: "hashed",
firstName: "Zone",
lastName: "Collector",
tenantId: tenantAId,
roles: ["COLLECTOR"],
isActive: true,
},
});
collectorUserId = collector.id;
// Create non-collector user (ADMIN role — for testing role validation)
const nonCollector = await prisma.user.create({
data: {
email: `zone-noncollector-${TS}@test.example`,
passwordHash: "hashed",
firstName: "Zone",
lastName: "NonCollector",
tenantId: tenantAId,
roles: ["ADMIN"],
isActive: true,
},
});
nonCollectorUserId = nonCollector.id;
// Create service plans
const planA = await prisma.servicePlan.create({
data: {
tenantId: tenantAId,
name: `Zone Test Plan A ${TS}`,
speed: "50 Mbps",
monthlyPrice: 49.99,
billingType: BillingType.POSTPAID,
isActive: true,
},
});
planAId = planA.id;
const planB = await prisma.servicePlan.create({
data: {
tenantId: tenantBId,
name: `Zone Test Plan B ${TS}`,
speed: "50 Mbps",
monthlyPrice: 49.99,
billingType: BillingType.POSTPAID,
isActive: true,
},
});
planBId = planB.id;
// Create 3 subscribers for Tenant A
const sub1 = await createTestSubscriber(tenantAId, planAId);
const sub2 = await createTestSubscriber(tenantAId, planAId);
const sub3 = await createTestSubscriber(tenantAId, planAId);
subAId = sub1.id;
subBId = sub2.id;
subCId = sub3.id;
// Create 2 zones for Tenant A
const zoneNorth = await prisma.zone.create({
data: {
tenantId: tenantAId,
name: `North-${TS}`,
description: "Northern zone",
isActive: true,
},
});
zoneNorthId = zoneNorth.id;
const zoneSouth = await prisma.zone.create({
data: {
tenantId: tenantAId,
name: `South-${TS}`,
description: "Southern zone",
isActive: true,
},
});
zoneSouthId = zoneSouth.id;
});
afterAll(async () => {
for (const tid of [tenantAId, tenantBId]) {
if (!tid) continue;
// 1. Zone assignments (FK on user + zone)
await prisma.zoneAssignment.deleteMany({ where: { tenantId: tid } }).catch(() => {});
// 2. Subscribers (FK on zone)
await prisma.subscriber.deleteMany({ where: { tenantId: tid } }).catch(() => {});
// 3. Service plans
await prisma.servicePlan.deleteMany({ where: { tenantId: tid } }).catch(() => {});
// 4. Zones
await prisma.zone.deleteMany({ where: { tenantId: tid } }).catch(() => {});
// 5. Accounting-related data (from COA seed)
await prisma.accountingPeriod.deleteMany({ where: { tenantId: tid } }).catch(() => {});
await prisma.account.deleteMany({ where: { tenantId: tid } }).catch(() => {});
// 6. Users
await prisma.user.deleteMany({ where: { tenantId: tid } }).catch(() => {});
// 7. Tenant
await prisma.tenant.delete({ where: { id: tid } }).catch(() => {});
}
await prisma.$disconnect();
});
// ===========================================================================
// createZone
// ===========================================================================
describe("createZone", () => {
it("creates a zone with name and description", async () => {
const zone = await createZone(tA(), tenantAId, {
name: `East-${TS}`,
description: "Eastern zone",
});
expect(zone.id).toBeDefined();
expect(zone.name).toBe(`East-${TS}`);
expect(zone.description).toBe("Eastern zone");
expect(zone.isActive).toBe(true);
expect(zone.tenantId).toBe(tenantAId);
// Cleanup — delete this zone after test
await prisma.zone.delete({ where: { id: zone.id } }).catch(() => {});
});
it("creates a zone without description", async () => {
const zone = await createZone(tA(), tenantAId, {
name: `West-${TS}`,
});
expect(zone.name).toBe(`West-${TS}`);
expect(zone.description).toBeNull();
await prisma.zone.delete({ where: { id: zone.id } }).catch(() => {});
});
it("throws on duplicate zone name within tenant", async () => {
await expect(
createZone(tA(), tenantAId, { name: `North-${TS}` })
).rejects.toThrow(/already exists/i);
});
it("throws on empty zone name", async () => {
await expect(
createZone(tA(), tenantAId, { name: "" })
).rejects.toThrow(/required/i);
});
});
// ===========================================================================
// updateZone
// ===========================================================================
describe("updateZone", () => {
it("updates zone name", async () => {
const zone = await createZone(tA(), tenantAId, { name: `UpdateTest-${TS}` });
const updated = await updateZone(tA(), zone.id, { name: `UpdatedName-${TS}` });
expect(updated.name).toBe(`UpdatedName-${TS}`);
await prisma.zone.delete({ where: { id: zone.id } }).catch(() => {});
});
it("updates zone description", async () => {
const zone = await createZone(tA(), tenantAId, { name: `DescTest-${TS}` });
const updated = await updateZone(tA(), zone.id, { description: "New description" });
expect(updated.description).toBe("New description");
await prisma.zone.delete({ where: { id: zone.id } }).catch(() => {});
});
it("sets isActive to false", async () => {
const zone = await createZone(tA(), tenantAId, { name: `ActiveTest-${TS}` });
const updated = await updateZone(tA(), zone.id, { isActive: false });
expect(updated.isActive).toBe(false);
await prisma.zone.delete({ where: { id: zone.id } }).catch(() => {});
});
});
// ===========================================================================
// listZones
// ===========================================================================
describe("listZones", () => {
it("returns zones with subscriber count and collector count", async () => {
const zones = await listZones(tA());
// At minimum North and South should be in the list
const north = zones.find((z: { name: string }) => z.name === `North-${TS}`);
const south = zones.find((z: { name: string }) => z.name === `South-${TS}`);
expect(north).toBeDefined();
expect(south).toBeDefined();
expect(typeof north.subscriberCount).toBe("number");
expect(typeof north.collectorCount).toBe("number");
});
it("returns zones ordered by name alphabetically", async () => {
const zones = await listZones(tA());
const names = zones.map((z: { name: string }) => z.name);
// Verify sorting (names should be alphabetically ordered)
const sorted = [...names].sort();
expect(names).toEqual(sorted);
});
});
// ===========================================================================
// assignSubscriberToZone
// ===========================================================================
describe("assignSubscriberToZone", () => {
it("assigns a subscriber to a zone (updates zoneId)", async () => {
const subscriber = await assignSubscriberToZone(tA(), subAId, zoneNorthId);
expect(subscriber.zoneId).toBe(zoneNorthId);
// Verify in DB
const dbSub = await prisma.subscriber.findUnique({ where: { id: subAId } });
expect(dbSub?.zoneId).toBe(zoneNorthId);
});
it("throws if zone not found in tenant scope", async () => {
await expect(
assignSubscriberToZone(tA(), subBId, "non-existent-zone-id")
).rejects.toThrow(/zone not found/i);
});
});
// ===========================================================================
// removeSubscriberFromZone
// ===========================================================================
describe("removeSubscriberFromZone", () => {
it("sets subscriber zoneId to null", async () => {
// First assign
await prisma.subscriber.update({
where: { id: subCId },
data: { zoneId: zoneSouthId },
});
// Then remove
const subscriber = await removeSubscriberFromZone(tA(), subCId);
expect(subscriber.zoneId).toBeNull();
const dbSub = await prisma.subscriber.findUnique({ where: { id: subCId } });
expect(dbSub?.zoneId).toBeNull();
});
});
// ===========================================================================
// assignCollectorToZone
// ===========================================================================
describe("assignCollectorToZone", () => {
it("creates a ZoneAssignment for a valid COLLECTOR user", async () => {
const assignment = await assignCollectorToZone(
tA(),
tenantAId,
collectorUserId,
zoneNorthId
);
expect(assignment.userId).toBe(collectorUserId);
expect(assignment.zoneId).toBe(zoneNorthId);
expect(assignment.tenantId).toBe(tenantAId);
});
it("is idempotent — assigning same collector+zone twice doesn't error", async () => {
// Assign again — should not throw
const assignment = await assignCollectorToZone(
tA(),
tenantAId,
collectorUserId,
zoneNorthId
);
expect(assignment.userId).toBe(collectorUserId);
});
it("throws if user does not have COLLECTOR role", async () => {
await expect(
assignCollectorToZone(tA(), tenantAId, nonCollectorUserId, zoneNorthId)
).rejects.toThrow(/COLLECTOR role/i);
});
it("throws if zone not found", async () => {
await expect(
assignCollectorToZone(tA(), tenantAId, collectorUserId, "non-existent-zone")
).rejects.toThrow(/zone not found/i);
});
});
// ===========================================================================
// getCollectorSubscribers
// ===========================================================================
describe("getCollectorSubscribers", () => {
beforeAll(async () => {
// Setup: assign subA to North zone, assign collector to North zone
await prisma.subscriber.update({
where: { id: subAId },
data: { zoneId: zoneNorthId },
});
await prisma.subscriber.update({
where: { id: subBId },
data: { zoneId: zoneSouthId },
});
// subC has no zone (from removeSubscriberFromZone test above)
// Make sure collector is assigned to North zone
await assignCollectorToZone(tA(), tenantAId, collectorUserId, zoneNorthId);
});
it("returns only subscribers in collector's assigned zones", async () => {
const subscribers = await getCollectorSubscribers(tA(), tenantAId, collectorUserId);
const ids = subscribers.map((s: { id: string }) => s.id);
// subA is in North (collector's zone) — should appear
expect(ids).toContain(subAId);
// subB is in South (not collector's zone) — should NOT appear
expect(ids).not.toContain(subBId);
// subC has no zone — should NOT appear
expect(ids).not.toContain(subCId);
});
it("returns subscriber basic info with zone name", async () => {
const subscribers = await getCollectorSubscribers(tA(), tenantAId, collectorUserId);
const sub = subscribers.find((s: { id: string }) => s.id === subAId);
expect(sub).toBeDefined();
expect(sub.accountNumber).toBeDefined();
expect(sub.firstName).toBeDefined();
expect(sub.lastName).toBeDefined();
expect(sub.address).toBeDefined();
expect(sub.zone).toBeDefined();
expect(sub.zone.name).toBe(`North-${TS}`);
});
it("throws error when collector has no zone assignments", async () => {
// Create a new collector user with no zone assignments
const unassignedCollector = await prisma.user.create({
data: {
email: `unassigned-collector-${TS}@test.example`,
passwordHash: "hashed",
firstName: "Unassigned",
lastName: "Collector",
tenantId: tenantAId,
roles: ["COLLECTOR"],
isActive: true,
},
});
await expect(
getCollectorSubscribers(tA(), tenantAId, unassignedCollector.id)
).rejects.toThrow(/no zone assignments/i);
// Cleanup
await prisma.user.delete({ where: { id: unassignedCollector.id } }).catch(() => {});
});
});
// ===========================================================================
// getCollectorZones
// ===========================================================================
describe("getCollectorZones", () => {
it("returns zones assigned to a collector", async () => {
const zones = await getCollectorZones(tA(), collectorUserId);
const ids = zones.map((z: { id: string }) => z.id);
expect(ids).toContain(zoneNorthId);
});
});
// ===========================================================================
// removeCollectorFromZone
// ===========================================================================
describe("removeCollectorFromZone", () => {
it("removes a zone assignment", async () => {
// First assign to South
await assignCollectorToZone(tA(), tenantAId, collectorUserId, zoneSouthId);
let zones = await getCollectorZones(tA(), collectorUserId);
expect(zones.map((z: { id: string }) => z.id)).toContain(zoneSouthId);
// Remove from South
await removeCollectorFromZone(tA(), tenantAId, collectorUserId, zoneSouthId);
zones = await getCollectorZones(tA(), collectorUserId);
expect(zones.map((z: { id: string }) => z.id)).not.toContain(zoneSouthId);
});
});
// ===========================================================================
// getZone (single zone with relations)
// ===========================================================================
describe("getZone", () => {
it("returns zone with subscribers and assignments", async () => {
const zone = await getZone(tA(), zoneNorthId);
expect(zone).not.toBeNull();
expect(zone.id).toBe(zoneNorthId);
expect(zone.name).toBe(`North-${TS}`);
expect(Array.isArray(zone.subscribers)).toBe(true);
expect(Array.isArray(zone.assignments)).toBe(true);
// subA should be in North's subscribers
const subIds = zone.subscribers.map((s: { id: string }) => s.id);
expect(subIds).toContain(subAId);
});
it("returns null for non-existent zone", async () => {
const zone = await getZone(tA(), "non-existent-zone-id");
expect(zone).toBeNull();
});
});
// ===========================================================================
// Cross-tenant isolation
// ===========================================================================
describe("Cross-tenant isolation", () => {
it("Tenant B cannot see Tenant A zones", async () => {
// Create a zone for Tenant B
const zoneBId = (
await prisma.zone.create({
data: {
tenantId: tenantBId,
name: `TenantB-Zone-${TS}`,
isActive: true,
},
})
).id;
// Tenant B's zone list
const tenantBZones = await listZones(tB());
const tenantBZoneIds = tenantBZones.map((z: { id: string }) => z.id);
// Tenant A's zones should not appear in Tenant B
expect(tenantBZoneIds).not.toContain(zoneNorthId);
expect(tenantBZoneIds).not.toContain(zoneSouthId);
// Tenant B's zone should appear for Tenant B
expect(tenantBZoneIds).toContain(zoneBId);
// Tenant A's zone list should not include Tenant B's zone
const tenantAZones = await listZones(tA());
const tenantAZoneIds = tenantAZones.map((z: { id: string }) => z.id);
expect(tenantAZoneIds).not.toContain(zoneBId);
// Cleanup
await prisma.zone.delete({ where: { id: zoneBId } }).catch(() => {});
});
it("getZone returns null for cross-tenant zone lookup", async () => {
// Create a zone for Tenant B
const zoneBId = (
await prisma.zone.create({
data: {
tenantId: tenantBId,
name: `TenantB-Isolation-${TS}`,
isActive: true,
},
})
).id;
// Tenant A's tenantPrisma cannot see Tenant B's zone
const result = await getZone(tA(), zoneBId);
expect(result).toBeNull();
// Cleanup
await prisma.zone.delete({ where: { id: zoneBId } }).catch(() => {});
});
});

View File

@@ -27,7 +27,7 @@ export interface CreateSubscriberInput {
email?: string;
phone?: string;
address: string;
zone?: string;
zoneId?: string;
servicePlanId: string;
notes?: string;
}
@@ -38,7 +38,7 @@ export interface UpdateSubscriberInput {
email?: string;
phone?: string;
address?: string;
zone?: string;
zoneId?: string;
servicePlanId?: string;
notes?: string;
autoSuspendDays?: number | null;
@@ -111,7 +111,7 @@ export async function createSubscriber(
tenantPrisma: TenantPrisma,
input: CreateSubscriberInput
) {
const { firstName, lastName, address, servicePlanId, email, phone, zone, notes } = input;
const { firstName, lastName, address, servicePlanId, email, phone, zoneId, notes } = input;
if (!firstName || firstName.trim() === "") {
throw new Error("First name is required");
@@ -150,7 +150,7 @@ export async function createSubscriber(
email: email?.trim() ?? null,
phone: phone?.trim() ?? null,
address: address.trim(),
zone: zone?.trim() ?? null,
zoneId: zoneId ?? null,
servicePlanId,
status: SubscriberStatus.ACTIVE,
billingDay,
@@ -190,7 +190,7 @@ export async function updateSubscriber(
if (updates.email !== undefined) data.email = updates.email?.trim() ?? null;
if (updates.phone !== undefined) data.phone = updates.phone?.trim() ?? null;
if (updates.address !== undefined) data.address = updates.address.trim();
if (updates.zone !== undefined) data.zone = updates.zone?.trim() ?? null;
if (updates.zoneId !== undefined) data.zoneId = updates.zoneId ?? null;
if (updates.servicePlanId !== undefined) data.servicePlanId = updates.servicePlanId;
if (updates.notes !== undefined) data.notes = updates.notes?.trim() ?? null;
if ("autoSuspendDays" in updates) data.autoSuspendDays = updates.autoSuspendDays ?? null;

View File

@@ -0,0 +1,375 @@
/**
* ZoneService — Zone CRUD, subscriber assignment, collector zone scoping.
*
* ARCHITECTURE:
* Zones group subscribers geographically for collector routing.
* - Zones are tenant-scoped; each tenant manages their own zones.
* - Subscribers are assigned to zones via Subscriber.zoneId FK.
* - Collectors are assigned to zones via ZoneAssignment join table.
* - Collectors can ONLY query subscribers in their assigned zones.
* This is a SECURITY BOUNDARY enforced at the data layer.
*
* COLLECTOR SCOPING RULE (from RESEARCH.md):
* getCollectorSubscribers THROWS if collector has no zone assignments.
* An unassigned collector should not see any subscriber data at all.
*/
import { Role } from "@prisma/client";
import { withTenantContext } from "@/lib/prisma-tenant";
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type TenantPrismaClient = any;
// ---------------------------------------------------------------------------
// Input types
// ---------------------------------------------------------------------------
export interface CreateZoneInput {
name: string;
description?: string;
}
export interface UpdateZoneInput {
name?: string;
description?: string;
isActive?: boolean;
}
export type ZoneServiceClient = ReturnType<typeof withTenantContext>;
// ---------------------------------------------------------------------------
// createZone
// ---------------------------------------------------------------------------
/**
* Create a new zone within the tenant.
*
* @throws Error if zone name already exists within the tenant
*/
export async function createZone(
tenantPrisma: TenantPrismaClient,
tenantId: string,
input: CreateZoneInput
) {
const { name, description } = input;
if (!name || name.trim() === "") {
throw new Error("Zone name is required");
}
// Check for duplicate name (the @@unique constraint will also catch this,
// but we throw a friendly error before hitting the DB constraint)
const existing = await tenantPrisma.zone.findFirst({
where: { name: name.trim() },
select: { id: true },
});
if (existing) {
throw new Error(`Zone with name "${name.trim()}" already exists`);
}
return tenantPrisma.zone.create({
data: {
tenantId,
name: name.trim(),
description: description?.trim() ?? null,
isActive: true,
},
});
}
// ---------------------------------------------------------------------------
// updateZone
// ---------------------------------------------------------------------------
/**
* Update a zone's name, description, or isActive flag.
*
* @throws Error if zone not found within tenant
*/
export async function updateZone(
tenantPrisma: TenantPrismaClient,
zoneId: string,
input: UpdateZoneInput
) {
const data: Record<string, unknown> = {};
if (input.name !== undefined) data.name = input.name.trim();
if (input.description !== undefined) data.description = input.description?.trim() ?? null;
if (input.isActive !== undefined) data.isActive = input.isActive;
return tenantPrisma.zone.update({
where: { id: zoneId },
data,
});
}
// ---------------------------------------------------------------------------
// listZones
// ---------------------------------------------------------------------------
/**
* List all zones for the tenant with subscriber count and assigned collector count.
*/
export async function listZones(tenantPrisma: TenantPrismaClient) {
const zones = await tenantPrisma.zone.findMany({
orderBy: { name: "asc" },
include: {
_count: {
select: {
subscribers: true,
assignments: true,
},
},
},
});
return zones.map((zone: {
id: string;
tenantId: string;
name: string;
description: string | null;
isActive: boolean;
createdAt: Date;
updatedAt: Date;
_count: { subscribers: number; assignments: number };
}) => ({
id: zone.id,
tenantId: zone.tenantId,
name: zone.name,
description: zone.description,
isActive: zone.isActive,
createdAt: zone.createdAt,
updatedAt: zone.updatedAt,
subscriberCount: zone._count.subscribers,
collectorCount: zone._count.assignments,
}));
}
// ---------------------------------------------------------------------------
// getZone
// ---------------------------------------------------------------------------
/**
* Get a single zone with subscribers and assignments.
*
* Returns null if not found within tenant scope.
*/
export async function getZone(tenantPrisma: TenantPrismaClient, zoneId: string) {
return tenantPrisma.zone.findFirst({
where: { id: zoneId },
include: {
subscribers: {
select: {
id: true,
accountNumber: true,
firstName: true,
lastName: true,
address: true,
status: true,
},
},
assignments: {
include: {
user: {
select: {
id: true,
firstName: true,
lastName: true,
email: true,
roles: true,
},
},
},
},
},
});
}
// ---------------------------------------------------------------------------
// assignSubscriberToZone
// ---------------------------------------------------------------------------
/**
* Assign a subscriber to a zone by updating the zoneId FK.
*
* @throws Error if subscriber or zone not found within tenant
*/
export async function assignSubscriberToZone(
tenantPrisma: TenantPrismaClient,
subscriberId: string,
zoneId: string
) {
// Verify zone exists in tenant scope
const zone = await tenantPrisma.zone.findFirst({
where: { id: zoneId },
select: { id: true },
});
if (!zone) {
throw new Error(`Zone not found: ${zoneId}`);
}
return tenantPrisma.subscriber.update({
where: { id: subscriberId },
data: { zoneId },
});
}
// ---------------------------------------------------------------------------
// removeSubscriberFromZone
// ---------------------------------------------------------------------------
/**
* Remove a subscriber from their zone by setting zoneId to null.
*/
export async function removeSubscriberFromZone(
tenantPrisma: TenantPrismaClient,
subscriberId: string
) {
return tenantPrisma.subscriber.update({
where: { id: subscriberId },
data: { zoneId: null },
});
}
// ---------------------------------------------------------------------------
// assignCollectorToZone
// ---------------------------------------------------------------------------
/**
* Assign a collector user to a zone by creating a ZoneAssignment.
*
* Validates that the user has the COLLECTOR role before creating the assignment.
*
* @throws Error if user doesn't have COLLECTOR role, or zone/user not found
*/
export async function assignCollectorToZone(
tenantPrisma: TenantPrismaClient,
tenantId: string,
userId: string,
zoneId: string
) {
// Validate user exists and has COLLECTOR role
const user = await tenantPrisma.user.findFirst({
where: { id: userId },
select: { id: true, roles: true },
});
if (!user) {
throw new Error(`User not found: ${userId}`);
}
if (!user.roles.includes(Role.COLLECTOR)) {
throw new Error(`User ${userId} does not have the COLLECTOR role`);
}
// Validate zone exists
const zone = await tenantPrisma.zone.findFirst({
where: { id: zoneId },
select: { id: true },
});
if (!zone) {
throw new Error(`Zone not found: ${zoneId}`);
}
// Upsert to avoid duplicate (tenantId, userId, zoneId) unique constraint errors
return tenantPrisma.zoneAssignment.upsert({
where: { tenantId_userId_zoneId: { tenantId, userId, zoneId } },
create: { tenantId, userId, zoneId },
update: {},
});
}
// ---------------------------------------------------------------------------
// removeCollectorFromZone
// ---------------------------------------------------------------------------
/**
* Remove a collector's assignment to a zone.
*/
export async function removeCollectorFromZone(
tenantPrisma: TenantPrismaClient,
tenantId: string,
userId: string,
zoneId: string
) {
return tenantPrisma.zoneAssignment.deleteMany({
where: { tenantId, userId, zoneId },
});
}
// ---------------------------------------------------------------------------
// getCollectorZones
// ---------------------------------------------------------------------------
/**
* Get all zones assigned to a collector user.
*/
export async function getCollectorZones(
tenantPrisma: TenantPrismaClient,
collectorUserId: string
) {
const assignments = await tenantPrisma.zoneAssignment.findMany({
where: { userId: collectorUserId },
include: {
zone: true,
},
});
return assignments.map((a: { zone: unknown }) => a.zone);
}
// ---------------------------------------------------------------------------
// getCollectorSubscribers
// ---------------------------------------------------------------------------
/**
* Get all subscribers within a collector's assigned zones.
*
* SECURITY BOUNDARY: Throws an error if the collector has no zone assignments.
* An unassigned collector should not be able to see ANY subscriber data.
* This is enforced at the data layer — CASL alone cannot enforce this rule
* because it requires checking zone assignments, not just role.
*
* @throws Error if collector has no zone assignments
*/
export async function getCollectorSubscribers(
tenantPrisma: TenantPrismaClient,
tenantId: string,
collectorUserId: string
) {
// Find zones assigned to this collector
const assignments = await tenantPrisma.zoneAssignment.findMany({
where: { userId: collectorUserId },
select: { zoneId: true },
});
if (assignments.length === 0) {
throw new Error(
`Collector ${collectorUserId} has no zone assignments — cannot query subscribers. ` +
`Assign the collector to at least one zone first.`
);
}
const zoneIds = assignments.map((a: { zoneId: string }) => a.zoneId);
return tenantPrisma.subscriber.findMany({
where: {
zoneId: { in: zoneIds },
},
select: {
id: true,
accountNumber: true,
firstName: true,
lastName: true,
address: true,
status: true,
zone: {
select: {
id: true,
name: true,
},
},
},
orderBy: [
{ zone: { name: "asc" } },
{ lastName: "asc" },
],
});
}