Files
kevin-asprec ea58620c7d docs(05): create phase plan
Phase 05: Visibility and Client Portal
- 5 plans in 3 waves
- 2 parallel (wave 1), 1 sequential (wave 2), 2 parallel (wave 3)
- Ready for execution

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 17:14:54 +08:00

11 KiB

phase, plan, type, wave, depends_on, files_modified, autonomous, must_haves
phase plan type wave depends_on files_modified autonomous must_haves
05-visibility-and-client-portal 03 execute 2
05-02
prisma/schema.prisma
src/lib/services/portal-ticket-service.ts
src/app/api/portal/tickets/route.ts
src/app/api/portal/tickets/[id]/route.ts
src/app/api/portal/tickets/[id]/comments/route.ts
src/app/api/portal/payments/coming-soon/route.ts
src/lib/__tests__/portal-ticket-service.test.ts
true
truths artifacts key_links
Subscriber can submit a support ticket from the portal
Subscriber can view their ticket list with current status
Subscriber can add follow-up comments to open tickets
Subscriber's ticket appears in staff's ticket queue
Online payment page shows outstanding balance and alternative payment instructions
path provides contains
prisma/schema.prisma TicketComment model for conversation threads model TicketComment
path provides exports
src/lib/services/portal-ticket-service.ts Subscriber-scoped ticket creation and commenting
createPortalTicket
listPortalTickets
getPortalTicket
addTicketComment
path provides exports
src/app/api/portal/tickets/route.ts GET (list) and POST (create) for portal tickets
GET
POST
path provides exports
src/app/api/portal/tickets/[id]/comments/route.ts GET (list) and POST (add) for ticket comments
GET
POST
path provides exports
src/app/api/portal/payments/coming-soon/route.ts GET endpoint returning balance and payment instructions
GET
from to via pattern
src/lib/services/portal-ticket-service.ts src/lib/services/ticket-service.ts delegates ticket creation to existing createTicket createTicket
from to via pattern
src/app/api/portal/tickets/route.ts src/lib/services/portal-ticket-service.ts createPortalTicket and listPortalTickets calls (createPortalTicket|listPortalTickets)
Build portal ticket submission with conversation threads, and scaffold the online payment "coming soon" page with outstanding balance display.

Purpose: PORT-03 (ticket submission) and PORT-05 (online payment scaffold). Subscribers can interact with support through the portal. Output: TicketComment model, portal ticket APIs, payment scaffold endpoint, passing 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/05-visibility-and-client-portal/05-CONTEXT.md @.planning/phases/05-visibility-and-client-portal/05-02-SUMMARY.md

@src/lib/services/ticket-service.ts @src/lib/services/portal-service.ts @src/lib/casl/permissions.ts @prisma/schema.prisma

Task 1: TicketComment model and portal ticket service prisma/schema.prisma, src/lib/services/portal-ticket-service.ts **Schema (prisma/schema.prisma):**

Add TicketComment model for conversation threads:

model TicketComment {
  id        String   @id @default(uuid())
  tenantId  String
  ticketId  String
  ticket    Ticket   @relation(fields: [ticketId], references: [id])
  /// Who posted this comment — can be subscriber (CLIENT) or staff
  authorId  String
  author    User     @relation(fields: [authorId], references: [id])
  /// For portal comments, link to the subscriber directly
  subscriberId String?
  message   String
  createdAt DateTime @default(now())

  @@index([tenantId])
  @@index([ticketId])
}

Add comments TicketComment[] relation to the Ticket model. Add ticketComments TicketComment[] relation to the User model.

Run npx prisma db push.

PortalTicketService (src/lib/services/portal-ticket-service.ts):

  1. createPortalTicket(db, tenantId, subscriberId, data: { categoryId, subject, description })

    • Look up the subscriber to get their User record (if they have one via the portal auth) OR use the subscriberId for the createdById field
    • Important: The subscriber may not have a User record. For portal ticket creation, we need a userId. Two approaches:
      • Option A (preferred): When portal auth creates a session, the session has a userId. Use that.
      • Option B: Create a shadow User record for the subscriber. Too complex.
    • Actually, re-examine: the existing Ticket model requires createdById -> User.id. But portal subscribers authenticate via Subscriber, not User. Resolution: When a subscriber gets portal access (passwordHash set), also create a corresponding User record with Role.CLIENT. This should be done in plan 02's auth setup. If plan 02 already handles this, use the userId from the session. If not, this task must handle it.
    • Simpler approach: Portal auth returns a user-like session. The subscriberId in the JWT IS the subscriber's ID. For ticket creation, use createdById as the session user's ID. But the session user for portal is mapped from Subscriber, not User table.
    • Resolution: Create portal tickets using the existing createTicket from ticket-service.ts, but pass source: 'PORTAL' (TicketSource.PORTAL exists in the enum). The createdById needs a valid User.id. So portal subscribers MUST have a corresponding User record with CLIENT role. Add this to the portal auth flow: when a subscriber logs in for the first time, ensure a User record exists with their info and CLIENT role. Use upsert by subscriber email or a portal-specific identifier.
    • Delegate actual ticket creation to existing createTicket() from ticket-service.ts with source=PORTAL
    • Return the created ticket
  2. listPortalTickets(db, subscriberId, options?: { page, limit })

    • Query Ticket where subscriberId, ordered by createdAt DESC
    • Include: category (name), latest comment
    • Return paginated list with status
  3. getPortalTicket(db, subscriberId, ticketId)

    • Get single ticket with ALL comments (conversation thread)
    • Verify ticket.subscriberId === subscriberId (security check)
    • Include: category, all comments ordered by createdAt ASC, each comment with author name
    • Return ticket with comments array
  4. addTicketComment(db, tenantId, subscriberId, ticketId, message)

    • Verify ticket exists AND ticket.subscriberId === subscriberId
    • Verify ticket is not CLOSED (cannot comment on closed tickets)
    • Create TicketComment with subscriberId set
    • Return the created comment

User record for portal subscribers: Add a helper ensurePortalUser(db, tenantId, subscriber) that:

  • Checks if a User with email=subscriber.accountNumber (or a portal-specific convention) and tenantId exists
  • If not, creates one with: email=portal-{accountNumber}@portal.local, roles=[CLIENT], firstName=subscriber.firstName, lastName=subscriber.lastName, passwordHash=subscriber.passwordHash
  • Returns the User.id
  • Call this during portal authentication (in auth-options.ts portal provider) or lazily on first ticket creation npx prisma db push succeeds; npx tsc --noEmit compiles TicketComment model exists; PortalTicketService creates tickets via existing ticket-service, supports conversation threads
Task 2: Portal ticket API routes, payment scaffold, and tests src/app/api/portal/tickets/route.ts, src/app/api/portal/tickets/[id]/route.ts, src/app/api/portal/tickets/[id]/comments/route.ts, src/app/api/portal/payments/coming-soon/route.ts, src/lib/__tests__/portal-ticket-service.test.ts **Portal Ticket API Routes:** All routes use the `withPortalAuth` helper from plan 02 (or re-implement the same pattern: check session.subscriberId exists).

GET /api/portal/tickets — List subscriber's tickets (paginated) POST /api/portal/tickets — Create a new ticket: accepts { categoryId, subject, description } GET /api/portal/tickets/[id] — Get ticket detail with conversation thread GET /api/portal/tickets/[id]/comments — List comments for a ticket POST /api/portal/tickets/[id]/comments — Add a comment: accepts { message }

For [id] routes, use the dynamic route handler pattern from Phase 2+:

export function GET(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
  // await params, then call service
}

Payment Scaffold (src/app/api/portal/payments/coming-soon/route.ts): GET endpoint using withPortalAuth:

  • Query subscriber's outstanding balance (sum of unpaid invoice amounts)
  • Query tenant settings for payment instructions (add paymentInstructions text field to TenantSettings if not present — nullable String?)
  • Return: { outstandingBalance: Decimal, paymentInstructions: string | null, message: "Online payments coming soon" }

If TenantSettings needs a new field, add it to schema.prisma and run npx prisma db push.

Integration Tests (src/lib/tests/portal-ticket-service.test.ts): Setup: Create tenant, create subscriber with passwordHash, ensure portal User record exists, seed ticket categories.

Tests (minimum 6):

  1. createPortalTicket creates ticket with PORTAL source — verify ticket.source === 'PORTAL'
  2. createPortalTicket ticket appears in staff listTickets — create via portal, query via staff service, verify it exists
  3. listPortalTickets returns only subscriber's tickets — create tickets for 2 subscribers, verify isolation
  4. addTicketComment creates conversation entry — add comment, verify it's linked to ticket
  5. addTicketComment rejects on closed ticket — close ticket, try to comment, expect error
  6. getPortalTicket returns ticket with full conversation thread — add 3 comments, verify all returned in order

Cleanup: ticketComments -> tickets -> ticketCategories -> subscribers -> servicePlans -> tenantSettings -> accountingPeriods -> accounts -> users -> tenant npx vitest run src/lib/__tests__/portal-ticket-service.test.ts — all tests pass Subscribers can create tickets, view ticket list, have conversations on tickets; payment scaffold shows balance; 6+ tests pass; PORT-03 and PORT-05 satisfied

- `npx prisma db push` applies TicketComment model and any TenantSettings additions - `npx tsc --noEmit` passes - `npx vitest run src/lib/__tests__/portal-ticket-service.test.ts` — all tests pass - Portal ticket appears in staff's existing ticket query (cross-system verification)

<success_criteria>

  • Subscriber creates ticket from portal with PORTAL source
  • Ticket conversation thread supports subscriber and staff messages
  • Closed tickets reject new comments
  • Payment scaffold returns outstanding balance and instructions
  • PORT-03, PORT-05 requirements satisfied </success_criteria>
After completion, create `.planning/phases/05-visibility-and-client-portal/05-03-SUMMARY.md`