diff --git a/.build_number b/.build_number index 9a69b8d..47edee8 100644 --- a/.build_number +++ b/.build_number @@ -1 +1 @@ -0403261902 +0703261729 diff --git a/backend/app/build_info.py b/backend/app/build_info.py index 089592a..82e0321 100644 --- a/backend/app/build_info.py +++ b/backend/app/build_info.py @@ -1,2 +1,2 @@ -BUILD_NUMBER = "0403261902" -CHANGELOG = '2026-03-04|Improve email deliverability headers and SMTP identity\n2026-03-04|Fix admin user email visibility\n2026-03-04|Harden auth flows and add backend quality gate\n2026-03-03|Fix email branding with inline logo and reliable MIME transport\n2026-03-03|Fix email template rendering for Outlook-safe branded content\n2026-03-03|Update all email templates with uniform branded graphics\n2026-03-03|Add branded HTML email templates\n2026-03-03|Add SMTP receipt logging for Exchange relay tracing\n2026-03-03|Fix shared request access and Jellyfin-ready pipeline status\n2026-03-03|Process 1 build 0303261507\n2026-03-03|Improve SQLite batching and diagnostics visibility\n2026-03-03|Add login page visibility controls\n2026-03-03|Hotfix: expand landing-page search to all requests\n2026-03-02|Hotfix: add logged-out password reset flow\n2026-03-02|Process 1 build 0203261953\n2026-03-02|Process 1 build 0203261610\n2026-03-02|Process 1 build 0203261608\n2026-03-02|Add dedicated profile invites page and fix mobile admin layout\n2026-03-01|Persist Seerr media failure suppression and reduce sync error noise\n2026-03-01|Add repository line ending policy\n2026-03-01|Finalize diagnostics, logging controls, and email test support\n2026-03-01|Add invite email templates and delivery workflow\n2026-02-28|Finalize dev-1.3 upgrades and Seerr updates\n2026-02-27|admin docs and layout refresh, build 2702261314\n2026-02-27|Build 2702261153: fix jellyfin sync user visibility\n2026-02-26|Build 2602262241: live request page updates\n2026-02-26|Build 2602262204\n2026-02-26|Build 2602262159: restore jellyfin-first user source\n2026-02-26|Build 2602262049: split magent settings and harden local login\n2026-02-26|Build 2602262030: add magent settings and hardening\n2026-02-26|Build 2602261731: fix user resync after nuclear wipe\n2026-02-26|Build 2602261717: master invite policy and self-service invite controls\n2026-02-26|Build 2602261636: self-service invites and count fixes\n2026-02-26|Build 2602261605: invite trace and cross-system user lifecycle\n2026-02-26|Build 2602261536: refine invite layouts and tighten UI\n2026-02-26|Build 2602261523: live updates, invite cleanup and nuclear resync\n2026-02-26|Build 2602261442: tidy users and invite layouts\n2026-02-26|Build 2602261409: unify invite management controls\n2026-02-26|Build 2602260214: invites profiles and expiry admin controls\n2026-02-26|Build 2602260022: enterprise UI refresh and users bulk auto-search\n2026-02-25|Build 2502262321: fix auto-search quality and per-user toggle\n2026-02-02|Build 0202261541: allow FQDN service URLs\n2026-01-30|Build 3001262148: single container\n2026-01-29|Build 2901262244: format changelog\n2026-01-29|Build 2901262240: cache users\n2026-01-29|Tidy full changelog\n2026-01-29|Update full changelog\n2026-01-29|Bake build number and changelog\n2026-01-29|Hardcode build number in backend\n2026-01-29|release: 2901262102\n2026-01-29|release: 2901262044\n2026-01-29|release: 2901262036\n2026-01-27|Hydrate missing artwork from Jellyseerr (build 271261539)\n2026-01-27|Fallback to TMDB when artwork cache fails (build 271261524)\n2026-01-27|Add service test buttons (build 271261335)\n2026-01-27|Bump build number (process 2) 271261322\n2026-01-27|Add cache load spinner (build 271261238)\n2026-01-27|Fix snapshot title fallback (build 271261228)\n2026-01-27|Fix request titles in snapshots (build 271261219)\n2026-01-27|Bump build number to 271261202\n2026-01-27|Clarify request sync settings (build 271261159)\n2026-01-27|Fix backend cache stats import (build 271261149)\n2026-01-27|Improve cache stats performance (build 271261145)\n2026-01-27|Add cache control artwork stats\n2026-01-26|Fix sync progress bar animation\n2026-01-26|Fix cache title hydration\n2026-01-25|Build 2501262041\n2026-01-25|Harden request cache titles and cache-only reads\n2026-01-25|Serve bundled branding assets by default\n2026-01-25|Seed branding logo from bundled assets\n2026-01-25|Tidy request sync controls\n2026-01-25|Add Jellyfin login cache and admin-only stats\n2026-01-25|Add user stats and activity tracking\n2026-01-25|Move account actions into avatar menu\n2026-01-25|Improve mobile header layout\n2026-01-25|Automate build number tagging and sync\n2026-01-25|Add site banner, build number, and changelog\n2026-01-24|Improve request handling and qBittorrent categories\n2026-01-24|Map Prowlarr releases to Arr indexers for manual grab\n2026-01-24|Clarify how-it-works steps and fixes\n2026-01-24|Document fix buttons in how-it-works\n2026-01-24|Route grabs through Sonarr/Radarr only\n2026-01-23|Use backend branding assets for logo and favicon\n2026-01-23|Copy public assets into frontend image\n2026-01-23|Fix backend Dockerfile paths for root context\n2026-01-23|Add Docker Hub compose override\n2026-01-23|Remove password fields from users page\n2026-01-23|Use bundled branding assets\n2026-01-23|Add default branding assets when missing\n2026-01-23|Show available status on landing when in Jellyfin\n2026-01-23|Fix cache titles and move feedback link\n2026-01-23|Add feedback form and webhook\n2026-01-23|Hide header actions when signed out\n2026-01-23|Fallback manual grab to qBittorrent\n2026-01-23|Split search actions and improve download options\n2026-01-23|Fix cache titles via Jellyseerr media lookup\n2026-01-22|Update README with Docker-first guide\n2026-01-22|Update README\n2026-01-22|Ignore build artifacts\n2026-01-22|Initial commit' +BUILD_NUMBER = "0703261729" +CHANGELOG = '2026-03-04|Process 1 build 0403261902\n2026-03-04|Improve email deliverability headers and SMTP identity\n2026-03-04|Fix admin user email visibility\n2026-03-04|Harden auth flows and add backend quality gate\n2026-03-03|Fix email branding with inline logo and reliable MIME transport\n2026-03-03|Fix email template rendering for Outlook-safe branded content\n2026-03-03|Update all email templates with uniform branded graphics\n2026-03-03|Add branded HTML email templates\n2026-03-03|Add SMTP receipt logging for Exchange relay tracing\n2026-03-03|Fix shared request access and Jellyfin-ready pipeline status\n2026-03-03|Process 1 build 0303261507\n2026-03-03|Improve SQLite batching and diagnostics visibility\n2026-03-03|Add login page visibility controls\n2026-03-03|Hotfix: expand landing-page search to all requests\n2026-03-02|Hotfix: add logged-out password reset flow\n2026-03-02|Process 1 build 0203261953\n2026-03-02|Process 1 build 0203261610\n2026-03-02|Process 1 build 0203261608\n2026-03-02|Add dedicated profile invites page and fix mobile admin layout\n2026-03-01|Persist Seerr media failure suppression and reduce sync error noise\n2026-03-01|Add repository line ending policy\n2026-03-01|Finalize diagnostics, logging controls, and email test support\n2026-03-01|Add invite email templates and delivery workflow\n2026-02-28|Finalize dev-1.3 upgrades and Seerr updates\n2026-02-27|admin docs and layout refresh, build 2702261314\n2026-02-27|Build 2702261153: fix jellyfin sync user visibility\n2026-02-26|Build 2602262241: live request page updates\n2026-02-26|Build 2602262204\n2026-02-26|Build 2602262159: restore jellyfin-first user source\n2026-02-26|Build 2602262049: split magent settings and harden local login\n2026-02-26|Build 2602262030: add magent settings and hardening\n2026-02-26|Build 2602261731: fix user resync after nuclear wipe\n2026-02-26|Build 2602261717: master invite policy and self-service invite controls\n2026-02-26|Build 2602261636: self-service invites and count fixes\n2026-02-26|Build 2602261605: invite trace and cross-system user lifecycle\n2026-02-26|Build 2602261536: refine invite layouts and tighten UI\n2026-02-26|Build 2602261523: live updates, invite cleanup and nuclear resync\n2026-02-26|Build 2602261442: tidy users and invite layouts\n2026-02-26|Build 2602261409: unify invite management controls\n2026-02-26|Build 2602260214: invites profiles and expiry admin controls\n2026-02-26|Build 2602260022: enterprise UI refresh and users bulk auto-search\n2026-02-25|Build 2502262321: fix auto-search quality and per-user toggle\n2026-02-02|Build 0202261541: allow FQDN service URLs\n2026-01-30|Build 3001262148: single container\n2026-01-29|Build 2901262244: format changelog\n2026-01-29|Build 2901262240: cache users\n2026-01-29|Tidy full changelog\n2026-01-29|Update full changelog\n2026-01-29|Bake build number and changelog\n2026-01-29|Hardcode build number in backend\n2026-01-29|release: 2901262102\n2026-01-29|release: 2901262044\n2026-01-29|release: 2901262036\n2026-01-27|Hydrate missing artwork from Jellyseerr (build 271261539)\n2026-01-27|Fallback to TMDB when artwork cache fails (build 271261524)\n2026-01-27|Add service test buttons (build 271261335)\n2026-01-27|Bump build number (process 2) 271261322\n2026-01-27|Add cache load spinner (build 271261238)\n2026-01-27|Fix snapshot title fallback (build 271261228)\n2026-01-27|Fix request titles in snapshots (build 271261219)\n2026-01-27|Bump build number to 271261202\n2026-01-27|Clarify request sync settings (build 271261159)\n2026-01-27|Fix backend cache stats import (build 271261149)\n2026-01-27|Improve cache stats performance (build 271261145)\n2026-01-27|Add cache control artwork stats\n2026-01-26|Fix sync progress bar animation\n2026-01-26|Fix cache title hydration\n2026-01-25|Build 2501262041\n2026-01-25|Harden request cache titles and cache-only reads\n2026-01-25|Serve bundled branding assets by default\n2026-01-25|Seed branding logo from bundled assets\n2026-01-25|Tidy request sync controls\n2026-01-25|Add Jellyfin login cache and admin-only stats\n2026-01-25|Add user stats and activity tracking\n2026-01-25|Move account actions into avatar menu\n2026-01-25|Improve mobile header layout\n2026-01-25|Automate build number tagging and sync\n2026-01-25|Add site banner, build number, and changelog\n2026-01-24|Improve request handling and qBittorrent categories\n2026-01-24|Map Prowlarr releases to Arr indexers for manual grab\n2026-01-24|Clarify how-it-works steps and fixes\n2026-01-24|Document fix buttons in how-it-works\n2026-01-24|Route grabs through Sonarr/Radarr only\n2026-01-23|Use backend branding assets for logo and favicon\n2026-01-23|Copy public assets into frontend image\n2026-01-23|Fix backend Dockerfile paths for root context\n2026-01-23|Add Docker Hub compose override\n2026-01-23|Remove password fields from users page\n2026-01-23|Use bundled branding assets\n2026-01-23|Add default branding assets when missing\n2026-01-23|Show available status on landing when in Jellyfin\n2026-01-23|Fix cache titles and move feedback link\n2026-01-23|Add feedback form and webhook\n2026-01-23|Hide header actions when signed out\n2026-01-23|Fallback manual grab to qBittorrent\n2026-01-23|Split search actions and improve download options\n2026-01-23|Fix cache titles via Jellyseerr media lookup\n2026-01-22|Update README with Docker-first guide\n2026-01-22|Update README\n2026-01-22|Ignore build artifacts\n2026-01-22|Initial commit' diff --git a/backend/app/db.py b/backend/app/db.py index b1c45d5..dbd5bb5 100644 --- a/backend/app/db.py +++ b/backend/app/db.py @@ -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,43 @@ 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, + status TEXT NOT NULL, + 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 +447,30 @@ 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) + """ + ) + 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 ( @@ -2879,6 +2941,417 @@ 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], + "status": row[9], + "priority": row[10], + "created_by_username": row[11], + "created_by_id": row[12], + "assignee_username": row[13], + "created_at": row[14], + "updated_at": row[15], + "last_activity_at": row[16], + } + + +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, + status: str = "new", + 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, + status, + 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, + status, + 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, + status, + 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, + 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(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, + status, + 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, + 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(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, +) -> 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 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() + total_items_row = conn.execute("SELECT COUNT(*) FROM portal_items").fetchone() + total_comments_row = conn.execute("SELECT COUNT(*) FROM portal_comments").fetchone() + 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}, + } + + def run_integrity_check() -> str: with _connect() as conn: row = conn.execute("PRAGMA integrity_check").fetchone() @@ -2922,6 +3395,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) diff --git a/backend/app/main.py b/backend/app/main.py index 2736606..a07105e 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -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) diff --git a/backend/app/routers/portal.py b/backend/app/routers/portal.py new file mode 100644 index 0000000..fc566db --- /dev/null +++ b/backend/app/routers/portal.py @@ -0,0 +1,474 @@ +from __future__ import annotations + +import logging +from typing import Any, Dict, Optional + +from fastapi import APIRouter, Depends, HTTPException, Query + +from ..auth import get_current_user +from ..db import ( + add_portal_comment, + count_portal_items, + create_portal_item, + get_portal_item, + get_portal_overview, + list_portal_comments, + list_portal_items, + update_portal_item, +) +from ..services.notifications import send_portal_notification + +router = APIRouter(prefix="/portal", tags=["portal"], dependencies=[Depends(get_current_user)]) +logger = logging.getLogger(__name__) + +PORTAL_KINDS = {"request", "issue", "feature"} +PORTAL_STATUSES = { + "new", + "triaging", + "planned", + "in_progress", + "blocked", + "done", + "declined", + "closed", +} +PORTAL_PRIORITIES = {"low", "normal", "high", "urgent"} +PORTAL_MEDIA_TYPES = {"movie", "tv"} + + +def _clean_text(value: Any) -> Optional[str]: + if value is None: + return None + if isinstance(value, str): + trimmed = value.strip() + return trimmed if trimmed else None + return str(value) + + +def _require_text(value: Any, field: str, *, max_length: int = 5000) -> str: + normalized = _clean_text(value) + if not normalized: + raise HTTPException(status_code=400, detail=f"{field} is required") + if len(normalized) > max_length: + raise HTTPException( + status_code=400, + detail=f"{field} is too long (max {max_length} characters)", + ) + return normalized + + +def _normalize_choice( + value: Any, + *, + field: str, + allowed: set[str], + default: Optional[str] = None, + allow_empty: bool = False, +) -> Optional[str]: + if value is None: + return default + normalized = _clean_text(value) + if not normalized: + return None if allow_empty else default + candidate = normalized.lower() + if candidate not in allowed: + allowed_values = ", ".join(sorted(allowed)) + raise HTTPException(status_code=400, detail=f"Invalid {field}. Allowed: {allowed_values}") + return candidate + + +def _normalize_year(value: Any, *, allow_empty: bool = True) -> Optional[int]: + if value is None: + return None + if isinstance(value, str): + stripped = value.strip() + if not stripped: + return None if allow_empty else 0 + value = stripped + try: + year = int(value) + except (TypeError, ValueError): + raise HTTPException(status_code=400, detail="year must be an integer") from None + if year < 1800 or year > 2100: + raise HTTPException(status_code=400, detail="year must be between 1800 and 2100") + return year + + +def _normalize_int(value: Any, field: str, *, allow_empty: bool = True) -> Optional[int]: + if value is None: + return None + if isinstance(value, str): + stripped = value.strip() + if not stripped: + return None if allow_empty else 0 + value = stripped + try: + return int(value) + except (TypeError, ValueError): + raise HTTPException(status_code=400, detail=f"{field} must be an integer") from None + + +def _normalize_bool(value: Any, *, default: bool = False) -> bool: + if value is None: + return default + if isinstance(value, bool): + return value + if isinstance(value, (int, float)): + return bool(value) + if isinstance(value, str): + candidate = value.strip().lower() + if candidate in {"1", "true", "yes", "on"}: + return True + if candidate in {"0", "false", "no", "off"}: + return False + raise HTTPException(status_code=400, detail="Boolean value expected") + + +def _is_admin(user: Dict[str, Any]) -> bool: + return str(user.get("role") or "").strip().lower() == "admin" + + +def _is_owner(user: Dict[str, Any], item: Dict[str, Any]) -> bool: + return str(user.get("username") or "") == str(item.get("created_by_username") or "") + + +def _serialize_item(item: Dict[str, Any], user: Dict[str, Any]) -> Dict[str, Any]: + is_admin = _is_admin(user) + is_owner = _is_owner(user, item) + serialized = dict(item) + serialized["permissions"] = { + "can_edit": is_admin or is_owner, + "can_comment": True, + "can_moderate": is_admin, + } + return serialized + + +async def _notify( + *, + event_type: str, + item: Dict[str, Any], + user: Dict[str, Any], + note: Optional[str] = None, +) -> None: + try: + result = await send_portal_notification( + event_type=event_type, + item=item, + actor_username=str(user.get("username") or "unknown"), + actor_role=str(user.get("role") or "user"), + note=note, + ) + logger.info( + "portal notification dispatched event=%s item_id=%s status=%s", + event_type, + item.get("id"), + result.get("status"), + ) + except Exception: + logger.exception( + "portal notification failed event=%s item_id=%s", + event_type, + item.get("id"), + ) + + +@router.get("/overview") +async def portal_overview(current_user: Dict[str, Any] = Depends(get_current_user)) -> Dict[str, Any]: + mine = count_portal_items(mine_username=str(current_user.get("username") or "")) + return { + "overview": get_portal_overview(), + "my_items": mine, + } + + +@router.get("/items") +async def portal_list_items( + kind: Optional[str] = None, + status: Optional[str] = None, + mine: bool = False, + search: Optional[str] = None, + limit: int = Query(default=50, ge=1, le=200), + offset: int = Query(default=0, ge=0), + current_user: Dict[str, Any] = Depends(get_current_user), +) -> Dict[str, Any]: + kind_value = _normalize_choice( + kind, field="kind", allowed=PORTAL_KINDS, allow_empty=True + ) + status_value = _normalize_choice( + status, field="status", allowed=PORTAL_STATUSES, allow_empty=True + ) + mine_username = str(current_user.get("username") or "") if mine else None + items = list_portal_items( + kind=kind_value, + status=status_value, + mine_username=mine_username, + search=_clean_text(search), + limit=limit, + offset=offset, + ) + total = count_portal_items( + kind=kind_value, + status=status_value, + mine_username=mine_username, + search=_clean_text(search), + ) + return { + "items": [_serialize_item(item, current_user) for item in items], + "total": total, + "limit": limit, + "offset": offset, + "has_more": offset + len(items) < total, + "filters": { + "kind": kind_value, + "status": status_value, + "mine": mine, + "search": _clean_text(search), + }, + } + + +@router.post("/items") +async def portal_create_item( + payload: Dict[str, Any], + current_user: Dict[str, Any] = Depends(get_current_user), +) -> Dict[str, Any]: + is_admin = _is_admin(current_user) + kind = _normalize_choice( + payload.get("kind"), + field="kind", + allowed=PORTAL_KINDS, + default="request", + ) + title = _require_text(payload.get("title"), "title", max_length=220) + description = _require_text(payload.get("description"), "description", max_length=10000) + media_type = _normalize_choice( + payload.get("media_type"), + field="media_type", + allowed=PORTAL_MEDIA_TYPES, + allow_empty=True, + ) + year = _normalize_year(payload.get("year")) + external_ref = _clean_text(payload.get("external_ref")) + source_system = _clean_text(payload.get("source_system")) if is_admin else None + source_request_id = ( + _normalize_int(payload.get("source_request_id"), "source_request_id") + if is_admin + else None + ) + status = _normalize_choice( + payload.get("status") if is_admin else None, + field="status", + allowed=PORTAL_STATUSES, + default="new", + ) + priority = _normalize_choice( + payload.get("priority"), + field="priority", + allowed=PORTAL_PRIORITIES, + default="normal", + ) + assignee_username = _clean_text(payload.get("assignee_username")) if is_admin else None + + created = create_portal_item( + kind=kind or "request", + title=title, + description=description, + created_by_username=str(current_user.get("username") or "unknown"), + created_by_id=_normalize_int(current_user.get("jellyseerr_user_id"), "jellyseerr_user_id"), + media_type=media_type, + year=year, + external_ref=external_ref, + source_system=source_system, + source_request_id=source_request_id, + status=status or "new", + priority=priority or "normal", + assignee_username=assignee_username, + ) + initial_comment = _clean_text(payload.get("comment")) + if initial_comment: + add_portal_comment( + int(created["id"]), + author_username=str(current_user.get("username") or "unknown"), + author_role=str(current_user.get("role") or "user"), + message=initial_comment, + is_internal=False, + ) + comments = list_portal_comments(int(created["id"]), include_internal=is_admin) + await _notify( + event_type="portal_item_created", + item=created, + user=current_user, + note=f"kind={created.get('kind')} priority={created.get('priority')}", + ) + return { + "item": _serialize_item(created, current_user), + "comments": comments, + } + + +@router.get("/items/{item_id}") +async def portal_get_item( + item_id: int, + current_user: Dict[str, Any] = Depends(get_current_user), +) -> Dict[str, Any]: + item = get_portal_item(item_id) + if not item: + raise HTTPException(status_code=404, detail="Portal item not found") + comments = list_portal_comments(item_id, include_internal=_is_admin(current_user)) + return { + "item": _serialize_item(item, current_user), + "comments": comments, + } + + +@router.patch("/items/{item_id}") +async def portal_update_item( + 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: + raise HTTPException(status_code=404, detail="Portal item not found") + is_admin = _is_admin(current_user) + is_owner = _is_owner(current_user, item) + if not (is_admin or is_owner): + raise HTTPException(status_code=403, detail="Only the owner or admin can edit this item") + + editable_owner_fields = {"title", "description", "media_type", "year", "external_ref"} + editable_admin_fields = { + "status", + "priority", + "assignee_username", + "source_system", + "source_request_id", + } + provided_fields = set(payload.keys()) + unknown_fields = provided_fields - (editable_owner_fields | editable_admin_fields) + if unknown_fields: + unknown = ", ".join(sorted(unknown_fields)) + raise HTTPException(status_code=400, detail=f"Unsupported fields: {unknown}") + if not is_admin: + forbidden = provided_fields - editable_owner_fields + if forbidden: + forbidden_text = ", ".join(sorted(forbidden)) + raise HTTPException( + status_code=403, detail=f"Admin access required to update: {forbidden_text}" + ) + + updates: Dict[str, Any] = {} + if "title" in payload: + updates["title"] = _require_text(payload.get("title"), "title", max_length=220) + if "description" in payload: + updates["description"] = _require_text( + payload.get("description"), "description", max_length=10000 + ) + if "media_type" in payload: + updates["media_type"] = _normalize_choice( + payload.get("media_type"), + field="media_type", + allowed=PORTAL_MEDIA_TYPES, + allow_empty=True, + ) + if "year" in payload: + updates["year"] = _normalize_year(payload.get("year")) + if "external_ref" in payload: + updates["external_ref"] = _clean_text(payload.get("external_ref")) + if is_admin: + if "status" in payload: + updates["status"] = _normalize_choice( + payload.get("status"), + field="status", + allowed=PORTAL_STATUSES, + default=item.get("status") or "new", + ) + if "priority" in payload: + updates["priority"] = _normalize_choice( + payload.get("priority"), + field="priority", + allowed=PORTAL_PRIORITIES, + default=item.get("priority") or "normal", + ) + if "assignee_username" in payload: + updates["assignee_username"] = _clean_text(payload.get("assignee_username")) + if "source_system" in payload: + updates["source_system"] = _clean_text(payload.get("source_system")) + if "source_request_id" in payload: + updates["source_request_id"] = _normalize_int( + payload.get("source_request_id"), "source_request_id" + ) + + if not updates: + comments = list_portal_comments(item_id, include_internal=is_admin) + return { + "item": _serialize_item(item, current_user), + "comments": comments, + } + + updated = update_portal_item(item_id, **updates) + if not updated: + raise HTTPException(status_code=404, detail="Portal item not found") + + changed_fields = [key for key in updates.keys() if item.get(key) != updated.get(key)] + if changed_fields: + await _notify( + event_type="portal_item_updated", + item=updated, + user=current_user, + note=f"changed={','.join(sorted(changed_fields))}", + ) + comments = list_portal_comments(item_id, include_internal=is_admin) + return { + "item": _serialize_item(updated, current_user), + "comments": comments, + } + + +@router.get("/items/{item_id}/comments") +async def portal_get_comments( + item_id: int, + limit: int = Query(default=200, ge=1, le=500), + current_user: Dict[str, Any] = Depends(get_current_user), +) -> Dict[str, Any]: + item = get_portal_item(item_id) + if not item: + raise HTTPException(status_code=404, detail="Portal item not found") + comments = list_portal_comments( + item_id, + include_internal=_is_admin(current_user), + limit=limit, + ) + return {"comments": comments} + + +@router.post("/items/{item_id}/comments") +async def portal_create_comment( + 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: + raise HTTPException(status_code=404, detail="Portal item not found") + is_admin = _is_admin(current_user) + message = _require_text(payload.get("message"), "message", max_length=10000) + is_internal = _normalize_bool(payload.get("is_internal"), default=False) + if is_internal and not is_admin: + raise HTTPException(status_code=403, detail="Only admins can add internal comments") + comment = add_portal_comment( + item_id, + author_username=str(current_user.get("username") or "unknown"), + author_role=str(current_user.get("role") or "user"), + message=message, + is_internal=is_internal, + ) + updated_item = get_portal_item(item_id) + if updated_item: + await _notify( + event_type="portal_comment_added", + item=updated_item, + user=current_user, + note=f"internal={is_internal}", + ) + return {"comment": comment} diff --git a/backend/app/services/invite_email.py b/backend/app/services/invite_email.py index e273017..cec7d41 100644 --- a/backend/app/services/invite_email.py +++ b/backend/app/services/invite_email.py @@ -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: diff --git a/backend/app/services/notifications.py b/backend/app/services/notifications.py new file mode 100644 index 0000000..2b47319 --- /dev/null +++ b/backend/app/services/notifications.py @@ -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 = ( + "
" + f"

{title}

" + f"

{message}

" + "" + f"" + f"" + f"" + f"" + "
Kind{_clean_text(payload.get('kind'))}
Status{_clean_text(payload.get('status'))}
Priority{_clean_text(payload.get('priority'))}
Requested by{_clean_text(payload.get('requested_by'))}
" + f"Open portal item" + "
" + ) + 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} diff --git a/frontend/app/globals.css b/frontend/app/globals.css index 26a62d7..18a3753 100644 --- a/frontend/app/globals.css +++ b/frontend/app/globals.css @@ -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; + } +} diff --git a/frontend/app/portal/page.tsx b/frontend/app/portal/page.tsx new file mode 100644 index 0000000..abe30a6 --- /dev/null +++ b/frontend/app/portal/page.tsx @@ -0,0 +1,839 @@ +'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 +} + +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 + by_status?: Record + } + 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: 'declined', label: 'Declined' }, + { value: 'closed', label: 'Closed' }, +] 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(null) + const [overview, setOverview] = useState(null) + const [items, setItems] = useState([]) + const [selectedItemId, setSelectedItemId] = useState(null) + const [selectedItem, setSelectedItem] = useState(null) + const [comments, setComments] = useState([]) + 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(null) + const [status, setStatus] = useState(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 [editPriority, setEditPriority] = useState('normal') + const [editAssignee, setEditAssignee] = useState('') + + const [commentText, setCommentText] = useState('') + const [commentInternal, setCommentInternal] = useState(false) + const [preselectedItemId, setPreselectedItemId] = useState(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') + 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 = { + 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 = { + title: editTitle, + description: editDescription, + media_type: editMediaType || null, + year: editYear.trim() ? toPositiveInt(editYear) : null, + external_ref: editExternalRef || null, + } + if (selectedItem.permissions?.can_moderate) { + 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
Loading request portal...
+ } + + return ( +
+
+
+

Request portal

+

+ Raise requests, issues, and feature ideas. Track progress and keep discussion in one place. +

+
+
+ + {error &&
{error}
} + {status &&
{status}
} + +
+
+ Total items + {Number(overview?.overview?.total_items ?? totalItems ?? 0)} +
+
+ Total comments + {Number(overview?.overview?.total_comments ?? 0)} +
+
+ My items + {Number(overview?.my_items ?? 0)} +
+
+ Visible + {items.length} +
+
+ +
+

Create item

+

+ Use Request for new content, Issue for broken behavior, and Feature for improvements. +

+
+ + + +