diff --git a/backend/app/services/issue_resolution.py b/backend/app/services/issue_resolution.py
index 2ca5daf..eed36e8 100644
--- a/backend/app/services/issue_resolution.py
+++ b/backend/app/services/issue_resolution.py
@@ -129,6 +129,10 @@ def _activity_metadata(entry: Dict[str, Any]) -> Dict[str, Any]:
def _repair_tracking(item_id: int) -> tuple[Dict[str, Any], list[Dict[str, Any]]]:
activity = list_portal_item_activity(item_id, limit=500)
for entry in reversed(activity):
+ # A rejected repair must not be proposed again simply because the same
+ # replacement file is still present. Wait for a NEW repair attempt.
+ if str(entry.get("event_type") or "") == "resolution_rejected":
+ return {}, activity
if str(entry.get("event_type") or "") not in _MEDIA_REPAIR_STARTED_EVENTS:
continue
tracking = _activity_metadata(entry).get("repairTracking")
@@ -205,27 +209,34 @@ async def _contact_reporter(item: Dict[str, Any]) -> Dict[str, Any]:
attempt_number = attempts + 1
reporter = get_user_by_username(str(item.get("created_by_username") or ""))
recipient = resolve_user_delivery_email(reporter)
- issue_url = _issue_url(int(item["id"]))
+ issue_url = f"{_app_url()}/issues/confirm/{int(item['id'])}"
sent = False
delivery_error: Optional[str] = None
if recipient:
- subject = f"Is your issue fixed? #{item['id']} {item.get('title') or ''}".strip()
+ subject = f"Ready to try again? Grizzlyflix issue #{item['id']}"
body_text = (
- f"We have marked issue #{item['id']} as fixed and need your confirmation.\n\n"
- f"Issue: {item.get('title') or 'Untitled issue'}\n"
- f"Confirmation request: {attempt_number} of {maximum}\n\n"
- f"Open the issue and choose whether it is fixed or still happening:\n{issue_url}\n\n"
- "If you do not respond, Magent will close the issue automatically after the configured confirmation period."
+ "Your repair looks ready to test.\n\n"
+ f"{item.get('title') or 'Your reported issue'}\n\n"
+ "Please try the affected content in Grizzlyflix. Is it fixed?\n\n"
+ f"YES — it works: {issue_url}#yes\n"
+ f"NO — still broken: {issue_url}#no\n\n"
+ "Confirm your answer in Magent. You may need to sign in first.\n"
+ "Yes closes the report. No keeps it open for another look.\n\n"
+ f"Reminder {attempt_number} of {maximum}. If we do not hear back after the reminder period, this report will close automatically."
)
body_html = (
- '
'
- '
Is your issue fixed?
'
- f'
We have marked issue #{int(item["id"])} as fixed and need your confirmation.
'
- f'
{escape(str(item.get("title") or "Untitled issue"))} '
- f'Confirmation request {attempt_number} of {maximum}
Confirm your answer in Magent. You may need to sign in first. Yes closes the report. No keeps it open for another look.
'
+ f'
Reminder {attempt_number} of {maximum} · Issue #{int(item["id"])} If we do not hear back after the reminder period, this report will close automatically.
'
+ '
'
)
try:
await send_generic_email(
diff --git a/backend/tests/test_issue_acceptance.py b/backend/tests/test_issue_acceptance.py
new file mode 100644
index 0000000..9604dc2
--- /dev/null
+++ b/backend/tests/test_issue_acceptance.py
@@ -0,0 +1,55 @@
+import json
+import unittest
+from unittest.mock import AsyncMock, patch
+
+from backend.app import db
+from backend.app.services import issue_resolution as service
+from backend.tests.test_backend_quality import TempDatabaseMixin
+
+
+class IssueAcceptanceTests(TempDatabaseMixin, unittest.IsolatedAsyncioTestCase):
+ def issue(self):
+ item = db.create_portal_item(kind="issue", title="Broken ", description="Repair",
+ created_by_username="reporter", created_by_id=None, status="in_progress", issue_type="broken_media")
+ self.start(item["id"])
+ return item
+
+ def start(self, item_id):
+ db.add_portal_item_activity(item_id, event_type="replacement_started", actor_username="reporter",
+ actor_role="user", message="New repair", metadata_json=json.dumps({"repairTracking": {"requestId": "12", "actionId": "replace_media"}}))
+
+ async def test_importing_or_unverified_media_does_not_email_reporter(self):
+ self.issue()
+ for phase in ["collecting", "indexing", "unavailable"]:
+ with patch.object(service, "_media_repair_evidence", new=AsyncMock(return_value={"complete": False, "phase": phase})), patch.object(service, "begin_issue_confirmation", new=AsyncMock()) as begin:
+ await service.process_active_media_repairs()
+ begin.assert_not_awaited()
+
+ async def test_verified_repair_emails_once_and_no_requires_a_new_repair(self):
+ item = self.issue()
+ with (
+ patch.object(service, "_media_repair_evidence", new=AsyncMock(return_value={"complete": True, "phase": "complete"})),
+ patch.object(service, "_workflow_settings", return_value=(3, 2, "days")),
+ patch.object(service, "get_user_by_username", return_value={"username": "reporter"}),
+ patch.object(service, "resolve_user_delivery_email", return_value="reporter@example.test"),
+ patch.object(service, "send_generic_email", new=AsyncMock()) as email,
+ ):
+ await service.process_active_media_repairs()
+ await service.process_active_media_repairs()
+ self.assertEqual(email.await_count, 1)
+ self.assertEqual(db.get_portal_item(item["id"])["status"], "awaiting_confirmation")
+ content = email.await_args.kwargs
+ self.assertIn("YES — it works", content["body_html"])
+ self.assertIn("NO — still broken", content["body_html"])
+ self.assertIn(f"/issues/confirm/{item['id']}#yes", content["body_html"])
+ self.assertIn("Broken <movie>", content["body_html"])
+ self.assertNotIn("", content["body_html"])
+ self.assertIn("Confirm your answer in Magent", content["body_text"])
+ service.respond_to_issue_confirmation(item["id"], resolved=False, actor_username="reporter", actor_role="user")
+ await service.process_active_media_repairs()
+ await service.process_due_issue_confirmations()
+ self.assertEqual(email.await_count, 1)
+ self.assertEqual(db.get_portal_item(item["id"])["status"], "in_progress")
+ self.start(item["id"])
+ await service.process_active_media_repairs()
+ self.assertEqual(email.await_count, 2)
diff --git a/frontend/UI.md b/frontend/UI.md
index 5387e5f..c3530e3 100644
--- a/frontend/UI.md
+++ b/frontend/UI.md
@@ -9,6 +9,7 @@
- Preserve the six-stage request pipeline: three columns on desktop, two on tablet, one on narrow screens. Issue reports remain a right-hand column on desktop and stack on smaller screens.
- A media repair starts a new collection cycle: preserve Requested/Approved and unaffected TV episodes, but do not reuse an old torrent or Jellyfin entry to mark the replacement ready. Show Pending → Downloading → Indexing → Available from collector/file evidence. Subtitle-only repairs must not reset video availability.
- Request action feedback uses a compact Latest activity card beside download status; full event history opens in a native modal dialog without expanding the page. Keep messages outcome-based and do not equate a successful service response with playable media.
+- Issue acceptance uses `ui/ResolutionChoice.tsx`: large YES/NO choices at the top of issue details and on `/issues/confirm/[id]`. Email links only open that page; answers require an authenticated POST. A NO must wait for a new repair before automatic acceptance is proposed again.
- Desktop navigation stays at the top; mobile navigation stays at the bottom. Dialogs must remain clear of both.
- The guided issue form uses `portal/IssueFlowStep.tsx`: show one expanded step, collapse completed answers into Change rows, and keep repairs behind the final submit action. Movies use the selected title's managed file directly; only TV needs a season/episode picker. Multi-select controls must expose `aria-pressed` and a visible selected state.
@@ -21,6 +22,7 @@ Build the frontend before reviewing. The scripts in `scripts/` run using Node an
- `review_issue_flow_ui.cjs`: fixture-only movie/TV issue flow, multi-device report payloads, subtitle routing, permissions, and collapsed-step navigation.
- `review_repair_pipeline_ui.cjs`: fixture-only movie/TV replacement stages, old-cycle progress rejection, desktop/mobile layout and automatic availability transitions.
- `review_activity_ui.cjs`: fixture-only latest activity, desktop/tablet/mobile placement, modal history, keyboard dismissal and focus restoration.
+- `review_acceptance_ui.cjs`: fixture-only acceptance choices, exact YES/NO submissions, email-link safety, permissions and sign-in return links.
- `review_settings_ui.cjs`: settings state, region-only saves, secret preservation, responsive controls and issue dialog placement.
The layout/settings reviews accept `REVIEW_BASE`, `REVIEW_LIVE_BASE`, `REVIEW_PLAYWRIGHT`, and `REVIEW_DIR`. Provide an authorised short-lived session through `REVIEW_SESSION` as `{ "name": "cookie-name", "token": "..." }` in the process environment, never in a committed file. Live writes are blocked; submission checks use fixtures. Screenshots may contain account information and must stay outside the repository.
diff --git a/frontend/app/issues/confirm/[id]/page.tsx b/frontend/app/issues/confirm/[id]/page.tsx
new file mode 100644
index 0000000..1fb561a
--- /dev/null
+++ b/frontend/app/issues/confirm/[id]/page.tsx
@@ -0,0 +1,63 @@
+'use client'
+
+import { useEffect, useState } from 'react'
+import { useParams, useRouter } from 'next/navigation'
+import { authFetch, getApiBase, clearToken } from '../../../lib/auth'
+import ResolutionChoice from '../../../ui/ResolutionChoice'
+
+type Issue = { id: number; kind: string; title: string; status: string; permissions?: { can_confirm_resolution?: boolean } }
+
+export default function ConfirmIssuePage() {
+ const { id } = useParams<{ id: string }>()
+ const router = useRouter()
+ const [item, setItem] = useState(null)
+ const [loading, setLoading] = useState(true)
+ const [busy, setBusy] = useState(false)
+ const [error, setError] = useState('')
+ const [result, setResult] = useState('')
+ const login = () => {
+ clearToken()
+ router.replace(`/login?next=${encodeURIComponent(`/issues/confirm/${id}`)}`)
+ }
+ useEffect(() => {
+ const controller = new AbortController()
+ setLoading(true); setItem(null); setError(''); setResult('')
+ const load = async () => {
+ try {
+ const response = await authFetch(`${getApiBase()}/portal/items/${id}`, { signal: controller.signal, cache: 'no-store' })
+ if (response.status === 401) { login(); return }
+ if (!response.ok) throw new Error('This issue is unavailable. Please sign in with the account that reported it.')
+ const data = await response.json()
+ if (data.item?.kind !== 'issue') throw new Error('This link does not belong to an issue.')
+ setItem(data.item)
+ } catch (err) { if (!controller.signal.aborted) setError(err instanceof Error ? err.message : 'Could not load this issue. Please try again.') }
+ finally { if (!controller.signal.aborted) setLoading(false) }
+ }
+ void load()
+ return () => controller.abort()
+ // The confirmation link identifies one issue. Never submit an answer on GET.
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [id])
+
+ const answer = async (resolved: boolean) => {
+ if (busy) return
+ setBusy(true); setError('')
+ try {
+ const response = await authFetch(`${getApiBase()}/portal/issues/${id}/resolution-response`, {
+ method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ resolved }),
+ })
+ if (response.status === 401) { login(); return }
+ if (!response.ok) throw new Error('Your answer could not be saved. This issue may already have been answered. Refresh to check before trying again.')
+ setResult(resolved ? 'Thanks! Your issue is now closed.' : 'Thanks for letting us know. Your issue stays open for another look.')
+ } catch (err) { setError(err instanceof Error ? err.message : 'Could not save your answer. Please try again.') }
+ finally { setBusy(false) }
+ }
+ return
+ {error &&
{error}
}
+ {loading ?
Loading your issue…
: result ?
{result}
Back to issues : item ? (
+ item.status === 'awaiting_confirmation' && item.permissions?.can_confirm_resolution
+ ? void answer(value)} />
+ :
{item.status === 'awaiting_confirmation' ? 'This question is for the person who reported the issue.' : 'No answer is needed right now.'}
{item.title}
View issue
+ ) : null}
+
+}
diff --git a/frontend/app/login/page.tsx b/frontend/app/login/page.tsx
index 3077065..3da35b0 100644
--- a/frontend/app/login/page.tsx
+++ b/frontend/app/login/page.tsx
@@ -70,7 +70,8 @@ export default function LoginPage() {
const data = await response.json()
if (!data?.authenticated) { setError('Could not sign in. Please try again.'); return }
setToken('cookie')
- window.location.assign('/')
+ const next = new URLSearchParams(window.location.search).get('next') || ''
+ window.location.assign(/^\/issues\/confirm\/\d+$/.test(next) ? next : '/')
} catch {
setError('Could not reach Magent. Check your connection and try again.')
} finally { setLoading(false) }
diff --git a/frontend/app/portal/PortalClient.tsx b/frontend/app/portal/PortalClient.tsx
index 130f5b7..a22c950 100644
--- a/frontend/app/portal/PortalClient.tsx
+++ b/frontend/app/portal/PortalClient.tsx
@@ -1,4 +1,5 @@
'use client'
+import ResolutionChoice from '../ui/ResolutionChoice'
import PageHeading from '../ui/PageHeading'
import IssueFlowStep from './IssueFlowStep'
@@ -1501,6 +1502,13 @@ export default function PortalClient({ workspace }: PortalClientProps) {
actions={workspace === 'issue' ? {visibleKindCount} reported {visibleKindCount === 1 ? 'issue' : 'issues'} : undefined}
/>
+ {workspace === 'issue' && items.filter((item) => item.status === 'awaiting_confirmation' && item.permissions?.can_confirm_resolution && item.created_by_username === me?.username).map((item) => (
+
+