Files
kevin-asprec d54b517e2e docs(03): create phase plan
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>
2026-03-05 07:11:44 +08:00

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
prisma/schema.prisma
src/lib/prisma-tenant.ts
src/lib/services/ticket-service.ts
src/lib/services/ticket-category-service.ts
src/lib/tenant.ts
src/app/api/tickets/route.ts
src/app/api/tickets/[id]/route.ts
src/app/api/tickets/[id]/status/route.ts
src/app/api/ticket-categories/route.ts
src/app/api/ticket-categories/[id]/route.ts
src/lib/__tests__/ticket-service.test.ts
true
truths artifacts key_links
Staff can create a support ticket with subject, description, priority, and category
Tickets follow lifecycle: OPEN -> ASSIGNED -> RESOLVED -> CLOSED
Invalid status transitions are rejected (e.g., CLOSED -> OPEN is invalid)
Admin can create, update, and deactivate ticket categories
Default ISP categories are seeded at tenant creation
Tickets with a deactivated category cannot be created
Ticket data is tenant-scoped
path provides contains
prisma/schema.prisma Ticket, TicketCategory models with enums model Ticket
path provides exports
src/lib/services/ticket-service.ts Ticket CRUD and status transitions
createTicket
updateTicket
getTicket
listTickets
transitionTicketStatus
path provides exports
src/lib/services/ticket-category-service.ts Category CRUD
createCategory
updateCategory
listCategories
path provides contains
src/lib/tenant.ts Default ticket category seeding in createTenant ticketCategory
path provides min_lines
src/lib/__tests__/ticket-service.test.ts Integration tests for ticket lifecycle, categories, transitions 150
from to via pattern
src/lib/services/ticket-service.ts src/lib/services/ticket-category-service.ts validates category isActive before ticket creation isActive
from to via pattern
src/lib/tenant.ts prisma/schema.prisma seeds default TicketCategory records in createTenant transaction ticketCategory.createMany
from to via pattern
src/app/api/tickets/[id]/status/route.ts src/lib/services/ticket-service.ts transitionTicketStatus with guard map transitionTicketStatus
Create the ticketing system: Ticket and TicketCategory models, ticket lifecycle (OPEN -> ASSIGNED -> RESOLVED -> CLOSED), admin-configurable categories with ISP default seeds, ticket CRUD API, status transition API with guard validation.

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)
  1. Add TicketCategory model:

    • id (uuid), tenantId, name (String), description (String?), isActive (Boolean default true), createdAt, updatedAt
    • @@unique([tenantId, name]), @@index([tenantId])
  2. 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[]
  3. Run npx prisma migrate dev --name add-tickets

  4. Add Ticket and TicketCategory to TENANT_SCOPED_MODELS in prisma-tenant.ts with FULL extension blocks (all 12 operations each — copy from subscriber block).

  5. 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 dev succeeds
    • npx tsc --noEmit passes
    • 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.
Task 2: Ticket service, category service, API routes, and integration tests src/lib/services/ticket-service.ts src/lib/services/ticket-category-service.ts src/app/api/tickets/route.ts src/app/api/tickets/[id]/route.ts src/app/api/tickets/[id]/status/route.ts src/app/api/ticket-categories/route.ts src/app/api/ticket-categories/[id]/route.ts src/lib/__tests__/ticket-service.test.ts 1. Create src/lib/services/ticket-category-service.ts: - `createCategory(tenantPrisma, tenantId, { name, description })` — create category - `updateCategory(tenantPrisma, categoryId, { name?, description?, isActive? })` — update/deactivate - `listCategories(tenantPrisma, { activeOnly?: boolean })` — list with optional active filter
  1. Create src/lib/services/ticket-service.ts:

    • Sequential number generation: generateTicketNumber(tenantPrisma) — same pattern as invoice/JE numbers. Query ticket.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 subscriber
    • transitionTicketStatus(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)
  2. 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
  3. 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 pass
    • npx tsc --noEmit passes 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.
- `npx prisma migrate dev` succeeds - `npx tsc --noEmit` passes - `npx vitest run src/lib/__tests__/ticket-service.test.ts` — all tests green - Ticket lifecycle (OPEN -> ASSIGNED -> RESOLVED -> CLOSED) enforced - Default categories seeded on tenant creation

<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>
After completion, create `.planning/phases/03-operational-modules/03-03-SUMMARY.md`