docs(03): create phase plan
Phase 03: Operational Modules - 5 plans in 3 waves - Wave 1: 03-01 (zones), 03-03 (tickets) — parallel - Wave 2: 03-02 (collector collections), 03-04 (job orders) — parallel - Wave 3: 03-05 (technician compensation) - Ready for execution Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -8,6 +8,8 @@ files_modified:
|
||||
- prisma/schema.prisma
|
||||
- src/lib/prisma-tenant.ts
|
||||
- src/lib/services/zone-service.ts
|
||||
- src/lib/casl/types.ts
|
||||
- src/lib/casl/permissions.ts
|
||||
- src/app/api/zones/route.ts
|
||||
- src/app/api/zones/[id]/route.ts
|
||||
- src/app/api/zones/[id]/subscribers/route.ts
|
||||
@@ -17,40 +19,44 @@ autonomous: true
|
||||
|
||||
must_haves:
|
||||
truths:
|
||||
- "Admin can create, update, and deactivate zones for their tenant"
|
||||
- "Admin can assign subscribers to a zone"
|
||||
- "Admin can assign a collector user to a zone"
|
||||
- "A collector can only see subscribers assigned to their zone(s)"
|
||||
- "Admin can create, read, update zones with name and description"
|
||||
- "Admin can assign subscribers to zones via zoneId FK"
|
||||
- "Admin can assign collectors to zones via ZoneAssignment join"
|
||||
- "Collector can only query subscribers within their assigned zones"
|
||||
- "Zone data is tenant-scoped — Tenant B cannot see Tenant A zones"
|
||||
artifacts:
|
||||
- path: "prisma/schema.prisma"
|
||||
provides: "Zone model with name, description, isActive; ZoneAssignment linking collector users to zones"
|
||||
provides: "Zone and ZoneAssignment models, Subscriber.zoneId FK replacing zone String?"
|
||||
contains: "model Zone"
|
||||
- path: "src/lib/services/zone-service.ts"
|
||||
provides: "Zone CRUD, subscriber zone assignment, collector zone assignment, getCollectorSubscribers"
|
||||
exports: ["ZoneService"]
|
||||
- path: "src/app/api/zones/route.ts"
|
||||
provides: "GET list zones, POST create zone"
|
||||
exports: ["GET", "POST"]
|
||||
provides: "Zone CRUD, subscriber assignment, collector zone scoping"
|
||||
exports: ["createZone", "updateZone", "listZones", "assignSubscriberToZone", "getCollectorSubscribers"]
|
||||
- path: "src/lib/prisma-tenant.ts"
|
||||
provides: "Tenant-scoped query blocks for Zone and ZoneAssignment"
|
||||
contains: "zone"
|
||||
- path: "src/lib/__tests__/zone-service.test.ts"
|
||||
provides: "Integration tests for zone CRUD, assignment, collector scoping"
|
||||
min_lines: 80
|
||||
min_lines: 100
|
||||
key_links:
|
||||
- from: "src/lib/services/zone-service.ts"
|
||||
to: "prisma/schema.prisma"
|
||||
via: "Prisma client queries on Zone and ZoneAssignment"
|
||||
pattern: "prisma\\.zone\\."
|
||||
via: "tenantPrisma.zone and tenantPrisma.zoneAssignment queries"
|
||||
pattern: "tenantPrisma\\.zone\\."
|
||||
- from: "src/app/api/zones/route.ts"
|
||||
to: "src/lib/services/zone-service.ts"
|
||||
via: "withPermission HOF wrapping service calls"
|
||||
pattern: "withPermission.*Zone"
|
||||
- from: "src/app/api/collectors/[id]/subscribers/route.ts"
|
||||
to: "src/lib/services/zone-service.ts"
|
||||
via: "getCollectorSubscribers returns only zone-scoped subscribers"
|
||||
via: "getCollectorSubscribers for zone-scoped subscriber list"
|
||||
pattern: "getCollectorSubscribers"
|
||||
---
|
||||
|
||||
<objective>
|
||||
Create the zone/territory system that scopes collectors to specific subscriber groups.
|
||||
Create the zone management system: Zone model, ZoneAssignment model (collector-to-zone), replace Subscriber.zone String? with Subscriber.zoneId FK, zone CRUD API, subscriber-to-zone assignment, and collector-scoped subscriber queries.
|
||||
|
||||
Purpose: Zones are the foundation for collector workflow — a collector can only collect from subscribers in their assigned zones. This must exist before collector field collection (03-02) can enforce proper scoping.
|
||||
|
||||
Output: Zone Prisma model, zone CRUD service and APIs, collector-to-zone and subscriber-to-zone assignment, scoped subscriber list for collectors, integration tests.
|
||||
Purpose: Zones are the foundation for collector routing — collectors can only collect from subscribers in their assigned zones. This is a security boundary enforced at the data layer.
|
||||
Output: Zone/ZoneAssignment models, zone-service.ts, 5 API routes, integration tests.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@@ -63,123 +69,136 @@ Output: Zone Prisma model, zone CRUD service and APIs, collector-to-zone and sub
|
||||
@.planning/ROADMAP.md
|
||||
@.planning/STATE.md
|
||||
@.planning/phases/03-operational-modules/03-CONTEXT.md
|
||||
@.planning/phases/03-operational-modules/03-RESEARCH.md
|
||||
@prisma/schema.prisma
|
||||
@src/lib/prisma-tenant.ts
|
||||
@src/lib/services/subscriber-service.ts
|
||||
@src/lib/middleware/with-permission.ts
|
||||
@src/lib/casl/types.ts
|
||||
@src/lib/casl/permissions.ts
|
||||
@src/lib/services/payment-service.ts (pattern reference for tenant-scoped service functions)
|
||||
@src/lib/__tests__/payment.test.ts (pattern reference for integration test setup/cleanup)
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 1: Zone and ZoneAssignment Prisma models + migration</name>
|
||||
<files>prisma/schema.prisma, src/lib/prisma-tenant.ts</files>
|
||||
<name>Task 1: Zone schema, migration, and tenant scoping</name>
|
||||
<files>
|
||||
prisma/schema.prisma
|
||||
src/lib/prisma-tenant.ts
|
||||
src/lib/casl/types.ts
|
||||
src/lib/casl/permissions.ts
|
||||
</files>
|
||||
<action>
|
||||
Add two new models to Prisma schema:
|
||||
1. Add Zone model to schema.prisma:
|
||||
- id (uuid), tenantId, name (String), description (String?), isActive (Boolean default true), createdAt, updatedAt
|
||||
- @@unique([tenantId, name]), @@index([tenantId])
|
||||
|
||||
1. **Zone model:**
|
||||
- id (uuid PK), tenantId, name (String), description (String?), isActive (Boolean default true)
|
||||
- createdAt, updatedAt
|
||||
- @@unique([tenantId, name]) — zone names unique per tenant
|
||||
- @@index([tenantId])
|
||||
- Relation: subscribers Subscriber[] (via Subscriber.zoneId — update Subscriber to add zoneId optional FK)
|
||||
- Relation: assignments ZoneAssignment[]
|
||||
2. Add ZoneAssignment model (collector-to-zone join):
|
||||
- id (uuid), tenantId, userId (String — the collector user), zoneId (String — FK to Zone)
|
||||
- Relations: user -> User, zone -> Zone
|
||||
- @@unique([tenantId, userId, zoneId]), @@index([tenantId]), @@index([userId]), @@index([zoneId])
|
||||
|
||||
2. **ZoneAssignment model:**
|
||||
- id (uuid PK), tenantId, zoneId (FK to Zone), userId (FK to User — the collector)
|
||||
- createdAt
|
||||
- @@unique([tenantId, zoneId, userId]) — prevent duplicate assignments
|
||||
- @@index([tenantId]), @@index([userId]), @@index([zoneId])
|
||||
|
||||
3. **Update Subscriber model:**
|
||||
- The Subscriber already has `zone String?` field. Replace it with a proper FK:
|
||||
3. Replace Subscriber.zone String? with Subscriber.zoneId String? (FK to Zone):
|
||||
- Remove `zone String?` field
|
||||
- Add `zoneId String?` and `zone Zone? @relation(fields: [zoneId], references: [id])`
|
||||
- Remove the old `zone String?` field (it was a placeholder for Phase 3)
|
||||
- Add @@index([tenantId, zoneId])
|
||||
|
||||
4. **Update User model:**
|
||||
- Add relation: `zoneAssignments ZoneAssignment[]`
|
||||
4. Add reverse relations on Zone: `subscribers Subscriber[]`, `assignments ZoneAssignment[]`
|
||||
Add reverse relation on User: `zoneAssignments ZoneAssignment[]`
|
||||
|
||||
5. **Add "zone" and "zoneAssignment" to TENANT_SCOPED_MODELS** in `src/lib/prisma-tenant.ts` and extend the withTenantContext() $extends block following the existing pattern.
|
||||
5. Run `npx prisma migrate dev --name add-zones` to create migration.
|
||||
|
||||
6. Run `npx prisma migrate dev --name add-zones` to create the migration.
|
||||
6. Add "Zone" to AppSubjects in types.ts (it is not currently listed). Add ZoneAssignment does NOT need its own subject — managed through Zone.
|
||||
|
||||
Important: The old `zone String?` on Subscriber is being replaced with `zoneId String?` (FK). The migration needs to handle this — drop the old column, add new column. No data migration needed (no production data).
|
||||
7. Update permissions.ts:
|
||||
- ADMIN: already has `manage all`
|
||||
- OFFICE_STAFF: add `can("manage", "Zone")`
|
||||
- COLLECTOR: add `can("read", "Zone")` (can see zones they are assigned to)
|
||||
- TECHNICIAN/CLIENT: no zone access
|
||||
|
||||
8. Add Zone and ZoneAssignment to TENANT_SCOPED_MODELS in prisma-tenant.ts with FULL 12-operation extension blocks (copy from subscriber block pattern — findMany, findFirst, findFirstOrThrow, findUnique, findUniqueOrThrow, create, createMany, update, updateMany, delete, deleteMany, upsert, count, aggregate, groupBy). Missing any operation is a security hole.
|
||||
</action>
|
||||
<verify>
|
||||
- `npx prisma migrate dev` completes without errors
|
||||
- `npx prisma generate` succeeds
|
||||
- Schema has Zone, ZoneAssignment models
|
||||
- Subscriber has zoneId FK instead of zone String
|
||||
- `npx prisma migrate dev` succeeds with no errors
|
||||
- `npx tsc --noEmit` passes (no TypeScript errors)
|
||||
- Grep prisma-tenant.ts confirms both "zone" and "zoneAssignment" appear in TENANT_SCOPED_MODELS
|
||||
- Grep types.ts confirms "Zone" in AppSubjects
|
||||
</verify>
|
||||
<done>Zone and ZoneAssignment models exist in schema, Subscriber.zoneId replaces zone String, TENANT_SCOPED_MODELS updated, migration applied.</done>
|
||||
<done>Zone and ZoneAssignment models exist in schema, migration applied, tenant scoping configured, CASL subjects and permissions updated.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: ZoneService + API routes + integration tests</name>
|
||||
<files>src/lib/services/zone-service.ts, src/app/api/zones/route.ts, src/app/api/zones/[id]/route.ts, src/app/api/zones/[id]/subscribers/route.ts, src/app/api/collectors/[id]/subscribers/route.ts, src/lib/__tests__/zone-service.test.ts</files>
|
||||
<name>Task 2: Zone service, API routes, and integration tests</name>
|
||||
<files>
|
||||
src/lib/services/zone-service.ts
|
||||
src/app/api/zones/route.ts
|
||||
src/app/api/zones/[id]/route.ts
|
||||
src/app/api/zones/[id]/subscribers/route.ts
|
||||
src/app/api/collectors/[id]/subscribers/route.ts
|
||||
src/lib/__tests__/zone-service.test.ts
|
||||
</files>
|
||||
<action>
|
||||
**ZoneService** (`src/lib/services/zone-service.ts`):
|
||||
- `createZone(db, { name, description })` — creates zone, returns zone
|
||||
- `updateZone(db, zoneId, { name?, description?, isActive? })` — updates zone
|
||||
- `listZones(db)` — returns all zones for tenant (active and inactive)
|
||||
- `assignSubscriberToZone(db, subscriberId, zoneId)` — updates subscriber.zoneId
|
||||
- `removeSubscriberFromZone(db, subscriberId)` — sets subscriber.zoneId to null
|
||||
- `assignCollectorToZone(db, userId, zoneId)` — creates ZoneAssignment (validates user has COLLECTOR role)
|
||||
- `removeCollectorFromZone(db, userId, zoneId)` — deletes ZoneAssignment
|
||||
- `getCollectorZones(db, userId)` — returns zones assigned to a collector
|
||||
- `getCollectorSubscribers(db, userId)` — returns subscribers in all zones assigned to this collector (the key scoping query). Include subscriber status and outstanding invoice count for the collector's field view.
|
||||
- `getZoneSubscribers(db, zoneId)` — returns subscribers in a specific zone
|
||||
1. Create src/lib/services/zone-service.ts with pure functions (tenantPrisma as first arg, tenantId as second for transactions):
|
||||
- `createZone(tenantPrisma, tenantId, { name, description })` — create zone, return zone
|
||||
- `updateZone(tenantPrisma, zoneId, { name?, description?, isActive? })` — update zone
|
||||
- `listZones(tenantPrisma)` — return all zones with subscriber count and assigned collector count
|
||||
- `getZone(tenantPrisma, zoneId)` — single zone with relations
|
||||
- `assignSubscriberToZone(tenantPrisma, subscriberId, zoneId)` — update subscriber.zoneId
|
||||
- `removeSubscriberFromZone(tenantPrisma, subscriberId)` — set subscriber.zoneId to null
|
||||
- `assignCollectorToZone(tenantPrisma, tenantId, userId, zoneId)` — create ZoneAssignment (validate user has COLLECTOR role)
|
||||
- `removeCollectorFromZone(tenantPrisma, tenantId, userId, zoneId)` — delete ZoneAssignment
|
||||
- `getCollectorSubscribers(tenantPrisma, tenantId, collectorUserId)` — find all zones assigned to collector, then find all subscribers in those zones. THROW error if collector has no zone assignments (security boundary per RESEARCH.md). Return subscribers with basic info (id, accountNumber, firstName, lastName, address, zone name).
|
||||
- `getCollectorZones(tenantPrisma, collectorUserId)` — return zones assigned to a collector
|
||||
|
||||
Follow existing service patterns: take tenantPrisma client as first arg (same as PaymentService, SubscriberService). Use `as any` cast pattern for tenantId injection (documented in 02-03 decision).
|
||||
2. Create API routes following withPermission pattern:
|
||||
- GET /api/zones: withPermission("read", "Zone") -> listZones
|
||||
- POST /api/zones: withPermission("create", "Zone") -> createZone, validate name required
|
||||
- GET /api/zones/[id]: withPermission("read", "Zone") -> getZone
|
||||
- PUT /api/zones/[id]: withPermission("update", "Zone") -> updateZone
|
||||
- POST /api/zones/[id]/subscribers: withPermission("update", "Zone") -> assignSubscriberToZone (body: { subscriberId })
|
||||
- DELETE /api/zones/[id]/subscribers: withPermission("update", "Zone") -> removeSubscriberFromZone (body: { subscriberId })
|
||||
- GET /api/collectors/[id]/subscribers: withPermission("read", "Subscriber") -> getCollectorSubscribers (collector can only query their own; admin/staff can query any collector)
|
||||
|
||||
**API Routes:**
|
||||
- `GET /api/zones` — list zones (ADMIN, OFFICE_STAFF, COLLECTOR can read)
|
||||
- `POST /api/zones` — create zone (ADMIN only)
|
||||
- `GET /api/zones/[id]` — get zone detail with subscriber count
|
||||
- `PUT /api/zones/[id]` — update zone (ADMIN only)
|
||||
- `POST /api/zones/[id]/subscribers` — assign subscriber to zone, body: { subscriberId }. ADMIN, OFFICE_STAFF.
|
||||
- `DELETE /api/zones/[id]/subscribers` — remove subscriber from zone, body: { subscriberId }. ADMIN, OFFICE_STAFF.
|
||||
- `GET /api/collectors/[id]/subscribers` — get subscribers for a specific collector (scoped by zone assignments). ADMIN, OFFICE_STAFF can query any collector; COLLECTOR can only query self.
|
||||
Use the dynamic route handler pattern from 02-04: `export async function GET(req, { params }) { return withPermission(...)(async (req, { user }) => { const { id } = params; ... })(req); }`
|
||||
|
||||
Use withPermission() HOF pattern from existing API routes. For dynamic [id] routes, use the closure pattern documented in 02-01 decision (withPermission doesn't support dynamic params directly).
|
||||
3. Create src/lib/__tests__/zone-service.test.ts integration tests:
|
||||
- Setup: create tenant, admin user, collector user, seed COA, create service plan, create 3 subscribers, create 2 zones
|
||||
- Test createZone: creates zone with name/description
|
||||
- Test createZone duplicate name: throws on duplicate name within tenant
|
||||
- Test updateZone: updates name, description, isActive
|
||||
- Test listZones: returns zones with counts
|
||||
- Test assignSubscriberToZone: subscriber.zoneId updated
|
||||
- Test assignCollectorToZone: ZoneAssignment created, validates COLLECTOR role
|
||||
- Test getCollectorSubscribers: returns only subscribers in collector's assigned zones
|
||||
- Test getCollectorSubscribers with no zones: throws error
|
||||
- Test cross-tenant isolation: zone created in Tenant A is invisible to Tenant B query
|
||||
- Cleanup afterAll in reverse order: zoneAssignments -> subscribers -> servicePlans -> zones -> users -> tenant (extend the established cleanup pattern)
|
||||
|
||||
**Integration Tests** (`src/lib/__tests__/zone-service.test.ts`):
|
||||
- Zone CRUD (create, update, list, deactivate)
|
||||
- Zone name uniqueness within tenant
|
||||
- Subscriber zone assignment and removal
|
||||
- Collector zone assignment and removal
|
||||
- getCollectorSubscribers returns only subscribers in collector's zones
|
||||
- getCollectorSubscribers returns empty for collector with no zone assignments
|
||||
- Collector cannot be assigned to zone if they don't have COLLECTOR role
|
||||
- Cross-tenant isolation (zone from tenant A not visible to tenant B)
|
||||
|
||||
Follow existing test patterns: beforeAll creates tenant+user+accounts, afterAll cleans up in correct order. Add Zone and ZoneAssignment to cleanup order.
|
||||
Follow the exact test setup pattern from payment.test.ts — use TS = Date.now() suffix, create via raw prisma for setup, test via tenantPrisma.
|
||||
</action>
|
||||
<verify>
|
||||
- `npx vitest run src/lib/__tests__/zone-service.test.ts` — all tests pass
|
||||
- `npx vitest run` — full suite passes (no regressions)
|
||||
- `npx tsc --noEmit` passes
|
||||
</verify>
|
||||
<done>ZoneService with zone CRUD, subscriber/collector assignment, and collector-scoped subscriber queries all working. API routes enforce RBAC. Integration tests prove zone scoping and tenant isolation.</done>
|
||||
<done>Zone CRUD works, subscribers can be assigned to zones, collectors can be assigned to zones, collector-scoped subscriber queries enforce zone boundary, cross-tenant isolation verified. All tests pass.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<verification>
|
||||
- Zone CRUD: create, update, deactivate zones
|
||||
- Subscriber assignment: assign/remove subscriber to/from zone
|
||||
- Collector assignment: assign/remove collector to/from zone
|
||||
- Collector scoping: collector sees only their zone's subscribers
|
||||
- Tenant isolation: zones are tenant-scoped
|
||||
- All existing tests still pass (no regressions from Subscriber.zone -> zoneId migration)
|
||||
- `npx prisma migrate dev` succeeds (schema valid)
|
||||
- `npx tsc --noEmit` passes (no TypeScript errors)
|
||||
- `npx vitest run src/lib/__tests__/zone-service.test.ts` — all tests green
|
||||
- Zone CRUD, subscriber assignment, collector scoping, and tenant isolation verified
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- Zone and ZoneAssignment models in Prisma schema with migration applied
|
||||
- ZoneService handles zone CRUD, subscriber assignment, collector assignment, and scoped queries
|
||||
- API routes enforce RBAC (admin creates zones, collectors query their subscribers)
|
||||
- Integration tests prove collector can only see subscribers in their assigned zones
|
||||
- Full test suite passes with no regressions
|
||||
- Zone and ZoneAssignment models exist with proper tenant scoping
|
||||
- Subscriber.zone String? replaced with Subscriber.zoneId FK
|
||||
- Zone CRUD API routes work with withPermission enforcement
|
||||
- Collectors can only query subscribers in their assigned zones
|
||||
- Cross-tenant isolation proven by test
|
||||
- All integration tests pass
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
|
||||
Reference in New Issue
Block a user