Add issue resolution confirmation workflow
This commit is contained in:
@@ -41,6 +41,7 @@ const SECTION_LABELS: Record<string, string> = {
|
||||
logs: 'Activity log',
|
||||
maintenance: 'Maintenance',
|
||||
requests: 'Request pipeline',
|
||||
'issue-workflow': 'Issue workflow',
|
||||
site: 'Site & login',
|
||||
}
|
||||
|
||||
@@ -95,6 +96,8 @@ const NUMBER_SETTINGS = new Set([
|
||||
'requests_poll_interval_seconds',
|
||||
'requests_delta_sync_interval_minutes',
|
||||
'requests_cleanup_days',
|
||||
'issue_confirmation_contact_attempts',
|
||||
'issue_confirmation_interval_value',
|
||||
])
|
||||
const BANNER_TONES = ['info', 'warning', 'error', 'maintenance']
|
||||
|
||||
@@ -115,6 +118,7 @@ const SECTION_DESCRIPTIONS: Record<string, string> = {
|
||||
prowlarr: 'Prowlarr connection used by Sonarr and Radarr for release searches.',
|
||||
qbittorrent: 'qBittorrent connection used for collector-owned download progress and diagnostics.',
|
||||
requests: 'Control how often requests are refreshed and cleaned up.',
|
||||
'issue-workflow': 'Control reporter confirmation, reminder timing, and automatic issue closure.',
|
||||
logs: 'Control log output and inspect recent application activity for troubleshooting.',
|
||||
maintenance: 'Repair cached data, clean historical records, and run recovery operations.',
|
||||
site: 'Sitewide banner, login page visibility, and version details. The changelog is generated from git history during release builds.',
|
||||
@@ -133,6 +137,7 @@ const SETTINGS_SECTION_MAP: Record<string, string | null> = {
|
||||
prowlarr: 'prowlarr',
|
||||
qbittorrent: 'qbittorrent',
|
||||
requests: 'requests',
|
||||
'issue-workflow': 'issue',
|
||||
cache: null,
|
||||
logs: 'log',
|
||||
maintenance: null,
|
||||
@@ -390,6 +395,18 @@ const STANDARD_SECTION_GROUPS: Record<
|
||||
keys: ['requests_cleanup_time', 'requests_cleanup_days'],
|
||||
},
|
||||
],
|
||||
'issue-workflow': [
|
||||
{
|
||||
key: 'issues-resolution-confirmation',
|
||||
title: 'Resolution confirmation',
|
||||
description: 'Choose how often Magent asks a reporter to confirm a fix before the issue is closed automatically.',
|
||||
keys: [
|
||||
'issue_confirmation_contact_attempts',
|
||||
'issue_confirmation_interval_value',
|
||||
'issue_confirmation_interval_unit',
|
||||
],
|
||||
},
|
||||
],
|
||||
logs: [
|
||||
{
|
||||
key: 'logs-output',
|
||||
@@ -413,6 +430,9 @@ const STANDARD_SECTION_GROUPS: Record<
|
||||
}
|
||||
|
||||
const SETTING_LABEL_OVERRIDES: Record<string, string> = {
|
||||
issue_confirmation_contact_attempts: 'Confirmation emails before auto-close',
|
||||
issue_confirmation_interval_value: 'Confirmation interval',
|
||||
issue_confirmation_interval_unit: 'Interval unit',
|
||||
jellyseerr_base_url: 'Seerr base URL',
|
||||
jellyseerr_api_key: 'Seerr API key',
|
||||
magent_application_url: 'Application URL',
|
||||
@@ -896,6 +916,12 @@ export default function SettingsPage({ section }: SettingsPageProps) {
|
||||
}, [artworkPrefetch])
|
||||
|
||||
const settingDescriptions: Record<string, string> = {
|
||||
issue_confirmation_contact_attempts:
|
||||
'Number of confirmation emails sent after an issue is marked fixed. Set 0 to send none and close immediately.',
|
||||
issue_confirmation_interval_value:
|
||||
'Amount of time between confirmation emails, and the final waiting period before automatic closure.',
|
||||
issue_confirmation_interval_unit:
|
||||
'Unit used for the confirmation interval: days, weeks, or months.',
|
||||
magent_application_url:
|
||||
'Canonical public URL for the Magent web app (used for links and reverse-proxy-aware features).',
|
||||
magent_application_port:
|
||||
@@ -2404,7 +2430,8 @@ export default function SettingsPage({ section }: SettingsPageProps) {
|
||||
<input
|
||||
name={setting.key}
|
||||
type="number"
|
||||
min={1}
|
||||
min={setting.key === 'issue_confirmation_contact_attempts' ? 0 : 1}
|
||||
max={setting.key === 'issue_confirmation_contact_attempts' ? 10 : setting.key === 'issue_confirmation_interval_value' ? 365 : undefined}
|
||||
step={1}
|
||||
value={value}
|
||||
onChange={(event) =>
|
||||
@@ -2417,6 +2444,32 @@ export default function SettingsPage({ section }: SettingsPageProps) {
|
||||
</label>
|
||||
)
|
||||
}
|
||||
if (setting.key === 'issue_confirmation_interval_unit') {
|
||||
return (
|
||||
<label key={setting.key} data-helper={helperText || undefined}>
|
||||
<span className="label-row">
|
||||
<span>{labelFromKey(setting.key)}</span>
|
||||
<span className="meta">
|
||||
{setting.isSet ? `Source: ${setting.source}` : 'Not set'}
|
||||
</span>
|
||||
</span>
|
||||
<select
|
||||
name={setting.key}
|
||||
value={value || 'days'}
|
||||
onChange={(event) =>
|
||||
setFormValues((current) => ({
|
||||
...current,
|
||||
[setting.key]: event.target.value,
|
||||
}))
|
||||
}
|
||||
>
|
||||
<option value="days">Days</option>
|
||||
<option value="weeks">Weeks</option>
|
||||
<option value="months">Months</option>
|
||||
</select>
|
||||
</label>
|
||||
)
|
||||
}
|
||||
if (setting.key === 'requests_data_source') {
|
||||
return (
|
||||
<label key={setting.key} data-helper={helperText || undefined}>
|
||||
|
||||
@@ -11,6 +11,7 @@ const ALLOWED_SECTIONS = new Set([
|
||||
'prowlarr',
|
||||
'qbittorrent',
|
||||
'requests',
|
||||
'issue-workflow',
|
||||
'cache',
|
||||
'logs',
|
||||
'maintenance',
|
||||
|
||||
@@ -2863,6 +2863,80 @@ button:disabled,
|
||||
color: var(--request-green);
|
||||
}
|
||||
|
||||
.issue-confirmation-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 24px;
|
||||
padding: 18px;
|
||||
border: 1px solid rgba(126, 215, 255, 0.34);
|
||||
border-radius: var(--ops-radius-lg);
|
||||
background: linear-gradient(120deg, rgba(14, 165, 233, 0.1), rgba(79, 70, 229, 0.07));
|
||||
}
|
||||
.issue-confirmation-card > div:first-child { display: grid; gap: 6px; }
|
||||
.issue-confirmation-card h3,
|
||||
.issue-confirmation-card p { margin: 0; }
|
||||
.issue-confirmation-card p,
|
||||
.issue-confirmation-card small { color: var(--ops-muted); line-height: 1.5; }
|
||||
.issue-confirmation-actions { display: flex; flex: 0 0 auto; gap: 8px; }
|
||||
.issue-activity-block {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
padding: 16px;
|
||||
border: 1px solid var(--ops-line-soft);
|
||||
border-radius: var(--ops-radius-lg);
|
||||
background: rgba(255, 255, 255, 0.018);
|
||||
}
|
||||
.issue-activity-heading { display: flex; align-items: center; justify-content: space-between; gap: 16px; }
|
||||
.issue-activity-heading h3 { margin: 0; }
|
||||
.issue-activity-list { display: grid; gap: 0; margin: 0; padding: 0; list-style: none; }
|
||||
.issue-activity-list li {
|
||||
display: grid;
|
||||
grid-template-columns: 12px minmax(0, 1fr) auto;
|
||||
align-items: start;
|
||||
gap: 10px;
|
||||
padding: 12px 0;
|
||||
border-top: 1px solid var(--ops-line-soft);
|
||||
}
|
||||
.issue-activity-list li:first-child { border-top: 0; }
|
||||
.issue-activity-list i {
|
||||
width: 9px;
|
||||
height: 9px;
|
||||
margin-top: 4px;
|
||||
border: 2px solid rgba(126, 215, 255, 0.52);
|
||||
border-radius: 50%;
|
||||
background: rgba(14, 165, 233, 0.18);
|
||||
}
|
||||
.issue-activity-list li > div { display: grid; gap: 3px; min-width: 0; }
|
||||
.issue-activity-list strong { color: var(--ops-text); font-size: 0.78rem; line-height: 1.45; }
|
||||
.issue-activity-list span,
|
||||
.issue-activity-list time { color: var(--ops-muted); font-size: 0.67rem; }
|
||||
.issue-activity-list time { white-space: nowrap; }
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.issue-confirmation-card {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.issue-confirmation-actions {
|
||||
display: grid;
|
||||
}
|
||||
|
||||
.issue-confirmation-actions button {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.issue-activity-list li {
|
||||
grid-template-columns: 12px minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.issue-activity-list time {
|
||||
grid-column: 2;
|
||||
white-space: normal;
|
||||
}
|
||||
}
|
||||
|
||||
.issue-media-poster {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -27,6 +27,7 @@ const NAV_GROUPS = [
|
||||
title: 'Request Pipeline',
|
||||
items: [
|
||||
{ href: '/admin/requests', label: 'Sync & retention' },
|
||||
{ href: '/admin/issue-workflow', label: 'Issue workflow' },
|
||||
{ href: '/admin/cache', label: 'Request cache' },
|
||||
{ href: '/admin/artwork', label: 'Artwork cache' },
|
||||
{ href: '/admin/requests-all', label: 'All requests' },
|
||||
|
||||
Reference in New Issue
Block a user