diff --git a/frontend/src/styles.css b/frontend/src/styles.css index 90b75fa..3e09195 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -296,6 +296,13 @@ footer a { color: var(--text); } .private-copy p { margin: 0; color: var(--muted); font-size: 13px; line-height: 1.7; white-space: pre-wrap; overflow-wrap: anywhere; } .diagnostic-download { margin-top: 22px; } .private-warning { padding: 16px; border-left: 2px solid var(--accent); color: var(--muted); background: var(--surface); font-size: 11px; } +.support-message { display: grid; grid-template-columns: 1fr auto; gap: 6px 16px; margin-top: 14px; padding: 14px; border: 1px solid var(--line); background: var(--surface); } +.support-message b, .support-message small { font-size: 11px; } +.support-message small { color: var(--muted); } +.support-message p { grid-column: 1 / -1; } +.message-composer { display: grid; gap: 12px; margin-top: 18px; } +.message-composer .field { padding: 0; border: 0; } +.message-composer button { justify-self: start; } @media (max-width: 760px) { .site-header, diff --git a/frontend/src/support.test.ts b/frontend/src/support.test.ts index d23cf83..9f24771 100644 --- a/frontend/src/support.test.ts +++ b/frontend/src/support.test.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, it, vi } from 'vitest' -import { adminLogin, deleteReceipt, loadProducts, randomIdempotencyKey, reconcileReceipt, updateAdminReportStatus } from './support' +import { adminLogin, deleteReceipt, loadProducts, randomIdempotencyKey, reconcileReceipt, sendAdminMessage, sendPrivateMessage, updateAdminReportStatus } from './support' afterEach(() => vi.unstubAllGlobals()) @@ -100,4 +100,23 @@ describe('support contract client', () => { headers: expect.objectContaining({ 'X-CSRF-Token': 'A'.repeat(43) }), })) }) + + it('posts private conversation messages through the scoped endpoints', async () => { + const response = { + contractVersion: 1, supportCode: 'OBI-ABCDE-23456', productId: 'synthetic-product', requestType: 'bug', + status: 'needs_information', createdAt: '2026-08-13T12:00:00Z', updatedAt: '2026-08-13T13:00:00Z', + retentionUntil: '2026-09-12T12:00:00Z', messages: [], + } + const fetch = vi.fn().mockImplementation(() => Promise.resolve(new Response(JSON.stringify(response), { + status: 201, headers: { 'Content-Type': 'application/json' }, + }))) + vi.stubGlobal('fetch', fetch) + const capability = 'abcdefghijklmnopqrstuvwxyzABCDEFGH_12345678' + + await sendPrivateMessage(capability, 'Reporter reply') + await sendAdminMessage('11111111-1111-4111-8111-111111111111', 'Maintainer question') + + expect(fetch).toHaveBeenNthCalledWith(1, `/api/v1/reports/${capability}/messages`, expect.objectContaining({ method: 'POST' })) + expect(fetch).toHaveBeenNthCalledWith(2, '/api/v1/admin/reports/11111111-1111-4111-8111-111111111111/messages', expect.objectContaining({ method: 'POST' })) + }) }) diff --git a/frontend/src/support.ts b/frontend/src/support.ts index 8cfbaae..2f43b8a 100644 --- a/frontend/src/support.ts +++ b/frontend/src/support.ts @@ -54,6 +54,14 @@ export interface PrivateStatus { createdAt: string updatedAt: string retentionUntil: string + messages: SupportMessage[] +} + +export interface SupportMessage { + id: string + author: 'maintainer' | 'reporter' + body: string + createdAt: string } export interface AdminSession { @@ -81,6 +89,7 @@ export interface AdminReportDetail extends AdminReportSummary { description: string contact?: string release: ReportMetadata['release'] + messages: SupportMessage[] } export interface AdminReportResponse { @@ -177,6 +186,15 @@ export async function deletePrivateReport(capability: string): Promise { if (!response.ok) throw await apiError(response) } +export async function sendPrivateMessage(capability: string, body: string): Promise { + const response = await fetch(`/api/v1/reports/${encodeURIComponent(capability)}/messages`, { + method: 'POST', headers: { Accept: 'application/json', 'Content-Type': 'application/json' }, + body: JSON.stringify({ body }), + }) + if (!response.ok) throw await apiError(response) + return (await response.json()) as PrivateStatus +} + export async function deleteReceipt(receipt: Receipt): Promise { const deletionUrl = new URL(receipt.deletionUrl, window.location.origin) const match = /^\/r\/([A-Za-z0-9_-]{43})$/.exec(deletionUrl.pathname) @@ -226,6 +244,14 @@ export async function updateAdminReportStatus(id: string, status: string): Promi }) } +export async function sendAdminMessage(id: string, body: string): Promise { + return adminJSON(`/api/v1/admin/reports/${encodeURIComponent(id)}/messages`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': adminCSRFToken }, + body: JSON.stringify({ body }), + }) +} + export function adminDiagnosticsURL(id: string): string { return `/api/v1/admin/reports/${encodeURIComponent(id)}/diagnostics` } diff --git a/frontend/src/views/AdminReportsView.vue b/frontend/src/views/AdminReportsView.vue index 0cb3c98..68f50d5 100644 --- a/frontend/src/views/AdminReportsView.vue +++ b/frontend/src/views/AdminReportsView.vue @@ -8,6 +8,7 @@ import { loadAdminReport, loadAdminReports, loadAdminSession, + sendAdminMessage, SupportApiError, updateAdminReportStatus, } from '../support' @@ -21,6 +22,7 @@ const loading = ref(true) const loadingDetail = ref(false) const saving = ref(false) const message = ref('') +const reply = ref('') const statuses = ['new', 'needs_information', 'accepted', 'duplicate', 'resolved', 'rejected'] @@ -68,6 +70,22 @@ async function changeStatus(event: Event) { } } +async function askReporter() { + if (!selected.value || !reply.value.trim()) return + saving.value = true + message.value = '' + try { + selected.value = await sendAdminMessage(selected.value.id, reply.value) + reply.value = '' + await refreshReports() + message.value = 'Message sent. The report now needs information.' + } catch (error) { + await handleError(error) + } finally { + saving.value = false + } +} + async function signOut() { try { await adminLogout() @@ -137,6 +155,18 @@ function readable(value: string): string {

Description

{{ selected.description }}

Private contact

{{ selected.contact }}

+

Private conversation

+

No messages yet.

+
+ {{ entry.author === 'maintainer' ? 'Maintainer' : 'Reporter' }} + {{ formatDate(entry.createdAt, true) }} +

{{ entry.body }}

+
+
+