37ccaa4fdc
Service organization (19 files moved into 6 subpackages): - services/instance/ — event_bus, health_monitor, lifecycle_hooks - services/config/ — config_profile_resolver - services/git/ — clone, git_operations, git_service - services/build/ — docker_build, manifest_compiler - services/terminal/ — terminal_manager, terminal_session - services/shared/ — correlation, file_service, notification_service, permission_fixer, readiness_probe, ssh_keys, tunnel, workspace_manager API router organization (16 files moved into 6 subpackages): - api/tool/ — tool_instances, tool_types, tool_definitions, tool_types_validation, sessions (extracted from tool_instances) - api/config/ — config_profiles, user_config - api/workspace/ — workspaces, workspace_files, workspace_git, workspace_instances - api/user/ — users, auth, ssh_keys - api/project/ — projects, git_repositories - api/system/ — health, events, notifications, dashboard, terminal, instance_proxy Updated main.py imports and all __init__.py re-exports. Sessions router extracted from tool_instances.py into api/tool/sessions.py. Quality gates: py_compile passed, ruff passed.
273 lines
8.1 KiB
Python
273 lines
8.1 KiB
Python
"""Notification persistence service."""
|
|
|
|
import uuid
|
|
from datetime import datetime, timezone
|
|
from typing import Any
|
|
|
|
from sqlalchemy import func, select, update
|
|
from sqlalchemy.engine import CursorResult
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from src.models import Notification
|
|
|
|
|
|
class NotificationService:
|
|
"""Singleton notification persistence service.
|
|
|
|
All methods filter by user_id to enforce strict ownership isolation.
|
|
"""
|
|
|
|
async def create_notification(
|
|
self,
|
|
session: AsyncSession,
|
|
user_id: uuid.UUID,
|
|
*,
|
|
category: str,
|
|
severity: str,
|
|
title: str,
|
|
message: str | None = None,
|
|
source_type: str | None = None,
|
|
source_id: uuid.UUID | None = None,
|
|
metadata: dict[str, Any] | None = None,
|
|
) -> Notification:
|
|
"""Insert a new notification row.
|
|
|
|
Args:
|
|
session: Database session.
|
|
user_id: Owner of the notification.
|
|
category: Notification category (e.g., instance, system, health).
|
|
severity: Severity level (e.g., info, warning, error, success).
|
|
title: Short notification title.
|
|
message: Optional longer message body.
|
|
source_type: Optional source entity type.
|
|
source_id: Optional source entity UUID.
|
|
metadata: Optional JSON metadata dictionary.
|
|
|
|
Returns:
|
|
The newly created Notification instance.
|
|
"""
|
|
notification = Notification(
|
|
user_id=user_id,
|
|
category=category,
|
|
severity=severity,
|
|
title=title,
|
|
message=message,
|
|
source_type=source_type,
|
|
source_id=source_id,
|
|
notification_metadata=metadata or {},
|
|
)
|
|
session.add(notification)
|
|
await session.commit()
|
|
await session.refresh(notification)
|
|
return notification
|
|
|
|
async def list_notifications(
|
|
self,
|
|
session: AsyncSession,
|
|
user_id: uuid.UUID,
|
|
*,
|
|
limit: int = 20,
|
|
offset: int = 0,
|
|
unread_only: bool = False,
|
|
mute_categories: list[str] | None = None,
|
|
) -> tuple[list[Notification], int]:
|
|
"""Return paginated notifications for a user.
|
|
|
|
Excludes dismissed notifications and applies optional filtering.
|
|
|
|
Args:
|
|
session: Database session.
|
|
user_id: Owner of the notifications.
|
|
limit: Maximum number of items to return.
|
|
offset: Number of items to skip.
|
|
unread_only: If True, only return unread notifications.
|
|
mute_categories: Categories to exclude from results.
|
|
|
|
Returns:
|
|
A tuple of (items, total_count).
|
|
"""
|
|
where_clauses = [
|
|
Notification.user_id == user_id,
|
|
Notification.dismissed_at.is_(None),
|
|
]
|
|
|
|
if unread_only:
|
|
where_clauses.append(Notification.read_at.is_(None))
|
|
|
|
if mute_categories:
|
|
where_clauses.append(Notification.category.not_in(mute_categories))
|
|
|
|
total_stmt = (
|
|
select(func.count()).select_from(Notification).where(*where_clauses)
|
|
)
|
|
total_result = await session.execute(total_stmt)
|
|
total = total_result.scalar_one()
|
|
|
|
items_stmt = (
|
|
select(Notification)
|
|
.where(*where_clauses)
|
|
.order_by(Notification.created_at.desc())
|
|
.limit(limit)
|
|
.offset(offset)
|
|
)
|
|
items_result = await session.execute(items_stmt)
|
|
items = list(items_result.scalars().all())
|
|
|
|
return items, total
|
|
|
|
async def get_unread_count(
|
|
self,
|
|
session: AsyncSession,
|
|
user_id: uuid.UUID,
|
|
) -> int:
|
|
"""Count unread, non-dismissed notifications for a user.
|
|
|
|
Args:
|
|
session: Database session.
|
|
user_id: Owner of the notifications.
|
|
|
|
Returns:
|
|
Number of unread notifications.
|
|
"""
|
|
stmt = (
|
|
select(func.count())
|
|
.select_from(Notification)
|
|
.where(
|
|
Notification.user_id == user_id,
|
|
Notification.read_at.is_(None),
|
|
Notification.dismissed_at.is_(None),
|
|
)
|
|
)
|
|
result = await session.execute(stmt)
|
|
return result.scalar_one()
|
|
|
|
async def mark_read(
|
|
self,
|
|
session: AsyncSession,
|
|
notification_id: uuid.UUID,
|
|
user_id: uuid.UUID,
|
|
) -> Notification:
|
|
"""Mark a single notification as read.
|
|
|
|
Args:
|
|
session: Database session.
|
|
notification_id: UUID of the notification to mark.
|
|
user_id: Owner of the notification.
|
|
|
|
Returns:
|
|
The updated Notification instance.
|
|
|
|
Raises:
|
|
ValueError: If the notification does not exist or is not owned by the user.
|
|
"""
|
|
notification = await self._get_owned_notification(
|
|
session, notification_id, user_id
|
|
)
|
|
notification.read_at = datetime.now(timezone.utc)
|
|
await session.commit()
|
|
await session.refresh(notification)
|
|
return notification
|
|
|
|
async def mark_all_read(
|
|
self,
|
|
session: AsyncSession,
|
|
user_id: uuid.UUID,
|
|
) -> int:
|
|
"""Mark all unread notifications as read for a user.
|
|
|
|
Args:
|
|
session: Database session.
|
|
user_id: Owner of the notifications.
|
|
|
|
Returns:
|
|
Number of rows updated.
|
|
"""
|
|
stmt = (
|
|
update(Notification)
|
|
.where(
|
|
Notification.user_id == user_id,
|
|
Notification.read_at.is_(None),
|
|
Notification.dismissed_at.is_(None),
|
|
)
|
|
.values(read_at=datetime.now(timezone.utc))
|
|
)
|
|
result: CursorResult[Any] = await session.execute(stmt) # type: ignore[assignment]
|
|
await session.commit()
|
|
return result.rowcount or 0
|
|
|
|
async def dismiss_all(
|
|
self,
|
|
session: AsyncSession,
|
|
user_id: uuid.UUID,
|
|
) -> int:
|
|
"""Soft-delete all non-dismissed notifications for a user.
|
|
|
|
Args:
|
|
session: Database session.
|
|
user_id: Owner of the notifications.
|
|
|
|
Returns:
|
|
Number of rows updated.
|
|
"""
|
|
stmt = (
|
|
update(Notification)
|
|
.where(
|
|
Notification.user_id == user_id,
|
|
Notification.dismissed_at.is_(None),
|
|
)
|
|
.values(dismissed_at=datetime.now(timezone.utc))
|
|
)
|
|
result: CursorResult[Any] = await session.execute(stmt) # type: ignore[assignment]
|
|
await session.commit()
|
|
return result.rowcount or 0
|
|
|
|
async def dismiss(
|
|
self,
|
|
session: AsyncSession,
|
|
notification_id: uuid.UUID,
|
|
user_id: uuid.UUID,
|
|
) -> None:
|
|
"""Soft-delete a notification by setting dismissed_at.
|
|
|
|
Args:
|
|
session: Database session.
|
|
notification_id: UUID of the notification to dismiss.
|
|
user_id: Owner of the notification.
|
|
|
|
Raises:
|
|
ValueError: If the notification does not exist or is not owned by the user.
|
|
"""
|
|
notification = await self._get_owned_notification(
|
|
session, notification_id, user_id
|
|
)
|
|
notification.dismissed_at = datetime.now(timezone.utc)
|
|
await session.commit()
|
|
|
|
async def _get_owned_notification(
|
|
self,
|
|
session: AsyncSession,
|
|
notification_id: uuid.UUID,
|
|
user_id: uuid.UUID,
|
|
) -> Notification:
|
|
"""Fetch a notification and verify ownership.
|
|
|
|
Args:
|
|
session: Database session.
|
|
notification_id: UUID of the notification.
|
|
user_id: Expected owner.
|
|
|
|
Returns:
|
|
The Notification instance.
|
|
|
|
Raises:
|
|
ValueError: If the notification does not exist or is not owned.
|
|
"""
|
|
notification = await session.get(Notification, notification_id)
|
|
if notification is None or notification.user_id != user_id:
|
|
raise ValueError("Notification not found")
|
|
return notification
|
|
|
|
|
|
# Module-level singleton instance
|
|
notification_service = NotificationService()
|