Compare commits

...

2 Commits

Author SHA1 Message Date
3989e90a9a Process 1 build 0803262038 2026-03-08 20:40:18 +13:00
4e2b902760 Process 1 build 0703261729 2026-03-07 17:30:58 +13:00
14 changed files with 3238 additions and 6 deletions

View File

@@ -1 +1 @@
0403261902
0803262038

File diff suppressed because one or more lines are too long

View File

@@ -20,6 +20,7 @@ SEERR_MEDIA_FAILURE_PERSISTENT_THRESHOLD = 3
SQLITE_BUSY_TIMEOUT_MS = 5_000
SQLITE_CACHE_SIZE_KIB = 32_768
SQLITE_MMAP_SIZE_BYTES = 256 * 1024 * 1024
_DB_UNSET = object()
def _db_path() -> str:
@@ -349,6 +350,49 @@ def init_db() -> None:
)
"""
)
conn.execute(
"""
CREATE TABLE IF NOT EXISTS portal_items (
id INTEGER PRIMARY KEY AUTOINCREMENT,
kind TEXT NOT NULL,
title TEXT NOT NULL,
description TEXT NOT NULL,
media_type TEXT,
year INTEGER,
external_ref TEXT,
source_system TEXT,
source_request_id INTEGER,
related_item_id INTEGER,
status TEXT NOT NULL,
workflow_request_status TEXT,
workflow_media_status TEXT,
issue_type TEXT,
issue_resolved_at TEXT,
metadata_json TEXT,
priority TEXT NOT NULL,
created_by_username TEXT NOT NULL,
created_by_id INTEGER,
assignee_username TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
last_activity_at TEXT NOT NULL
)
"""
)
conn.execute(
"""
CREATE TABLE IF NOT EXISTS portal_comments (
id INTEGER PRIMARY KEY AUTOINCREMENT,
item_id INTEGER NOT NULL,
author_username TEXT NOT NULL,
author_role TEXT NOT NULL,
message TEXT NOT NULL,
is_internal INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL,
FOREIGN KEY(item_id) REFERENCES portal_items(id) ON DELETE CASCADE
)
"""
)
conn.execute(
"""
CREATE INDEX IF NOT EXISTS idx_requests_cache_created_at
@@ -409,6 +453,48 @@ def init_db() -> None:
ON password_reset_tokens (expires_at)
"""
)
conn.execute(
"""
CREATE INDEX IF NOT EXISTS idx_portal_items_kind_status
ON portal_items (kind, status, updated_at DESC, id DESC)
"""
)
conn.execute(
"""
CREATE INDEX IF NOT EXISTS idx_portal_items_creator
ON portal_items (created_by_username, updated_at DESC, id DESC)
"""
)
conn.execute(
"""
CREATE INDEX IF NOT EXISTS idx_portal_items_status
ON portal_items (status, updated_at DESC, id DESC)
"""
)
try:
conn.execute(
"""
CREATE INDEX IF NOT EXISTS idx_portal_items_workflow
ON portal_items (kind, workflow_request_status, workflow_media_status, updated_at DESC, id DESC)
"""
)
except sqlite3.OperationalError:
pass
try:
conn.execute(
"""
CREATE INDEX IF NOT EXISTS idx_portal_items_related_item
ON portal_items (related_item_id, updated_at DESC, id DESC)
"""
)
except sqlite3.OperationalError:
pass
conn.execute(
"""
CREATE INDEX IF NOT EXISTS idx_portal_comments_item_created
ON portal_comments (item_id, created_at DESC, id DESC)
"""
)
conn.execute(
"""
CREATE TABLE IF NOT EXISTS user_activity (
@@ -491,6 +577,48 @@ def init_db() -> None:
conn.execute("ALTER TABLE signup_invites ADD COLUMN recipient_email TEXT")
except sqlite3.OperationalError:
pass
try:
conn.execute("ALTER TABLE portal_items ADD COLUMN related_item_id INTEGER")
except sqlite3.OperationalError:
pass
try:
conn.execute("ALTER TABLE portal_items ADD COLUMN workflow_request_status TEXT")
except sqlite3.OperationalError:
pass
try:
conn.execute("ALTER TABLE portal_items ADD COLUMN workflow_media_status TEXT")
except sqlite3.OperationalError:
pass
try:
conn.execute("ALTER TABLE portal_items ADD COLUMN issue_type TEXT")
except sqlite3.OperationalError:
pass
try:
conn.execute("ALTER TABLE portal_items ADD COLUMN issue_resolved_at TEXT")
except sqlite3.OperationalError:
pass
try:
conn.execute("ALTER TABLE portal_items ADD COLUMN metadata_json TEXT")
except sqlite3.OperationalError:
pass
try:
conn.execute(
"""
CREATE INDEX IF NOT EXISTS idx_portal_items_workflow
ON portal_items (kind, workflow_request_status, workflow_media_status, updated_at DESC, id DESC)
"""
)
except sqlite3.OperationalError:
pass
try:
conn.execute(
"""
CREATE INDEX IF NOT EXISTS idx_portal_items_related_item
ON portal_items (related_item_id, updated_at DESC, id DESC)
"""
)
except sqlite3.OperationalError:
pass
try:
conn.execute(
"""
@@ -2879,6 +3007,535 @@ def clear_seerr_media_failure(media_type: Optional[str], tmdb_id: Optional[int])
)
def _portal_item_from_row(row: tuple[Any, ...]) -> Dict[str, Any]:
return {
"id": row[0],
"kind": row[1],
"title": row[2],
"description": row[3],
"media_type": row[4],
"year": row[5],
"external_ref": row[6],
"source_system": row[7],
"source_request_id": row[8],
"related_item_id": row[9],
"status": row[10],
"workflow_request_status": row[11],
"workflow_media_status": row[12],
"issue_type": row[13],
"issue_resolved_at": row[14],
"metadata_json": row[15],
"priority": row[16],
"created_by_username": row[17],
"created_by_id": row[18],
"assignee_username": row[19],
"created_at": row[20],
"updated_at": row[21],
"last_activity_at": row[22],
}
def _portal_comment_from_row(row: tuple[Any, ...]) -> Dict[str, Any]:
return {
"id": row[0],
"item_id": row[1],
"author_username": row[2],
"author_role": row[3],
"message": row[4],
"is_internal": bool(row[5]),
"created_at": row[6],
}
def create_portal_item(
*,
kind: str,
title: str,
description: str,
created_by_username: str,
created_by_id: Optional[int],
media_type: Optional[str] = None,
year: Optional[int] = None,
external_ref: Optional[str] = None,
source_system: Optional[str] = None,
source_request_id: Optional[int] = None,
related_item_id: Optional[int] = None,
status: str = "new",
workflow_request_status: Optional[str] = None,
workflow_media_status: Optional[str] = None,
issue_type: Optional[str] = None,
issue_resolved_at: Optional[str] = None,
metadata_json: Optional[str] = None,
priority: str = "normal",
assignee_username: Optional[str] = None,
) -> Dict[str, Any]:
now = datetime.now(timezone.utc).isoformat()
with _connect() as conn:
cursor = conn.execute(
"""
INSERT INTO portal_items (
kind,
title,
description,
media_type,
year,
external_ref,
source_system,
source_request_id,
related_item_id,
status,
workflow_request_status,
workflow_media_status,
issue_type,
issue_resolved_at,
metadata_json,
priority,
created_by_username,
created_by_id,
assignee_username,
created_at,
updated_at,
last_activity_at
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
kind,
title,
description,
media_type,
year,
external_ref,
source_system,
source_request_id,
related_item_id,
status,
workflow_request_status,
workflow_media_status,
issue_type,
issue_resolved_at,
metadata_json,
priority,
created_by_username,
created_by_id,
assignee_username,
now,
now,
now,
),
)
item_id = cursor.lastrowid
created = get_portal_item(item_id)
if not created:
raise RuntimeError("Portal item could not be loaded after insert.")
logger.info(
"portal item created id=%s kind=%s status=%s priority=%s created_by=%s",
created["id"],
created["kind"],
created["status"],
created["priority"],
created["created_by_username"],
)
return created
def get_portal_item(item_id: int) -> Optional[Dict[str, Any]]:
with _connect() as conn:
row = conn.execute(
"""
SELECT
id,
kind,
title,
description,
media_type,
year,
external_ref,
source_system,
source_request_id,
related_item_id,
status,
workflow_request_status,
workflow_media_status,
issue_type,
issue_resolved_at,
metadata_json,
priority,
created_by_username,
created_by_id,
assignee_username,
created_at,
updated_at,
last_activity_at
FROM portal_items
WHERE id = ?
""",
(item_id,),
).fetchone()
return _portal_item_from_row(row) if row else None
def list_portal_items(
*,
kind: Optional[str] = None,
status: Optional[str] = None,
workflow_request_status: Optional[str] = None,
workflow_media_status: Optional[str] = None,
source_system: Optional[str] = None,
source_request_id: Optional[int] = None,
related_item_id: Optional[int] = None,
mine_username: Optional[str] = None,
search: Optional[str] = None,
limit: int = 100,
offset: int = 0,
) -> list[Dict[str, Any]]:
clauses: list[str] = []
params: list[Any] = []
if isinstance(kind, str) and kind.strip():
clauses.append("kind = ?")
params.append(kind.strip().lower())
if isinstance(status, str) and status.strip():
clauses.append("status = ?")
params.append(status.strip().lower())
if isinstance(workflow_request_status, str) and workflow_request_status.strip():
clauses.append("workflow_request_status = ?")
params.append(workflow_request_status.strip().lower())
if isinstance(workflow_media_status, str) and workflow_media_status.strip():
clauses.append("workflow_media_status = ?")
params.append(workflow_media_status.strip().lower())
if isinstance(source_system, str) and source_system.strip():
clauses.append("source_system = ?")
params.append(source_system.strip().lower())
if isinstance(source_request_id, int):
clauses.append("source_request_id = ?")
params.append(source_request_id)
if isinstance(related_item_id, int):
clauses.append("related_item_id = ?")
params.append(related_item_id)
if isinstance(mine_username, str) and mine_username.strip():
clauses.append("created_by_username = ?")
params.append(mine_username.strip())
if isinstance(search, str) and search.strip():
token = f"%{search.strip().lower()}%"
clauses.append("(LOWER(title) LIKE ? OR LOWER(description) LIKE ? OR CAST(id AS TEXT) = ?)")
params.extend([token, token, search.strip()])
where_sql = f"WHERE {' AND '.join(clauses)}" if clauses else ""
safe_limit = max(1, min(int(limit), 500))
safe_offset = max(0, int(offset))
params.extend([safe_limit, safe_offset])
with _connect() as conn:
rows = conn.execute(
f"""
SELECT
id,
kind,
title,
description,
media_type,
year,
external_ref,
source_system,
source_request_id,
related_item_id,
status,
workflow_request_status,
workflow_media_status,
issue_type,
issue_resolved_at,
metadata_json,
priority,
created_by_username,
created_by_id,
assignee_username,
created_at,
updated_at,
last_activity_at
FROM portal_items
{where_sql}
ORDER BY last_activity_at DESC, id DESC
LIMIT ? OFFSET ?
""",
tuple(params),
).fetchall()
return [_portal_item_from_row(row) for row in rows]
def count_portal_items(
*,
kind: Optional[str] = None,
status: Optional[str] = None,
workflow_request_status: Optional[str] = None,
workflow_media_status: Optional[str] = None,
source_system: Optional[str] = None,
source_request_id: Optional[int] = None,
related_item_id: Optional[int] = None,
mine_username: Optional[str] = None,
search: Optional[str] = None,
) -> int:
clauses: list[str] = []
params: list[Any] = []
if isinstance(kind, str) and kind.strip():
clauses.append("kind = ?")
params.append(kind.strip().lower())
if isinstance(status, str) and status.strip():
clauses.append("status = ?")
params.append(status.strip().lower())
if isinstance(workflow_request_status, str) and workflow_request_status.strip():
clauses.append("workflow_request_status = ?")
params.append(workflow_request_status.strip().lower())
if isinstance(workflow_media_status, str) and workflow_media_status.strip():
clauses.append("workflow_media_status = ?")
params.append(workflow_media_status.strip().lower())
if isinstance(source_system, str) and source_system.strip():
clauses.append("source_system = ?")
params.append(source_system.strip().lower())
if isinstance(source_request_id, int):
clauses.append("source_request_id = ?")
params.append(source_request_id)
if isinstance(related_item_id, int):
clauses.append("related_item_id = ?")
params.append(related_item_id)
if isinstance(mine_username, str) and mine_username.strip():
clauses.append("created_by_username = ?")
params.append(mine_username.strip())
if isinstance(search, str) and search.strip():
token = f"%{search.strip().lower()}%"
clauses.append("(LOWER(title) LIKE ? OR LOWER(description) LIKE ? OR CAST(id AS TEXT) = ?)")
params.extend([token, token, search.strip()])
where_sql = f"WHERE {' AND '.join(clauses)}" if clauses else ""
with _connect() as conn:
row = conn.execute(
f"SELECT COUNT(*) FROM portal_items {where_sql}",
tuple(params),
).fetchone()
return int(row[0] or 0) if row else 0
def update_portal_item(
item_id: int,
*,
title: Any = _DB_UNSET,
description: Any = _DB_UNSET,
status: Any = _DB_UNSET,
priority: Any = _DB_UNSET,
assignee_username: Any = _DB_UNSET,
media_type: Any = _DB_UNSET,
year: Any = _DB_UNSET,
external_ref: Any = _DB_UNSET,
source_system: Any = _DB_UNSET,
source_request_id: Any = _DB_UNSET,
related_item_id: Any = _DB_UNSET,
workflow_request_status: Any = _DB_UNSET,
workflow_media_status: Any = _DB_UNSET,
issue_type: Any = _DB_UNSET,
issue_resolved_at: Any = _DB_UNSET,
metadata_json: Any = _DB_UNSET,
) -> Optional[Dict[str, Any]]:
updates: list[str] = []
params: list[Any] = []
if title is not _DB_UNSET:
updates.append("title = ?")
params.append(title)
if description is not _DB_UNSET:
updates.append("description = ?")
params.append(description)
if status is not _DB_UNSET:
updates.append("status = ?")
params.append(status)
if priority is not _DB_UNSET:
updates.append("priority = ?")
params.append(priority)
if assignee_username is not _DB_UNSET:
updates.append("assignee_username = ?")
params.append(assignee_username)
if media_type is not _DB_UNSET:
updates.append("media_type = ?")
params.append(media_type)
if year is not _DB_UNSET:
updates.append("year = ?")
params.append(year)
if external_ref is not _DB_UNSET:
updates.append("external_ref = ?")
params.append(external_ref)
if source_system is not _DB_UNSET:
updates.append("source_system = ?")
params.append(source_system)
if source_request_id is not _DB_UNSET:
updates.append("source_request_id = ?")
params.append(source_request_id)
if related_item_id is not _DB_UNSET:
updates.append("related_item_id = ?")
params.append(related_item_id)
if workflow_request_status is not _DB_UNSET:
updates.append("workflow_request_status = ?")
params.append(workflow_request_status)
if workflow_media_status is not _DB_UNSET:
updates.append("workflow_media_status = ?")
params.append(workflow_media_status)
if issue_type is not _DB_UNSET:
updates.append("issue_type = ?")
params.append(issue_type)
if issue_resolved_at is not _DB_UNSET:
updates.append("issue_resolved_at = ?")
params.append(issue_resolved_at)
if metadata_json is not _DB_UNSET:
updates.append("metadata_json = ?")
params.append(metadata_json)
if not updates:
return get_portal_item(item_id)
now = datetime.now(timezone.utc).isoformat()
updates.append("updated_at = ?")
updates.append("last_activity_at = ?")
params.extend([now, now, item_id])
with _connect() as conn:
changed = conn.execute(
f"""
UPDATE portal_items
SET {', '.join(updates)}
WHERE id = ?
""",
tuple(params),
).rowcount
if not changed:
return None
updated = get_portal_item(item_id)
if updated:
logger.info(
"portal item updated id=%s status=%s priority=%s assignee=%s",
updated["id"],
updated["status"],
updated["priority"],
updated["assignee_username"],
)
return updated
def add_portal_comment(
item_id: int,
*,
author_username: str,
author_role: str,
message: str,
is_internal: bool = False,
) -> Dict[str, Any]:
now = datetime.now(timezone.utc).isoformat()
with _connect() as conn:
cursor = conn.execute(
"""
INSERT INTO portal_comments (
item_id,
author_username,
author_role,
message,
is_internal,
created_at
)
VALUES (?, ?, ?, ?, ?, ?)
""",
(
item_id,
author_username,
author_role,
message,
1 if is_internal else 0,
now,
),
)
conn.execute(
"""
UPDATE portal_items
SET last_activity_at = ?, updated_at = ?
WHERE id = ?
""",
(now, now, item_id),
)
comment_id = cursor.lastrowid
row = conn.execute(
"""
SELECT id, item_id, author_username, author_role, message, is_internal, created_at
FROM portal_comments
WHERE id = ?
""",
(comment_id,),
).fetchone()
if not row:
raise RuntimeError("Portal comment could not be loaded after insert.")
comment = _portal_comment_from_row(row)
logger.info(
"portal comment created id=%s item_id=%s author=%s internal=%s",
comment["id"],
comment["item_id"],
comment["author_username"],
comment["is_internal"],
)
return comment
def list_portal_comments(item_id: int, *, include_internal: bool = True, limit: int = 200) -> list[Dict[str, Any]]:
clauses = ["item_id = ?"]
params: list[Any] = [item_id]
if not include_internal:
clauses.append("is_internal = 0")
safe_limit = max(1, min(int(limit), 500))
params.append(safe_limit)
with _connect() as conn:
rows = conn.execute(
f"""
SELECT id, item_id, author_username, author_role, message, is_internal, created_at
FROM portal_comments
WHERE {' AND '.join(clauses)}
ORDER BY created_at ASC, id ASC
LIMIT ?
""",
tuple(params),
).fetchall()
return [_portal_comment_from_row(row) for row in rows]
def get_portal_overview() -> Dict[str, Any]:
with _connect() as conn:
kind_rows = conn.execute(
"""
SELECT kind, COUNT(*)
FROM portal_items
GROUP BY kind
"""
).fetchall()
status_rows = conn.execute(
"""
SELECT status, COUNT(*)
FROM portal_items
GROUP BY status
"""
).fetchall()
request_workflow_rows = conn.execute(
"""
SELECT
COALESCE(workflow_request_status, ''),
COALESCE(workflow_media_status, ''),
COUNT(*)
FROM portal_items
WHERE kind = 'request'
GROUP BY workflow_request_status, workflow_media_status
"""
).fetchall()
total_items_row = conn.execute("SELECT COUNT(*) FROM portal_items").fetchone()
total_comments_row = conn.execute("SELECT COUNT(*) FROM portal_comments").fetchone()
request_workflow: Dict[str, Dict[str, int]] = {}
for row in request_workflow_rows:
request_status = str(row[0] or "")
media_status = str(row[1] or "")
request_workflow.setdefault(request_status, {})
request_workflow[request_status][media_status] = int(row[2] or 0)
return {
"total_items": int(total_items_row[0] or 0) if total_items_row else 0,
"total_comments": int(total_comments_row[0] or 0) if total_comments_row else 0,
"by_kind": {str(row[0]): int(row[1] or 0) for row in kind_rows},
"by_status": {str(row[0]): int(row[1] or 0) for row in status_rows},
"request_workflow": request_workflow,
}
def run_integrity_check() -> str:
with _connect() as conn:
row = conn.execute("PRAGMA integrity_check").fetchone()
@@ -2922,6 +3579,8 @@ def get_database_diagnostics() -> Dict[str, Any]:
"snapshots": int(conn.execute("SELECT COUNT(*) FROM snapshots").fetchone()[0] or 0),
"seerr_media_failures": int(conn.execute("SELECT COUNT(*) FROM seerr_media_failures").fetchone()[0] or 0),
"password_reset_tokens": int(conn.execute("SELECT COUNT(*) FROM password_reset_tokens").fetchone()[0] or 0),
"portal_items": int(conn.execute("SELECT COUNT(*) FROM portal_items").fetchone()[0] or 0),
"portal_comments": int(conn.execute("SELECT COUNT(*) FROM portal_comments").fetchone()[0] or 0),
}
row_count_ms = round((perf_counter() - row_count_started) * 1000, 1)

View File

@@ -24,6 +24,7 @@ from .routers.status import router as status_router
from .routers.feedback import router as feedback_router
from .routers.site import router as site_router
from .routers.events import router as events_router
from .routers.portal import router as portal_router
from .services.jellyfin_sync import run_daily_jellyfin_sync
from .logging_config import (
bind_request_id,
@@ -228,3 +229,4 @@ app.include_router(status_router)
app.include_router(feedback_router)
app.include_router(site_router)
app.include_router(events_router)
app.include_router(portal_router)

File diff suppressed because it is too large Load Diff

View File

@@ -1165,6 +1165,38 @@ async def send_templated_email(
}
async def send_generic_email(
*,
recipient_email: str,
subject: str,
body_text: str,
body_html: str = "",
) -> Dict[str, str]:
ready, detail = smtp_email_config_ready()
if not ready:
raise RuntimeError(detail)
resolved_email = _normalize_email(recipient_email)
if not resolved_email:
raise RuntimeError("A valid recipient email is required.")
receipt = await asyncio.to_thread(
_send_email_sync,
recipient_email=resolved_email,
subject=subject.strip() or f"{env_settings.app_name} notification",
body_text=body_text.strip(),
body_html=body_html.strip(),
)
logger.info("Generic email sent recipient=%s subject=%s", resolved_email, subject)
return {
"recipient_email": resolved_email,
"subject": subject.strip() or f"{env_settings.app_name} notification",
**{
key: value
for key, value in receipt.items()
if key in {"provider_message_id", "provider_internal_id", "data_response"}
},
}
async def send_test_email(recipient_email: Optional[str] = None) -> Dict[str, str]:
ready, detail = smtp_email_config_ready()
if not ready:

View File

@@ -0,0 +1,276 @@
from __future__ import annotations
import logging
from typing import Any, Dict, Optional
from urllib.parse import quote
import httpx
from ..config import settings as env_settings
from ..db import get_setting
from ..runtime import get_runtime_settings
from .invite_email import send_generic_email
logger = logging.getLogger(__name__)
def _clean_text(value: Any, fallback: str = "") -> str:
if value is None:
return fallback
if isinstance(value, str):
trimmed = value.strip()
return trimmed if trimmed else fallback
return str(value)
def _split_emails(value: str) -> list[str]:
if not value:
return []
parts = [entry.strip() for entry in value.replace(";", ",").split(",")]
return [entry for entry in parts if entry and "@" in entry]
def _resolve_app_url() -> str:
runtime = get_runtime_settings()
for candidate in (
runtime.magent_application_url,
runtime.magent_proxy_base_url,
env_settings.cors_allow_origin,
):
normalized = _clean_text(candidate)
if normalized:
return normalized.rstrip("/")
port = int(getattr(runtime, "magent_application_port", 3000) or 3000)
return f"http://localhost:{port}"
def _portal_item_url(item_id: int) -> str:
return f"{_resolve_app_url()}/portal?item={item_id}"
async def _http_post_json(url: str, payload: Dict[str, Any]) -> Dict[str, Any]:
async with httpx.AsyncClient(timeout=12.0) as client:
response = await client.post(url, json=payload)
response.raise_for_status()
try:
body = response.json()
except ValueError:
body = response.text
return {"status_code": response.status_code, "body": body}
async def _send_discord(title: str, message: str, payload: Dict[str, Any]) -> Dict[str, Any]:
runtime = get_runtime_settings()
webhook = _clean_text(runtime.magent_notify_discord_webhook_url) or _clean_text(
runtime.discord_webhook_url
)
if not webhook:
return {"status": "skipped", "detail": "Discord webhook not configured."}
data = {
"content": f"**{title}**\n{message}",
"embeds": [
{
"title": title,
"description": message,
"fields": [
{"name": "Type", "value": _clean_text(payload.get("kind"), "unknown"), "inline": True},
{"name": "Status", "value": _clean_text(payload.get("status"), "unknown"), "inline": True},
{"name": "Priority", "value": _clean_text(payload.get("priority"), "normal"), "inline": True},
],
"url": _clean_text(payload.get("item_url")),
}
],
}
result = await _http_post_json(webhook, data)
return {"status": "ok", "detail": f"Discord accepted ({result['status_code']})."}
async def _send_telegram(title: str, message: str) -> Dict[str, Any]:
runtime = get_runtime_settings()
bot_token = _clean_text(runtime.magent_notify_telegram_bot_token)
chat_id = _clean_text(runtime.magent_notify_telegram_chat_id)
if not bot_token or not chat_id:
return {"status": "skipped", "detail": "Telegram is not configured."}
url = f"https://api.telegram.org/bot{bot_token}/sendMessage"
payload = {"chat_id": chat_id, "text": f"{title}\n\n{message}", "disable_web_page_preview": True}
result = await _http_post_json(url, payload)
return {"status": "ok", "detail": f"Telegram accepted ({result['status_code']})."}
async def _send_webhook(payload: Dict[str, Any]) -> Dict[str, Any]:
runtime = get_runtime_settings()
webhook = _clean_text(runtime.magent_notify_webhook_url)
if not webhook:
return {"status": "skipped", "detail": "Generic webhook is not configured."}
result = await _http_post_json(webhook, payload)
return {"status": "ok", "detail": f"Webhook accepted ({result['status_code']})."}
async def _send_push(title: str, message: str, payload: Dict[str, Any]) -> Dict[str, Any]:
runtime = get_runtime_settings()
provider = _clean_text(runtime.magent_notify_push_provider, "ntfy").lower()
base_url = _clean_text(runtime.magent_notify_push_base_url)
token = _clean_text(runtime.magent_notify_push_token)
topic = _clean_text(runtime.magent_notify_push_topic)
if provider == "ntfy":
if not base_url or not topic:
return {"status": "skipped", "detail": "ntfy needs base URL and topic."}
url = f"{base_url.rstrip('/')}/{quote(topic)}"
headers = {"Title": title, "Tags": "magent,portal"}
async with httpx.AsyncClient(timeout=12.0) as client:
response = await client.post(url, content=message.encode("utf-8"), headers=headers)
response.raise_for_status()
return {"status": "ok", "detail": f"ntfy accepted ({response.status_code})."}
if provider == "gotify":
if not base_url or not token:
return {"status": "skipped", "detail": "Gotify needs base URL and token."}
url = f"{base_url.rstrip('/')}/message?token={quote(token)}"
body = {"title": title, "message": message, "priority": 5, "extras": {"client::display": {"contentType": "text/plain"}}}
result = await _http_post_json(url, body)
return {"status": "ok", "detail": f"Gotify accepted ({result['status_code']})."}
if provider == "pushover":
user_key = _clean_text(runtime.magent_notify_push_user_key)
if not token or not user_key:
return {"status": "skipped", "detail": "Pushover needs token and user key."}
form = {"token": token, "user": user_key, "title": title, "message": message}
async with httpx.AsyncClient(timeout=12.0) as client:
response = await client.post("https://api.pushover.net/1/messages.json", data=form)
response.raise_for_status()
return {"status": "ok", "detail": f"Pushover accepted ({response.status_code})."}
if provider == "discord":
return await _send_discord(title, message, payload)
if provider == "telegram":
return await _send_telegram(title, message)
if provider == "webhook":
return await _send_webhook(payload)
return {"status": "skipped", "detail": f"Unsupported push provider '{provider}'."}
async def _send_email(title: str, message: str, payload: Dict[str, Any]) -> Dict[str, Any]:
runtime = get_runtime_settings()
recipients = _split_emails(_clean_text(get_setting("portal_notification_recipients")))
fallback = _clean_text(runtime.magent_notify_email_from_address)
if fallback and fallback not in recipients:
recipients.append(fallback)
if not recipients:
return {"status": "skipped", "detail": "No portal notification recipient is configured."}
body_text = (
f"{title}\n\n"
f"{message}\n\n"
f"Kind: {_clean_text(payload.get('kind'))}\n"
f"Status: {_clean_text(payload.get('status'))}\n"
f"Priority: {_clean_text(payload.get('priority'))}\n"
f"Requested by: {_clean_text(payload.get('requested_by'))}\n"
f"Open: {_clean_text(payload.get('item_url'))}\n"
)
body_html = (
"<div style=\"font-family:Segoe UI,Arial,sans-serif; color:#132033;\">"
f"<h2 style=\"margin:0 0 12px;\">{title}</h2>"
f"<p style=\"margin:0 0 16px; line-height:1.7;\">{message}</p>"
"<table style=\"border-collapse:collapse; width:100%; margin:0 0 16px;\">"
f"<tr><td style=\"padding:6px 0; color:#6b778c;\">Kind</td><td style=\"padding:6px 0; font-weight:700;\">{_clean_text(payload.get('kind'))}</td></tr>"
f"<tr><td style=\"padding:6px 0; color:#6b778c;\">Status</td><td style=\"padding:6px 0; font-weight:700;\">{_clean_text(payload.get('status'))}</td></tr>"
f"<tr><td style=\"padding:6px 0; color:#6b778c;\">Priority</td><td style=\"padding:6px 0; font-weight:700;\">{_clean_text(payload.get('priority'))}</td></tr>"
f"<tr><td style=\"padding:6px 0; color:#6b778c;\">Requested by</td><td style=\"padding:6px 0; font-weight:700;\">{_clean_text(payload.get('requested_by'))}</td></tr>"
"</table>"
f"<a href=\"{_clean_text(payload.get('item_url'))}\" style=\"display:inline-block; padding:10px 16px; border-radius:999px; background:#1c6bff; color:#fff; text-decoration:none; font-weight:700;\">Open portal item</a>"
"</div>"
)
deliveries: list[Dict[str, Any]] = []
for recipient in recipients:
try:
result = await send_generic_email(
recipient_email=recipient,
subject=title,
body_text=body_text,
body_html=body_html,
)
deliveries.append({"recipient": recipient, "status": "ok", **result})
except Exception as exc:
deliveries.append({"recipient": recipient, "status": "error", "detail": str(exc)})
successful = [entry for entry in deliveries if entry.get("status") == "ok"]
if successful:
return {"status": "ok", "detail": f"Email sent to {len(successful)} recipient(s).", "deliveries": deliveries}
return {"status": "error", "detail": "Email delivery failed for all recipients.", "deliveries": deliveries}
async def send_portal_notification(
*,
event_type: str,
item: Dict[str, Any],
actor_username: str,
actor_role: str,
note: Optional[str] = None,
) -> Dict[str, Any]:
runtime = get_runtime_settings()
if not runtime.magent_notify_enabled:
return {"status": "skipped", "detail": "Notifications are disabled.", "channels": {}}
item_id = int(item.get("id") or 0)
title = f"{env_settings.app_name} portal update: {item.get('title') or f'Item #{item_id}'}"
message_lines = [
f"Event: {event_type}",
f"Actor: {actor_username} ({actor_role})",
f"Item #{item_id} is now '{_clean_text(item.get('status'), 'unknown')}'.",
]
if note:
message_lines.append(f"Note: {note}")
message_lines.append(f"Open: {_portal_item_url(item_id)}")
message = "\n".join(message_lines)
payload = {
"type": "portal.notification",
"event": event_type,
"item_id": item_id,
"item_url": _portal_item_url(item_id),
"kind": _clean_text(item.get("kind")),
"status": _clean_text(item.get("status")),
"priority": _clean_text(item.get("priority")),
"requested_by": _clean_text(item.get("created_by_username")),
"actor_username": actor_username,
"actor_role": actor_role,
"note": note or "",
}
channels: Dict[str, Dict[str, Any]] = {}
if runtime.magent_notify_discord_enabled:
try:
channels["discord"] = await _send_discord(title, message, payload)
except Exception as exc:
channels["discord"] = {"status": "error", "detail": str(exc)}
if runtime.magent_notify_telegram_enabled:
try:
channels["telegram"] = await _send_telegram(title, message)
except Exception as exc:
channels["telegram"] = {"status": "error", "detail": str(exc)}
if runtime.magent_notify_webhook_enabled:
try:
channels["webhook"] = await _send_webhook(payload)
except Exception as exc:
channels["webhook"] = {"status": "error", "detail": str(exc)}
if runtime.magent_notify_push_enabled:
try:
channels["push"] = await _send_push(title, message, payload)
except Exception as exc:
channels["push"] = {"status": "error", "detail": str(exc)}
if runtime.magent_notify_email_enabled:
try:
channels["email"] = await _send_email(title, message, payload)
except Exception as exc:
channels["email"] = {"status": "error", "detail": str(exc)}
successful = [name for name, value in channels.items() if value.get("status") == "ok"]
failed = [name for name, value in channels.items() if value.get("status") == "error"]
skipped = [name for name, value in channels.items() if value.get("status") == "skipped"]
logger.info(
"portal notification event=%s item_id=%s successful=%s failed=%s skipped=%s",
event_type,
item_id,
successful,
failed,
skipped,
)
overall = "ok" if successful and not failed else "error" if failed and not successful else "partial"
if not channels:
overall = "skipped"
return {"status": overall, "channels": channels}

View File

@@ -9,6 +9,7 @@ from starlette.requests import Request
from backend.app import db
from backend.app.config import settings
from backend.app.routers import auth as auth_router
from backend.app.routers import portal as portal_router
from backend.app.security import PASSWORD_POLICY_MESSAGE, validate_password_policy
from backend.app.services import password_reset
@@ -143,3 +144,58 @@ class AuthFlowTests(TempDatabaseMixin, unittest.IsolatedAsyncioTestCase):
context.exception.detail,
"recipient_email is required and must be a valid email address.",
)
class PortalWorkflowTests(TempDatabaseMixin, unittest.TestCase):
def test_legacy_request_status_maps_to_workflow(self) -> None:
item = {"kind": "request", "status": "in_progress"}
serialized = portal_router._serialize_item(item, {"username": "tester", "role": "user"})
workflow = serialized.get("workflow") or {}
self.assertEqual(workflow.get("request_status"), "approved")
self.assertEqual(workflow.get("media_status"), "processing")
def test_invalid_pipeline_transition_is_rejected(self) -> None:
with self.assertRaises(HTTPException) as context:
portal_router._validate_pipeline_transition(
"approved",
"processing",
"pending",
"pending",
)
self.assertEqual(context.exception.status_code, 400)
def test_portal_workflow_filters(self) -> None:
db.create_portal_item(
kind="request",
title="Request A",
description="A",
created_by_username="alpha",
created_by_id=None,
status="processing",
workflow_request_status="approved",
workflow_media_status="processing",
)
db.create_portal_item(
kind="request",
title="Request B",
description="B",
created_by_username="bravo",
created_by_id=None,
status="pending",
workflow_request_status="pending",
workflow_media_status="pending",
)
processing = db.list_portal_items(
kind="request",
workflow_request_status="approved",
workflow_media_status="processing",
limit=10,
offset=0,
)
pending_count = db.count_portal_items(
kind="request",
workflow_request_status="pending",
workflow_media_status="pending",
)
self.assertEqual(len(processing), 1)
self.assertEqual(pending_count, 1)

View File

@@ -6558,3 +6558,222 @@ textarea {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
/* Portal */
.portal-page {
display: grid;
gap: 16px;
}
.portal-overview-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 10px;
}
.portal-overview-card {
border: 1px solid var(--line);
border-radius: 10px;
background: var(--panel);
padding: 12px 14px;
display: grid;
gap: 4px;
}
.portal-overview-card span {
color: var(--muted);
font-size: 0.76rem;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.portal-overview-card strong {
font-size: 1.25rem;
color: var(--text);
}
.portal-create-panel {
display: grid;
gap: 12px;
}
.portal-form-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
}
.portal-field-span-2 {
grid-column: span 2;
}
.portal-toolbar {
display: grid;
grid-template-columns: 160px 180px minmax(0, 1fr) auto;
gap: 10px;
align-items: end;
}
.portal-toolbar label span {
display: block;
margin-bottom: 6px;
font-size: 0.78rem;
color: var(--muted);
}
.portal-search-filter input {
width: 100%;
}
.portal-mine-toggle {
align-self: center;
margin-top: 20px;
}
.portal-workspace {
display: grid;
grid-template-columns: minmax(300px, 360px) minmax(0, 1fr);
gap: 12px;
}
.portal-list-panel,
.portal-detail-panel {
display: grid;
gap: 12px;
align-content: start;
}
.portal-item-list {
display: grid;
gap: 10px;
max-height: 900px;
overflow: auto;
padding-right: 2px;
}
.portal-item-row {
border: 1px solid var(--line);
border-radius: 10px;
background: var(--panel-soft);
padding: 12px;
text-align: left;
transition: border-color 0.2s ease, box-shadow 0.2s ease;
}
.portal-item-row.is-active {
border-color: var(--accent);
box-shadow: 0 0 0 1px rgba(107, 146, 255, 0.25);
}
.portal-item-row-title {
display: flex;
gap: 8px;
align-items: center;
flex-wrap: wrap;
}
.portal-item-row p {
margin: 8px 0;
color: var(--muted);
font-size: 0.9rem;
line-height: 1.45;
}
.portal-item-row-meta {
display: flex;
gap: 10px;
flex-wrap: wrap;
color: var(--muted);
font-size: 0.78rem;
}
.portal-comments-block {
border-top: 1px solid var(--line);
padding-top: 12px;
display: grid;
gap: 10px;
}
.portal-comment-list {
display: grid;
gap: 8px;
max-height: 420px;
overflow: auto;
padding-right: 2px;
}
.portal-comment-card {
border: 1px solid var(--line);
border-radius: 8px;
padding: 10px 12px;
background: var(--panel-soft);
}
.portal-comment-card header {
display: flex;
gap: 8px;
flex-wrap: wrap;
align-items: center;
margin-bottom: 6px;
color: var(--muted);
font-size: 0.78rem;
}
.portal-comment-card p {
margin: 0;
color: var(--text);
white-space: pre-wrap;
line-height: 1.45;
}
.portal-comment-form {
display: grid;
gap: 10px;
}
@media (max-width: 1200px) {
.portal-overview-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.portal-toolbar {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.portal-search-filter,
.portal-mine-toggle {
grid-column: span 2;
}
.portal-mine-toggle {
margin-top: 0;
}
.portal-workspace {
grid-template-columns: 1fr;
}
.portal-item-list {
max-height: 460px;
}
}
@media (max-width: 760px) {
.portal-form-grid {
grid-template-columns: 1fr;
}
.portal-field-span-2 {
grid-column: span 1;
}
.portal-overview-grid,
.portal-toolbar {
grid-template-columns: 1fr;
}
.portal-search-filter,
.portal-mine-toggle {
grid-column: span 1;
}
}

View File

@@ -0,0 +1,927 @@
'use client'
import { useEffect, useState } from 'react'
import { useRouter } from 'next/navigation'
import { authFetch, clearToken, getApiBase, getToken } from '../lib/auth'
type PortalPermissions = {
can_edit?: boolean
can_comment?: boolean
can_moderate?: boolean
}
type PortalItem = {
id: number
kind: 'request' | 'issue' | 'feature'
title: string
description: string
media_type?: 'movie' | 'tv' | null
year?: number | null
external_ref?: string | null
source_system?: string | null
source_request_id?: number | null
status: string
priority: string
created_by_username: string
assignee_username?: string | null
created_at: string
updated_at: string
last_activity_at: string
permissions?: PortalPermissions
workflow?: {
request_status?: string
media_status?: string
stage_label?: string
is_terminal?: boolean
}
issue?: {
issue_type?: string
related_item_id?: number | null
is_resolved?: boolean
resolved_at?: string | null
}
}
type PortalComment = {
id: number
item_id: number
author_username: string
author_role: string
message: string
is_internal: boolean
created_at: string
}
type PortalOverview = {
overview?: {
total_items?: number
total_comments?: number
by_kind?: Record<string, number>
by_status?: Record<string, number>
}
my_items?: number
}
type UserProfile = {
username: string
role: string
}
const KIND_OPTIONS = [
{ value: 'request', label: 'Request' },
{ value: 'issue', label: 'Issue' },
{ value: 'feature', label: 'Feature' },
] as const
const STATUS_OPTIONS = [
{ value: 'new', label: 'New' },
{ value: 'triaging', label: 'Triaging' },
{ value: 'planned', label: 'Planned' },
{ value: 'in_progress', label: 'In progress' },
{ value: 'blocked', label: 'Blocked' },
{ value: 'done', label: 'Done' },
{ value: 'pending', label: 'Pending approval' },
{ value: 'approved', label: 'Approved' },
{ value: 'processing', label: 'Processing' },
{ value: 'partially_available', label: 'Partially available' },
{ value: 'available', label: 'Available' },
{ value: 'failed', label: 'Failed' },
{ value: 'declined', label: 'Declined' },
{ value: 'closed', label: 'Closed' },
] as const
const REQUEST_STATUS_OPTIONS = [
{ value: 'pending', label: 'Pending approval' },
{ value: 'approved', label: 'Approved' },
{ value: 'declined', label: 'Declined' },
] as const
const MEDIA_STATUS_OPTIONS = [
{ value: 'pending', label: 'Pending' },
{ value: 'processing', label: 'Processing' },
{ value: 'partially_available', label: 'Partially available' },
{ value: 'available', label: 'Available' },
{ value: 'failed', label: 'Failed' },
{ value: 'unknown', label: 'Unknown' },
] as const
const PRIORITY_OPTIONS = [
{ value: 'low', label: 'Low' },
{ value: 'normal', label: 'Normal' },
{ value: 'high', label: 'High' },
{ value: 'urgent', label: 'Urgent' },
] as const
const MEDIA_TYPE_OPTIONS = [
{ value: '', label: 'None' },
{ value: 'movie', label: 'Movie' },
{ value: 'tv', label: 'TV' },
] as const
const formatDate = (value?: string | null) => {
if (!value) return 'Never'
const parsed = new Date(value)
if (Number.isNaN(parsed.valueOf())) return value
return parsed.toLocaleString()
}
const toPositiveInt = (value: string) => {
const parsed = Number.parseInt(value, 10)
if (Number.isNaN(parsed) || parsed <= 0) return null
return parsed
}
export default function PortalPage() {
const router = useRouter()
const [me, setMe] = useState<UserProfile | null>(null)
const [overview, setOverview] = useState<PortalOverview | null>(null)
const [items, setItems] = useState<PortalItem[]>([])
const [selectedItemId, setSelectedItemId] = useState<number | null>(null)
const [selectedItem, setSelectedItem] = useState<PortalItem | null>(null)
const [comments, setComments] = useState<PortalComment[]>([])
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 [error, setError] = useState<string | null>(null)
const [status, setStatus] = useState<string | null>(null)
const [totalItems, setTotalItems] = useState(0)
const [hasMore, setHasMore] = useState(false)
const [filterKind, setFilterKind] = useState('')
const [filterStatus, setFilterStatus] = useState('')
const [filterMine, setFilterMine] = useState(false)
const [filterSearch, setFilterSearch] = useState('')
const [createKind, setCreateKind] = useState<'request' | 'issue' | 'feature'>('request')
const [createTitle, setCreateTitle] = useState('')
const [createDescription, setCreateDescription] = useState('')
const [createMediaType, setCreateMediaType] = useState('')
const [createYear, setCreateYear] = useState('')
const [createExternalRef, setCreateExternalRef] = useState('')
const [createPriority, setCreatePriority] = useState<'low' | 'normal' | 'high' | 'urgent'>('normal')
const [editTitle, setEditTitle] = useState('')
const [editDescription, setEditDescription] = useState('')
const [editMediaType, setEditMediaType] = useState('')
const [editYear, setEditYear] = useState('')
const [editExternalRef, setEditExternalRef] = useState('')
const [editStatus, setEditStatus] = useState('new')
const [editRequestStatus, setEditRequestStatus] = useState('pending')
const [editMediaStatus, setEditMediaStatus] = useState('pending')
const [editPriority, setEditPriority] = useState('normal')
const [editAssignee, setEditAssignee] = useState('')
const [commentText, setCommentText] = useState('')
const [commentInternal, setCommentInternal] = useState(false)
const [preselectedItemId, setPreselectedItemId] = useState<number | null>(null)
const isAdmin = me?.role === 'admin'
useEffect(() => {
if (typeof window === 'undefined') return
const raw = new URLSearchParams(window.location.search).get('item')
if (!raw) {
setPreselectedItemId(null)
return
}
const parsed = Number.parseInt(raw, 10)
setPreselectedItemId(Number.isNaN(parsed) || parsed <= 0 ? null : parsed)
}, [])
const loadMe = async () => {
const baseUrl = getApiBase()
const response = await authFetch(`${baseUrl}/auth/me`)
if (!response.ok) {
if (response.status === 401) {
clearToken()
router.push('/login')
return null
}
throw new Error(`Failed to load session (${response.status})`)
}
const data = await response.json()
const profile: UserProfile = {
username: data?.username ?? 'unknown',
role: data?.role ?? 'user',
}
setMe(profile)
return profile
}
const loadOverview = async () => {
try {
const baseUrl = getApiBase()
const response = await authFetch(`${baseUrl}/portal/overview`)
if (!response.ok) {
if (response.status === 401) {
clearToken()
router.push('/login')
return
}
throw new Error(`Failed to load portal overview (${response.status})`)
}
const data = await response.json()
setOverview(data)
} catch (err) {
console.error(err)
}
}
const loadItem = async (itemId: number) => {
setLoadingItem(true)
try {
const baseUrl = getApiBase()
const response = await authFetch(`${baseUrl}/portal/items/${itemId}`)
if (!response.ok) {
if (response.status === 401) {
clearToken()
router.push('/login')
return
}
if (response.status === 404) {
setSelectedItem(null)
setComments([])
return
}
throw new Error(`Failed to load portal item (${response.status})`)
}
const data = await response.json()
const item = (data?.item ?? null) as PortalItem | null
setSelectedItem(item)
setComments(Array.isArray(data?.comments) ? data.comments : [])
} catch (err) {
console.error(err)
setError('Could not load portal item details.')
} finally {
setLoadingItem(false)
}
}
const loadItems = async (options?: { preferItemId?: number | null }) => {
setLoadingItems(true)
try {
const baseUrl = getApiBase()
const params = new URLSearchParams({
limit: '60',
offset: '0',
})
if (filterKind) params.set('kind', filterKind)
if (filterStatus) params.set('status', filterStatus)
if (filterMine) params.set('mine', '1')
const trimmedSearch = filterSearch.trim()
if (trimmedSearch) params.set('search', trimmedSearch)
const response = await authFetch(`${baseUrl}/portal/items?${params.toString()}`)
if (!response.ok) {
if (response.status === 401) {
clearToken()
router.push('/login')
return
}
throw new Error(`Failed to load portal items (${response.status})`)
}
const data = await response.json()
const loadedItems = Array.isArray(data?.items) ? (data.items as PortalItem[]) : []
setItems(loadedItems)
setTotalItems(Number(data?.total ?? loadedItems.length ?? 0))
setHasMore(Boolean(data?.has_more))
const preferred = options?.preferItemId ?? selectedItemId ?? preselectedItemId
if (preferred && loadedItems.some((item) => item.id === preferred)) {
setSelectedItemId(preferred)
} else if (loadedItems.length > 0) {
setSelectedItemId(loadedItems[0].id)
} else {
setSelectedItemId(null)
setSelectedItem(null)
setComments([])
}
} catch (err) {
console.error(err)
setError('Could not load portal items.')
} finally {
setLoadingItems(false)
}
}
useEffect(() => {
if (!getToken()) {
router.push('/login')
return
}
const bootstrap = async () => {
try {
setError(null)
await loadMe()
await Promise.all([loadOverview(), loadItems({ preferItemId: preselectedItemId })])
} catch (err) {
console.error(err)
setError('Could not load request portal.')
}
}
void bootstrap()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [router])
useEffect(() => {
if (!getToken()) {
return
}
void loadItems({ preferItemId: preselectedItemId })
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [filterKind, filterStatus, filterMine, filterSearch])
useEffect(() => {
if (selectedItemId == null) return
void loadItem(selectedItemId)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedItemId])
useEffect(() => {
if (!selectedItem) return
setEditTitle(selectedItem.title ?? '')
setEditDescription(selectedItem.description ?? '')
setEditMediaType(selectedItem.media_type ?? '')
setEditYear(selectedItem.year == null ? '' : String(selectedItem.year))
setEditExternalRef(selectedItem.external_ref ?? '')
setEditStatus(selectedItem.status ?? 'new')
setEditRequestStatus(selectedItem.workflow?.request_status ?? 'pending')
setEditMediaStatus(selectedItem.workflow?.media_status ?? 'pending')
setEditPriority(selectedItem.priority ?? 'normal')
setEditAssignee(selectedItem.assignee_username ?? '')
}, [selectedItem])
const createItem = async (event: React.FormEvent) => {
event.preventDefault()
setCreating(true)
setError(null)
setStatus(null)
try {
const payload: Record<string, any> = {
kind: createKind,
title: createTitle,
description: createDescription,
media_type: createMediaType || null,
year: createYear.trim() ? toPositiveInt(createYear) : null,
external_ref: createExternalRef || null,
priority: createPriority,
}
const baseUrl = getApiBase()
const response = await authFetch(`${baseUrl}/portal/items`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
})
if (!response.ok) {
if (response.status === 401) {
clearToken()
router.push('/login')
return
}
const text = await response.text()
throw new Error(text || 'Could not create portal item.')
}
const data = await response.json()
const item = data?.item as PortalItem | undefined
setStatus('Portal item created.')
setCreateTitle('')
setCreateDescription('')
setCreateMediaType('')
setCreateYear('')
setCreateExternalRef('')
setCreatePriority('normal')
await Promise.all([
loadItems({ preferItemId: item?.id ?? null }),
loadOverview(),
])
} catch (err) {
console.error(err)
setError(err instanceof Error ? err.message : 'Could not create portal item.')
} finally {
setCreating(false)
}
}
const saveItem = async (event: React.FormEvent) => {
event.preventDefault()
if (!selectedItem) return
setSaving(true)
setError(null)
setStatus(null)
try {
const payload: Record<string, any> = {
title: editTitle,
description: editDescription,
media_type: editMediaType || null,
year: editYear.trim() ? toPositiveInt(editYear) : null,
external_ref: editExternalRef || null,
}
if (selectedItem.permissions?.can_moderate) {
if (selectedItem.kind === 'request') {
payload.request_status = editRequestStatus
payload.media_status = editMediaStatus
} else {
payload.status = editStatus
}
payload.priority = editPriority
payload.assignee_username = editAssignee || null
}
const baseUrl = getApiBase()
const response = await authFetch(`${baseUrl}/portal/items/${selectedItem.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
})
if (!response.ok) {
if (response.status === 401) {
clearToken()
router.push('/login')
return
}
const text = await response.text()
throw new Error(text || 'Could not update portal item.')
}
const data = await response.json()
setSelectedItem((data?.item ?? null) as PortalItem | null)
setComments(Array.isArray(data?.comments) ? data.comments : [])
setStatus('Portal item updated.')
await Promise.all([
loadItems({ preferItemId: selectedItem.id }),
loadOverview(),
])
} catch (err) {
console.error(err)
setError(err instanceof Error ? err.message : 'Could not update portal item.')
} finally {
setSaving(false)
}
}
const postComment = async (event: React.FormEvent) => {
event.preventDefault()
if (!selectedItem) return
if (!commentText.trim()) {
setError('Comment message is required.')
return
}
setCommenting(true)
setError(null)
setStatus(null)
try {
const baseUrl = getApiBase()
const response = await authFetch(`${baseUrl}/portal/items/${selectedItem.id}/comments`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
message: commentText,
is_internal: commentInternal,
}),
})
if (!response.ok) {
if (response.status === 401) {
clearToken()
router.push('/login')
return
}
const text = await response.text()
throw new Error(text || 'Could not add comment.')
}
setCommentText('')
setCommentInternal(false)
setStatus('Comment added.')
await Promise.all([
loadItem(selectedItem.id),
loadItems({ preferItemId: selectedItem.id }),
loadOverview(),
])
} catch (err) {
console.error(err)
setError(err instanceof Error ? err.message : 'Could not add comment.')
} finally {
setCommenting(false)
}
}
if (loadingItems && !items.length) {
return <main className="card">Loading request portal...</main>
}
return (
<main className="card portal-page">
<div className="user-directory-panel-header">
<div>
<h1>Request portal</h1>
<p className="lede">
Raise requests, issues, and feature ideas. Track progress and keep discussion in one place.
</p>
</div>
</div>
{error && <div className="error-banner">{error}</div>}
{status && <div className="status-banner">{status}</div>}
<section className="portal-overview-grid">
<div className="portal-overview-card">
<span>Total items</span>
<strong>{Number(overview?.overview?.total_items ?? totalItems ?? 0)}</strong>
</div>
<div className="portal-overview-card">
<span>Total comments</span>
<strong>{Number(overview?.overview?.total_comments ?? 0)}</strong>
</div>
<div className="portal-overview-card">
<span>My items</span>
<strong>{Number(overview?.my_items ?? 0)}</strong>
</div>
<div className="portal-overview-card">
<span>Visible</span>
<strong>{items.length}</strong>
</div>
</section>
<section className="admin-panel portal-create-panel">
<h2>Create item</h2>
<p className="lede">
Use <strong>Request</strong> for new content, <strong>Issue</strong> for broken behavior, and <strong>Feature</strong> for improvements.
</p>
<form onSubmit={createItem} className="admin-form compact-form portal-form-grid">
<label>
<span>Type</span>
<select
value={createKind}
onChange={(event) =>
setCreateKind(event.target.value as 'request' | 'issue' | 'feature')
}
>
{KIND_OPTIONS.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</label>
<label>
<span>Priority</span>
<select
value={createPriority}
onChange={(event) =>
setCreatePriority(event.target.value as 'low' | 'normal' | 'high' | 'urgent')
}
>
{PRIORITY_OPTIONS.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</label>
<label className="portal-field-span-2">
<span>Title</span>
<input
required
value={createTitle}
onChange={(event) => setCreateTitle(event.target.value)}
placeholder="Short summary of the request or issue"
/>
</label>
<label className="portal-field-span-2">
<span>Description</span>
<textarea
required
rows={4}
value={createDescription}
onChange={(event) => setCreateDescription(event.target.value)}
placeholder="Add details, expected behavior, and any context."
/>
</label>
<label>
<span>Media type</span>
<select value={createMediaType} onChange={(event) => setCreateMediaType(event.target.value)}>
{MEDIA_TYPE_OPTIONS.map((option) => (
<option key={option.value || 'none'} value={option.value}>
{option.label}
</option>
))}
</select>
</label>
<label>
<span>Year</span>
<input
value={createYear}
onChange={(event) => setCreateYear(event.target.value)}
inputMode="numeric"
placeholder="Optional"
/>
</label>
<label className="portal-field-span-2">
<span>External reference</span>
<input
value={createExternalRef}
onChange={(event) => setCreateExternalRef(event.target.value)}
placeholder="Optional: URL, ticket number, or request id"
/>
</label>
<div className="admin-inline-actions portal-field-span-2">
<button type="submit" disabled={creating}>
{creating ? 'Creating…' : 'Create portal item'}
</button>
</div>
</form>
</section>
<section className="portal-toolbar">
<label>
<span>Type</span>
<select value={filterKind} onChange={(event) => setFilterKind(event.target.value)}>
<option value="">All</option>
{KIND_OPTIONS.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</label>
<label>
<span>Status</span>
<select value={filterStatus} onChange={(event) => setFilterStatus(event.target.value)}>
<option value="">All</option>
{STATUS_OPTIONS.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</label>
<label className="portal-search-filter">
<span>Search</span>
<input
value={filterSearch}
onChange={(event) => setFilterSearch(event.target.value)}
placeholder="Title, description, or item id"
/>
</label>
<label className="inline-checkbox portal-mine-toggle">
<input
type="checkbox"
checked={filterMine}
onChange={(event) => setFilterMine(event.target.checked)}
/>
My items only
</label>
</section>
<div className="portal-workspace">
<section className="admin-panel portal-list-panel">
<div className="user-directory-panel-header">
<div>
<h2>Items</h2>
<p className="lede">
{totalItems} total
{hasMore ? ' (showing first 60)' : ''}
</p>
</div>
</div>
{items.length === 0 ? (
<div className="status-banner">No portal items match this filter.</div>
) : (
<div className="portal-item-list">
{items.map((item) => (
<button
key={item.id}
type="button"
className={`portal-item-row ${selectedItemId === item.id ? 'is-active' : ''}`}
onClick={() => setSelectedItemId(item.id)}
>
<div className="portal-item-row-main">
<div className="portal-item-row-title">
<strong>{item.title}</strong>
<span className="small-pill">{item.kind}</span>
<span className="small-pill is-muted">{item.priority}</span>
</div>
<p>{item.description}</p>
<div className="portal-item-row-meta">
<span>#{item.id}</span>
<span>
Status:{' '}
{item.kind === 'request'
? item.workflow?.stage_label ?? item.status
: item.status}
</span>
<span>By: {item.created_by_username}</span>
<span>Updated: {formatDate(item.last_activity_at)}</span>
</div>
</div>
</button>
))}
</div>
)}
</section>
<section className="admin-panel portal-detail-panel">
{!selectedItemId ? (
<div className="status-banner">Select an item to view details.</div>
) : loadingItem ? (
<div className="status-banner">Loading details</div>
) : !selectedItem ? (
<div className="status-banner">Item not found.</div>
) : (
<>
<div className="user-directory-panel-header">
<div>
<h2>Item #{selectedItem.id}</h2>
<p className="lede">
Created by {selectedItem.created_by_username} on {formatDate(selectedItem.created_at)}
</p>
{selectedItem.kind === 'request' && (
<p className="lede">
Pipeline:{' '}
<strong>
{selectedItem.workflow?.request_status ?? 'pending'} /{' '}
{selectedItem.workflow?.media_status ?? 'pending'}
</strong>{' '}
({selectedItem.workflow?.stage_label ?? 'Pending'})
</p>
)}
</div>
</div>
<form className="admin-form compact-form portal-form-grid" onSubmit={saveItem}>
<label className="portal-field-span-2">
<span>Title</span>
<input
value={editTitle}
onChange={(event) => setEditTitle(event.target.value)}
disabled={!selectedItem.permissions?.can_edit}
/>
</label>
<label className="portal-field-span-2">
<span>Description</span>
<textarea
rows={4}
value={editDescription}
onChange={(event) => setEditDescription(event.target.value)}
disabled={!selectedItem.permissions?.can_edit}
/>
</label>
<label>
<span>Media type</span>
<select
value={editMediaType}
onChange={(event) => setEditMediaType(event.target.value)}
disabled={!selectedItem.permissions?.can_edit}
>
{MEDIA_TYPE_OPTIONS.map((option) => (
<option key={option.value || 'none'} value={option.value}>
{option.label}
</option>
))}
</select>
</label>
<label>
<span>Year</span>
<input
value={editYear}
onChange={(event) => setEditYear(event.target.value)}
inputMode="numeric"
disabled={!selectedItem.permissions?.can_edit}
/>
</label>
<label className="portal-field-span-2">
<span>External reference</span>
<input
value={editExternalRef}
onChange={(event) => setEditExternalRef(event.target.value)}
disabled={!selectedItem.permissions?.can_edit}
/>
</label>
{selectedItem.permissions?.can_moderate && (
<>
{selectedItem.kind === 'request' ? (
<>
<label>
<span>Request status</span>
<select
value={editRequestStatus}
onChange={(event) => setEditRequestStatus(event.target.value)}
>
{REQUEST_STATUS_OPTIONS.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</label>
<label>
<span>Media status</span>
<select
value={editMediaStatus}
onChange={(event) => setEditMediaStatus(event.target.value)}
>
{MEDIA_STATUS_OPTIONS.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</label>
</>
) : (
<label>
<span>Status</span>
<select value={editStatus} onChange={(event) => setEditStatus(event.target.value)}>
{STATUS_OPTIONS.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</label>
)}
<label>
<span>Priority</span>
<select
value={editPriority}
onChange={(event) => setEditPriority(event.target.value)}
>
{PRIORITY_OPTIONS.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</label>
<label className="portal-field-span-2">
<span>Assignee username</span>
<input
value={editAssignee}
onChange={(event) => setEditAssignee(event.target.value)}
placeholder="Optional assignee"
/>
</label>
</>
)}
<div className="admin-inline-actions portal-field-span-2">
<button
type="submit"
disabled={saving || !selectedItem.permissions?.can_edit}
>
{saving ? 'Saving…' : 'Save changes'}
</button>
</div>
</form>
<div className="portal-comments-block">
<h3>Comments</h3>
{comments.length === 0 ? (
<div className="status-banner">No comments yet.</div>
) : (
<div className="portal-comment-list">
{comments.map((comment) => (
<article key={comment.id} className="portal-comment-card">
<header>
<strong>{comment.author_username}</strong>
<span className="small-pill">{comment.author_role}</span>
{comment.is_internal && <span className="small-pill is-muted">internal</span>}
<span>{formatDate(comment.created_at)}</span>
</header>
<p>{comment.message}</p>
</article>
))}
</div>
)}
<form onSubmit={postComment} className="admin-form compact-form portal-comment-form">
<label>
<span>Add comment</span>
<textarea
rows={3}
value={commentText}
onChange={(event) => setCommentText(event.target.value)}
placeholder="Add an update, troubleshooting note, or next step."
/>
</label>
{isAdmin && (
<label className="inline-checkbox">
<input
type="checkbox"
checked={commentInternal}
onChange={(event) => setCommentInternal(event.target.checked)}
/>
Internal comment (admin only)
</label>
)}
<div className="admin-inline-actions">
<button type="submit" disabled={commenting}>
{commenting ? 'Posting…' : 'Post comment'}
</button>
</div>
</form>
</div>
</>
)}
</section>
</div>
</main>
)
}

View File

@@ -42,6 +42,7 @@ export default function HeaderActions() {
<div className="header-actions-right">
<a href="/">Requests</a>
<a href="/profile/invites">Invites</a>
<a href="/portal">Portal</a>
</div>
</div>
)

View File

@@ -1,2 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/types/routes.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.

View File

@@ -1,12 +1,12 @@
{
"name": "magent-frontend",
"version": "0403261902",
"version": "0803262038",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "magent-frontend",
"version": "0403261902",
"version": "0803262038",
"dependencies": {
"next": "16.1.6",
"react": "19.2.4",

View File

@@ -1,7 +1,7 @@
{
"name": "magent-frontend",
"private": true,
"version": "0403261902",
"version": "0803262038",
"scripts": {
"dev": "next dev",
"build": "next build",