Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions frontend/src/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
21 changes: 20 additions & 1 deletion frontend/src/support.test.ts
Original file line number Diff line number Diff line change
@@ -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())

Expand Down Expand Up @@ -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' }))
})
})
26 changes: 26 additions & 0 deletions frontend/src/support.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -81,6 +89,7 @@ export interface AdminReportDetail extends AdminReportSummary {
description: string
contact?: string
release: ReportMetadata['release']
messages: SupportMessage[]
}

export interface AdminReportResponse {
Expand Down Expand Up @@ -177,6 +186,15 @@ export async function deletePrivateReport(capability: string): Promise<void> {
if (!response.ok) throw await apiError(response)
}

export async function sendPrivateMessage(capability: string, body: string): Promise<PrivateStatus> {
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<void> {
const deletionUrl = new URL(receipt.deletionUrl, window.location.origin)
const match = /^\/r\/([A-Za-z0-9_-]{43})$/.exec(deletionUrl.pathname)
Expand Down Expand Up @@ -226,6 +244,14 @@ export async function updateAdminReportStatus(id: string, status: string): Promi
})
}

export async function sendAdminMessage(id: string, body: string): Promise<AdminReportDetail> {
return adminJSON<AdminReportDetail>(`/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`
}
Expand Down
30 changes: 30 additions & 0 deletions frontend/src/views/AdminReportsView.vue
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
loadAdminReport,
loadAdminReports,
loadAdminSession,
sendAdminMessage,
SupportApiError,
updateAdminReportStatus,
} from '../support'
Expand All @@ -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']

Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -137,6 +155,18 @@ function readable(value: string): string {
</dl>
<div class="private-copy"><h3>Description</h3><p>{{ selected.description }}</p></div>
<div v-if="selected.contact" class="private-copy"><h3>Private contact</h3><p>{{ selected.contact }}</p></div>
<div class="private-copy"><h3>Private conversation</h3>
<p v-if="selected.messages.length === 0">No messages yet.</p>
<article v-for="entry in selected.messages" :key="entry.id" class="support-message">
<b>{{ entry.author === 'maintainer' ? 'Maintainer' : 'Reporter' }}</b>
<small>{{ formatDate(entry.createdAt, true) }}</small>
<p>{{ entry.body }}</p>
</article>
<form class="message-composer" @submit.prevent="askReporter">
<label class="field"><span>Ask for more details</span><textarea v-model="reply" maxlength="8192" rows="4" required /></label>
<button class="primary" type="submit" :disabled="saving || !reply.trim()">{{ saving ? 'Sending...' : 'Send private message' }}</button>
</form>
</div>
<a v-if="selected.hasDiagnostics" class="secondary diagnostic-download" :href="adminDiagnosticsURL(selected.id)">Download diagnostic ZIP</a>
<p class="private-warning">Private report data must not be copied into a public issue without reviewing and removing personal information.</p>
</template>
Expand Down
31 changes: 30 additions & 1 deletion frontend/src/views/PrivateStatusView.vue
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import { useRoute } from 'vue-router'
import type { PrivateStatus } from '../support'
import { deletePrivateReport, loadPrivateStatus } from '../support'
import { deletePrivateReport, loadPrivateStatus, sendPrivateMessage } from '../support'

const route = useRoute()
const capability = computed(() => String(route.params.capability ?? ''))
Expand All @@ -11,6 +11,8 @@ const loading = ref(true)
const deleting = ref(false)
const deleted = ref(false)
const message = ref('')
const reply = ref('')
const sending = ref(false)
const controller = new AbortController()

onMounted(async () => {
Expand Down Expand Up @@ -45,6 +47,21 @@ async function removeReport() {
deleting.value = false
}
}

async function sendReply() {
if (!reply.value.trim()) return
sending.value = true
message.value = ''
try {
report.value = await sendPrivateMessage(capability.value, reply.value)
reply.value = ''
message.value = 'Your reply was sent privately.'
} catch (error) {
message.value = error instanceof Error ? error.message : 'Your reply could not be sent.'
} finally {
sending.value = false
}
}
</script>

<template>
Expand All @@ -61,6 +78,18 @@ async function removeReport() {
<div><dt>Submitted</dt><dd>{{ formatDate(report.createdAt, true) }}</dd></div>
<div><dt>Private data expires</dt><dd>{{ formatDate(report.retentionUntil) }}</dd></div>
</dl>
<div v-if="!deleted" class="private-copy"><h2>Conversation</h2>
<p v-if="report.messages.length === 0">Support has not sent any messages yet.</p>
<article v-for="entry in report.messages" :key="entry.id" class="support-message">
<b>{{ entry.author === 'maintainer' ? 'Support' : 'You' }}</b>
<small>{{ formatDate(entry.createdAt, true) }}</small>
<p>{{ entry.body }}</p>
</article>
<form class="message-composer" @submit.prevent="sendReply">
<label class="field"><span>Reply privately</span><textarea v-model="reply" maxlength="8192" rows="4" required /></label>
<button class="primary" type="submit" :disabled="sending || !reply.trim()">{{ sending ? 'Sending...' : 'Send reply' }}</button>
</form>
</div>
<p v-if="!deleted">This link is private. Anyone who has it can view or delete this request. Do not post it publicly.</p>
<button v-if="!deleted" class="danger" type="button" :disabled="deleting" @click="removeReport">{{ deleting ? 'Deleting...' : 'Delete private request' }}</button>
</template>
Expand Down
1 change: 1 addition & 0 deletions internal/domain/admin.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,4 +37,5 @@ type AdminReportDetail struct {
Description string `json:"description"`
Contact string `json:"contact,omitempty"`
Release ReleaseMetadata `json:"release"`
Messages []Message `json:"messages"`
}
18 changes: 18 additions & 0 deletions internal/domain/report.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ const (
ContractVersion = 1
MaxMetadataBytes = 32 * 1024
MaxDiagnosticArchiveBytes = 4 * 1024 * 1024
MaxMessageBytes = 8 * 1024
)

func (value ReportStatus) Valid() bool {
Expand Down Expand Up @@ -101,4 +102,21 @@ type PrivateStatus struct {
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
RetentionUntil time.Time `json:"retentionUntil"`
Messages []Message `json:"messages"`
}

type MessageAuthor string

const (
MessageAuthorMaintainer MessageAuthor = "maintainer"
MessageAuthorReporter MessageAuthor = "reporter"
)

type Message struct {
ID string `json:"id"`
Author MessageAuthor `json:"author"`
Body string `json:"body"`
CreatedAt time.Time `json:"createdAt"`
BodyCiphertext []byte `json:"-"`
ReportID string `json:"-"`
}
Loading
Loading