Process 1 build 0703261729
This commit is contained in:
@@ -1 +1 @@
|
|||||||
0403261902
|
0703261729
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -20,6 +20,7 @@ SEERR_MEDIA_FAILURE_PERSISTENT_THRESHOLD = 3
|
|||||||
SQLITE_BUSY_TIMEOUT_MS = 5_000
|
SQLITE_BUSY_TIMEOUT_MS = 5_000
|
||||||
SQLITE_CACHE_SIZE_KIB = 32_768
|
SQLITE_CACHE_SIZE_KIB = 32_768
|
||||||
SQLITE_MMAP_SIZE_BYTES = 256 * 1024 * 1024
|
SQLITE_MMAP_SIZE_BYTES = 256 * 1024 * 1024
|
||||||
|
_DB_UNSET = object()
|
||||||
|
|
||||||
|
|
||||||
def _db_path() -> str:
|
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(
|
conn.execute(
|
||||||
"""
|
"""
|
||||||
CREATE INDEX IF NOT EXISTS idx_requests_cache_created_at
|
CREATE INDEX IF NOT EXISTS idx_requests_cache_created_at
|
||||||
@@ -409,6 +447,30 @@ def init_db() -> None:
|
|||||||
ON password_reset_tokens (expires_at)
|
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(
|
conn.execute(
|
||||||
"""
|
"""
|
||||||
CREATE TABLE IF NOT EXISTS user_activity (
|
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:
|
def run_integrity_check() -> str:
|
||||||
with _connect() as conn:
|
with _connect() as conn:
|
||||||
row = conn.execute("PRAGMA integrity_check").fetchone()
|
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),
|
"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),
|
"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),
|
"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)
|
row_count_ms = round((perf_counter() - row_count_started) * 1000, 1)
|
||||||
|
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ from .routers.status import router as status_router
|
|||||||
from .routers.feedback import router as feedback_router
|
from .routers.feedback import router as feedback_router
|
||||||
from .routers.site import router as site_router
|
from .routers.site import router as site_router
|
||||||
from .routers.events import router as events_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 .services.jellyfin_sync import run_daily_jellyfin_sync
|
||||||
from .logging_config import (
|
from .logging_config import (
|
||||||
bind_request_id,
|
bind_request_id,
|
||||||
@@ -228,3 +229,4 @@ app.include_router(status_router)
|
|||||||
app.include_router(feedback_router)
|
app.include_router(feedback_router)
|
||||||
app.include_router(site_router)
|
app.include_router(site_router)
|
||||||
app.include_router(events_router)
|
app.include_router(events_router)
|
||||||
|
app.include_router(portal_router)
|
||||||
|
|||||||
474
backend/app/routers/portal.py
Normal file
474
backend/app/routers/portal.py
Normal file
@@ -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}
|
||||||
@@ -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]:
|
async def send_test_email(recipient_email: Optional[str] = None) -> Dict[str, str]:
|
||||||
ready, detail = smtp_email_config_ready()
|
ready, detail = smtp_email_config_ready()
|
||||||
if not ready:
|
if not ready:
|
||||||
|
|||||||
276
backend/app/services/notifications.py
Normal file
276
backend/app/services/notifications.py
Normal file
@@ -0,0 +1,276 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from typing import Any, Dict, Optional
|
||||||
|
from urllib.parse import quote
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from ..config import settings as env_settings
|
||||||
|
from ..db import get_setting
|
||||||
|
from ..runtime import get_runtime_settings
|
||||||
|
from .invite_email import send_generic_email
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _clean_text(value: Any, fallback: str = "") -> str:
|
||||||
|
if value is None:
|
||||||
|
return fallback
|
||||||
|
if isinstance(value, str):
|
||||||
|
trimmed = value.strip()
|
||||||
|
return trimmed if trimmed else fallback
|
||||||
|
return str(value)
|
||||||
|
|
||||||
|
|
||||||
|
def _split_emails(value: str) -> list[str]:
|
||||||
|
if not value:
|
||||||
|
return []
|
||||||
|
parts = [entry.strip() for entry in value.replace(";", ",").split(",")]
|
||||||
|
return [entry for entry in parts if entry and "@" in entry]
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_app_url() -> str:
|
||||||
|
runtime = get_runtime_settings()
|
||||||
|
for candidate in (
|
||||||
|
runtime.magent_application_url,
|
||||||
|
runtime.magent_proxy_base_url,
|
||||||
|
env_settings.cors_allow_origin,
|
||||||
|
):
|
||||||
|
normalized = _clean_text(candidate)
|
||||||
|
if normalized:
|
||||||
|
return normalized.rstrip("/")
|
||||||
|
port = int(getattr(runtime, "magent_application_port", 3000) or 3000)
|
||||||
|
return f"http://localhost:{port}"
|
||||||
|
|
||||||
|
|
||||||
|
def _portal_item_url(item_id: int) -> str:
|
||||||
|
return f"{_resolve_app_url()}/portal?item={item_id}"
|
||||||
|
|
||||||
|
|
||||||
|
async def _http_post_json(url: str, payload: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
async with httpx.AsyncClient(timeout=12.0) as client:
|
||||||
|
response = await client.post(url, json=payload)
|
||||||
|
response.raise_for_status()
|
||||||
|
try:
|
||||||
|
body = response.json()
|
||||||
|
except ValueError:
|
||||||
|
body = response.text
|
||||||
|
return {"status_code": response.status_code, "body": body}
|
||||||
|
|
||||||
|
|
||||||
|
async def _send_discord(title: str, message: str, payload: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
runtime = get_runtime_settings()
|
||||||
|
webhook = _clean_text(runtime.magent_notify_discord_webhook_url) or _clean_text(
|
||||||
|
runtime.discord_webhook_url
|
||||||
|
)
|
||||||
|
if not webhook:
|
||||||
|
return {"status": "skipped", "detail": "Discord webhook not configured."}
|
||||||
|
data = {
|
||||||
|
"content": f"**{title}**\n{message}",
|
||||||
|
"embeds": [
|
||||||
|
{
|
||||||
|
"title": title,
|
||||||
|
"description": message,
|
||||||
|
"fields": [
|
||||||
|
{"name": "Type", "value": _clean_text(payload.get("kind"), "unknown"), "inline": True},
|
||||||
|
{"name": "Status", "value": _clean_text(payload.get("status"), "unknown"), "inline": True},
|
||||||
|
{"name": "Priority", "value": _clean_text(payload.get("priority"), "normal"), "inline": True},
|
||||||
|
],
|
||||||
|
"url": _clean_text(payload.get("item_url")),
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
result = await _http_post_json(webhook, data)
|
||||||
|
return {"status": "ok", "detail": f"Discord accepted ({result['status_code']})."}
|
||||||
|
|
||||||
|
|
||||||
|
async def _send_telegram(title: str, message: str) -> Dict[str, Any]:
|
||||||
|
runtime = get_runtime_settings()
|
||||||
|
bot_token = _clean_text(runtime.magent_notify_telegram_bot_token)
|
||||||
|
chat_id = _clean_text(runtime.magent_notify_telegram_chat_id)
|
||||||
|
if not bot_token or not chat_id:
|
||||||
|
return {"status": "skipped", "detail": "Telegram is not configured."}
|
||||||
|
url = f"https://api.telegram.org/bot{bot_token}/sendMessage"
|
||||||
|
payload = {"chat_id": chat_id, "text": f"{title}\n\n{message}", "disable_web_page_preview": True}
|
||||||
|
result = await _http_post_json(url, payload)
|
||||||
|
return {"status": "ok", "detail": f"Telegram accepted ({result['status_code']})."}
|
||||||
|
|
||||||
|
|
||||||
|
async def _send_webhook(payload: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
runtime = get_runtime_settings()
|
||||||
|
webhook = _clean_text(runtime.magent_notify_webhook_url)
|
||||||
|
if not webhook:
|
||||||
|
return {"status": "skipped", "detail": "Generic webhook is not configured."}
|
||||||
|
result = await _http_post_json(webhook, payload)
|
||||||
|
return {"status": "ok", "detail": f"Webhook accepted ({result['status_code']})."}
|
||||||
|
|
||||||
|
|
||||||
|
async def _send_push(title: str, message: str, payload: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
runtime = get_runtime_settings()
|
||||||
|
provider = _clean_text(runtime.magent_notify_push_provider, "ntfy").lower()
|
||||||
|
base_url = _clean_text(runtime.magent_notify_push_base_url)
|
||||||
|
token = _clean_text(runtime.magent_notify_push_token)
|
||||||
|
topic = _clean_text(runtime.magent_notify_push_topic)
|
||||||
|
if provider == "ntfy":
|
||||||
|
if not base_url or not topic:
|
||||||
|
return {"status": "skipped", "detail": "ntfy needs base URL and topic."}
|
||||||
|
url = f"{base_url.rstrip('/')}/{quote(topic)}"
|
||||||
|
headers = {"Title": title, "Tags": "magent,portal"}
|
||||||
|
async with httpx.AsyncClient(timeout=12.0) as client:
|
||||||
|
response = await client.post(url, content=message.encode("utf-8"), headers=headers)
|
||||||
|
response.raise_for_status()
|
||||||
|
return {"status": "ok", "detail": f"ntfy accepted ({response.status_code})."}
|
||||||
|
if provider == "gotify":
|
||||||
|
if not base_url or not token:
|
||||||
|
return {"status": "skipped", "detail": "Gotify needs base URL and token."}
|
||||||
|
url = f"{base_url.rstrip('/')}/message?token={quote(token)}"
|
||||||
|
body = {"title": title, "message": message, "priority": 5, "extras": {"client::display": {"contentType": "text/plain"}}}
|
||||||
|
result = await _http_post_json(url, body)
|
||||||
|
return {"status": "ok", "detail": f"Gotify accepted ({result['status_code']})."}
|
||||||
|
if provider == "pushover":
|
||||||
|
user_key = _clean_text(runtime.magent_notify_push_user_key)
|
||||||
|
if not token or not user_key:
|
||||||
|
return {"status": "skipped", "detail": "Pushover needs token and user key."}
|
||||||
|
form = {"token": token, "user": user_key, "title": title, "message": message}
|
||||||
|
async with httpx.AsyncClient(timeout=12.0) as client:
|
||||||
|
response = await client.post("https://api.pushover.net/1/messages.json", data=form)
|
||||||
|
response.raise_for_status()
|
||||||
|
return {"status": "ok", "detail": f"Pushover accepted ({response.status_code})."}
|
||||||
|
if provider == "discord":
|
||||||
|
return await _send_discord(title, message, payload)
|
||||||
|
if provider == "telegram":
|
||||||
|
return await _send_telegram(title, message)
|
||||||
|
if provider == "webhook":
|
||||||
|
return await _send_webhook(payload)
|
||||||
|
return {"status": "skipped", "detail": f"Unsupported push provider '{provider}'."}
|
||||||
|
|
||||||
|
|
||||||
|
async def _send_email(title: str, message: str, payload: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
|
runtime = get_runtime_settings()
|
||||||
|
recipients = _split_emails(_clean_text(get_setting("portal_notification_recipients")))
|
||||||
|
fallback = _clean_text(runtime.magent_notify_email_from_address)
|
||||||
|
if fallback and fallback not in recipients:
|
||||||
|
recipients.append(fallback)
|
||||||
|
if not recipients:
|
||||||
|
return {"status": "skipped", "detail": "No portal notification recipient is configured."}
|
||||||
|
|
||||||
|
body_text = (
|
||||||
|
f"{title}\n\n"
|
||||||
|
f"{message}\n\n"
|
||||||
|
f"Kind: {_clean_text(payload.get('kind'))}\n"
|
||||||
|
f"Status: {_clean_text(payload.get('status'))}\n"
|
||||||
|
f"Priority: {_clean_text(payload.get('priority'))}\n"
|
||||||
|
f"Requested by: {_clean_text(payload.get('requested_by'))}\n"
|
||||||
|
f"Open: {_clean_text(payload.get('item_url'))}\n"
|
||||||
|
)
|
||||||
|
body_html = (
|
||||||
|
"<div style=\"font-family:Segoe UI,Arial,sans-serif; color:#132033;\">"
|
||||||
|
f"<h2 style=\"margin:0 0 12px;\">{title}</h2>"
|
||||||
|
f"<p style=\"margin:0 0 16px; line-height:1.7;\">{message}</p>"
|
||||||
|
"<table style=\"border-collapse:collapse; width:100%; margin:0 0 16px;\">"
|
||||||
|
f"<tr><td style=\"padding:6px 0; color:#6b778c;\">Kind</td><td style=\"padding:6px 0; font-weight:700;\">{_clean_text(payload.get('kind'))}</td></tr>"
|
||||||
|
f"<tr><td style=\"padding:6px 0; color:#6b778c;\">Status</td><td style=\"padding:6px 0; font-weight:700;\">{_clean_text(payload.get('status'))}</td></tr>"
|
||||||
|
f"<tr><td style=\"padding:6px 0; color:#6b778c;\">Priority</td><td style=\"padding:6px 0; font-weight:700;\">{_clean_text(payload.get('priority'))}</td></tr>"
|
||||||
|
f"<tr><td style=\"padding:6px 0; color:#6b778c;\">Requested by</td><td style=\"padding:6px 0; font-weight:700;\">{_clean_text(payload.get('requested_by'))}</td></tr>"
|
||||||
|
"</table>"
|
||||||
|
f"<a href=\"{_clean_text(payload.get('item_url'))}\" style=\"display:inline-block; padding:10px 16px; border-radius:999px; background:#1c6bff; color:#fff; text-decoration:none; font-weight:700;\">Open portal item</a>"
|
||||||
|
"</div>"
|
||||||
|
)
|
||||||
|
deliveries: list[Dict[str, Any]] = []
|
||||||
|
for recipient in recipients:
|
||||||
|
try:
|
||||||
|
result = await send_generic_email(
|
||||||
|
recipient_email=recipient,
|
||||||
|
subject=title,
|
||||||
|
body_text=body_text,
|
||||||
|
body_html=body_html,
|
||||||
|
)
|
||||||
|
deliveries.append({"recipient": recipient, "status": "ok", **result})
|
||||||
|
except Exception as exc:
|
||||||
|
deliveries.append({"recipient": recipient, "status": "error", "detail": str(exc)})
|
||||||
|
successful = [entry for entry in deliveries if entry.get("status") == "ok"]
|
||||||
|
if successful:
|
||||||
|
return {"status": "ok", "detail": f"Email sent to {len(successful)} recipient(s).", "deliveries": deliveries}
|
||||||
|
return {"status": "error", "detail": "Email delivery failed for all recipients.", "deliveries": deliveries}
|
||||||
|
|
||||||
|
|
||||||
|
async def send_portal_notification(
|
||||||
|
*,
|
||||||
|
event_type: str,
|
||||||
|
item: Dict[str, Any],
|
||||||
|
actor_username: str,
|
||||||
|
actor_role: str,
|
||||||
|
note: Optional[str] = None,
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
runtime = get_runtime_settings()
|
||||||
|
if not runtime.magent_notify_enabled:
|
||||||
|
return {"status": "skipped", "detail": "Notifications are disabled.", "channels": {}}
|
||||||
|
|
||||||
|
item_id = int(item.get("id") or 0)
|
||||||
|
title = f"{env_settings.app_name} portal update: {item.get('title') or f'Item #{item_id}'}"
|
||||||
|
message_lines = [
|
||||||
|
f"Event: {event_type}",
|
||||||
|
f"Actor: {actor_username} ({actor_role})",
|
||||||
|
f"Item #{item_id} is now '{_clean_text(item.get('status'), 'unknown')}'.",
|
||||||
|
]
|
||||||
|
if note:
|
||||||
|
message_lines.append(f"Note: {note}")
|
||||||
|
message_lines.append(f"Open: {_portal_item_url(item_id)}")
|
||||||
|
message = "\n".join(message_lines)
|
||||||
|
payload = {
|
||||||
|
"type": "portal.notification",
|
||||||
|
"event": event_type,
|
||||||
|
"item_id": item_id,
|
||||||
|
"item_url": _portal_item_url(item_id),
|
||||||
|
"kind": _clean_text(item.get("kind")),
|
||||||
|
"status": _clean_text(item.get("status")),
|
||||||
|
"priority": _clean_text(item.get("priority")),
|
||||||
|
"requested_by": _clean_text(item.get("created_by_username")),
|
||||||
|
"actor_username": actor_username,
|
||||||
|
"actor_role": actor_role,
|
||||||
|
"note": note or "",
|
||||||
|
}
|
||||||
|
|
||||||
|
channels: Dict[str, Dict[str, Any]] = {}
|
||||||
|
if runtime.magent_notify_discord_enabled:
|
||||||
|
try:
|
||||||
|
channels["discord"] = await _send_discord(title, message, payload)
|
||||||
|
except Exception as exc:
|
||||||
|
channels["discord"] = {"status": "error", "detail": str(exc)}
|
||||||
|
if runtime.magent_notify_telegram_enabled:
|
||||||
|
try:
|
||||||
|
channels["telegram"] = await _send_telegram(title, message)
|
||||||
|
except Exception as exc:
|
||||||
|
channels["telegram"] = {"status": "error", "detail": str(exc)}
|
||||||
|
if runtime.magent_notify_webhook_enabled:
|
||||||
|
try:
|
||||||
|
channels["webhook"] = await _send_webhook(payload)
|
||||||
|
except Exception as exc:
|
||||||
|
channels["webhook"] = {"status": "error", "detail": str(exc)}
|
||||||
|
if runtime.magent_notify_push_enabled:
|
||||||
|
try:
|
||||||
|
channels["push"] = await _send_push(title, message, payload)
|
||||||
|
except Exception as exc:
|
||||||
|
channels["push"] = {"status": "error", "detail": str(exc)}
|
||||||
|
if runtime.magent_notify_email_enabled:
|
||||||
|
try:
|
||||||
|
channels["email"] = await _send_email(title, message, payload)
|
||||||
|
except Exception as exc:
|
||||||
|
channels["email"] = {"status": "error", "detail": str(exc)}
|
||||||
|
|
||||||
|
successful = [name for name, value in channels.items() if value.get("status") == "ok"]
|
||||||
|
failed = [name for name, value in channels.items() if value.get("status") == "error"]
|
||||||
|
skipped = [name for name, value in channels.items() if value.get("status") == "skipped"]
|
||||||
|
logger.info(
|
||||||
|
"portal notification event=%s item_id=%s successful=%s failed=%s skipped=%s",
|
||||||
|
event_type,
|
||||||
|
item_id,
|
||||||
|
successful,
|
||||||
|
failed,
|
||||||
|
skipped,
|
||||||
|
)
|
||||||
|
overall = "ok" if successful and not failed else "error" if failed and not successful else "partial"
|
||||||
|
if not channels:
|
||||||
|
overall = "skipped"
|
||||||
|
return {"status": overall, "channels": channels}
|
||||||
@@ -6558,3 +6558,222 @@ textarea {
|
|||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
839
frontend/app/portal/page.tsx
Normal file
839
frontend/app/portal/page.tsx
Normal file
@@ -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<string, number>
|
||||||
|
by_status?: Record<string, number>
|
||||||
|
}
|
||||||
|
my_items?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
type UserProfile = {
|
||||||
|
username: string
|
||||||
|
role: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const KIND_OPTIONS = [
|
||||||
|
{ value: 'request', label: 'Request' },
|
||||||
|
{ value: 'issue', label: 'Issue' },
|
||||||
|
{ value: 'feature', label: 'Feature' },
|
||||||
|
] as const
|
||||||
|
|
||||||
|
const STATUS_OPTIONS = [
|
||||||
|
{ value: 'new', label: 'New' },
|
||||||
|
{ value: 'triaging', label: 'Triaging' },
|
||||||
|
{ value: 'planned', label: 'Planned' },
|
||||||
|
{ value: 'in_progress', label: 'In progress' },
|
||||||
|
{ value: 'blocked', label: 'Blocked' },
|
||||||
|
{ value: 'done', label: 'Done' },
|
||||||
|
{ value: '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<UserProfile | null>(null)
|
||||||
|
const [overview, setOverview] = useState<PortalOverview | null>(null)
|
||||||
|
const [items, setItems] = useState<PortalItem[]>([])
|
||||||
|
const [selectedItemId, setSelectedItemId] = useState<number | null>(null)
|
||||||
|
const [selectedItem, setSelectedItem] = useState<PortalItem | null>(null)
|
||||||
|
const [comments, setComments] = useState<PortalComment[]>([])
|
||||||
|
const [loadingItems, setLoadingItems] = useState(true)
|
||||||
|
const [loadingItem, setLoadingItem] = useState(false)
|
||||||
|
const [creating, setCreating] = useState(false)
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
const [commenting, setCommenting] = useState(false)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [status, setStatus] = useState<string | null>(null)
|
||||||
|
const [totalItems, setTotalItems] = useState(0)
|
||||||
|
const [hasMore, setHasMore] = useState(false)
|
||||||
|
|
||||||
|
const [filterKind, setFilterKind] = useState('')
|
||||||
|
const [filterStatus, setFilterStatus] = useState('')
|
||||||
|
const [filterMine, setFilterMine] = useState(false)
|
||||||
|
const [filterSearch, setFilterSearch] = useState('')
|
||||||
|
|
||||||
|
const [createKind, setCreateKind] = useState<'request' | 'issue' | 'feature'>('request')
|
||||||
|
const [createTitle, setCreateTitle] = useState('')
|
||||||
|
const [createDescription, setCreateDescription] = useState('')
|
||||||
|
const [createMediaType, setCreateMediaType] = useState('')
|
||||||
|
const [createYear, setCreateYear] = useState('')
|
||||||
|
const [createExternalRef, setCreateExternalRef] = useState('')
|
||||||
|
const [createPriority, setCreatePriority] = useState<'low' | 'normal' | 'high' | 'urgent'>('normal')
|
||||||
|
|
||||||
|
const [editTitle, setEditTitle] = useState('')
|
||||||
|
const [editDescription, setEditDescription] = useState('')
|
||||||
|
const [editMediaType, setEditMediaType] = useState('')
|
||||||
|
const [editYear, setEditYear] = useState('')
|
||||||
|
const [editExternalRef, setEditExternalRef] = useState('')
|
||||||
|
const [editStatus, setEditStatus] = useState('new')
|
||||||
|
const [editPriority, setEditPriority] = useState('normal')
|
||||||
|
const [editAssignee, setEditAssignee] = useState('')
|
||||||
|
|
||||||
|
const [commentText, setCommentText] = useState('')
|
||||||
|
const [commentInternal, setCommentInternal] = useState(false)
|
||||||
|
const [preselectedItemId, setPreselectedItemId] = useState<number | null>(null)
|
||||||
|
|
||||||
|
const isAdmin = me?.role === 'admin'
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (typeof window === 'undefined') return
|
||||||
|
const raw = new URLSearchParams(window.location.search).get('item')
|
||||||
|
if (!raw) {
|
||||||
|
setPreselectedItemId(null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const parsed = Number.parseInt(raw, 10)
|
||||||
|
setPreselectedItemId(Number.isNaN(parsed) || parsed <= 0 ? null : parsed)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const loadMe = async () => {
|
||||||
|
const baseUrl = getApiBase()
|
||||||
|
const response = await authFetch(`${baseUrl}/auth/me`)
|
||||||
|
if (!response.ok) {
|
||||||
|
if (response.status === 401) {
|
||||||
|
clearToken()
|
||||||
|
router.push('/login')
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
throw new Error(`Failed to load session (${response.status})`)
|
||||||
|
}
|
||||||
|
const data = await response.json()
|
||||||
|
const profile: UserProfile = {
|
||||||
|
username: data?.username ?? 'unknown',
|
||||||
|
role: data?.role ?? 'user',
|
||||||
|
}
|
||||||
|
setMe(profile)
|
||||||
|
return profile
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadOverview = async () => {
|
||||||
|
try {
|
||||||
|
const baseUrl = getApiBase()
|
||||||
|
const response = await authFetch(`${baseUrl}/portal/overview`)
|
||||||
|
if (!response.ok) {
|
||||||
|
if (response.status === 401) {
|
||||||
|
clearToken()
|
||||||
|
router.push('/login')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
throw new Error(`Failed to load portal overview (${response.status})`)
|
||||||
|
}
|
||||||
|
const data = await response.json()
|
||||||
|
setOverview(data)
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadItem = async (itemId: number) => {
|
||||||
|
setLoadingItem(true)
|
||||||
|
try {
|
||||||
|
const baseUrl = getApiBase()
|
||||||
|
const response = await authFetch(`${baseUrl}/portal/items/${itemId}`)
|
||||||
|
if (!response.ok) {
|
||||||
|
if (response.status === 401) {
|
||||||
|
clearToken()
|
||||||
|
router.push('/login')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (response.status === 404) {
|
||||||
|
setSelectedItem(null)
|
||||||
|
setComments([])
|
||||||
|
return
|
||||||
|
}
|
||||||
|
throw new Error(`Failed to load portal item (${response.status})`)
|
||||||
|
}
|
||||||
|
const data = await response.json()
|
||||||
|
const item = (data?.item ?? null) as PortalItem | null
|
||||||
|
setSelectedItem(item)
|
||||||
|
setComments(Array.isArray(data?.comments) ? data.comments : [])
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err)
|
||||||
|
setError('Could not load portal item details.')
|
||||||
|
} finally {
|
||||||
|
setLoadingItem(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadItems = async (options?: { preferItemId?: number | null }) => {
|
||||||
|
setLoadingItems(true)
|
||||||
|
try {
|
||||||
|
const baseUrl = getApiBase()
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
limit: '60',
|
||||||
|
offset: '0',
|
||||||
|
})
|
||||||
|
if (filterKind) params.set('kind', filterKind)
|
||||||
|
if (filterStatus) params.set('status', filterStatus)
|
||||||
|
if (filterMine) params.set('mine', '1')
|
||||||
|
const trimmedSearch = filterSearch.trim()
|
||||||
|
if (trimmedSearch) params.set('search', trimmedSearch)
|
||||||
|
|
||||||
|
const response = await authFetch(`${baseUrl}/portal/items?${params.toString()}`)
|
||||||
|
if (!response.ok) {
|
||||||
|
if (response.status === 401) {
|
||||||
|
clearToken()
|
||||||
|
router.push('/login')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
throw new Error(`Failed to load portal items (${response.status})`)
|
||||||
|
}
|
||||||
|
const data = await response.json()
|
||||||
|
const loadedItems = Array.isArray(data?.items) ? (data.items as PortalItem[]) : []
|
||||||
|
setItems(loadedItems)
|
||||||
|
setTotalItems(Number(data?.total ?? loadedItems.length ?? 0))
|
||||||
|
setHasMore(Boolean(data?.has_more))
|
||||||
|
|
||||||
|
const preferred = options?.preferItemId ?? selectedItemId ?? preselectedItemId
|
||||||
|
if (preferred && loadedItems.some((item) => item.id === preferred)) {
|
||||||
|
setSelectedItemId(preferred)
|
||||||
|
} else if (loadedItems.length > 0) {
|
||||||
|
setSelectedItemId(loadedItems[0].id)
|
||||||
|
} else {
|
||||||
|
setSelectedItemId(null)
|
||||||
|
setSelectedItem(null)
|
||||||
|
setComments([])
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err)
|
||||||
|
setError('Could not load portal items.')
|
||||||
|
} finally {
|
||||||
|
setLoadingItems(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!getToken()) {
|
||||||
|
router.push('/login')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const bootstrap = async () => {
|
||||||
|
try {
|
||||||
|
setError(null)
|
||||||
|
await loadMe()
|
||||||
|
await Promise.all([loadOverview(), loadItems({ preferItemId: preselectedItemId })])
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err)
|
||||||
|
setError('Could not load request portal.')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
void bootstrap()
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [router])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!getToken()) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
void loadItems({ preferItemId: preselectedItemId })
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [filterKind, filterStatus, filterMine, filterSearch])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (selectedItemId == null) return
|
||||||
|
void loadItem(selectedItemId)
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [selectedItemId])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!selectedItem) return
|
||||||
|
setEditTitle(selectedItem.title ?? '')
|
||||||
|
setEditDescription(selectedItem.description ?? '')
|
||||||
|
setEditMediaType(selectedItem.media_type ?? '')
|
||||||
|
setEditYear(selectedItem.year == null ? '' : String(selectedItem.year))
|
||||||
|
setEditExternalRef(selectedItem.external_ref ?? '')
|
||||||
|
setEditStatus(selectedItem.status ?? 'new')
|
||||||
|
setEditPriority(selectedItem.priority ?? 'normal')
|
||||||
|
setEditAssignee(selectedItem.assignee_username ?? '')
|
||||||
|
}, [selectedItem])
|
||||||
|
|
||||||
|
const createItem = async (event: React.FormEvent) => {
|
||||||
|
event.preventDefault()
|
||||||
|
setCreating(true)
|
||||||
|
setError(null)
|
||||||
|
setStatus(null)
|
||||||
|
try {
|
||||||
|
const payload: Record<string, any> = {
|
||||||
|
kind: createKind,
|
||||||
|
title: createTitle,
|
||||||
|
description: createDescription,
|
||||||
|
media_type: createMediaType || null,
|
||||||
|
year: createYear.trim() ? toPositiveInt(createYear) : null,
|
||||||
|
external_ref: createExternalRef || null,
|
||||||
|
priority: createPriority,
|
||||||
|
}
|
||||||
|
const baseUrl = getApiBase()
|
||||||
|
const response = await authFetch(`${baseUrl}/portal/items`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
})
|
||||||
|
if (!response.ok) {
|
||||||
|
if (response.status === 401) {
|
||||||
|
clearToken()
|
||||||
|
router.push('/login')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const text = await response.text()
|
||||||
|
throw new Error(text || 'Could not create portal item.')
|
||||||
|
}
|
||||||
|
const data = await response.json()
|
||||||
|
const item = data?.item as PortalItem | undefined
|
||||||
|
setStatus('Portal item created.')
|
||||||
|
setCreateTitle('')
|
||||||
|
setCreateDescription('')
|
||||||
|
setCreateMediaType('')
|
||||||
|
setCreateYear('')
|
||||||
|
setCreateExternalRef('')
|
||||||
|
setCreatePriority('normal')
|
||||||
|
await Promise.all([
|
||||||
|
loadItems({ preferItemId: item?.id ?? null }),
|
||||||
|
loadOverview(),
|
||||||
|
])
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err)
|
||||||
|
setError(err instanceof Error ? err.message : 'Could not create portal item.')
|
||||||
|
} finally {
|
||||||
|
setCreating(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const saveItem = async (event: React.FormEvent) => {
|
||||||
|
event.preventDefault()
|
||||||
|
if (!selectedItem) return
|
||||||
|
setSaving(true)
|
||||||
|
setError(null)
|
||||||
|
setStatus(null)
|
||||||
|
try {
|
||||||
|
const payload: Record<string, any> = {
|
||||||
|
title: editTitle,
|
||||||
|
description: editDescription,
|
||||||
|
media_type: editMediaType || null,
|
||||||
|
year: editYear.trim() ? toPositiveInt(editYear) : null,
|
||||||
|
external_ref: editExternalRef || null,
|
||||||
|
}
|
||||||
|
if (selectedItem.permissions?.can_moderate) {
|
||||||
|
payload.status = editStatus
|
||||||
|
payload.priority = editPriority
|
||||||
|
payload.assignee_username = editAssignee || null
|
||||||
|
}
|
||||||
|
const baseUrl = getApiBase()
|
||||||
|
const response = await authFetch(`${baseUrl}/portal/items/${selectedItem.id}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
})
|
||||||
|
if (!response.ok) {
|
||||||
|
if (response.status === 401) {
|
||||||
|
clearToken()
|
||||||
|
router.push('/login')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const text = await response.text()
|
||||||
|
throw new Error(text || 'Could not update portal item.')
|
||||||
|
}
|
||||||
|
const data = await response.json()
|
||||||
|
setSelectedItem((data?.item ?? null) as PortalItem | null)
|
||||||
|
setComments(Array.isArray(data?.comments) ? data.comments : [])
|
||||||
|
setStatus('Portal item updated.')
|
||||||
|
await Promise.all([
|
||||||
|
loadItems({ preferItemId: selectedItem.id }),
|
||||||
|
loadOverview(),
|
||||||
|
])
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err)
|
||||||
|
setError(err instanceof Error ? err.message : 'Could not update portal item.')
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const postComment = async (event: React.FormEvent) => {
|
||||||
|
event.preventDefault()
|
||||||
|
if (!selectedItem) return
|
||||||
|
if (!commentText.trim()) {
|
||||||
|
setError('Comment message is required.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setCommenting(true)
|
||||||
|
setError(null)
|
||||||
|
setStatus(null)
|
||||||
|
try {
|
||||||
|
const baseUrl = getApiBase()
|
||||||
|
const response = await authFetch(`${baseUrl}/portal/items/${selectedItem.id}/comments`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
message: commentText,
|
||||||
|
is_internal: commentInternal,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
if (!response.ok) {
|
||||||
|
if (response.status === 401) {
|
||||||
|
clearToken()
|
||||||
|
router.push('/login')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const text = await response.text()
|
||||||
|
throw new Error(text || 'Could not add comment.')
|
||||||
|
}
|
||||||
|
setCommentText('')
|
||||||
|
setCommentInternal(false)
|
||||||
|
setStatus('Comment added.')
|
||||||
|
await Promise.all([
|
||||||
|
loadItem(selectedItem.id),
|
||||||
|
loadItems({ preferItemId: selectedItem.id }),
|
||||||
|
loadOverview(),
|
||||||
|
])
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err)
|
||||||
|
setError(err instanceof Error ? err.message : 'Could not add comment.')
|
||||||
|
} finally {
|
||||||
|
setCommenting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loadingItems && !items.length) {
|
||||||
|
return <main className="card">Loading request portal...</main>
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="card portal-page">
|
||||||
|
<div className="user-directory-panel-header">
|
||||||
|
<div>
|
||||||
|
<h1>Request portal</h1>
|
||||||
|
<p className="lede">
|
||||||
|
Raise requests, issues, and feature ideas. Track progress and keep discussion in one place.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <div className="error-banner">{error}</div>}
|
||||||
|
{status && <div className="status-banner">{status}</div>}
|
||||||
|
|
||||||
|
<section className="portal-overview-grid">
|
||||||
|
<div className="portal-overview-card">
|
||||||
|
<span>Total items</span>
|
||||||
|
<strong>{Number(overview?.overview?.total_items ?? totalItems ?? 0)}</strong>
|
||||||
|
</div>
|
||||||
|
<div className="portal-overview-card">
|
||||||
|
<span>Total comments</span>
|
||||||
|
<strong>{Number(overview?.overview?.total_comments ?? 0)}</strong>
|
||||||
|
</div>
|
||||||
|
<div className="portal-overview-card">
|
||||||
|
<span>My items</span>
|
||||||
|
<strong>{Number(overview?.my_items ?? 0)}</strong>
|
||||||
|
</div>
|
||||||
|
<div className="portal-overview-card">
|
||||||
|
<span>Visible</span>
|
||||||
|
<strong>{items.length}</strong>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="admin-panel portal-create-panel">
|
||||||
|
<h2>Create item</h2>
|
||||||
|
<p className="lede">
|
||||||
|
Use <strong>Request</strong> for new content, <strong>Issue</strong> for broken behavior, and <strong>Feature</strong> for improvements.
|
||||||
|
</p>
|
||||||
|
<form onSubmit={createItem} className="admin-form compact-form portal-form-grid">
|
||||||
|
<label>
|
||||||
|
<span>Type</span>
|
||||||
|
<select
|
||||||
|
value={createKind}
|
||||||
|
onChange={(event) =>
|
||||||
|
setCreateKind(event.target.value as 'request' | 'issue' | 'feature')
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{KIND_OPTIONS.map((option) => (
|
||||||
|
<option key={option.value} value={option.value}>
|
||||||
|
{option.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>Priority</span>
|
||||||
|
<select
|
||||||
|
value={createPriority}
|
||||||
|
onChange={(event) =>
|
||||||
|
setCreatePriority(event.target.value as 'low' | 'normal' | 'high' | 'urgent')
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{PRIORITY_OPTIONS.map((option) => (
|
||||||
|
<option key={option.value} value={option.value}>
|
||||||
|
{option.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label className="portal-field-span-2">
|
||||||
|
<span>Title</span>
|
||||||
|
<input
|
||||||
|
required
|
||||||
|
value={createTitle}
|
||||||
|
onChange={(event) => setCreateTitle(event.target.value)}
|
||||||
|
placeholder="Short summary of the request or issue"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="portal-field-span-2">
|
||||||
|
<span>Description</span>
|
||||||
|
<textarea
|
||||||
|
required
|
||||||
|
rows={4}
|
||||||
|
value={createDescription}
|
||||||
|
onChange={(event) => setCreateDescription(event.target.value)}
|
||||||
|
placeholder="Add details, expected behavior, and any context."
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>Media type</span>
|
||||||
|
<select value={createMediaType} onChange={(event) => setCreateMediaType(event.target.value)}>
|
||||||
|
{MEDIA_TYPE_OPTIONS.map((option) => (
|
||||||
|
<option key={option.value || 'none'} value={option.value}>
|
||||||
|
{option.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>Year</span>
|
||||||
|
<input
|
||||||
|
value={createYear}
|
||||||
|
onChange={(event) => setCreateYear(event.target.value)}
|
||||||
|
inputMode="numeric"
|
||||||
|
placeholder="Optional"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="portal-field-span-2">
|
||||||
|
<span>External reference</span>
|
||||||
|
<input
|
||||||
|
value={createExternalRef}
|
||||||
|
onChange={(event) => setCreateExternalRef(event.target.value)}
|
||||||
|
placeholder="Optional: URL, ticket number, or request id"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<div className="admin-inline-actions portal-field-span-2">
|
||||||
|
<button type="submit" disabled={creating}>
|
||||||
|
{creating ? 'Creating…' : 'Create portal item'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="portal-toolbar">
|
||||||
|
<label>
|
||||||
|
<span>Type</span>
|
||||||
|
<select value={filterKind} onChange={(event) => setFilterKind(event.target.value)}>
|
||||||
|
<option value="">All</option>
|
||||||
|
{KIND_OPTIONS.map((option) => (
|
||||||
|
<option key={option.value} value={option.value}>
|
||||||
|
{option.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>Status</span>
|
||||||
|
<select value={filterStatus} onChange={(event) => setFilterStatus(event.target.value)}>
|
||||||
|
<option value="">All</option>
|
||||||
|
{STATUS_OPTIONS.map((option) => (
|
||||||
|
<option key={option.value} value={option.value}>
|
||||||
|
{option.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label className="portal-search-filter">
|
||||||
|
<span>Search</span>
|
||||||
|
<input
|
||||||
|
value={filterSearch}
|
||||||
|
onChange={(event) => setFilterSearch(event.target.value)}
|
||||||
|
placeholder="Title, description, or item id"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="inline-checkbox portal-mine-toggle">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={filterMine}
|
||||||
|
onChange={(event) => setFilterMine(event.target.checked)}
|
||||||
|
/>
|
||||||
|
My items only
|
||||||
|
</label>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div className="portal-workspace">
|
||||||
|
<section className="admin-panel portal-list-panel">
|
||||||
|
<div className="user-directory-panel-header">
|
||||||
|
<div>
|
||||||
|
<h2>Items</h2>
|
||||||
|
<p className="lede">
|
||||||
|
{totalItems} total
|
||||||
|
{hasMore ? ' (showing first 60)' : ''}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{items.length === 0 ? (
|
||||||
|
<div className="status-banner">No portal items match this filter.</div>
|
||||||
|
) : (
|
||||||
|
<div className="portal-item-list">
|
||||||
|
{items.map((item) => (
|
||||||
|
<button
|
||||||
|
key={item.id}
|
||||||
|
type="button"
|
||||||
|
className={`portal-item-row ${selectedItemId === item.id ? 'is-active' : ''}`}
|
||||||
|
onClick={() => setSelectedItemId(item.id)}
|
||||||
|
>
|
||||||
|
<div className="portal-item-row-main">
|
||||||
|
<div className="portal-item-row-title">
|
||||||
|
<strong>{item.title}</strong>
|
||||||
|
<span className="small-pill">{item.kind}</span>
|
||||||
|
<span className="small-pill is-muted">{item.priority}</span>
|
||||||
|
</div>
|
||||||
|
<p>{item.description}</p>
|
||||||
|
<div className="portal-item-row-meta">
|
||||||
|
<span>#{item.id}</span>
|
||||||
|
<span>Status: {item.status}</span>
|
||||||
|
<span>By: {item.created_by_username}</span>
|
||||||
|
<span>Updated: {formatDate(item.last_activity_at)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="admin-panel portal-detail-panel">
|
||||||
|
{!selectedItemId ? (
|
||||||
|
<div className="status-banner">Select an item to view details.</div>
|
||||||
|
) : loadingItem ? (
|
||||||
|
<div className="status-banner">Loading details…</div>
|
||||||
|
) : !selectedItem ? (
|
||||||
|
<div className="status-banner">Item not found.</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="user-directory-panel-header">
|
||||||
|
<div>
|
||||||
|
<h2>Item #{selectedItem.id}</h2>
|
||||||
|
<p className="lede">
|
||||||
|
Created by {selectedItem.created_by_username} on {formatDate(selectedItem.created_at)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form className="admin-form compact-form portal-form-grid" onSubmit={saveItem}>
|
||||||
|
<label className="portal-field-span-2">
|
||||||
|
<span>Title</span>
|
||||||
|
<input
|
||||||
|
value={editTitle}
|
||||||
|
onChange={(event) => setEditTitle(event.target.value)}
|
||||||
|
disabled={!selectedItem.permissions?.can_edit}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="portal-field-span-2">
|
||||||
|
<span>Description</span>
|
||||||
|
<textarea
|
||||||
|
rows={4}
|
||||||
|
value={editDescription}
|
||||||
|
onChange={(event) => setEditDescription(event.target.value)}
|
||||||
|
disabled={!selectedItem.permissions?.can_edit}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>Media type</span>
|
||||||
|
<select
|
||||||
|
value={editMediaType}
|
||||||
|
onChange={(event) => setEditMediaType(event.target.value)}
|
||||||
|
disabled={!selectedItem.permissions?.can_edit}
|
||||||
|
>
|
||||||
|
{MEDIA_TYPE_OPTIONS.map((option) => (
|
||||||
|
<option key={option.value || 'none'} value={option.value}>
|
||||||
|
{option.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>Year</span>
|
||||||
|
<input
|
||||||
|
value={editYear}
|
||||||
|
onChange={(event) => setEditYear(event.target.value)}
|
||||||
|
inputMode="numeric"
|
||||||
|
disabled={!selectedItem.permissions?.can_edit}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="portal-field-span-2">
|
||||||
|
<span>External reference</span>
|
||||||
|
<input
|
||||||
|
value={editExternalRef}
|
||||||
|
onChange={(event) => setEditExternalRef(event.target.value)}
|
||||||
|
disabled={!selectedItem.permissions?.can_edit}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
{selectedItem.permissions?.can_moderate && (
|
||||||
|
<>
|
||||||
|
<label>
|
||||||
|
<span>Status</span>
|
||||||
|
<select value={editStatus} onChange={(event) => setEditStatus(event.target.value)}>
|
||||||
|
{STATUS_OPTIONS.map((option) => (
|
||||||
|
<option key={option.value} value={option.value}>
|
||||||
|
{option.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
<span>Priority</span>
|
||||||
|
<select
|
||||||
|
value={editPriority}
|
||||||
|
onChange={(event) => setEditPriority(event.target.value)}
|
||||||
|
>
|
||||||
|
{PRIORITY_OPTIONS.map((option) => (
|
||||||
|
<option key={option.value} value={option.value}>
|
||||||
|
{option.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label className="portal-field-span-2">
|
||||||
|
<span>Assignee username</span>
|
||||||
|
<input
|
||||||
|
value={editAssignee}
|
||||||
|
onChange={(event) => setEditAssignee(event.target.value)}
|
||||||
|
placeholder="Optional assignee"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<div className="admin-inline-actions portal-field-span-2">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={saving || !selectedItem.permissions?.can_edit}
|
||||||
|
>
|
||||||
|
{saving ? 'Saving…' : 'Save changes'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div className="portal-comments-block">
|
||||||
|
<h3>Comments</h3>
|
||||||
|
{comments.length === 0 ? (
|
||||||
|
<div className="status-banner">No comments yet.</div>
|
||||||
|
) : (
|
||||||
|
<div className="portal-comment-list">
|
||||||
|
{comments.map((comment) => (
|
||||||
|
<article key={comment.id} className="portal-comment-card">
|
||||||
|
<header>
|
||||||
|
<strong>{comment.author_username}</strong>
|
||||||
|
<span className="small-pill">{comment.author_role}</span>
|
||||||
|
{comment.is_internal && <span className="small-pill is-muted">internal</span>}
|
||||||
|
<span>{formatDate(comment.created_at)}</span>
|
||||||
|
</header>
|
||||||
|
<p>{comment.message}</p>
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<form onSubmit={postComment} className="admin-form compact-form portal-comment-form">
|
||||||
|
<label>
|
||||||
|
<span>Add comment</span>
|
||||||
|
<textarea
|
||||||
|
rows={3}
|
||||||
|
value={commentText}
|
||||||
|
onChange={(event) => setCommentText(event.target.value)}
|
||||||
|
placeholder="Add an update, troubleshooting note, or next step."
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
{isAdmin && (
|
||||||
|
<label className="inline-checkbox">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={commentInternal}
|
||||||
|
onChange={(event) => setCommentInternal(event.target.checked)}
|
||||||
|
/>
|
||||||
|
Internal comment (admin only)
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
<div className="admin-inline-actions">
|
||||||
|
<button type="submit" disabled={commenting}>
|
||||||
|
{commenting ? 'Posting…' : 'Post comment'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -42,6 +42,7 @@ export default function HeaderActions() {
|
|||||||
<div className="header-actions-right">
|
<div className="header-actions-right">
|
||||||
<a href="/">Requests</a>
|
<a href="/">Requests</a>
|
||||||
<a href="/profile/invites">Invites</a>
|
<a href="/profile/invites">Invites</a>
|
||||||
|
<a href="/portal">Portal</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
4
frontend/next-env.d.ts
vendored
4
frontend/next-env.d.ts
vendored
@@ -1,2 +1,6 @@
|
|||||||
/// <reference types="next" />
|
/// <reference types="next" />
|
||||||
/// <reference types="next/image-types/global" />
|
/// <reference types="next/image-types/global" />
|
||||||
|
import "./.next/types/routes.d.ts";
|
||||||
|
|
||||||
|
// NOTE: This file should not be edited
|
||||||
|
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||||
|
|||||||
4
frontend/package-lock.json
generated
4
frontend/package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "magent-frontend",
|
"name": "magent-frontend",
|
||||||
"version": "0403261902",
|
"version": "0703261729",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "magent-frontend",
|
"name": "magent-frontend",
|
||||||
"version": "0403261902",
|
"version": "0703261729",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"next": "16.1.6",
|
"next": "16.1.6",
|
||||||
"react": "19.2.4",
|
"react": "19.2.4",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "magent-frontend",
|
"name": "magent-frontend",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0403261902",
|
"version": "0703261729",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev",
|
"dev": "next dev",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
|
|||||||
Reference in New Issue
Block a user