From 214df6cd92fbaec750380f1a41bbeadeb9d9750b Mon Sep 17 00:00:00 2001 From: kevin-asprec Date: Thu, 5 Mar 2026 07:30:34 +0800 Subject: [PATCH] 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) --- .../api/collectors/[id]/subscribers/route.ts | 61 ++ src/app/api/subscribers/[id]/route.ts | 4 +- src/app/api/subscribers/route.ts | 6 +- src/app/api/zones/[id]/route.ts | 97 +++ src/app/api/zones/[id]/subscribers/route.ts | 115 ++++ src/app/api/zones/route.ts | 83 +++ src/lib/__tests__/subscriber.test.ts | 2 +- src/lib/__tests__/zone-service.test.ts | 618 ++++++++++++++++++ src/lib/services/subscriber-service.ts | 10 +- src/lib/services/zone-service.ts | 375 +++++++++++ 10 files changed, 1360 insertions(+), 11 deletions(-) create mode 100644 src/app/api/collectors/[id]/subscribers/route.ts create mode 100644 src/app/api/zones/[id]/route.ts create mode 100644 src/app/api/zones/[id]/subscribers/route.ts create mode 100644 src/app/api/zones/route.ts create mode 100644 src/lib/__tests__/zone-service.test.ts create mode 100644 src/lib/services/zone-service.ts diff --git a/src/app/api/collectors/[id]/subscribers/route.ts b/src/app/api/collectors/[id]/subscribers/route.ts new file mode 100644 index 0000000..8993721 --- /dev/null +++ b/src/app/api/collectors/[id]/subscribers/route.ts @@ -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); +} diff --git a/src/app/api/subscribers/[id]/route.ts b/src/app/api/subscribers/[id]/route.ts index 9f1e18d..1447161 100644 --- a/src/app/api/subscribers/[id]/route.ts +++ b/src/app/api/subscribers/[id]/route.ts @@ -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, diff --git a/src/app/api/subscribers/route.ts b/src/app/api/subscribers/route.ts index a7e4fb4..83a054c 100644 --- a/src/app/api/subscribers/route.ts +++ b/src/app/api/subscribers/route.ts @@ -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; @@ -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, }); diff --git a/src/app/api/zones/[id]/route.ts b/src/app/api/zones/[id]/route.ts new file mode 100644 index 0000000..0130c70 --- /dev/null +++ b/src/app/api/zones/[id]/route.ts @@ -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; + + 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); +} diff --git a/src/app/api/zones/[id]/subscribers/route.ts b/src/app/api/zones/[id]/subscribers/route.ts new file mode 100644 index 0000000..d9efded --- /dev/null +++ b/src/app/api/zones/[id]/subscribers/route.ts @@ -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; + + 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; + + 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); +} diff --git a/src/app/api/zones/route.ts b/src/app/api/zones/route.ts new file mode 100644 index 0000000..b757987 --- /dev/null +++ b/src/app/api/zones/route.ts @@ -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; + + 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 }); + } + } +); diff --git a/src/lib/__tests__/subscriber.test.ts b/src/lib/__tests__/subscriber.test.ts index 1fec822..bc3639b 100644 --- a/src/lib/__tests__/subscriber.test.ts +++ b/src/lib/__tests__/subscriber.test.ts @@ -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", }); diff --git a/src/lib/__tests__/zone-service.test.ts b/src/lib/__tests__/zone-service.test.ts new file mode 100644 index 0000000..44134ab --- /dev/null +++ b/src/lib/__tests__/zone-service.test.ts @@ -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(() => {}); + }); +}); diff --git a/src/lib/services/subscriber-service.ts b/src/lib/services/subscriber-service.ts index 27944dc..884950f 100644 --- a/src/lib/services/subscriber-service.ts +++ b/src/lib/services/subscriber-service.ts @@ -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; diff --git a/src/lib/services/zone-service.ts b/src/lib/services/zone-service.ts new file mode 100644 index 0000000..3c4f3d0 --- /dev/null +++ b/src/lib/services/zone-service.ts @@ -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; + +// --------------------------------------------------------------------------- +// 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 = {}; + 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" }, + ], + }); +}