Add issue resolution confirmation workflow
Magent CI/CD / verify (push) Successful in 10m35s
Magent CI/CD / deploy-prod (push) Skipped
Magent CI/CD / deploy-beta (push) Successful in 12s

This commit is contained in:
2026-09-01 11:40:43 +12:00
parent 393b8c2a88
commit 2adbed7259
14 changed files with 1155 additions and 9 deletions
+136 -3
View File
@@ -8,6 +8,7 @@ type PortalPermissions = {
can_edit?: boolean
can_comment?: boolean
can_moderate?: boolean
can_confirm_resolution?: boolean
}
type PortalItem = {
@@ -39,6 +40,16 @@ type PortalItem = {
related_item_id?: number | null
is_resolved?: boolean
resolved_at?: string | null
confirmation?: {
status?: string | null
attempts_sent?: number
maximum_attempts?: number
last_contact_at?: string | null
next_contact_at?: string | null
interval_value?: number | null
interval_unit?: string | null
last_delivery_succeeded?: boolean | null
}
}
}
@@ -52,6 +63,16 @@ type PortalComment = {
created_at: string
}
type PortalActivity = {
id: number | string
item_id: number
event_type: string
actor_username: string
actor_role: string
message: string
created_at: string
}
type PortalOverview = {
overview?: {
total_items?: number
@@ -208,7 +229,8 @@ const STATUS_OPTIONS = [
{ value: 'planned', label: 'Planned' },
{ value: 'in_progress', label: 'In progress' },
{ value: 'blocked', label: 'Blocked' },
{ value: 'done', label: 'Done' },
{ value: 'awaiting_confirmation', label: 'Fixed - ask reporter to confirm' },
{ value: 'done', label: 'Resolved (legacy)' },
{ value: 'pending', label: 'Pending approval' },
{ value: 'approved', label: 'Approved' },
{ value: 'processing', label: 'Processing' },
@@ -263,7 +285,8 @@ const ISSUE_FILTER_STATUS_OPTIONS = [
{ value: 'planned', label: 'Planned' },
{ value: 'in_progress', label: 'In progress' },
{ value: 'blocked', label: 'Blocked' },
{ value: 'done', label: 'Done' },
{ value: 'awaiting_confirmation', label: 'Waiting for confirmation' },
{ value: 'done', label: 'Previously resolved' },
{ value: 'closed', label: 'Closed' },
] as const
@@ -274,6 +297,20 @@ const formatDate = (value?: string | null) => {
return parsed.toLocaleString()
}
const formatIssueStatus = (value?: string | null) => {
const labels: Record<string, string> = {
new: 'New',
triaging: 'Triaging',
planned: 'Planned',
in_progress: 'In progress',
blocked: 'Blocked',
awaiting_confirmation: 'Waiting for reporter confirmation',
done: 'Resolved',
closed: 'Closed',
}
return labels[String(value ?? '').toLowerCase()] ?? String(value ?? 'Unknown').replaceAll('_', ' ')
}
const formatFileSize = (value?: number | null) => {
if (!value || value <= 0) return 'Size unavailable'
const units = ['B', 'KB', 'MB', 'GB', 'TB']
@@ -301,11 +338,13 @@ export default function PortalClient({ workspace }: PortalClientProps) {
const [selectedItemId, setSelectedItemId] = useState<number | null>(null)
const [selectedItem, setSelectedItem] = useState<PortalItem | null>(null)
const [comments, setComments] = useState<PortalComment[]>([])
const [activity, setActivity] = useState<PortalActivity[]>([])
const [loadingItems, setLoadingItems] = useState(true)
const [loadingItem, setLoadingItem] = useState(false)
const [creating, setCreating] = useState(false)
const [saving, setSaving] = useState(false)
const [commenting, setCommenting] = useState(false)
const [respondingResolution, setRespondingResolution] = useState(false)
const [error, setError] = useState<string | null>(null)
const [status, setStatus] = useState<string | null>(null)
const [totalItems, setTotalItems] = useState(0)
@@ -455,6 +494,7 @@ export default function PortalClient({ workspace }: PortalClientProps) {
if (response.status === 404) {
setSelectedItem(null)
setComments([])
setActivity([])
return
}
throw new Error(`Failed to load portal item (${response.status})`)
@@ -463,6 +503,7 @@ export default function PortalClient({ workspace }: PortalClientProps) {
const item = (data?.item ?? null) as PortalItem | null
setSelectedItem(item)
setComments(Array.isArray(data?.comments) ? data.comments : [])
setActivity(Array.isArray(data?.activity) ? data.activity : [])
} catch (err) {
console.error(err)
setError('Could not load portal item details.')
@@ -1021,6 +1062,7 @@ export default function PortalClient({ workspace }: PortalClientProps) {
setSelectedItemId(null)
setSelectedItem(null)
setComments([])
setActivity([])
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [workspace])
@@ -1139,6 +1181,7 @@ export default function PortalClient({ workspace }: PortalClientProps) {
const data = await response.json()
setSelectedItem((data?.item ?? null) as PortalItem | null)
setComments(Array.isArray(data?.comments) ? data.comments : [])
setActivity(Array.isArray(data?.activity) ? data.activity : [])
setStatus('Portal item updated.')
await Promise.all([
loadItems({ preferItemId: selectedItem.id }),
@@ -1197,6 +1240,40 @@ export default function PortalClient({ workspace }: PortalClientProps) {
}
}
const respondToResolution = async (resolved: boolean) => {
if (!selectedItem) return
setRespondingResolution(true)
setError(null)
setStatus(null)
try {
const response = await authFetch(`${getApiBase()}/portal/issues/${selectedItem.id}/resolution-response`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ resolved }),
})
if (!response.ok) {
if (response.status === 401) {
clearToken()
router.push('/login')
return
}
const text = await response.text()
throw new Error(text || 'Could not record your confirmation.')
}
const data = await response.json()
setSelectedItem((data?.item ?? null) as PortalItem | null)
setComments(Array.isArray(data?.comments) ? data.comments : [])
setActivity(Array.isArray(data?.activity) ? data.activity : [])
setStatus(resolved ? 'Thanks. This issue has been closed.' : 'Thanks. The issue is back in progress for another look.')
await Promise.all([loadItems({ preferItemId: selectedItem.id }), loadOverview()])
} catch (err) {
console.error(err)
setError(err instanceof Error ? err.message : 'Could not record your confirmation.')
} finally {
setRespondingResolution(false)
}
}
if (loadingItems && !items.length) {
return <main className="card">Loading {workspace === 'issue' ? 'issues' : 'requests'}...</main>
}
@@ -1809,7 +1886,7 @@ export default function PortalClient({ workspace }: PortalClientProps) {
Status:{' '}
{item.kind === 'request'
? item.workflow?.stage_label ?? item.status
: item.status}
: formatIssueStatus(item.status)}
</span>
<span>By: {item.created_by_username}</span>
<span>Updated: {formatDate(item.last_activity_at)}</span>
@@ -1876,6 +1953,34 @@ export default function PortalClient({ workspace }: PortalClientProps) {
</div>
) : null}
{selectedItem.kind === 'issue' && selectedItem.status === 'awaiting_confirmation' ? (
<section className="issue-confirmation-card" aria-live="polite">
<div>
<span className="section-kicker">Resolution check</span>
<h3>Has this issue been fixed?</h3>
<p>
Magent is waiting for the reporter to confirm the result.
{(selectedItem.issue?.confirmation?.maximum_attempts ?? 0) > 0
? ` ${selectedItem.issue?.confirmation?.attempts_sent ?? 0} of ${selectedItem.issue?.confirmation?.maximum_attempts ?? 0} confirmation emails have been attempted.`
: ' Confirmation emails are disabled, so this issue will close automatically.'}
</p>
{selectedItem.issue?.confirmation?.next_contact_at ? (
<small>Next reminder or automatic closure check: {formatDate(selectedItem.issue.confirmation.next_contact_at)}</small>
) : null}
</div>
{selectedItem.permissions?.can_confirm_resolution ? (
<div className="issue-confirmation-actions">
<button type="button" disabled={respondingResolution} onClick={() => void respondToResolution(true)}>
Yes, it is fixed
</button>
<button type="button" className="ghost-button" disabled={respondingResolution} onClick={() => void respondToResolution(false)}>
No, it is still happening
</button>
</div>
) : null}
</section>
) : null}
<form className="admin-form compact-form portal-form-grid" onSubmit={saveItem}>
<label className="portal-field-span-2">
<span>Title</span>
@@ -2015,6 +2120,34 @@ export default function PortalClient({ workspace }: PortalClientProps) {
</div>
</form>
{selectedItem.kind === 'issue' ? (
<section className="issue-activity-block">
<div className="issue-activity-heading">
<div>
<span className="section-kicker">Recorded work</span>
<h3>Issue activity</h3>
</div>
<span className="small-pill">{activity.length} events</span>
</div>
{activity.length === 0 ? (
<div className="status-banner">No issue activity has been recorded yet.</div>
) : (
<ol className="issue-activity-list">
{activity.map((entry) => (
<li key={entry.id}>
<i aria-hidden="true" />
<div>
<strong>{entry.message}</strong>
<span>{entry.actor_username} ({entry.actor_role})</span>
</div>
<time dateTime={entry.created_at}>{formatDate(entry.created_at)}</time>
</li>
))}
</ol>
)}
</section>
) : null}
<div className="portal-comments-block">
<h3>Comments</h3>
{comments.length === 0 ? (