diff --git a/.build_number b/.build_number index 47edee8..cc43b70 100644 --- a/.build_number +++ b/.build_number @@ -1 +1 @@ -0703261729 +0803262038 diff --git a/backend/app/build_info.py b/backend/app/build_info.py index 82e0321..cd9cb7b 100644 --- a/backend/app/build_info.py +++ b/backend/app/build_info.py @@ -1,2 +1,2 @@ -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' +BUILD_NUMBER = "0803262038" +CHANGELOG = '2026-03-07|Process 1 build 0703261729\n2026-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 dbd5bb5..2bc3892 100644 --- a/backend/app/db.py +++ b/backend/app/db.py @@ -362,7 +362,13 @@ def init_db() -> None: 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, @@ -465,6 +471,24 @@ def init_db() -> None: 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 @@ -553,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( """ @@ -2952,14 +3018,20 @@ def _portal_item_from_row(row: tuple[Any, ...]) -> Dict[str, Any]: "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], + "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], } @@ -2987,7 +3059,13 @@ def create_portal_item( 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]: @@ -3004,7 +3082,13 @@ def create_portal_item( 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, @@ -3013,7 +3097,7 @@ def create_portal_item( updated_at, last_activity_at ) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) """, ( kind, @@ -3024,7 +3108,13 @@ def create_portal_item( 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, @@ -3063,7 +3153,13 @@ def get_portal_item(item_id: int) -> Optional[Dict[str, Any]]: 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, @@ -3083,6 +3179,11 @@ 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, @@ -3096,6 +3197,21 @@ def list_portal_items( 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()) @@ -3120,7 +3236,13 @@ def list_portal_items( 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, @@ -3142,6 +3264,11 @@ 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: @@ -3153,6 +3280,21 @@ def count_portal_items( 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()) @@ -3182,6 +3324,12 @@ def update_portal_item( 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] = [] @@ -3215,6 +3363,24 @@ def update_portal_item( 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() @@ -3342,13 +3508,31 @@ def get_portal_overview() -> Dict[str, Any]: 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, } diff --git a/backend/app/routers/portal.py b/backend/app/routers/portal.py index fc566db..2b95ff3 100644 --- a/backend/app/routers/portal.py +++ b/backend/app/routers/portal.py @@ -1,7 +1,8 @@ from __future__ import annotations import logging -from typing import Any, Dict, Optional +from datetime import datetime, timezone +from typing import Any, Dict, Optional, Tuple from fastapi import APIRouter, Depends, HTTPException, Query @@ -23,6 +24,7 @@ logger = logging.getLogger(__name__) PORTAL_KINDS = {"request", "issue", "feature"} PORTAL_STATUSES = { + # Existing generic statuses "new", "triaging", "planned", @@ -31,9 +33,66 @@ PORTAL_STATUSES = { "done", "declined", "closed", + # Seerr-style request pipeline statuses + "pending", + "approved", + "processing", + "partially_available", + "available", + "failed", } PORTAL_PRIORITIES = {"low", "normal", "high", "urgent"} PORTAL_MEDIA_TYPES = {"movie", "tv"} +PORTAL_REQUEST_STATUSES = {"pending", "approved", "declined"} +PORTAL_MEDIA_STATUSES = { + "unknown", + "pending", + "processing", + "partially_available", + "available", + "failed", +} +PORTAL_ISSUE_TYPES = { + "general", + "playback", + "subtitle", + "quality", + "metadata", + "missing_content", + "other", +} + +REQUEST_STATUS_TRANSITIONS: Dict[str, set[str]] = { + "pending": {"pending", "approved", "declined"}, + "approved": {"approved", "declined"}, + "declined": {"declined", "pending", "approved"}, +} + +MEDIA_STATUS_TRANSITIONS: Dict[str, set[str]] = { + "unknown": {"unknown", "pending", "processing", "failed"}, + "pending": {"pending", "processing", "partially_available", "available", "failed"}, + "processing": {"processing", "partially_available", "available", "failed"}, + "partially_available": {"partially_available", "processing", "available", "failed"}, + "available": {"available", "processing"}, + "failed": {"failed", "processing", "available"}, +} + +LEGACY_STATUS_TO_WORKFLOW: Dict[str, Tuple[str, str]] = { + "new": ("pending", "pending"), + "triaging": ("pending", "pending"), + "planned": ("approved", "pending"), + "in_progress": ("approved", "processing"), + "blocked": ("approved", "failed"), + "done": ("approved", "available"), + "closed": ("approved", "available"), + "pending": ("pending", "pending"), + "approved": ("approved", "pending"), + "declined": ("declined", "unknown"), + "processing": ("approved", "processing"), + "partially_available": ("approved", "partially_available"), + "available": ("approved", "available"), + "failed": ("approved", "failed"), +} def _clean_text(value: Any) -> Optional[str]: @@ -124,6 +183,154 @@ def _normalize_bool(value: Any, *, default: bool = False) -> bool: raise HTTPException(status_code=400, detail="Boolean value expected") +def _workflow_to_item_status(request_status: str, media_status: str) -> str: + if request_status == "declined": + return "declined" + if request_status == "pending": + return "pending" + if media_status == "available": + return "available" + if media_status == "partially_available": + return "partially_available" + if media_status == "failed": + return "failed" + if media_status == "processing": + return "processing" + return "approved" + + +def _item_status_to_workflow(item: Dict[str, Any]) -> Tuple[str, str]: + request_status = _normalize_choice( + item.get("workflow_request_status"), + field="request_status", + allowed=PORTAL_REQUEST_STATUSES, + allow_empty=True, + ) + media_status = _normalize_choice( + item.get("workflow_media_status"), + field="media_status", + allowed=PORTAL_MEDIA_STATUSES, + allow_empty=True, + ) + if request_status and media_status: + return request_status, media_status + + status = _clean_text(item.get("status")) + if status: + mapped = LEGACY_STATUS_TO_WORKFLOW.get(status.lower()) + if mapped: + return mapped + return "pending", "pending" + + +def _stage_label_for_workflow(request_status: str, media_status: str) -> str: + if request_status == "declined": + return "Declined" + if request_status == "pending": + return "Waiting for approval" + if media_status == "available": + return "Ready to watch" + if media_status == "partially_available": + return "Partially available" + if media_status == "processing": + return "Working on it" + if media_status == "failed": + return "Needs attention" + return "Approved" + + +def _normalize_request_pipeline( + request_status: Optional[str], + media_status: Optional[str], + *, + fallback_request_status: str = "pending", + fallback_media_status: str = "pending", +) -> Tuple[str, str]: + normalized_request = _normalize_choice( + request_status, + field="request_status", + allowed=PORTAL_REQUEST_STATUSES, + default=fallback_request_status, + ) + normalized_media = _normalize_choice( + media_status, + field="media_status", + allowed=PORTAL_MEDIA_STATUSES, + default=fallback_media_status, + ) + request_value = normalized_request or fallback_request_status + media_value = normalized_media or fallback_media_status + + if request_value == "declined": + return request_value, "unknown" + if request_value == "pending": + if media_value not in {"pending", "unknown"}: + media_value = "pending" + return request_value, media_value + if media_value == "unknown": + media_value = "pending" + return request_value, media_value + + +def _validate_pipeline_transition( + current_request: str, + current_media: str, + requested_request: str, + requested_media: str, +) -> Tuple[str, str]: + allowed_request = REQUEST_STATUS_TRANSITIONS.get(current_request, {current_request}) + if requested_request not in allowed_request: + allowed_text = ", ".join(sorted(allowed_request)) + raise HTTPException( + status_code=400, + detail=( + f"Invalid request_status transition: {current_request} -> {requested_request}. " + f"Allowed: {allowed_text}" + ), + ) + + normalized_request, normalized_media = _normalize_request_pipeline( + requested_request, + requested_media, + fallback_request_status=current_request, + fallback_media_status=current_media, + ) + if normalized_request != "approved": + return normalized_request, normalized_media + + if current_request != "approved": + allowed_media = PORTAL_MEDIA_STATUSES - {"unknown"} + else: + allowed_media = MEDIA_STATUS_TRANSITIONS.get(current_media, {current_media}) + if normalized_media not in allowed_media: + allowed_text = ", ".join(sorted(allowed_media)) + raise HTTPException( + status_code=400, + detail=( + f"Invalid media_status transition: {current_media} -> {normalized_media}. " + f"Allowed: {allowed_text}" + ), + ) + return normalized_request, normalized_media + + +def _ensure_item_exists(item_id: Optional[int], *, field: str = "related_item_id") -> None: + if item_id is None: + return + target = get_portal_item(item_id) + if not target: + raise HTTPException(status_code=400, detail=f"{field} references an unknown portal item") + + +def _sanitize_metadata_json(value: Any) -> Optional[str]: + text = _clean_text(value) + if text is None: + return None + if len(text) > 50000: + raise HTTPException(status_code=400, detail="metadata_json is too long (max 50000 characters)") + return text + + def _is_admin(user: Dict[str, Any]) -> bool: return str(user.get("role") or "").strip().lower() == "admin" @@ -140,7 +347,24 @@ def _serialize_item(item: Dict[str, Any], user: Dict[str, Any]) -> Dict[str, Any "can_edit": is_admin or is_owner, "can_comment": True, "can_moderate": is_admin, + "can_raise_issue": str(item.get("kind") or "") == "request", } + kind = str(item.get("kind") or "").strip().lower() + if kind == "request": + request_status, media_status = _item_status_to_workflow(item) + serialized["workflow"] = { + "request_status": request_status, + "media_status": media_status, + "stage_label": _stage_label_for_workflow(request_status, media_status), + "is_terminal": media_status in {"available", "failed"} or request_status == "declined", + } + elif kind == "issue": + serialized["issue"] = { + "issue_type": _clean_text(item.get("issue_type")) or "general", + "related_item_id": _normalize_int(item.get("related_item_id"), "related_item_id"), + "is_resolved": bool(_clean_text(item.get("issue_resolved_at"))), + "resolved_at": _clean_text(item.get("issue_resolved_at")), + } return serialized @@ -186,6 +410,11 @@ async def portal_overview(current_user: Dict[str, Any] = Depends(get_current_use async def portal_list_items( kind: Optional[str] = None, status: Optional[str] = None, + request_status: Optional[str] = None, + media_status: Optional[str] = None, + source_system: Optional[str] = None, + source_request_id: Optional[int] = None, + related_item_id: Optional[int] = None, mine: bool = False, search: Optional[str] = None, limit: int = Query(default=50, ge=1, le=200), @@ -198,10 +427,24 @@ async def portal_list_items( status_value = _normalize_choice( status, field="status", allowed=PORTAL_STATUSES, allow_empty=True ) + request_status_value = _normalize_choice( + request_status, field="request_status", allowed=PORTAL_REQUEST_STATUSES, allow_empty=True + ) + media_status_value = _normalize_choice( + media_status, field="media_status", allowed=PORTAL_MEDIA_STATUSES, allow_empty=True + ) + source_system_value = _clean_text(source_system) + if source_system_value: + source_system_value = source_system_value.lower() mine_username = str(current_user.get("username") or "") if mine else None items = list_portal_items( kind=kind_value, status=status_value, + workflow_request_status=request_status_value, + workflow_media_status=media_status_value, + source_system=source_system_value, + source_request_id=source_request_id, + related_item_id=related_item_id, mine_username=mine_username, search=_clean_text(search), limit=limit, @@ -210,6 +453,11 @@ async def portal_list_items( total = count_portal_items( kind=kind_value, status=status_value, + workflow_request_status=request_status_value, + workflow_media_status=media_status_value, + source_system=source_system_value, + source_request_id=source_request_id, + related_item_id=related_item_id, mine_username=mine_username, search=_clean_text(search), ) @@ -222,6 +470,59 @@ async def portal_list_items( "filters": { "kind": kind_value, "status": status_value, + "request_status": request_status_value, + "media_status": media_status_value, + "source_system": source_system_value, + "source_request_id": source_request_id, + "related_item_id": related_item_id, + "mine": mine, + "search": _clean_text(search), + }, + } + + +@router.get("/requests") +async def portal_list_requests( + request_status: Optional[str] = None, + media_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]: + mine_username = str(current_user.get("username") or "") if mine else None + request_status_value = _normalize_choice( + request_status, field="request_status", allowed=PORTAL_REQUEST_STATUSES, allow_empty=True + ) + media_status_value = _normalize_choice( + media_status, field="media_status", allowed=PORTAL_MEDIA_STATUSES, allow_empty=True + ) + items = list_portal_items( + kind="request", + workflow_request_status=request_status_value, + workflow_media_status=media_status_value, + mine_username=mine_username, + search=_clean_text(search), + limit=limit, + offset=offset, + ) + total = count_portal_items( + kind="request", + workflow_request_status=request_status_value, + workflow_media_status=media_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": { + "request_status": request_status_value, + "media_status": media_status_value, "mine": mine, "search": _clean_text(search), }, @@ -251,17 +552,45 @@ async def portal_create_item( 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 + if source_system: + source_system = source_system.lower() 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", - ) + related_item_id = _normalize_int(payload.get("related_item_id"), "related_item_id") + _ensure_item_exists(related_item_id) + workflow_request_status: Optional[str] = None + workflow_media_status: Optional[str] = None + issue_type: Optional[str] = None + issue_resolved_at: Optional[str] = None + status: Optional[str] = None + if kind == "request": + workflow_request_status, workflow_media_status = _normalize_request_pipeline( + payload.get("request_status"), + payload.get("media_status"), + fallback_request_status="pending", + fallback_media_status="pending", + ) + status = _workflow_to_item_status(workflow_request_status, workflow_media_status) + else: + status = _normalize_choice( + payload.get("status") if is_admin else None, + field="status", + allowed=PORTAL_STATUSES, + default="new", + ) + if kind == "issue": + issue_type = _normalize_choice( + payload.get("issue_type"), + field="issue_type", + allowed=PORTAL_ISSUE_TYPES, + default="general", + ) + if related_item_id is not None and not source_system: + source_system = "portal_request" + source_request_id = related_item_id priority = _normalize_choice( payload.get("priority"), field="priority", @@ -269,6 +598,7 @@ async def portal_create_item( default="normal", ) assignee_username = _clean_text(payload.get("assignee_username")) if is_admin else None + metadata_json = _sanitize_metadata_json(payload.get("metadata_json")) if is_admin else None created = create_portal_item( kind=kind or "request", @@ -281,7 +611,13 @@ async def portal_create_item( external_ref=external_ref, source_system=source_system, source_request_id=source_request_id, + related_item_id=related_item_id, status=status or "new", + workflow_request_status=workflow_request_status, + workflow_media_status=workflow_media_status, + issue_type=issue_type, + issue_resolved_at=issue_resolved_at, + metadata_json=metadata_json, priority=priority or "normal", assignee_username=assignee_username, ) @@ -307,6 +643,174 @@ async def portal_create_item( } +@router.post("/requests/{item_id}/issues") +async def portal_create_issue_for_request( + item_id: int, + payload: Dict[str, Any], + current_user: Dict[str, Any] = Depends(get_current_user), +) -> Dict[str, Any]: + request_item = get_portal_item(item_id) + if not request_item: + raise HTTPException(status_code=404, detail="Portal request not found") + if str(request_item.get("kind") or "").lower() != "request": + raise HTTPException(status_code=400, detail="Only request items can have linked issues") + + title = _require_text(payload.get("title"), "title", max_length=220) + description = _require_text(payload.get("description"), "description", max_length=10000) + issue_type = _normalize_choice( + payload.get("issue_type"), + field="issue_type", + allowed=PORTAL_ISSUE_TYPES, + default="general", + ) + status = _normalize_choice( + payload.get("status"), + field="status", + allowed=PORTAL_STATUSES, + default="new", + ) + priority = _normalize_choice( + payload.get("priority"), + field="priority", + allowed=PORTAL_PRIORITIES, + default="normal", + ) + created = create_portal_item( + kind="issue", + 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=request_item.get("media_type"), + year=request_item.get("year"), + external_ref=_clean_text(payload.get("external_ref")), + source_system="portal_request", + source_request_id=item_id, + related_item_id=item_id, + status=status or "new", + issue_type=issue_type, + priority=priority or "normal", + assignee_username=_clean_text(payload.get("assignee_username")) if _is_admin(current_user) else None, + ) + 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(current_user)) + await _notify( + event_type="portal_issue_created", + item=created, + user=current_user, + note=f"linked_request_id={item_id}", + ) + return { + "item": _serialize_item(created, current_user), + "comments": comments, + "linked_request_id": item_id, + } + + +@router.get("/requests/{item_id}/issues") +async def portal_list_request_issues( + item_id: int, + 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]: + request_item = get_portal_item(item_id) + if not request_item: + raise HTTPException(status_code=404, detail="Portal request not found") + if str(request_item.get("kind") or "").lower() != "request": + raise HTTPException(status_code=400, detail="Only request items can have linked issues") + + items = list_portal_items( + kind="issue", + related_item_id=item_id, + limit=limit, + offset=offset, + ) + total = count_portal_items(kind="issue", related_item_id=item_id) + return { + "items": [_serialize_item(item, current_user) for item in items], + "total": total, + "limit": limit, + "offset": offset, + "has_more": offset + len(items) < total, + "linked_request_id": item_id, + } + + +@router.patch("/requests/{item_id}/pipeline") +async def portal_update_request_pipeline( + item_id: int, + payload: Dict[str, Any], + current_user: Dict[str, Any] = Depends(get_current_user), +) -> Dict[str, Any]: + if not _is_admin(current_user): + raise HTTPException(status_code=403, detail="Admin access required") + item = get_portal_item(item_id) + if not item: + raise HTTPException(status_code=404, detail="Portal request not found") + if str(item.get("kind") or "").lower() != "request": + raise HTTPException(status_code=400, detail="Only request items support pipeline updates") + + current_request_status, current_media_status = _item_status_to_workflow(item) + requested_request = _normalize_choice( + payload.get("request_status"), + field="request_status", + allowed=PORTAL_REQUEST_STATUSES, + default=current_request_status, + ) or current_request_status + requested_media = _normalize_choice( + payload.get("media_status"), + field="media_status", + allowed=PORTAL_MEDIA_STATUSES, + default=current_media_status, + ) or current_media_status + next_request_status, next_media_status = _validate_pipeline_transition( + current_request_status, + current_media_status, + requested_request, + requested_media, + ) + next_status = _workflow_to_item_status(next_request_status, next_media_status) + updated = update_portal_item( + item_id, + status=next_status, + workflow_request_status=next_request_status, + workflow_media_status=next_media_status, + ) + if not updated: + raise HTTPException(status_code=404, detail="Portal request not found") + + comment_text = _clean_text(payload.get("comment")) + if comment_text: + add_portal_comment( + item_id, + author_username=str(current_user.get("username") or "unknown"), + author_role=str(current_user.get("role") or "admin"), + message=comment_text, + is_internal=_normalize_bool(payload.get("is_internal"), default=False), + ) + + await _notify( + event_type="portal_request_pipeline_updated", + item=updated, + user=current_user, + note=f"{current_request_status}/{current_media_status} -> {next_request_status}/{next_media_status}", + ) + comments = list_portal_comments(item_id, include_internal=True) + return { + "item": _serialize_item(updated, current_user), + "comments": comments, + } + + @router.get("/items/{item_id}") async def portal_get_item( item_id: int, @@ -343,6 +847,12 @@ async def portal_update_item( "assignee_username", "source_system", "source_request_id", + "related_item_id", + "request_status", + "media_status", + "issue_type", + "issue_resolved_at", + "metadata_json", } provided_fields = set(payload.keys()) unknown_fields = provided_fields - (editable_owner_fields | editable_admin_fields) @@ -376,13 +886,7 @@ async def portal_update_item( 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", - ) + kind = str(item.get("kind") or "").lower() if "priority" in payload: updates["priority"] = _normalize_choice( payload.get("priority"), @@ -393,11 +897,89 @@ async def portal_update_item( 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")) + source_system = _clean_text(payload.get("source_system")) + updates["source_system"] = source_system.lower() if source_system else None if "source_request_id" in payload: updates["source_request_id"] = _normalize_int( payload.get("source_request_id"), "source_request_id" ) + if "related_item_id" in payload: + related_item_id = _normalize_int(payload.get("related_item_id"), "related_item_id") + _ensure_item_exists(related_item_id) + updates["related_item_id"] = related_item_id + if "metadata_json" in payload: + updates["metadata_json"] = _sanitize_metadata_json(payload.get("metadata_json")) + + if kind == "request": + current_request_status, current_media_status = _item_status_to_workflow(item) + request_status_input = payload.get("request_status") + media_status_input = payload.get("media_status") + explicit_status = payload.get("status") + if explicit_status is not None and request_status_input is None and media_status_input is None: + explicit_status_normalized = _normalize_choice( + explicit_status, + field="status", + allowed=PORTAL_STATUSES, + default=item.get("status") or "pending", + ) + request_status_input, media_status_input = LEGACY_STATUS_TO_WORKFLOW.get( + explicit_status_normalized or "pending", + (current_request_status, current_media_status), + ) + + if request_status_input is not None or media_status_input is not None: + requested_request = _normalize_choice( + request_status_input, + field="request_status", + allowed=PORTAL_REQUEST_STATUSES, + default=current_request_status, + ) or current_request_status + requested_media = _normalize_choice( + media_status_input, + field="media_status", + allowed=PORTAL_MEDIA_STATUSES, + default=current_media_status, + ) or current_media_status + next_request_status, next_media_status = _validate_pipeline_transition( + current_request_status, + current_media_status, + requested_request, + requested_media, + ) + updates["workflow_request_status"] = next_request_status + updates["workflow_media_status"] = next_media_status + updates["status"] = _workflow_to_item_status(next_request_status, next_media_status) + elif "status" in payload: + updates["status"] = _normalize_choice( + payload.get("status"), + field="status", + allowed=PORTAL_STATUSES, + default=item.get("status") or "pending", + ) + else: + if "status" in payload: + updates["status"] = _normalize_choice( + payload.get("status"), + field="status", + allowed=PORTAL_STATUSES, + default=item.get("status") or "new", + ) + if kind == "issue": + if "issue_type" in payload: + updates["issue_type"] = _normalize_choice( + payload.get("issue_type"), + field="issue_type", + allowed=PORTAL_ISSUE_TYPES, + default=item.get("issue_type") or "general", + ) + if "issue_resolved_at" in payload: + updates["issue_resolved_at"] = _clean_text(payload.get("issue_resolved_at")) + if "status" in payload: + next_status = str(updates.get("status") or item.get("status") or "").lower() + if next_status in {"done", "closed"}: + updates.setdefault("issue_resolved_at", datetime.now(timezone.utc).isoformat()) + elif next_status in {"new", "triaging", "planned", "in_progress", "blocked"}: + updates.setdefault("issue_resolved_at", None) if not updates: comments = list_portal_comments(item_id, include_internal=is_admin) diff --git a/backend/tests/test_backend_quality.py b/backend/tests/test_backend_quality.py index cc462ea..a5e36e2 100644 --- a/backend/tests/test_backend_quality.py +++ b/backend/tests/test_backend_quality.py @@ -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) diff --git a/frontend/app/portal/page.tsx b/frontend/app/portal/page.tsx index abe30a6..ba7f391 100644 --- a/frontend/app/portal/page.tsx +++ b/frontend/app/portal/page.tsx @@ -28,6 +28,18 @@ type PortalItem = { 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 = { @@ -68,10 +80,31 @@ const STATUS_OPTIONS = [ { 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' }, @@ -135,6 +168,8 @@ export default function PortalPage() { 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('') @@ -312,6 +347,8 @@ export default function PortalPage() { 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]) @@ -382,7 +419,12 @@ export default function PortalPage() { external_ref: editExternalRef || null, } if (selectedItem.permissions?.can_moderate) { - payload.status = editStatus + if (selectedItem.kind === 'request') { + payload.request_status = editRequestStatus + payload.media_status = editMediaStatus + } else { + payload.status = editStatus + } payload.priority = editPriority payload.assignee_username = editAssignee || null } @@ -661,7 +703,12 @@ export default function PortalPage() {

{item.description}

#{item.id} - Status: {item.status} + + Status:{' '} + {item.kind === 'request' + ? item.workflow?.stage_label ?? item.status + : item.status} + By: {item.created_by_username} Updated: {formatDate(item.last_activity_at)}
@@ -687,6 +734,16 @@ export default function PortalPage() {

Created by {selectedItem.created_by_username} on {formatDate(selectedItem.created_at)}

+ {selectedItem.kind === 'request' && ( +

+ Pipeline:{' '} + + {selectedItem.workflow?.request_status ?? 'pending'} /{' '} + {selectedItem.workflow?.media_status ?? 'pending'} + {' '} + ({selectedItem.workflow?.stage_label ?? 'Pending'}) +

+ )} @@ -741,16 +798,47 @@ export default function PortalPage() { {selectedItem.permissions?.can_moderate && ( <> - + {selectedItem.kind === 'request' ? ( + <> + + + + ) : ( + + )}