4.2 KiB
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 owninclude: { ticket: { select: { id: true, code: true } } }— a single Prisma query using the existingticketrelation already on both models, not a hand-written SQL join or a second round-trip. - Rationale: Both
SLARunandEscalationEventalready have aticketrelation (@relation(fields: [ticketId], references: [id])) — Prisma'sincludeturns this into one query, not N+1, and needs no new repository dependency onticketsRepository. - Alternatives considered: A second batched
ticketsRepository.findByIds(...)call — works, butincludeis simpler and already idiomatic in this codebase's own repositories (e.g. 011-agent-ticket-queue'sfindAssignedToAgent).
Decision: status filter on GET /admin/sla-runs is validated against SLA_RUN_STATUSES
- Decision:
statusis an optional query param validated withz.enum(['running', 'paused', 'warning', 'breached', 'completed']).optional()— the same status vocabularySLARun.statusalready 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:
limitisz.coerce.number().int().positive().max(200).default(50). - Rationale: Unlike
SLARun(bounded by currently-open tickets) orProduct(bounded by catalog size),EscalationEventrows 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
limitis enough for a "recent escalations" monitoring view.
Decision: product-catalog integration status is a derived string, not the raw ProductIntegration row
- Decision:
GET /admin/productsreturnsintegrationStatus: 'active' | 'suspended' | null(nullwhenproduct.integrationis absent) — never the fullProductIntegrationobject. - Rationale:
ProductIntegration.credentialRefis 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 futureProductIntegrationfield addition for accidental exposure through a public-adjacent list view (this route is admin-only, but the same discipline this codebase already applies toAssignedTicketSummary'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
includeIntegrationStatusquery param to the existing public, ungatedGET /products. - Rationale:
GET /productsis 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 separaterequireRole('ADMIN')route keeps the gate unconditional and obvious. - Alternatives considered: The query-flag approach above — rejected for the reason stated.