--- phase: 03-operational-modules plan: "01" subsystem: database tags: [prisma, zones, multi-tenancy, collector-routing, casl, api] # Dependency graph requires: - phase: 02-subscriber-and-billing-core provides: Subscriber model with zone String? field that was replaced by zoneId FK - phase: 01-foundation provides: withTenantContext() Prisma extension, withPermission() HOF, CASL types/permissions provides: - 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 affects: - 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 # Tech tracking tech-stack: 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" key-files: created: - 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 modified: - 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 key-decisions: - "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" patterns-established: - "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" # Metrics duration: 15min completed: 2026-03-05 --- # Phase 3 Plan 01: Zone Management Summary **Zone and ZoneAssignment models with collector-scoped subscriber queries — security boundary enforced by throwing on unassigned collectors** ## Performance - **Duration:** 15 min - **Started:** 2026-03-04T23:20:14Z - **Completed:** 2026-03-05T07:31:00Z - **Tasks:** 2 - **Files modified:** 15 ## Accomplishments - Zone model with tenant scoping, ZoneAssignment collector-to-zone join table, and Subscriber.zoneId FK replacing the old String? zone field - Complete zone CRUD API with withPermission HOF enforcement (5 routes) - Collector security boundary: getCollectorSubscribers throws if collector has no zone assignments, enforced at service layer - 25 integration tests — all passing — covering CRUD, subscriber assignment, collector scoping, and cross-tenant isolation ## Task Commits Each task was committed atomically: 1. **Task 1: Zone schema, migration, and tenant scoping** - `56f5d07` (feat) 2. **Task 2: Zone service, API routes, and integration tests** - `214df6c` (feat) **Plan metadata:** `[pending]` (docs: complete plan) ## Files Created/Modified - `prisma/schema.prisma` - Zone and ZoneAssignment models, Subscriber.zoneId FK - `prisma/migrations/20260305000000_add_zones/migration.sql` - Migration SQL - `src/lib/prisma-tenant.ts` - Zone and ZoneAssignment tenant scoping blocks (full 15 ops each) - `src/lib/casl/types.ts` - Added "Zone" to AppSubjects - `src/lib/casl/permissions.ts` - OFFICE_STAFF: manage Zone, COLLECTOR: read Zone - `src/lib/services/zone-service.ts` - 10 service functions for zone management - `src/app/api/zones/route.ts` - GET list, POST create - `src/app/api/zones/[id]/route.ts` - GET single, PUT update - `src/app/api/zones/[id]/subscribers/route.ts` - POST assign, DELETE remove - `src/app/api/collectors/[id]/subscribers/route.ts` - GET collector-scoped subscriber list - `src/lib/__tests__/zone-service.test.ts` - 25 integration tests - `src/lib/services/subscriber-service.ts` - zone -> zoneId field update (auto-fix) - `src/app/api/subscribers/route.ts` - zone -> zoneId body field (auto-fix) - `src/app/api/subscribers/[id]/route.ts` - zone -> zoneId update field (auto-fix) - `src/lib/__tests__/subscriber.test.ts` - removed zone field from test (auto-fix) ## Decisions Made - Collector security boundary enforced at data layer: `getCollectorSubscribers` throws (not empty return) when collector has no zone assignments — "no zones = no access" is explicit, not silent - ZoneAssignment `upsert` for idempotent collector assignment: duplicate assign calls don't throw errors - Subscriber.zone String? replaced with Subscriber.zoneId FK: required for proper relational queries and JOIN-based ordering in `getCollectorSubscribers` - COLLECTOR gets `can("read", "Zone")` in CASL: coarse-grained gate, data layer enforces which specific zones - Migration applied via Docker exec + `prisma migrate resolve --applied`: `prisma migrate dev` fails in non-interactive environments; this is the established workaround from Phase 1 ## Deviations from Plan ### Auto-fixed Issues **1. [Rule 1 - Bug] Fixed subscriber-service.ts zone String? -> zoneId FK** - **Found during:** Task 2 (zone service creation) - **Issue:** subscriber-service.ts still referenced `zone` field (CreateSubscriberInput, UpdateSubscriberInput, create/update logic) — field no longer exists in schema after migration - **Fix:** Updated input interfaces to use `zoneId?: string`, updated create/update to use `zoneId` instead of `zone?.trim()`, fixed API routes that passed `zone` to the service - **Files modified:** 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 - **Verification:** `npx tsc --noEmit` passes, subscriber test fixture updated - **Committed in:** 214df6c (Task 2 commit) --- **Total deviations:** 1 auto-fixed (Rule 1 - Bug) **Impact on plan:** Necessary for correctness — the old `zone` String field was removed from schema, service code must use `zoneId` FK. ## Issues Encountered - `prisma migrate dev --name add-zones` failed in non-interactive bash environment (pre-existing issue from Phase 1). Workaround: generate SQL via `prisma migrate diff --script`, write migration file manually, apply via Docker exec psql, then run `prisma migrate resolve --applied`. This is the same pattern used in Phase 1. ## Next Phase Readiness - Zone foundation complete — ready for 03-02 (Collector Management) which builds on zone assignments - Subscriber.zoneId FK in place — collector routing is now data-layer enforced - getCollectorSubscribers security boundary tested and verified --- *Phase: 03-operational-modules* *Completed: 2026-03-05*