Phase 03: Operational Modules - 5 plans in 3 waves - Wave 1: 03-01 (zones), 03-03 (tickets) — parallel - Wave 2: 03-02 (collector collections), 03-04 (job orders) — parallel - Wave 3: 03-05 (technician compensation) - Ready for execution Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
12 KiB
phase, plan, type, wave, depends_on, files_modified, autonomous, must_haves
| phase | plan | type | wave | depends_on | files_modified | autonomous | must_haves | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 03-operational-modules | 03 | execute | 1 |
|
true |
|
Purpose: Tickets are the intake mechanism for customer issues. They must exist before job orders (03-04) can be created from them. Category configurability avoids hardcoded enums. Output: Ticket/TicketCategory models, ticket-service.ts, ticket-category-service.ts, updated tenant.ts, 5 API routes, integration tests.
<execution_context> @C:\Users\KevinAsprec.claude/get-shit-done/workflows/execute-plan.md @C:\Users\KevinAsprec.claude/get-shit-done/templates/summary.md </execution_context>
@.planning/PROJECT.md @.planning/ROADMAP.md @.planning/STATE.md @.planning/phases/03-operational-modules/03-CONTEXT.md @.planning/phases/03-operational-modules/03-RESEARCH.md @prisma/schema.prisma @src/lib/prisma-tenant.ts @src/lib/tenant.ts @src/lib/casl/types.ts @src/lib/casl/permissions.ts @src/lib/services/payment-service.ts (pattern reference) @src/lib/__tests__/payment.test.ts (pattern reference) Task 1: Ticket schema, categories, migration, and tenant scoping prisma/schema.prisma src/lib/prisma-tenant.ts src/lib/tenant.ts 1. Add enums to schema.prisma: - `enum TicketStatus { OPEN ASSIGNED RESOLVED CLOSED }` - `enum TicketPriority { LOW MEDIUM HIGH URGENT }` - `enum TicketSource { STAFF SUBSCRIBER }` (supports Phase 5 subscriber portal)-
Add TicketCategory model:
- id (uuid), tenantId, name (String), description (String?), isActive (Boolean default true), createdAt, updatedAt
- @@unique([tenantId, name]), @@index([tenantId])
-
Add Ticket model:
- id (uuid), tenantId
- ticketNumber (String) — auto-generated TKT-NNNN
- subject (String), description (String)
- categoryId (String, FK to TicketCategory)
- priority (TicketPriority, default MEDIUM)
- status (TicketStatus, default OPEN)
- source (TicketSource, default STAFF)
- subscriberId (String?, FK to Subscriber — which subscriber this ticket is about)
- createdById (String, FK to User — staff or subscriber who created it)
- resolvedAt (DateTime?), closedAt (DateTime?)
- notes (String?) — internal notes
- createdAt, updatedAt
- Relations: category -> TicketCategory, subscriber -> Subscriber, createdBy -> User
- @@unique([tenantId, ticketNumber]), @@index([tenantId]), @@index([tenantId, status]), @@index([tenantId, categoryId]), @@index([subscriberId])
- Add reverse relations: Subscriber.tickets Ticket[], User.createdTickets Ticket[], TicketCategory.tickets Ticket[]
-
Run
npx prisma migrate dev --name add-tickets -
Add Ticket and TicketCategory to TENANT_SCOPED_MODELS in prisma-tenant.ts with FULL extension blocks (all 12 operations each — copy from subscriber block).
-
Update src/lib/tenant.ts createTenant function: after seedChartOfAccounts, add seed for default ticket categories within the same transaction:
const defaultCategories = [ { name: "No Connection", description: "Subscriber has no internet connection", tenantId: tenant.id }, { name: "Slow Speed", description: "Connection speed below expected plan speed", tenantId: tenant.id }, { name: "Billing Inquiry", description: "Questions about bills or payments", tenantId: tenant.id }, { name: "New Installation", description: "Request for new service installation", tenantId: tenant.id }, { name: "Equipment Issue", description: "Router, ONU, or cable problems", tenantId: tenant.id }, { name: "Other", description: "Other issues not covered by categories above", tenantId: tenant.id }, ]; await tx.ticketCategory.createMany({ data: defaultCategories });IMPORTANT: This is inside the $transaction callback, so use
tx(raw client) and include tenantId explicitly.npx prisma migrate devsucceedsnpx tsc --noEmitpasses- Grep prisma-tenant.ts confirms "ticket" and "ticketCategory" in TENANT_SCOPED_MODELS
- Grep tenant.ts confirms "ticketCategory" seeding Ticket and TicketCategory models exist, migration applied, tenant scoping configured, default categories seeded in tenant creation.
-
Create src/lib/services/ticket-service.ts:
- Sequential number generation:
generateTicketNumber(tenantPrisma)— same pattern as invoice/JE numbers. Queryticket.findMany({ where: { ticketNumber: { startsWith: "TKT-" } }, orderBy: { ticketNumber: "desc" }, take: 1 }), parse last 4 digits, increment, pad to 4. createTicket(tenantPrisma, tenantId, { subject, description, categoryId, priority?, subscriberId?, createdById, source? }):- Validate category exists AND isActive=true — throw if deactivated
- Generate ticketNumber
- Create ticket with status OPEN
updateTicket(tenantPrisma, ticketId, { subject?, description?, categoryId?, priority?, notes? })— update metadata only (NOT status)getTicket(tenantPrisma, ticketId)— include category, subscriber, createdBy, jobOrders (empty array for now, relation added in 03-04)listTickets(tenantPrisma, { status?, categoryId?, priority?, subscriberId?, page?, limit? })— filtered list with pagination, include category and subscribertransitionTicketStatus(tenantPrisma, ticketId, newStatus):- Define VALID_TICKET_TRANSITIONS map: OPEN -> [ASSIGNED, CLOSED] ASSIGNED -> [OPEN, RESOLVED] RESOLVED -> [CLOSED, OPEN] CLOSED -> [] (terminal)
- Load current ticket, validate transition is allowed, update status
- Set resolvedAt on transition to RESOLVED, closedAt on transition to CLOSED
- NOTE: OPEN -> ASSIGNED is triggered by job order creation (03-04), not manually
- NOTE: ASSIGNED -> RESOLVED is triggered by auto-resolve (03-04) when all jobs complete
- Both can also be called manually by staff via API
resolveTicket(tenantPrisma, ticketId)— convenience wrapper around transitionTicketStatus that is IDEMPOTENT: if ticket is already RESOLVED, return silently (prevents race condition per RESEARCH.md pitfall 5)
- Sequential number generation:
-
Create API routes:
- GET /api/tickets: withPermission("read", "Ticket") -> listTickets with query param filters
- POST /api/tickets: withPermission("create", "Ticket") -> createTicket (body: { subject, description, categoryId, priority?, subscriberId? })
- GET /api/tickets/[id]: withPermission("read", "Ticket") -> getTicket (dynamic route pattern)
- PUT /api/tickets/[id]: withPermission("update", "Ticket") -> updateTicket (dynamic route pattern)
- POST /api/tickets/[id]/status: withPermission("update", "Ticket") -> transitionTicketStatus (body: { status })
- GET /api/ticket-categories: withPermission("read", "Ticket") -> listCategories
- POST /api/ticket-categories: withPermission("manage", "Ticket") -> createCategory (admin/staff only via manage check)
- PUT /api/ticket-categories/[id]: withPermission("manage", "Ticket") -> updateCategory
-
Create src/lib/tests/ticket-service.test.ts:
- Setup: create tenant (this now auto-seeds categories via updated tenant.ts), admin user
- Test: default categories are seeded on tenant creation (6 categories)
- Test: createCategory adds a new category
- Test: updateCategory deactivates a category (isActive=false)
- Test: createTicket with valid category succeeds, returns TKT-0001
- Test: createTicket with deactivated category throws
- Test: second ticket gets TKT-0002
- Test: transitionTicketStatus OPEN -> CLOSED succeeds
- Test: transitionTicketStatus CLOSED -> OPEN throws (terminal state)
- Test: transitionTicketStatus OPEN -> RESOLVED throws (invalid)
- Test: resolveTicket is idempotent (calling on RESOLVED ticket does not throw)
- Test: listTickets with status filter returns correct subset
- Test: cross-tenant isolation (Tenant B cannot see Tenant A tickets)
- Cleanup: tickets -> ticketCategories -> subscribers -> users -> tenant NOTE: Since tenant creation now seeds ticketCategories, cleanup must delete them. Use tenantId filter.
npx vitest run src/lib/__tests__/ticket-service.test.ts— all tests passnpx tsc --noEmitpasses Ticket CRUD works with sequential numbering, status transitions enforce guard map, categories are admin-configurable with ISP defaults, deactivated categories rejected, idempotent resolve, cross-tenant isolation verified.
<success_criteria>
- Ticket and TicketCategory models with tenant scoping
- Status transition guard map rejects invalid transitions
- Default 6 ISP categories seeded at tenant creation
- Deactivated categories cannot be used for new tickets
- Sequential ticket numbering (TKT-NNNN)
- resolveTicket is idempotent
- Cross-tenant isolation verified by test
- All integration tests pass </success_criteria>