Tasks completed: 2/2 - Task 1: Zone schema, migration, and tenant scoping - Task 2: Zone service, API routes, and integration tests SUMMARY: .planning/phases/03-operational-modules/03-01-SUMMARY.md Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
149 lines
7.7 KiB
Markdown
149 lines
7.7 KiB
Markdown
---
|
|
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*
|