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}

' - f'Confirm the outcome' - '

If you do not respond, Magent will close the issue automatically after the configured confirmation period.

' - '
' + '
' + '
' + '

GRIZZLYFLIX · MAGENT

' + '

Ready to try again?

' + '

Your repair looks ready to test. Give the affected content a try, then let us know:

' + f'

{escape(str(item.get("title") or "Your reported issue"))}

' + '

Is it fixed?

' + f'YES — it works' + f'NO — still broken' + '

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) => ( +
+

Is it fixed?

{item.title}

+ Answer YES or NO → +
+ ))} + {workspace === 'request' ? (
- - - ) : null} -
- ) : null}