--- phase: 03-operational-modules plan: "03" type: execute wave: 1 depends_on: [] files_modified: - 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 autonomous: true must_haves: truths: - "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" artifacts: - path: "prisma/schema.prisma" provides: "Ticket, TicketCategory models with enums" contains: "model Ticket" - path: "src/lib/services/ticket-service.ts" provides: "Ticket CRUD and status transitions" exports: ["createTicket", "updateTicket", "getTicket", "listTickets", "transitionTicketStatus"] - path: "src/lib/services/ticket-category-service.ts" provides: "Category CRUD" exports: ["createCategory", "updateCategory", "listCategories"] - path: "src/lib/tenant.ts" provides: "Default ticket category seeding in createTenant" contains: "ticketCategory" - path: "src/lib/__tests__/ticket-service.test.ts" provides: "Integration tests for ticket lifecycle, categories, transitions" min_lines: 150 key_links: - from: "src/lib/services/ticket-service.ts" to: "src/lib/services/ticket-category-service.ts" via: "validates category isActive before ticket creation" pattern: "isActive" - from: "src/lib/tenant.ts" to: "prisma/schema.prisma" via: "seeds default TicketCategory records in createTenant transaction" pattern: "ticketCategory\\.createMany" - from: "src/app/api/tickets/[id]/status/route.ts" to: "src/lib/services/ticket-service.ts" via: "transitionTicketStatus with guard map" pattern: "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. @C:\Users\KevinAsprec\.claude/get-shit-done/workflows/execute-plan.md @C:\Users\KevinAsprec\.claude/get-shit-done/templates/summary.md @.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) 2. Add TicketCategory model: - id (uuid), tenantId, name (String), description (String?), isActive (Boolean default true), createdAt, updatedAt - @@unique([tenantId, name]), @@index([tenantId]) 3. 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[] 4. Run `npx prisma migrate dev --name add-tickets` 5. Add Ticket and TicketCategory to TENANT_SCOPED_MODELS in prisma-tenant.ts with FULL extension blocks (all 12 operations each — copy from subscriber block). 6. 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 2. 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) 3. 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 4. 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 - 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 After completion, create `.planning/phases/03-operational-modules/03-03-SUMMARY.md`