Files

4.2 KiB

Research: Admin List Views

Decision: project ticket id/code via a second query, not a raw join

  • Decision: Each repository method fetches its own rows (SLARun[]/EscalationEvent[]) with Prisma's own include: { ticket: { select: { id: true, code: true } } } — a single Prisma query using the existing ticket relation already on both models, not a hand-written SQL join or a second round-trip.
  • Rationale: Both SLARun and EscalationEvent already have a ticket relation (@relation(fields: [ticketId], references: [id])) — Prisma's include turns this into one query, not N+1, and needs no new repository dependency on ticketsRepository.
  • Alternatives considered: A second batched ticketsRepository.findByIds(...) call — works, but include is simpler and already idiomatic in this codebase's own repositories (e.g. 011-agent-ticket-queue's findAssignedToAgent).

Decision: status filter on GET /admin/sla-runs is validated against SLA_RUN_STATUSES

  • Decision: status is an optional query param validated with z.enum(['running', 'paused', 'warning', 'breached', 'completed']).optional() — the same status vocabulary SLARun.status already uses (008-sla-escalation).
  • Rationale: A typo'd status silently returning zero rows (if left as a free string) would be a confusing, silent failure mode for a monitoring view; validating it up front makes an invalid filter a clear 400, matching this codebase's existing "resolve/validate first, then act" convention (e.g. 011's proactive existence checks).
  • Alternatives considered: A free-text z.string().optional() — rejected for the silent- wrong-filter risk above.

Decision: GET /admin/escalation-events defaults to limit=50, capped at 200

  • Decision: limit is z.coerce.number().int().positive().max(200).default(50).
  • Rationale: Unlike SLARun (bounded by currently-open tickets) or Product (bounded by catalog size), EscalationEvent rows only ever accumulate — an unbounded list would grow without limit. A sane default plus a hard ceiling avoids both an accidentally-enormous response and a caller needing to know to always pass one.
  • Alternatives considered: True cursor-based pagination — rejected as more than this feature's own scope calls for (spec.md Assumptions); a capped limit is enough for a "recent escalations" monitoring view.

Decision: product-catalog integration status is a derived string, not the raw ProductIntegration row

  • Decision: GET /admin/products returns integrationStatus: 'active' | 'suspended' | null (null when product.integration is absent) — never the full ProductIntegration object.
  • Rationale: ProductIntegration.credentialRef is an encrypted secret at rest (002-saas-integration); even encrypted, there's no reason for a list-view response to include it, or any other integration-internal field (rateLimitPerMinute, allowedScope, etc.) this screen doesn't render (FR-003's own "constraints" — plan.md).
  • Alternatives considered: Nesting the full include: { integration: true } result under the product — rejected; a derived, minimal field is both simpler for the frontend and doesn't require re-auditing every future ProductIntegration field addition for accidental exposure through a public-adjacent list view (this route is admin-only, but the same discipline this codebase already applies to AssignedTicketSummary's own minimal projection applies here too).

Decision: GET /admin/products is a new admin route, not an extension of the existing public GET /products

  • Decision: A separate route rather than adding an optional includeIntegrationStatus query param to the existing public, ungated GET /products.
  • Rationale: GET /products is intentionally public (spec.md Assumptions of 002-saas-integration's own catalog read); layering an admin-only field onto a public route via a query flag would make that route's own auth requirement conditional on which fields were requested — a confusing, easy-to-get-wrong pattern. A separate requireRole('ADMIN') route keeps the gate unconditional and obvious.
  • Alternatives considered: The query-flag approach above — rejected for the reason stated.