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
+9
View File
@@ -85,6 +85,15 @@ class Settings(BaseSettings):
requests_data_source: str = Field( requests_data_source: str = Field(
default="prefer_cache", validation_alias=AliasChoices("REQUESTS_DATA_SOURCE") default="prefer_cache", validation_alias=AliasChoices("REQUESTS_DATA_SOURCE")
) )
issue_confirmation_contact_attempts: int = Field(
default=2, validation_alias=AliasChoices("ISSUE_CONFIRMATION_CONTACT_ATTEMPTS")
)
issue_confirmation_interval_value: int = Field(
default=3, validation_alias=AliasChoices("ISSUE_CONFIRMATION_INTERVAL_VALUE")
)
issue_confirmation_interval_unit: str = Field(
default="days", validation_alias=AliasChoices("ISSUE_CONFIRMATION_INTERVAL_UNIT")
)
artwork_cache_mode: str = Field( artwork_cache_mode: str = Field(
default="remote", validation_alias=AliasChoices("ARTWORK_CACHE_MODE") default="remote", validation_alias=AliasChoices("ARTWORK_CACHE_MODE")
) )
+106
View File
@@ -400,6 +400,27 @@ def init_db() -> None:
) )
""" """
) )
conn.execute(
"""
CREATE TABLE IF NOT EXISTS portal_item_activity (
id INTEGER PRIMARY KEY AUTOINCREMENT,
item_id INTEGER NOT NULL,
event_type TEXT NOT NULL,
actor_username TEXT NOT NULL,
actor_role TEXT NOT NULL,
message TEXT NOT NULL,
metadata_json TEXT,
created_at TEXT NOT NULL,
FOREIGN KEY(item_id) REFERENCES portal_items(id) ON DELETE CASCADE
)
"""
)
conn.execute(
"""
CREATE INDEX IF NOT EXISTS idx_portal_item_activity_item
ON portal_item_activity (item_id, created_at ASC, id ASC)
"""
)
conn.execute( conn.execute(
""" """
CREATE INDEX IF NOT EXISTS idx_requests_cache_created_at CREATE INDEX IF NOT EXISTS idx_requests_cache_created_at
@@ -3580,6 +3601,91 @@ def list_portal_comments(item_id: int, *, include_internal: bool = True, limit:
return [_portal_comment_from_row(row) for row in rows] return [_portal_comment_from_row(row) for row in rows]
def add_portal_item_activity(
item_id: int,
*,
event_type: str,
actor_username: str,
actor_role: str,
message: str,
metadata_json: Optional[str] = None,
) -> Dict[str, Any]:
now = datetime.now(timezone.utc).isoformat()
with _connect() as conn:
cursor = conn.execute(
"""
INSERT INTO portal_item_activity (
item_id,
event_type,
actor_username,
actor_role,
message,
metadata_json,
created_at
)
VALUES (?, ?, ?, ?, ?, ?, ?)
""",
(
item_id,
event_type,
actor_username,
actor_role,
message,
metadata_json,
now,
),
)
activity_id = cursor.lastrowid
row = conn.execute(
"""
SELECT id, item_id, event_type, actor_username, actor_role, message, metadata_json, created_at
FROM portal_item_activity
WHERE id = ?
""",
(activity_id,),
).fetchone()
if not row:
raise RuntimeError("Portal activity could not be loaded after insert.")
return {
"id": row[0],
"item_id": row[1],
"event_type": row[2],
"actor_username": row[3],
"actor_role": row[4],
"message": row[5],
"metadata_json": row[6],
"created_at": row[7],
}
def list_portal_item_activity(item_id: int, *, limit: int = 300) -> list[Dict[str, Any]]:
safe_limit = max(1, min(int(limit), 500))
with _connect() as conn:
rows = conn.execute(
"""
SELECT id, item_id, event_type, actor_username, actor_role, message, metadata_json, created_at
FROM portal_item_activity
WHERE item_id = ?
ORDER BY created_at ASC, id ASC
LIMIT ?
""",
(item_id, safe_limit),
).fetchall()
return [
{
"id": row[0],
"item_id": row[1],
"event_type": row[2],
"actor_username": row[3],
"actor_role": row[4],
"message": row[5],
"metadata_json": row[6],
"created_at": row[7],
}
for row in rows
]
def get_portal_overview() -> Dict[str, Any]: def get_portal_overview() -> Dict[str, Any]:
with _connect() as conn: with _connect() as conn:
kind_rows = conn.execute( kind_rows = conn.execute(
+2
View File
@@ -27,6 +27,7 @@ from .routers.events import router as events_router
from .routers.portal import router as portal_router from .routers.portal import router as portal_router
from .routers.operations import router as operations_router from .routers.operations import router as operations_router
from .services.jellyfin_sync import run_daily_jellyfin_sync from .services.jellyfin_sync import run_daily_jellyfin_sync
from .services.issue_resolution import run_issue_confirmation_loop
from .services.operation_progress import ( from .services.operation_progress import (
begin_operation, begin_operation,
finish_operation, finish_operation,
@@ -255,6 +256,7 @@ async def startup() -> None:
_launch_background_task("requests-delta-loop", run_requests_delta_loop) _launch_background_task("requests-delta-loop", run_requests_delta_loop)
_launch_background_task("requests-full-sync", run_daily_requests_full_sync) _launch_background_task("requests-full-sync", run_daily_requests_full_sync)
_launch_background_task("db-cleanup", run_daily_db_cleanup) _launch_background_task("db-cleanup", run_daily_db_cleanup)
_launch_background_task("issue-confirmation", run_issue_confirmation_loop)
logger.info("startup complete") logger.info("startup complete")
+23
View File
@@ -234,6 +234,9 @@ SETTING_KEYS: List[str] = [
"requests_cleanup_time", "requests_cleanup_time",
"requests_cleanup_days", "requests_cleanup_days",
"requests_data_source", "requests_data_source",
"issue_confirmation_contact_attempts",
"issue_confirmation_interval_value",
"issue_confirmation_interval_unit",
"site_banner_enabled", "site_banner_enabled",
"site_banner_message", "site_banner_message",
"site_banner_tone", "site_banner_tone",
@@ -661,6 +664,26 @@ async def update_settings(payload: Dict[str, Any]) -> Dict[str, Any]:
changed_keys.append(key) changed_keys.append(key)
continue continue
value_to_store = str(value).strip() if isinstance(value, str) else str(value) value_to_store = str(value).strip() if isinstance(value, str) else str(value)
if key == "issue_confirmation_contact_attempts":
try:
attempts = int(value_to_store)
except (TypeError, ValueError) as exc:
raise HTTPException(status_code=400, detail="Confirmation contacts must be a whole number from 0 to 10") from exc
if attempts < 0 or attempts > 10:
raise HTTPException(status_code=400, detail="Confirmation contacts must be from 0 to 10")
value_to_store = str(attempts)
if key == "issue_confirmation_interval_value":
try:
interval_value = int(value_to_store)
except (TypeError, ValueError) as exc:
raise HTTPException(status_code=400, detail="Confirmation interval must be a whole number") from exc
if interval_value < 1 or interval_value > 365:
raise HTTPException(status_code=400, detail="Confirmation interval must be from 1 to 365")
value_to_store = str(interval_value)
if key == "issue_confirmation_interval_unit":
value_to_store = value_to_store.lower()
if value_to_store not in {"days", "weeks", "months"}:
raise HTTPException(status_code=400, detail="Confirmation interval unit must be days, weeks, or months")
if key in URL_SETTING_KEYS and value_to_store: if key in URL_SETTING_KEYS and value_to_store:
try: try:
value_to_store = _normalize_service_url(value_to_store) value_to_store = _normalize_service_url(value_to_store)
+185 -3
View File
@@ -11,15 +11,22 @@ from fastapi import APIRouter, Depends, HTTPException, Query
from ..auth import get_current_user from ..auth import get_current_user
from ..clients.jellyfin import JellyfinClient from ..clients.jellyfin import JellyfinClient
from ..db import ( from ..db import (
add_portal_item_activity,
add_portal_comment, add_portal_comment,
count_portal_items, count_portal_items,
create_portal_item, create_portal_item,
get_portal_item, get_portal_item,
get_portal_overview, get_portal_overview,
list_portal_comments, list_portal_comments,
list_portal_item_activity,
list_portal_items, list_portal_items,
update_portal_item, update_portal_item,
) )
from ..services.issue_resolution import (
begin_issue_confirmation,
issue_resolution_state,
respond_to_issue_confirmation,
)
from ..services.notifications import send_portal_notification from ..services.notifications import send_portal_notification
from ..runtime import get_runtime_settings from ..runtime import get_runtime_settings
@@ -37,6 +44,7 @@ PORTAL_STATUSES = {
"done", "done",
"declined", "declined",
"closed", "closed",
"awaiting_confirmation",
# Seerr-style request pipeline statuses # Seerr-style request pipeline statuses
"pending", "pending",
"approved", "approved",
@@ -389,6 +397,11 @@ def _serialize_item(item: Dict[str, Any], user: Dict[str, Any]) -> Dict[str, Any
"can_comment": True, "can_comment": True,
"can_moderate": is_admin, "can_moderate": is_admin,
"can_raise_issue": str(item.get("kind") or "") == "request", "can_raise_issue": str(item.get("kind") or "") == "request",
"can_confirm_resolution": (
str(item.get("kind") or "").lower() == "issue"
and str(item.get("status") or "").lower() == "awaiting_confirmation"
and (is_admin or is_owner)
),
} }
kind = str(item.get("kind") or "").strip().lower() kind = str(item.get("kind") or "").strip().lower()
if kind == "request": if kind == "request":
@@ -400,15 +413,84 @@ def _serialize_item(item: Dict[str, Any], user: Dict[str, Any]) -> Dict[str, Any
"is_terminal": media_status in {"available", "failed"} or request_status == "declined", "is_terminal": media_status in {"available", "failed"} or request_status == "declined",
} }
elif kind == "issue": elif kind == "issue":
resolution = issue_resolution_state(item)
serialized["issue"] = { serialized["issue"] = {
"issue_type": _clean_text(item.get("issue_type")) or "general", "issue_type": _clean_text(item.get("issue_type")) or "general",
"related_item_id": _normalize_int(item.get("related_item_id"), "related_item_id"), "related_item_id": _normalize_int(item.get("related_item_id"), "related_item_id"),
"is_resolved": bool(_clean_text(item.get("issue_resolved_at"))), "is_resolved": bool(_clean_text(item.get("issue_resolved_at"))),
"resolved_at": _clean_text(item.get("issue_resolved_at")), "resolved_at": _clean_text(item.get("issue_resolved_at")),
"confirmation": {
"status": resolution.get("status"),
"attempts_sent": int(resolution.get("attemptsSent") or 0),
"maximum_attempts": int(resolution.get("maximumAttempts") or 0),
"last_contact_at": resolution.get("lastContactAt"),
"next_contact_at": resolution.get("nextContactAt"),
"interval_value": resolution.get("intervalValue"),
"interval_unit": resolution.get("intervalUnit"),
"last_delivery_succeeded": resolution.get("lastDeliverySucceeded"),
},
} }
return serialized return serialized
def _activity_payload(item: Dict[str, Any], *, include_internal: bool = False) -> list[Dict[str, Any]]:
activity = [
entry
for entry in list_portal_item_activity(int(item["id"]), limit=300)
if include_internal or entry.get("event_type") != "internal_note_added"
]
if not any(entry.get("event_type") == "item_created" for entry in activity):
activity.insert(
0,
{
"id": f"created-{item['id']}",
"item_id": item["id"],
"event_type": "item_created",
"actor_username": item.get("created_by_username") or "unknown",
"actor_role": "user",
"message": (
"Issue raised and added to the support queue."
if str(item.get("kind") or "").lower() == "issue"
else "Portal item created."
),
"metadata_json": None,
"created_at": item.get("created_at"),
},
)
if include_internal:
return activity
public_activity: list[Dict[str, Any]] = []
for entry in activity:
public_entry = {key: value for key, value in entry.items() if key != "metadata_json"}
actor_role = str(entry.get("actor_role") or "user").lower()
public_entry["actor_username"] = (
"Magent"
if actor_role == "system"
else "Support team"
if actor_role == "admin"
else "Reporter"
)
public_entry["actor_role"] = "system" if actor_role == "system" else "support" if actor_role == "admin" else "user"
public_activity.append(public_entry)
return public_activity
def _record_activity(
item_id: int,
*,
event_type: str,
message: str,
user: Dict[str, Any],
) -> None:
add_portal_item_activity(
item_id,
event_type=event_type,
actor_username=str(user.get("username") or "unknown"),
actor_role=str(user.get("role") or "user"),
message=message,
)
async def _notify( async def _notify(
*, *,
event_type: str, event_type: str,
@@ -778,6 +860,16 @@ async def portal_create_item(
priority=priority or "normal", priority=priority or "normal",
assignee_username=assignee_username, assignee_username=assignee_username,
) )
_record_activity(
int(created["id"]),
event_type="item_created",
message=(
"Issue raised and added to the support queue."
if created.get("kind") == "issue"
else f"{str(created.get('kind') or 'Portal item').capitalize()} created."
),
user=current_user,
)
initial_comment = _clean_text(payload.get("comment")) initial_comment = _clean_text(payload.get("comment"))
if initial_comment: if initial_comment:
add_portal_comment( add_portal_comment(
@@ -797,6 +889,7 @@ async def portal_create_item(
return { return {
"item": _serialize_item(created, current_user), "item": _serialize_item(created, current_user),
"comments": comments, "comments": comments,
"activity": _activity_payload(created, include_internal=_is_admin(current_user)),
} }
@@ -849,6 +942,12 @@ async def portal_create_issue_for_request(
priority=priority or "normal", priority=priority or "normal",
assignee_username=_clean_text(payload.get("assignee_username")) if _is_admin(current_user) else None, assignee_username=_clean_text(payload.get("assignee_username")) if _is_admin(current_user) else None,
) )
_record_activity(
int(created["id"]),
event_type="item_created",
message=f"Issue raised and linked to collection request #{item_id}.",
user=current_user,
)
initial_comment = _clean_text(payload.get("comment")) initial_comment = _clean_text(payload.get("comment"))
if initial_comment: if initial_comment:
add_portal_comment( add_portal_comment(
@@ -868,6 +967,7 @@ async def portal_create_issue_for_request(
return { return {
"item": _serialize_item(created, current_user), "item": _serialize_item(created, current_user),
"comments": comments, "comments": comments,
"activity": _activity_payload(created, include_internal=_is_admin(current_user)),
"linked_request_id": item_id, "linked_request_id": item_id,
} }
@@ -980,6 +1080,7 @@ async def portal_get_item(
return { return {
"item": _serialize_item(item, current_user), "item": _serialize_item(item, current_user),
"comments": comments, "comments": comments,
"activity": _activity_payload(item, include_internal=_is_admin(current_user)),
} }
@@ -994,6 +1095,7 @@ async def portal_update_item(
raise HTTPException(status_code=404, detail="Portal item not found") raise HTTPException(status_code=404, detail="Portal item not found")
is_admin = _is_admin(current_user) is_admin = _is_admin(current_user)
is_owner = _is_owner(current_user, item) is_owner = _is_owner(current_user, item)
item_kind = str(item.get("kind") or "").lower()
if not (is_admin or is_owner): if not (is_admin or is_owner):
raise HTTPException(status_code=403, detail="Only the owner or admin can edit this item") raise HTTPException(status_code=403, detail="Only the owner or admin can edit this item")
@@ -1043,7 +1145,7 @@ async def portal_update_item(
if "external_ref" in payload: if "external_ref" in payload:
updates["external_ref"] = _clean_text(payload.get("external_ref")) updates["external_ref"] = _clean_text(payload.get("external_ref"))
if is_admin: if is_admin:
kind = str(item.get("kind") or "").lower() kind = item_kind
if "priority" in payload: if "priority" in payload:
updates["priority"] = _normalize_choice( updates["priority"] = _normalize_choice(
payload.get("priority"), payload.get("priority"),
@@ -1133,9 +1235,9 @@ async def portal_update_item(
updates["issue_resolved_at"] = _clean_text(payload.get("issue_resolved_at")) updates["issue_resolved_at"] = _clean_text(payload.get("issue_resolved_at"))
if "status" in payload: if "status" in payload:
next_status = str(updates.get("status") or item.get("status") or "").lower() next_status = str(updates.get("status") or item.get("status") or "").lower()
if next_status in {"done", "closed"}: if next_status == "closed":
updates.setdefault("issue_resolved_at", datetime.now(timezone.utc).isoformat()) updates.setdefault("issue_resolved_at", datetime.now(timezone.utc).isoformat())
elif next_status in {"new", "triaging", "planned", "in_progress", "blocked"}: elif next_status in {"new", "triaging", "planned", "in_progress", "blocked", "done", "awaiting_confirmation"}:
updates.setdefault("issue_resolved_at", None) updates.setdefault("issue_resolved_at", None)
if not updates: if not updates:
@@ -1143,14 +1245,47 @@ async def portal_update_item(
return { return {
"item": _serialize_item(item, current_user), "item": _serialize_item(item, current_user),
"comments": comments, "comments": comments,
"activity": _activity_payload(item, include_internal=is_admin),
} }
updated = update_portal_item(item_id, **updates) updated = update_portal_item(item_id, **updates)
if not updated: if not updated:
raise HTTPException(status_code=404, detail="Portal item not found") raise HTTPException(status_code=404, detail="Portal item not found")
requested_issue_status = str(updates.get("status") or "").lower()
should_start_confirmation = item_kind == "issue" and (
(requested_issue_status == "done" and str(item.get("status") or "").lower() != "done")
or (
requested_issue_status == "awaiting_confirmation"
and str(item.get("status") or "").lower() != "awaiting_confirmation"
)
)
if should_start_confirmation:
try:
updated = await begin_issue_confirmation(
item_id,
actor_username=str(current_user.get("username") or "unknown"),
actor_role=str(current_user.get("role") or "admin"),
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
changed_fields = [key for key in updates.keys() if item.get(key) != updated.get(key)] changed_fields = [key for key in updates.keys() if item.get(key) != updated.get(key)]
if changed_fields: if changed_fields:
if item_kind == "issue" and not should_start_confirmation:
old_status = str(item.get("status") or "unknown").replace("_", " ")
new_status = str(updated.get("status") or "unknown").replace("_", " ")
activity_message = (
f"Status changed from {old_status} to {new_status}."
if item.get("status") != updated.get("status")
else f"Issue details updated: {', '.join(sorted(changed_fields))}."
)
_record_activity(
item_id,
event_type="status_changed" if item.get("status") != updated.get("status") else "issue_updated",
message=activity_message,
user=current_user,
)
await _notify( await _notify(
event_type="portal_item_updated", event_type="portal_item_updated",
item=updated, item=updated,
@@ -1161,6 +1296,42 @@ async def portal_update_item(
return { return {
"item": _serialize_item(updated, current_user), "item": _serialize_item(updated, current_user),
"comments": comments, "comments": comments,
"activity": _activity_payload(updated, include_internal=is_admin),
}
@router.post("/issues/{item_id}/resolution-response")
async def portal_issue_resolution_response(
item_id: int,
payload: Dict[str, Any],
current_user: Dict[str, Any] = Depends(get_current_user),
) -> Dict[str, Any]:
item = get_portal_item(item_id)
if not item or str(item.get("kind") or "").lower() != "issue":
raise HTTPException(status_code=404, detail="Issue not found")
if not (_is_admin(current_user) or _is_owner(current_user, item)):
raise HTTPException(status_code=403, detail="Only the reporter or an admin can confirm this resolution")
if not isinstance(payload.get("resolved"), bool):
raise HTTPException(status_code=400, detail="resolved must be true or false")
try:
updated = respond_to_issue_confirmation(
item_id,
resolved=payload["resolved"],
actor_username=str(current_user.get("username") or "unknown"),
actor_role=str(current_user.get("role") or "user"),
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
await _notify(
event_type="portal_issue_resolution_confirmed" if payload["resolved"] else "portal_issue_resolution_rejected",
item=updated,
user=current_user,
note="resolved=true" if payload["resolved"] else "resolved=false",
)
return {
"item": _serialize_item(updated, current_user),
"comments": list_portal_comments(item_id, include_internal=_is_admin(current_user)),
"activity": _activity_payload(updated, include_internal=_is_admin(current_user)),
} }
@@ -1202,6 +1373,17 @@ async def portal_create_comment(
message=message, message=message,
is_internal=is_internal, is_internal=is_internal,
) )
if str(item.get("kind") or "").lower() == "issue":
_record_activity(
item_id,
event_type="internal_note_added" if is_internal else "comment_added",
message=(
f"Internal troubleshooting note: {message[:240]}"
if is_internal
else f"Support update: {message[:240]}"
),
user=current_user,
)
updated_item = get_portal_item(item_id) updated_item = get_portal_item(item_id)
if updated_item: if updated_item:
await _notify( await _notify(
+66 -1
View File
@@ -19,6 +19,8 @@ from ..auth import get_current_user
from ..runtime import get_runtime_settings from ..runtime import get_runtime_settings
from .images import cache_tmdb_image, is_tmdb_cached from .images import cache_tmdb_image, is_tmdb_cached
from ..db import ( from ..db import (
add_portal_item_activity,
get_portal_item,
save_action, save_action,
get_recent_actions, get_recent_actions,
get_recent_snapshots, get_recent_snapshots,
@@ -1796,6 +1798,46 @@ def _replacement_file_payload(
} }
def _linked_issue_for_replacement(
issue_id: Any,
*,
request_id: str,
user: Dict[str, str],
) -> Optional[Dict[str, Any]]:
if issue_id is None:
return None
if not isinstance(issue_id, int) or issue_id <= 0:
raise HTTPException(status_code=400, detail="A valid linked issue is required")
issue = get_portal_item(issue_id)
if not issue or str(issue.get("kind") or "").lower() != "issue":
raise HTTPException(status_code=404, detail="Linked issue not found")
if str(issue.get("external_ref") or "") != f"/requests/{request_id}":
raise HTTPException(status_code=409, detail="The issue is not linked to this request")
is_admin = str(user.get("role") or "").lower() == "admin"
is_owner = str(issue.get("created_by_username") or "").lower() == str(user.get("username") or "").lower()
if not (is_admin or is_owner):
raise HTTPException(status_code=403, detail="You cannot update this linked issue")
return issue
def _record_replacement_activity(
issue: Optional[Dict[str, Any]],
*,
user: Dict[str, str],
event_type: str,
message: str,
) -> None:
if not issue:
return
add_portal_item_activity(
int(issue["id"]),
event_type=event_type,
actor_username=str(user.get("username") or "unknown"),
actor_role=str(user.get("role") or "user"),
message=message,
)
async def _resolve_root_folder_path(client: Any, root_folder: str, service_name: str) -> str: async def _resolve_root_folder_path(client: Any, root_folder: str, service_name: str) -> str:
if root_folder.isdigit(): if root_folder.isdigit():
folders = await client.get_root_folders() folders = await client.get_root_folders()
@@ -1915,6 +1957,11 @@ async def action_replace_media(
file_id = payload.get("file_id") file_id = payload.get("file_id")
if not isinstance(file_id, int) or file_id <= 0: if not isinstance(file_id, int) or file_id <= 0:
raise HTTPException(status_code=400, detail="A valid managed file is required") raise HTTPException(status_code=400, detail="A valid managed file is required")
linked_issue = _linked_issue_for_replacement(
payload.get("issue_id"),
request_id=request_id,
user=user,
)
runtime = get_runtime_settings() runtime = get_runtime_settings()
seerr = JellyseerrClient(runtime.jellyseerr_base_url, runtime.jellyseerr_api_key) seerr = JellyseerrClient(runtime.jellyseerr_base_url, runtime.jellyseerr_api_key)
@@ -1976,7 +2023,13 @@ async def action_replace_media(
await sonarr.search_episodes(episode_ids) await sonarr.search_episodes(episode_ids)
else: else:
raise HTTPException(status_code=400, detail="Unknown request type") raise HTTPException(status_code=400, detail="Unknown request type")
except HTTPException: except HTTPException as exc:
_record_replacement_activity(
linked_issue,
user=user,
event_type="replacement_failed",
message=f"The media replacement could not be started: {exc.detail}",
)
raise raise
except Exception as exc: except Exception as exc:
logger.exception("%s media replacement failed request_id=%s file_id=%s", collector, request_id, file_id) logger.exception("%s media replacement failed request_id=%s file_id=%s", collector, request_id, file_id)
@@ -1992,6 +2045,12 @@ async def action_replace_media(
"failed", "failed",
detail, detail,
) )
_record_replacement_activity(
linked_issue,
user=user,
event_type="replacement_failed",
message=detail,
)
raise HTTPException(status_code=502, detail=detail) from exc raise HTTPException(status_code=502, detail=detail) from exc
message = f"{collector} removed {target_name} and started a replacement search." message = f"{collector} removed {target_name} and started a replacement search."
@@ -2003,6 +2062,12 @@ async def action_replace_media(
"ok", "ok",
message, message,
) )
_record_replacement_activity(
linked_issue,
user=user,
event_type="replacement_started",
message=message,
)
return { return {
"status": "ok", "status": "ok",
"message": message, "message": message,
+2
View File
@@ -19,6 +19,8 @@ _INT_FIELDS = {
"requests_poll_interval_seconds", "requests_poll_interval_seconds",
"requests_delta_sync_interval_minutes", "requests_delta_sync_interval_minutes",
"requests_cleanup_days", "requests_cleanup_days",
"issue_confirmation_contact_attempts",
"issue_confirmation_interval_value",
"magent_notify_email_smtp_port", "magent_notify_email_smtp_port",
} }
_BOOL_FIELDS = { _BOOL_FIELDS = {
+378
View File
@@ -0,0 +1,378 @@
from __future__ import annotations
import asyncio
from datetime import datetime, timedelta, timezone
from html import escape
import json
import logging
from typing import Any, Dict, Optional
from ..config import settings as env_settings
from ..db import (
add_portal_item_activity,
get_portal_item,
get_user_by_username,
list_portal_items,
update_portal_item,
)
from ..runtime import get_runtime_settings
from .invite_email import resolve_user_delivery_email, send_generic_email
logger = logging.getLogger(__name__)
_SYSTEM_USER = "Magent"
def _now() -> datetime:
return datetime.now(timezone.utc)
def _parse_datetime(value: Any) -> Optional[datetime]:
if not isinstance(value, str) or not value.strip():
return None
try:
parsed = datetime.fromisoformat(value.strip().replace("Z", "+00:00"))
except ValueError:
return None
return parsed.replace(tzinfo=parsed.tzinfo or timezone.utc).astimezone(timezone.utc)
def _metadata(item: Dict[str, Any]) -> Dict[str, Any]:
raw = item.get("metadata_json")
if not isinstance(raw, str) or not raw.strip():
return {}
try:
parsed = json.loads(raw)
except (TypeError, ValueError):
return {}
return parsed if isinstance(parsed, dict) else {}
def issue_resolution_state(item: Dict[str, Any]) -> Dict[str, Any]:
state = _metadata(item).get("resolutionConfirmation")
return dict(state) if isinstance(state, dict) else {}
def _metadata_with_resolution(item: Dict[str, Any], state: Dict[str, Any]) -> str:
metadata = _metadata(item)
metadata["resolutionConfirmation"] = state
return json.dumps(metadata, separators=(",", ":"), sort_keys=True)
def _interval_delta(value: int, unit: str) -> timedelta:
safe_value = max(1, min(int(value), 365))
normalized_unit = str(unit or "days").strip().lower()
if normalized_unit == "weeks":
return timedelta(weeks=safe_value)
if normalized_unit == "months":
return timedelta(days=30 * safe_value)
return timedelta(days=safe_value)
def _workflow_settings() -> tuple[int, int, str]:
runtime = get_runtime_settings()
attempts = max(0, min(int(runtime.issue_confirmation_contact_attempts or 0), 10))
interval_value = max(1, min(int(runtime.issue_confirmation_interval_value or 1), 365))
interval_unit = str(runtime.issue_confirmation_interval_unit or "days").strip().lower()
if interval_unit not in {"days", "weeks", "months"}:
interval_unit = "days"
return attempts, interval_value, interval_unit
def _app_url() -> str:
runtime = get_runtime_settings()
for value in (runtime.magent_application_url, runtime.magent_proxy_base_url, env_settings.cors_allow_origin):
candidate = str(value or "").strip()
if candidate:
return candidate.rstrip("/")
return f"http://localhost:{int(runtime.magent_application_port or 3000)}"
def _issue_url(item_id: int) -> str:
return f"{_app_url()}/portal/issues?item={item_id}"
def _activity(
item_id: int,
event_type: str,
message: str,
*,
actor_username: str = _SYSTEM_USER,
actor_role: str = "system",
metadata: Optional[Dict[str, Any]] = None,
) -> None:
add_portal_item_activity(
item_id,
event_type=event_type,
actor_username=actor_username,
actor_role=actor_role,
message=message,
metadata_json=json.dumps(metadata, separators=(",", ":"), sort_keys=True) if metadata else None,
)
def _close_issue(
item: Dict[str, Any],
*,
reason: str,
confirmed: bool,
actor_username: str = _SYSTEM_USER,
actor_role: str = "system",
) -> Dict[str, Any]:
now = _now().isoformat()
state = issue_resolution_state(item)
state.update(
{
"status": "confirmed" if confirmed else "auto_closed",
"confirmedAt": now if confirmed else state.get("confirmedAt"),
"closedAt": now,
"nextContactAt": None,
"closedReason": reason,
}
)
updated = update_portal_item(
int(item["id"]),
status="closed",
issue_resolved_at=now,
metadata_json=_metadata_with_resolution(item, state),
)
if not updated:
raise RuntimeError("Issue could not be closed")
_activity(
int(item["id"]),
"resolution_confirmed" if confirmed else "issue_auto_closed",
reason,
actor_username=actor_username,
actor_role=actor_role,
)
return updated
async def _contact_reporter(item: Dict[str, Any]) -> Dict[str, Any]:
maximum, interval_value, interval_unit = _workflow_settings()
state = issue_resolution_state(item)
attempts = max(0, int(state.get("attemptsSent") or 0))
if maximum <= 0:
return _close_issue(
item,
reason="Issue closed automatically because reporter confirmation emails are disabled.",
confirmed=False,
)
if attempts >= maximum:
return _close_issue(
item,
reason=f"Issue closed automatically after {attempts} confirmation email attempt(s) without a response.",
confirmed=False,
)
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"]))
sent = False
delivery_error: Optional[str] = None
if recipient:
subject = f"Is your issue fixed? #{item['id']} {item.get('title') or ''}".strip()
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."
)
body_html = (
'<div style="font-family:Segoe UI,Arial,sans-serif;color:#132033;">'
'<h2 style="margin:0 0 12px;">Is your issue fixed?</h2>'
f'<p style="line-height:1.6;">We have marked issue <strong>#{int(item["id"])}</strong> as fixed and need your confirmation.</p>'
f'<p style="line-height:1.6;"><strong>{escape(str(item.get("title") or "Untitled issue"))}</strong><br>'
f'Confirmation request {attempt_number} of {maximum}</p>'
f'<a href="{escape(issue_url)}" style="display:inline-block;padding:11px 18px;border-radius:8px;background:#1c6bff;color:#fff;text-decoration:none;font-weight:700;">Confirm the outcome</a>'
'<p style="margin-top:18px;color:#64748b;line-height:1.6;">If you do not respond, Magent will close the issue automatically after the configured confirmation period.</p>'
'</div>'
)
try:
await send_generic_email(
recipient_email=recipient,
subject=subject,
body_text=body_text,
body_html=body_html,
)
sent = True
except Exception as exc:
delivery_error = str(exc)
logger.exception("issue confirmation email failed item_id=%s attempt=%s", item.get("id"), attempt_number)
else:
delivery_error = "No email address is stored for the reporter."
now = _now()
state.update(
{
"status": "awaiting_confirmation",
"attemptsSent": attempt_number,
"maximumAttempts": maximum,
"lastContactAt": now.isoformat(),
"nextContactAt": (now + _interval_delta(interval_value, interval_unit)).isoformat(),
"intervalValue": interval_value,
"intervalUnit": interval_unit,
"lastDeliverySucceeded": sent,
"lastDeliveryError": delivery_error,
}
)
updated = update_portal_item(
int(item["id"]),
metadata_json=_metadata_with_resolution(item, state),
)
if not updated:
raise RuntimeError("Issue confirmation schedule could not be saved")
if sent:
message = f"Confirmation email {attempt_number} of {maximum} was sent to the reporter."
else:
message = f"Confirmation email {attempt_number} of {maximum} could not be delivered."
_activity(
int(item["id"]),
"confirmation_email_sent" if sent else "confirmation_email_failed",
message,
metadata={
"attempt": attempt_number,
"maximum": maximum,
"nextContactAt": state["nextContactAt"],
"deliveryError": delivery_error,
},
)
return updated
async def begin_issue_confirmation(
item_id: int,
*,
actor_username: str,
actor_role: str,
) -> Dict[str, Any]:
item = get_portal_item(item_id)
if not item or str(item.get("kind") or "").lower() != "issue":
raise ValueError("Issue not found")
now = _now().isoformat()
maximum, interval_value, interval_unit = _workflow_settings()
state = {
"status": "awaiting_confirmation",
"startedAt": now,
"attemptsSent": 0,
"maximumAttempts": maximum,
"lastContactAt": None,
"nextContactAt": now,
"intervalValue": interval_value,
"intervalUnit": interval_unit,
"confirmedAt": None,
"closedAt": None,
}
updated = update_portal_item(
item_id,
status="awaiting_confirmation",
issue_resolved_at=None,
metadata_json=_metadata_with_resolution(item, state),
)
if not updated:
raise RuntimeError("Issue confirmation workflow could not be started")
_activity(
item_id,
"resolution_proposed",
"The issue was marked fixed and sent to the reporter for confirmation.",
actor_username=actor_username,
actor_role=actor_role,
metadata={"maximumAttempts": maximum, "intervalValue": interval_value, "intervalUnit": interval_unit},
)
return await _contact_reporter(updated)
def respond_to_issue_confirmation(
item_id: int,
*,
resolved: bool,
actor_username: str,
actor_role: str,
) -> Dict[str, Any]:
item = get_portal_item(item_id)
if not item or str(item.get("kind") or "").lower() != "issue":
raise ValueError("Issue not found")
if str(item.get("status") or "").lower() != "awaiting_confirmation":
raise ValueError("This issue is not waiting for resolution confirmation")
if resolved:
return _close_issue(
item,
reason="The reporter confirmed that the issue is fixed.",
confirmed=True,
actor_username=actor_username,
actor_role=actor_role,
)
now = _now().isoformat()
state = issue_resolution_state(item)
state.update(
{
"status": "reported_still_broken",
"reporterResponseAt": now,
"nextContactAt": None,
"closedAt": None,
}
)
updated = update_portal_item(
item_id,
status="in_progress",
issue_resolved_at=None,
metadata_json=_metadata_with_resolution(item, state),
)
if not updated:
raise RuntimeError("Issue could not be reopened")
_activity(
item_id,
"resolution_rejected",
"The reporter said the issue is still happening. The issue was returned to In progress.",
actor_username=actor_username,
actor_role=actor_role,
)
return updated
async def process_due_issue_confirmations(now: Optional[datetime] = None) -> Dict[str, int]:
current = (now or _now()).astimezone(timezone.utc)
items = list_portal_items(kind="issue", status="awaiting_confirmation", limit=500)
result = {"checked": len(items), "contacted": 0, "closed": 0, "failed": 0}
maximum, _, _ = _workflow_settings()
for item in items:
state = issue_resolution_state(item)
due_at = _parse_datetime(state.get("nextContactAt"))
if due_at and due_at > current:
continue
try:
attempts = max(0, int(state.get("attemptsSent") or 0))
if maximum <= 0 or attempts >= maximum:
_close_issue(
item,
reason=(
"Issue closed automatically because reporter confirmation emails are disabled."
if maximum <= 0
else f"Issue closed automatically after {attempts} confirmation email attempt(s) without a response."
),
confirmed=False,
)
result["closed"] += 1
else:
await _contact_reporter(item)
result["contacted"] += 1
except Exception:
result["failed"] += 1
logger.exception("issue confirmation processing failed item_id=%s", item.get("id"))
return result
async def run_issue_confirmation_loop() -> None:
while True:
try:
result = await process_due_issue_confirmations()
if result["contacted"] or result["closed"] or result["failed"]:
logger.info("issue confirmation sweep complete result=%s", result)
except asyncio.CancelledError:
raise
except Exception:
logger.exception("issue confirmation sweep failed")
await asyncio.sleep(15 * 60)
+118 -1
View File
@@ -23,6 +23,7 @@ from backend.app.routers import site as site_router
from backend.app.routers import status as status_router from backend.app.routers import status as status_router
from backend.app.security import PASSWORD_POLICY_MESSAGE, validate_password_policy from backend.app.security import PASSWORD_POLICY_MESSAGE, validate_password_policy
from backend.app.services import password_reset from backend.app.services import password_reset
from backend.app.services import issue_resolution
from backend.app.services.operation_progress import ( from backend.app.services.operation_progress import (
begin_operation, begin_operation,
finish_operation, finish_operation,
@@ -1290,16 +1291,29 @@ class MediaReplacementTests(unittest.IsolatedAsyncioTestCase):
patch.object(requests_router, "build_snapshot", new=AsyncMock(return_value=snapshot)), patch.object(requests_router, "build_snapshot", new=AsyncMock(return_value=snapshot)),
patch.object(requests_router, "RadarrClient", return_value=radarr), patch.object(requests_router, "RadarrClient", return_value=radarr),
patch.object(requests_router, "save_action"), patch.object(requests_router, "save_action"),
patch.object(
requests_router,
"get_portal_item",
return_value={
"id": 12,
"kind": "issue",
"external_ref": "/requests/3914",
"created_by_username": "admin",
},
),
patch.object(requests_router, "add_portal_item_activity") as add_activity,
): ):
result = await requests_router.action_replace_media( result = await requests_router.action_replace_media(
"3914", "3914",
{"file_id": 77, "confirmed": True}, {"file_id": 77, "confirmed": True, "issue_id": 12},
{"username": "admin", "role": "admin", "auto_search_enabled": True}, {"username": "admin", "role": "admin", "auto_search_enabled": True},
) )
self.assertEqual(result["status"], "ok") self.assertEqual(result["status"], "ok")
radarr.delete_movie_file.assert_awaited_once_with(77) radarr.delete_movie_file.assert_awaited_once_with(77)
radarr.search.assert_awaited_once_with(44) radarr.search.assert_awaited_once_with(44)
add_activity.assert_called_once()
self.assertEqual(add_activity.call_args.kwargs["event_type"], "replacement_started")
async def test_tv_replacement_options_return_only_safe_file_details(self) -> None: async def test_tv_replacement_options_return_only_safe_file_details(self) -> None:
snapshot = Snapshot( snapshot = Snapshot(
@@ -1460,3 +1474,106 @@ class PortalWorkflowTests(TempDatabaseMixin, unittest.TestCase):
) )
self.assertEqual(len(processing), 1) self.assertEqual(len(processing), 1)
self.assertEqual(pending_count, 1) self.assertEqual(pending_count, 1)
class IssueResolutionWorkflowTests(TempDatabaseMixin, unittest.IsolatedAsyncioTestCase):
def _create_issue(self, *, status: str = "in_progress") -> dict:
return db.create_portal_item(
kind="issue",
title="Missing content: Test movie",
description="The title is missing.",
created_by_username="reporter",
created_by_id=None,
status=status,
issue_type="missing_content",
)
async def test_marking_issue_fixed_records_contact_and_confirmation(self) -> None:
issue = self._create_issue()
reporter = {"username": "reporter", "email": "reporter@example.com"}
with (
patch.object(issue_resolution, "_workflow_settings", return_value=(2, 3, "days")),
patch.object(issue_resolution, "get_user_by_username", return_value=reporter),
patch.object(issue_resolution, "resolve_user_delivery_email", return_value="reporter@example.com"),
patch.object(issue_resolution, "send_generic_email", new=AsyncMock(return_value=None)) as send_email,
):
waiting = await issue_resolution.begin_issue_confirmation(
int(issue["id"]),
actor_username="admin",
actor_role="admin",
)
self.assertEqual(waiting["status"], "awaiting_confirmation")
state = issue_resolution.issue_resolution_state(waiting)
self.assertEqual(state["attemptsSent"], 1)
self.assertEqual(state["maximumAttempts"], 2)
send_email.assert_awaited_once()
activity = db.list_portal_item_activity(int(issue["id"]))
self.assertEqual(
[event["event_type"] for event in activity],
["resolution_proposed", "confirmation_email_sent"],
)
closed = issue_resolution.respond_to_issue_confirmation(
int(issue["id"]),
resolved=True,
actor_username="reporter",
actor_role="user",
)
self.assertEqual(closed["status"], "closed")
self.assertIsNotNone(closed["issue_resolved_at"])
self.assertEqual(db.list_portal_item_activity(int(issue["id"]))[-1]["event_type"], "resolution_confirmed")
async def test_zero_confirmation_emails_closes_issue_immediately(self) -> None:
issue = self._create_issue()
with patch.object(issue_resolution, "_workflow_settings", return_value=(0, 1, "days")):
closed = await issue_resolution.begin_issue_confirmation(
int(issue["id"]),
actor_username="admin",
actor_role="admin",
)
self.assertEqual(closed["status"], "closed")
self.assertEqual(
[event["event_type"] for event in db.list_portal_item_activity(int(issue["id"]))],
["resolution_proposed", "issue_auto_closed"],
)
async def test_saving_waiting_issue_does_not_restart_confirmation_cycle(self) -> None:
issue = self._create_issue(status="awaiting_confirmation")
user = {"username": "admin", "role": "admin"}
with patch.object(portal_router, "begin_issue_confirmation", new=AsyncMock()) as begin_confirmation:
result = await portal_router.portal_update_item(
int(issue["id"]),
{"title": "Updated title", "status": "awaiting_confirmation"},
user,
)
self.assertEqual(result["item"]["title"], "Updated title")
begin_confirmation.assert_not_awaited()
def test_public_activity_hides_internal_notes_and_admin_identity(self) -> None:
issue = self._create_issue()
db.add_portal_item_activity(
int(issue["id"]),
event_type="internal_note_added",
actor_username="private-admin-name",
actor_role="admin",
message="Internal diagnostic detail",
)
db.add_portal_item_activity(
int(issue["id"]),
event_type="status_changed",
actor_username="private-admin-name",
actor_role="admin",
message="Status changed to in progress.",
metadata_json='{"private":true}',
)
public_activity = portal_router._activity_payload(issue)
self.assertNotIn("Internal diagnostic detail", str(public_activity))
self.assertNotIn("private-admin-name", str(public_activity))
self.assertNotIn("metadata_json", public_activity[-1])
self.assertEqual(public_activity[-1]["actor_username"], "Support team")
+54 -1
View File
@@ -41,6 +41,7 @@ const SECTION_LABELS: Record<string, string> = {
logs: 'Activity log', logs: 'Activity log',
maintenance: 'Maintenance', maintenance: 'Maintenance',
requests: 'Request pipeline', requests: 'Request pipeline',
'issue-workflow': 'Issue workflow',
site: 'Site & login', site: 'Site & login',
} }
@@ -95,6 +96,8 @@ const NUMBER_SETTINGS = new Set([
'requests_poll_interval_seconds', 'requests_poll_interval_seconds',
'requests_delta_sync_interval_minutes', 'requests_delta_sync_interval_minutes',
'requests_cleanup_days', 'requests_cleanup_days',
'issue_confirmation_contact_attempts',
'issue_confirmation_interval_value',
]) ])
const BANNER_TONES = ['info', 'warning', 'error', 'maintenance'] 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.', prowlarr: 'Prowlarr connection used by Sonarr and Radarr for release searches.',
qbittorrent: 'qBittorrent connection used for collector-owned download progress and diagnostics.', qbittorrent: 'qBittorrent connection used for collector-owned download progress and diagnostics.',
requests: 'Control how often requests are refreshed and cleaned up.', 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.', logs: 'Control log output and inspect recent application activity for troubleshooting.',
maintenance: 'Repair cached data, clean historical records, and run recovery operations.', 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.', 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', prowlarr: 'prowlarr',
qbittorrent: 'qbittorrent', qbittorrent: 'qbittorrent',
requests: 'requests', requests: 'requests',
'issue-workflow': 'issue',
cache: null, cache: null,
logs: 'log', logs: 'log',
maintenance: null, maintenance: null,
@@ -390,6 +395,18 @@ const STANDARD_SECTION_GROUPS: Record<
keys: ['requests_cleanup_time', 'requests_cleanup_days'], 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: [ logs: [
{ {
key: 'logs-output', key: 'logs-output',
@@ -413,6 +430,9 @@ const STANDARD_SECTION_GROUPS: Record<
} }
const SETTING_LABEL_OVERRIDES: Record<string, string> = { 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_base_url: 'Seerr base URL',
jellyseerr_api_key: 'Seerr API key', jellyseerr_api_key: 'Seerr API key',
magent_application_url: 'Application URL', magent_application_url: 'Application URL',
@@ -896,6 +916,12 @@ export default function SettingsPage({ section }: SettingsPageProps) {
}, [artworkPrefetch]) }, [artworkPrefetch])
const settingDescriptions: Record<string, string> = { 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: magent_application_url:
'Canonical public URL for the Magent web app (used for links and reverse-proxy-aware features).', 'Canonical public URL for the Magent web app (used for links and reverse-proxy-aware features).',
magent_application_port: magent_application_port:
@@ -2404,7 +2430,8 @@ export default function SettingsPage({ section }: SettingsPageProps) {
<input <input
name={setting.key} name={setting.key}
type="number" 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} step={1}
value={value} value={value}
onChange={(event) => onChange={(event) =>
@@ -2417,6 +2444,32 @@ export default function SettingsPage({ section }: SettingsPageProps) {
</label> </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') { if (setting.key === 'requests_data_source') {
return ( return (
<label key={setting.key} data-helper={helperText || undefined}> <label key={setting.key} data-helper={helperText || undefined}>
+1
View File
@@ -11,6 +11,7 @@ const ALLOWED_SECTIONS = new Set([
'prowlarr', 'prowlarr',
'qbittorrent', 'qbittorrent',
'requests', 'requests',
'issue-workflow',
'cache', 'cache',
'logs', 'logs',
'maintenance', 'maintenance',
+74
View File
@@ -2863,6 +2863,80 @@ button:disabled,
color: var(--request-green); 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 { .issue-media-poster {
display: grid; display: grid;
place-items: center; place-items: center;
+136 -3
View File
@@ -8,6 +8,7 @@ type PortalPermissions = {
can_edit?: boolean can_edit?: boolean
can_comment?: boolean can_comment?: boolean
can_moderate?: boolean can_moderate?: boolean
can_confirm_resolution?: boolean
} }
type PortalItem = { type PortalItem = {
@@ -39,6 +40,16 @@ type PortalItem = {
related_item_id?: number | null related_item_id?: number | null
is_resolved?: boolean is_resolved?: boolean
resolved_at?: string | null 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 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 = { type PortalOverview = {
overview?: { overview?: {
total_items?: number total_items?: number
@@ -208,7 +229,8 @@ const STATUS_OPTIONS = [
{ value: 'planned', label: 'Planned' }, { value: 'planned', label: 'Planned' },
{ value: 'in_progress', label: 'In progress' }, { value: 'in_progress', label: 'In progress' },
{ value: 'blocked', label: 'Blocked' }, { 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: 'pending', label: 'Pending approval' },
{ value: 'approved', label: 'Approved' }, { value: 'approved', label: 'Approved' },
{ value: 'processing', label: 'Processing' }, { value: 'processing', label: 'Processing' },
@@ -263,7 +285,8 @@ const ISSUE_FILTER_STATUS_OPTIONS = [
{ value: 'planned', label: 'Planned' }, { value: 'planned', label: 'Planned' },
{ value: 'in_progress', label: 'In progress' }, { value: 'in_progress', label: 'In progress' },
{ value: 'blocked', label: 'Blocked' }, { value: 'blocked', label: 'Blocked' },
{ value: 'done', label: 'Done' }, { value: 'awaiting_confirmation', label: 'Waiting for confirmation' },
{ value: 'done', label: 'Previously resolved' },
{ value: 'closed', label: 'Closed' }, { value: 'closed', label: 'Closed' },
] as const ] as const
@@ -274,6 +297,20 @@ const formatDate = (value?: string | null) => {
return parsed.toLocaleString() 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) => { const formatFileSize = (value?: number | null) => {
if (!value || value <= 0) return 'Size unavailable' if (!value || value <= 0) return 'Size unavailable'
const units = ['B', 'KB', 'MB', 'GB', 'TB'] 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 [selectedItemId, setSelectedItemId] = useState<number | null>(null)
const [selectedItem, setSelectedItem] = useState<PortalItem | null>(null) const [selectedItem, setSelectedItem] = useState<PortalItem | null>(null)
const [comments, setComments] = useState<PortalComment[]>([]) const [comments, setComments] = useState<PortalComment[]>([])
const [activity, setActivity] = useState<PortalActivity[]>([])
const [loadingItems, setLoadingItems] = useState(true) const [loadingItems, setLoadingItems] = useState(true)
const [loadingItem, setLoadingItem] = useState(false) const [loadingItem, setLoadingItem] = useState(false)
const [creating, setCreating] = useState(false) const [creating, setCreating] = useState(false)
const [saving, setSaving] = useState(false) const [saving, setSaving] = useState(false)
const [commenting, setCommenting] = useState(false) const [commenting, setCommenting] = useState(false)
const [respondingResolution, setRespondingResolution] = useState(false)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const [status, setStatus] = useState<string | null>(null) const [status, setStatus] = useState<string | null>(null)
const [totalItems, setTotalItems] = useState(0) const [totalItems, setTotalItems] = useState(0)
@@ -455,6 +494,7 @@ export default function PortalClient({ workspace }: PortalClientProps) {
if (response.status === 404) { if (response.status === 404) {
setSelectedItem(null) setSelectedItem(null)
setComments([]) setComments([])
setActivity([])
return return
} }
throw new Error(`Failed to load portal item (${response.status})`) 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 const item = (data?.item ?? null) as PortalItem | null
setSelectedItem(item) setSelectedItem(item)
setComments(Array.isArray(data?.comments) ? data.comments : []) setComments(Array.isArray(data?.comments) ? data.comments : [])
setActivity(Array.isArray(data?.activity) ? data.activity : [])
} catch (err) { } catch (err) {
console.error(err) console.error(err)
setError('Could not load portal item details.') setError('Could not load portal item details.')
@@ -1021,6 +1062,7 @@ export default function PortalClient({ workspace }: PortalClientProps) {
setSelectedItemId(null) setSelectedItemId(null)
setSelectedItem(null) setSelectedItem(null)
setComments([]) setComments([])
setActivity([])
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [workspace]) }, [workspace])
@@ -1139,6 +1181,7 @@ export default function PortalClient({ workspace }: PortalClientProps) {
const data = await response.json() const data = await response.json()
setSelectedItem((data?.item ?? null) as PortalItem | null) setSelectedItem((data?.item ?? null) as PortalItem | null)
setComments(Array.isArray(data?.comments) ? data.comments : []) setComments(Array.isArray(data?.comments) ? data.comments : [])
setActivity(Array.isArray(data?.activity) ? data.activity : [])
setStatus('Portal item updated.') setStatus('Portal item updated.')
await Promise.all([ await Promise.all([
loadItems({ preferItemId: selectedItem.id }), 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) { if (loadingItems && !items.length) {
return <main className="card">Loading {workspace === 'issue' ? 'issues' : 'requests'}...</main> return <main className="card">Loading {workspace === 'issue' ? 'issues' : 'requests'}...</main>
} }
@@ -1809,7 +1886,7 @@ export default function PortalClient({ workspace }: PortalClientProps) {
Status:{' '} Status:{' '}
{item.kind === 'request' {item.kind === 'request'
? item.workflow?.stage_label ?? item.status ? item.workflow?.stage_label ?? item.status
: item.status} : formatIssueStatus(item.status)}
</span> </span>
<span>By: {item.created_by_username}</span> <span>By: {item.created_by_username}</span>
<span>Updated: {formatDate(item.last_activity_at)}</span> <span>Updated: {formatDate(item.last_activity_at)}</span>
@@ -1876,6 +1953,34 @@ export default function PortalClient({ workspace }: PortalClientProps) {
</div> </div>
) : null} ) : 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}> <form className="admin-form compact-form portal-form-grid" onSubmit={saveItem}>
<label className="portal-field-span-2"> <label className="portal-field-span-2">
<span>Title</span> <span>Title</span>
@@ -2015,6 +2120,34 @@ export default function PortalClient({ workspace }: PortalClientProps) {
</div> </div>
</form> </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"> <div className="portal-comments-block">
<h3>Comments</h3> <h3>Comments</h3>
{comments.length === 0 ? ( {comments.length === 0 ? (
+1
View File
@@ -27,6 +27,7 @@ const NAV_GROUPS = [
title: 'Request Pipeline', title: 'Request Pipeline',
items: [ items: [
{ href: '/admin/requests', label: 'Sync & retention' }, { href: '/admin/requests', label: 'Sync & retention' },
{ href: '/admin/issue-workflow', label: 'Issue workflow' },
{ href: '/admin/cache', label: 'Request cache' }, { href: '/admin/cache', label: 'Request cache' },
{ href: '/admin/artwork', label: 'Artwork cache' }, { href: '/admin/artwork', label: 'Artwork cache' },
{ href: '/admin/requests-all', label: 'All requests' }, { href: '/admin/requests-all', label: 'All requests' },