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:
61
src/app/api/collectors/[id]/subscribers/route.ts
Normal file
61
src/app/api/collectors/[id]/subscribers/route.ts
Normal 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);
|
||||||
|
}
|
||||||
@@ -48,7 +48,7 @@ export function GET(req: NextRequest, { params }: { params: Promise<{ id: string
|
|||||||
*
|
*
|
||||||
* Update subscriber profile fields.
|
* Update subscriber profile fields.
|
||||||
* Status changes must use PATCH /api/subscribers/[id]/status.
|
* 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.
|
* 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,
|
email: updates.email as string | undefined,
|
||||||
phone: updates.phone as string | undefined,
|
phone: updates.phone as string | undefined,
|
||||||
address: updates.address 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,
|
servicePlanId: updates.servicePlanId as string | undefined,
|
||||||
notes: updates.notes as string | undefined,
|
notes: updates.notes as string | undefined,
|
||||||
autoSuspendDays: updates.autoSuspendDays as number | null | undefined,
|
autoSuspendDays: updates.autoSuspendDays as number | null | undefined,
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ export const GET = withPermission("read", "Subscriber")(
|
|||||||
* POST /api/subscribers
|
* POST /api/subscribers
|
||||||
*
|
*
|
||||||
* Register a new subscriber.
|
* 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.
|
* Requires: manage on Subscriber subject.
|
||||||
*
|
*
|
||||||
@@ -93,7 +93,7 @@ export const POST = withPermission("manage", "Subscriber")(
|
|||||||
email,
|
email,
|
||||||
phone,
|
phone,
|
||||||
address,
|
address,
|
||||||
zone,
|
zoneId,
|
||||||
servicePlanId,
|
servicePlanId,
|
||||||
notes,
|
notes,
|
||||||
} = body as Record<string, unknown>;
|
} = body as Record<string, unknown>;
|
||||||
@@ -120,7 +120,7 @@ export const POST = withPermission("manage", "Subscriber")(
|
|||||||
email: email as string | undefined,
|
email: email as string | undefined,
|
||||||
phone: phone as string | undefined,
|
phone: phone as string | undefined,
|
||||||
address,
|
address,
|
||||||
zone: zone as string | undefined,
|
zoneId: zoneId as string | undefined,
|
||||||
servicePlanId,
|
servicePlanId,
|
||||||
notes: notes as string | undefined,
|
notes: notes as string | undefined,
|
||||||
});
|
});
|
||||||
|
|||||||
97
src/app/api/zones/[id]/route.ts
Normal file
97
src/app/api/zones/[id]/route.ts
Normal 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);
|
||||||
|
}
|
||||||
115
src/app/api/zones/[id]/subscribers/route.ts
Normal file
115
src/app/api/zones/[id]/subscribers/route.ts
Normal 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);
|
||||||
|
}
|
||||||
83
src/app/api/zones/route.ts
Normal file
83
src/app/api/zones/route.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
@@ -256,7 +256,7 @@ describe("Subscriber CRUD", () => {
|
|||||||
email: `alice-${TEST_TIMESTAMP}@example.com`,
|
email: `alice-${TEST_TIMESTAMP}@example.com`,
|
||||||
phone: "+1-555-0100",
|
phone: "+1-555-0100",
|
||||||
address: "123 Main St, Springfield",
|
address: "123 Main St, Springfield",
|
||||||
zone: "Zone A",
|
// zone field removed — use zoneId FK (zone assignment via zone-service)
|
||||||
servicePlanId: planId,
|
servicePlanId: planId,
|
||||||
notes: "Test subscriber",
|
notes: "Test subscriber",
|
||||||
});
|
});
|
||||||
|
|||||||
618
src/lib/__tests__/zone-service.test.ts
Normal file
618
src/lib/__tests__/zone-service.test.ts
Normal 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(() => {});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -27,7 +27,7 @@ export interface CreateSubscriberInput {
|
|||||||
email?: string;
|
email?: string;
|
||||||
phone?: string;
|
phone?: string;
|
||||||
address: string;
|
address: string;
|
||||||
zone?: string;
|
zoneId?: string;
|
||||||
servicePlanId: string;
|
servicePlanId: string;
|
||||||
notes?: string;
|
notes?: string;
|
||||||
}
|
}
|
||||||
@@ -38,7 +38,7 @@ export interface UpdateSubscriberInput {
|
|||||||
email?: string;
|
email?: string;
|
||||||
phone?: string;
|
phone?: string;
|
||||||
address?: string;
|
address?: string;
|
||||||
zone?: string;
|
zoneId?: string;
|
||||||
servicePlanId?: string;
|
servicePlanId?: string;
|
||||||
notes?: string;
|
notes?: string;
|
||||||
autoSuspendDays?: number | null;
|
autoSuspendDays?: number | null;
|
||||||
@@ -111,7 +111,7 @@ export async function createSubscriber(
|
|||||||
tenantPrisma: TenantPrisma,
|
tenantPrisma: TenantPrisma,
|
||||||
input: CreateSubscriberInput
|
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() === "") {
|
if (!firstName || firstName.trim() === "") {
|
||||||
throw new Error("First name is required");
|
throw new Error("First name is required");
|
||||||
@@ -150,7 +150,7 @@ export async function createSubscriber(
|
|||||||
email: email?.trim() ?? null,
|
email: email?.trim() ?? null,
|
||||||
phone: phone?.trim() ?? null,
|
phone: phone?.trim() ?? null,
|
||||||
address: address.trim(),
|
address: address.trim(),
|
||||||
zone: zone?.trim() ?? null,
|
zoneId: zoneId ?? null,
|
||||||
servicePlanId,
|
servicePlanId,
|
||||||
status: SubscriberStatus.ACTIVE,
|
status: SubscriberStatus.ACTIVE,
|
||||||
billingDay,
|
billingDay,
|
||||||
@@ -190,7 +190,7 @@ export async function updateSubscriber(
|
|||||||
if (updates.email !== undefined) data.email = updates.email?.trim() ?? null;
|
if (updates.email !== undefined) data.email = updates.email?.trim() ?? null;
|
||||||
if (updates.phone !== undefined) data.phone = updates.phone?.trim() ?? null;
|
if (updates.phone !== undefined) data.phone = updates.phone?.trim() ?? null;
|
||||||
if (updates.address !== undefined) data.address = updates.address.trim();
|
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.servicePlanId !== undefined) data.servicePlanId = updates.servicePlanId;
|
||||||
if (updates.notes !== undefined) data.notes = updates.notes?.trim() ?? null;
|
if (updates.notes !== undefined) data.notes = updates.notes?.trim() ?? null;
|
||||||
if ("autoSuspendDays" in updates) data.autoSuspendDays = updates.autoSuspendDays ?? null;
|
if ("autoSuspendDays" in updates) data.autoSuspendDays = updates.autoSuspendDays ?? null;
|
||||||
|
|||||||
375
src/lib/services/zone-service.ts
Normal file
375
src/lib/services/zone-service.ts
Normal 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" },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user