T01: core infrastructure + auth + multi-tenant + RLS
- 10 models: tenants, users, user_tenants, roles, sessions, audit_log, deletion_log, notifications, password_reset_tokens, api_tokens - Session-based auth (Redis + PostgreSQL audit trail) - Multi-tenant with ORM-level filtering + PostgreSQL RLS (set_config) - RBAC with roles/permissions + field-level permissions - CSRF protection via Origin header validation - Auth rate limiting (Redis counters with TTL) - CORS with explicit origins (no wildcard) - Health endpoint (no auth required) - Notification service + audit log middleware - 29 tests, 26 ACs, all passing - Coverage: 62% (infrastructure modules pending coverage in later tasks)
This commit is contained in:
@@ -0,0 +1,123 @@
|
||||
"""Notification service — create and manage user notifications."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import select, func, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.notification import Notification
|
||||
|
||||
|
||||
async def create_notification(
|
||||
db: AsyncSession,
|
||||
tenant_id: uuid.UUID,
|
||||
user_id: uuid.UUID,
|
||||
type: str,
|
||||
title: str,
|
||||
body: str | None = None,
|
||||
) -> Notification:
|
||||
"""Create a new notification for a user."""
|
||||
notif = Notification(
|
||||
tenant_id=tenant_id,
|
||||
user_id=user_id,
|
||||
type=type,
|
||||
title=title,
|
||||
body=body,
|
||||
)
|
||||
db.add(notif)
|
||||
await db.flush()
|
||||
return notif
|
||||
|
||||
|
||||
async def list_notifications(
|
||||
db: AsyncSession,
|
||||
tenant_id: uuid.UUID,
|
||||
user_id: uuid.UUID,
|
||||
page: int = 1,
|
||||
page_size: int = 25,
|
||||
) -> dict[str, Any]:
|
||||
"""List notifications for a user, unread first, then by created_at desc."""
|
||||
offset = (page - 1) * page_size
|
||||
# Count total
|
||||
count_q = select(func.count()).select_from(Notification).where(
|
||||
Notification.tenant_id == tenant_id,
|
||||
Notification.user_id == user_id,
|
||||
)
|
||||
total = (await db.execute(count_q)).scalar() or 0
|
||||
|
||||
# Query — unread first (read_at IS NULL), then newest
|
||||
q = (
|
||||
select(Notification)
|
||||
.where(
|
||||
Notification.tenant_id == tenant_id,
|
||||
Notification.user_id == user_id,
|
||||
)
|
||||
.order_by(
|
||||
Notification.read_at.isnot(None), # False (unread) sorts first
|
||||
Notification.created_at.desc(),
|
||||
)
|
||||
.offset(offset)
|
||||
.limit(page_size)
|
||||
)
|
||||
result = await db.execute(q)
|
||||
items = result.scalars().all()
|
||||
|
||||
return {
|
||||
"items": [_notification_to_dict(n) for n in items],
|
||||
"total": total,
|
||||
"page": page,
|
||||
"page_size": page_size,
|
||||
}
|
||||
|
||||
|
||||
async def mark_notification_read(
|
||||
db: AsyncSession,
|
||||
tenant_id: uuid.UUID,
|
||||
user_id: uuid.UUID,
|
||||
notification_id: uuid.UUID,
|
||||
) -> Notification | None:
|
||||
"""Mark a notification as read."""
|
||||
from datetime import datetime, timezone
|
||||
q = (
|
||||
update(Notification)
|
||||
.where(
|
||||
Notification.id == notification_id,
|
||||
Notification.tenant_id == tenant_id,
|
||||
Notification.user_id == user_id,
|
||||
)
|
||||
.values(read_at=datetime.now(timezone.utc))
|
||||
.returning(Notification)
|
||||
)
|
||||
result = await db.execute(q)
|
||||
row = result.scalar_one_or_none()
|
||||
return row
|
||||
|
||||
|
||||
async def get_unread_count(
|
||||
db: AsyncSession,
|
||||
tenant_id: uuid.UUID,
|
||||
user_id: uuid.UUID,
|
||||
) -> int:
|
||||
"""Get unread notification count for a user."""
|
||||
q = select(func.count()).select_from(Notification).where(
|
||||
Notification.tenant_id == tenant_id,
|
||||
Notification.user_id == user_id,
|
||||
Notification.read_at.is_(None),
|
||||
)
|
||||
result = await db.execute(q)
|
||||
return result.scalar() or 0
|
||||
|
||||
|
||||
def _notification_to_dict(n: Notification) -> dict[str, Any]:
|
||||
"""Convert a notification to a dict."""
|
||||
return {
|
||||
"id": str(n.id),
|
||||
"type": n.type,
|
||||
"title": n.title,
|
||||
"body": n.body,
|
||||
"read_at": n.read_at.isoformat() if n.read_at else None,
|
||||
"created_at": n.created_at.isoformat() if n.created_at else None,
|
||||
}
|
||||
Reference in New Issue
Block a user