| 03-operational-modules |
01 |
database |
| prisma |
| zones |
| multi-tenancy |
| collector-routing |
| casl |
| api |
|
| phase |
provides |
| 02-subscriber-and-billing-core |
Subscriber model with zone String? field that was replaced by zoneId FK |
|
| phase |
provides |
| 01-foundation |
withTenantContext() Prisma extension, withPermission() HOF, CASL types/permissions |
|
|
| Zone model with tenant scoping and @@unique([tenantId, name]) |
| ZoneAssignment join table (collector-to-zone mapping) |
| Subscriber.zoneId FK replacing Subscriber.zone String? |
| zone-service.ts with 10 exported functions (createZone, updateZone, listZones, getZone, assignSubscriberToZone, removeSubscriberFromZone, assignCollectorToZone, removeCollectorFromZone, getCollectorZones, getCollectorSubscribers) |
| 5 API route files (zones CRUD, subscriber assignment, collector subscriber scoping) |
| 25 integration tests covering all zone operations and cross-tenant isolation |
|
| 03-02 (Collector Management) — zone assignment is foundation for collector routes |
| 03-03 and later — any phase working with subscriber data must handle zoneId not zone |
|
| added |
patterns |
|
|
| Collector security boundary: getCollectorSubscribers throws if no zone assignments (zero-access default) |
| ZoneAssignment as join table with upsert for idempotent collector assignment |
| Zone tenant scoping follows full 15-operation extension block pattern |
|
|
| created |
modified |
| prisma/migrations/20260305000000_add_zones/migration.sql |
| 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 |
|
| prisma/schema.prisma |
| src/lib/prisma-tenant.ts |
| src/lib/casl/types.ts |
| src/lib/casl/permissions.ts |
| src/lib/services/subscriber-service.ts |
| src/app/api/subscribers/route.ts |
| src/app/api/subscribers/[id]/route.ts |
| src/lib/__tests__/subscriber.test.ts |
|
|
| Collector security boundary enforced at data layer: getCollectorSubscribers throws on no zone assignments (not just returns empty) |
| ZoneAssignment upsert for idempotent assign: duplicate assignment returns existing record, not error |
| Subscriber.zone String? replaced with Subscriber.zoneId FK: cleaner relational model, enables JOIN queries |
| COLLECTOR can('read', 'Zone') in permissions: coarse-grained, data layer enforces which zones |
| OFFICE_STAFF can('manage', 'Zone'): zone configuration is staff responsibility |
| Migration applied via psql exec (Docker container) + prisma migrate resolve --applied: non-interactive CLI workaround |
|
| Zone security pattern: THROW (not empty return) when collector has no zone assignments |
| Collector API self-restriction: collector can only query their own subscriber list; admin/staff can query any |
|
15min |
2026-03-05 |