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:
kevin-asprec
2026-03-05 07:32:21 +08:00
parent 214df6cd92
commit 2321bf2ada
2 changed files with 162 additions and 9 deletions

View File

@@ -9,12 +9,12 @@ See: .planning/PROJECT.md (updated 2026-03-04)
## Current Position
Phase: 2 of 5 (Subscriber and Billing Core) — COMPLETE
Plan: 5 of 5 in phase 2 (11/20 total complete)
Status: Phase 2 complete. 02-01 (COA), 02-02 (JournalEntryService), 02-03 (Subscriber), 02-04 (BillingEngine), 02-05 (PaymentService) all done.
Last activity: 2026-03-04 — Completed 02-05-PLAN.md (PaymentService, 29 tests, 265 total)
Phase: 3 of 5 (Operational Modules) — In progress
Plan: 1 of 5 in phase 3 (12/20 total complete)
Status: Phase 3 started. 03-01 (Zone Management) complete. Zones, ZoneAssignment, collector scoping all implemented.
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
@@ -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 |
**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)
- Trend: stable — 02-05 was complex but tests passed on second run
- 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 — 03-01 had a migration workaround but tests passed on first run
*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]: 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
- [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
@@ -112,6 +117,6 @@ None.
## Session Continuity
Last session: 2026-03-04T15:52:55Z
Stopped at: Completed 02-05-PLAN.md (PaymentService + 29 tests — Phase 2 complete)
Last session: 2026-03-05T07:31:00Z
Stopped at: Completed 03-01-PLAN.md (Zone management — 25 tests, Zone/ZoneAssignment models, collector scoping)
Resume file: None

View 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*