docs(03-01): complete zone management plan
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>
This commit is contained in:
@@ -9,12 +9,12 @@ See: .planning/PROJECT.md (updated 2026-03-04)
|
|||||||
|
|
||||||
## Current Position
|
## Current Position
|
||||||
|
|
||||||
Phase: 2 of 5 (Subscriber and Billing Core) — COMPLETE
|
Phase: 3 of 5 (Operational Modules) — In progress
|
||||||
Plan: 5 of 5 in phase 2 (11/20 total complete)
|
Plan: 1 of 5 in phase 3 (12/20 total complete)
|
||||||
Status: Phase 2 complete. 02-01 (COA), 02-02 (JournalEntryService), 02-03 (Subscriber), 02-04 (BillingEngine), 02-05 (PaymentService) all done.
|
Status: Phase 3 started. 03-01 (Zone Management) complete. Zones, ZoneAssignment, collector scoping all implemented.
|
||||||
Last activity: 2026-03-04 — Completed 02-05-PLAN.md (PaymentService, 29 tests, 265 total)
|
Last activity: 2026-03-05 — Completed 03-01-PLAN.md (Zone management, 25 tests)
|
||||||
|
|
||||||
Progress: [███████████░] 55% (11/20 plans across all phases)
|
Progress: [████████████░] 60% (12/20 plans across all phases)
|
||||||
|
|
||||||
## Performance Metrics
|
## Performance Metrics
|
||||||
|
|
||||||
@@ -31,8 +31,8 @@ Progress: [███████████░] 55% (11/20 plans across all pha
|
|||||||
| 02-subscriber-and-billing-core | 5/5 complete | 57 min | 11.4 min |
|
| 02-subscriber-and-billing-core | 5/5 complete | 57 min | 11.4 min |
|
||||||
|
|
||||||
**Recent Trend:**
|
**Recent Trend:**
|
||||||
- Last 9 plans: 01-02 (8 min), 01-03 (9 min), 01-04 (7 min), 01-05 (6 min), 02-01 (7 min), 02-03 (7 min), 02-02 (16 min), 02-04 (12 min), 02-05 (8 min)
|
- Last 10 plans: 01-02 (8 min), 01-03 (9 min), 01-04 (7 min), 01-05 (6 min), 02-01 (7 min), 02-03 (7 min), 02-02 (16 min), 02-04 (12 min), 02-05 (8 min), 03-01 (15 min)
|
||||||
- Trend: stable — 02-05 was complex but tests passed on second run
|
- Trend: stable — 03-01 had a migration workaround but tests passed on first run
|
||||||
|
|
||||||
*Updated after each plan completion*
|
*Updated after each plan completion*
|
||||||
|
|
||||||
@@ -95,6 +95,11 @@ Recent decisions affecting current work:
|
|||||||
- [02-05]: Overpayment to subscriber.creditBalance atomically with JE — same pattern as invoice.amountPaid from 02-04
|
- [02-05]: Overpayment to subscriber.creditBalance atomically with JE — same pattern as invoice.amountPaid from 02-04
|
||||||
- [02-05]: Outstanding report computed in JS after fetching — Prisma doesn't support computed fields in WHERE/ORDER BY; acceptable for ISP scale
|
- [02-05]: Outstanding report computed in JS after fetching — Prisma doesn't support computed fields in WHERE/ORDER BY; acceptable for ISP scale
|
||||||
- [02-05]: Test invoiceCounter for periodStart uniqueness — monotonic counter generates unique periodStart per invoice, avoids @@unique([tenantId, subscriberId, periodStart]) in test helpers
|
- [02-05]: Test invoiceCounter for periodStart uniqueness — monotonic counter generates unique periodStart per invoice, avoids @@unique([tenantId, subscriberId, periodStart]) in test helpers
|
||||||
|
- [03-01]: Collector security boundary enforced at service layer: getCollectorSubscribers THROWS (not empty return) when collector has no zone assignments — zero-access default
|
||||||
|
- [03-01]: ZoneAssignment upsert for idempotent collector assignment — duplicate assign calls don't throw errors
|
||||||
|
- [03-01]: Subscriber.zone String? replaced with Subscriber.zoneId FK — required for relational queries and JOIN-based ordering
|
||||||
|
- [03-01]: COLLECTOR gets can("read", "Zone") in CASL: coarse-grained gate, data layer enforces which specific zones
|
||||||
|
- [03-01]: Migration applied via Docker exec psql + prisma migrate resolve --applied (non-interactive CLI workaround)
|
||||||
|
|
||||||
### Pending Todos
|
### Pending Todos
|
||||||
|
|
||||||
@@ -112,6 +117,6 @@ None.
|
|||||||
|
|
||||||
## Session Continuity
|
## Session Continuity
|
||||||
|
|
||||||
Last session: 2026-03-04T15:52:55Z
|
Last session: 2026-03-05T07:31:00Z
|
||||||
Stopped at: Completed 02-05-PLAN.md (PaymentService + 29 tests — Phase 2 complete)
|
Stopped at: Completed 03-01-PLAN.md (Zone management — 25 tests, Zone/ZoneAssignment models, collector scoping)
|
||||||
Resume file: None
|
Resume file: None
|
||||||
|
|||||||
148
.planning/phases/03-operational-modules/03-01-SUMMARY.md
Normal file
148
.planning/phases/03-operational-modules/03-01-SUMMARY.md
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
---
|
||||||
|
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*
|
||||||
Reference in New Issue
Block a user