oliver-sales-ops-platform/backend/app/api/notifications.py
DJP 3f7531a1bf Approval workflow backend: notifications + Mailgun email
The two gated stages (3 = qualification, 14 = approval gates) now have a
real workflow. An admin / orchestrator picks approvers, the backend creates
Approval rows, fires in-app Notifications, and sends a Mailgun email with
a deeplink to the approval page. Each approval gets a unique email_token
so links are per-recipient. When the approver submits a decision the
opportunity owner is notified back.

End-to-end smoke test on the Versuni opp:
1. POST /opportunities/2/stages/3/approvals — created Approval #1,
   notification queued, Mailgun "skipped" (no API key in dev) and logged.
2. POST /opportunities/2/stages/3/complete blocked: "pending approvals
   from commercial".
3. POST /approvals/1/decision {"decision":"approve",...} — status 'approved'.
4. POST /opportunities/2/stages/3/complete — succeeds, advances to stage 4.

Schema:
- New table 'notifications' (user_id, type enum, title, body, related FKs,
  read flag, indexes on user_id+read and user_id+created_at).
- Approvals table gets email_token (unique), email_sent_at, email_to.
- Migration 0004 (filename + revision id kept short — alembic_version is
  varchar(32), names longer than that crash the migration runner).

Services:
- mailgun.send_email — gracefully no-ops when MAILGUN_API_KEY is empty,
  logging the would-be payload so dev environments work without creds.
  Selects api.eu.mailgun.net when MAILGUN_REGION=eu.
- approval_service.request_approval — creates approval + notification +
  fires email, all in one call. Email is best-effort (logs on failure
  but doesn't roll back the approval).
- approval_service.record_decision — flips status, stamps decided_at,
  notifies the opportunity owner if there is one.
- notification_service.create_notification — thin helper.

Auth middleware now upserts the AppUser on every authenticated request
(including the dev bypass), so logged-in users automatically appear in
the approver directory at /api/users.

API:
- POST /opportunities/{id}/stages/{n}/approvals (only stages 3, 14)
- GET /opportunities/{id}/stages/{n}/approvals
- GET /approvals/me — my pending approvals
- GET /approvals/{id} — context (approval + opportunity summary)
- GET /approvals/by-token/{token} — same context, opened by email link
- POST /approvals/{id}/decision — {decision: 'approve'|'reject', comment}
- GET /notifications/me, /me/unread-count, PUT {id}/read,
  POST /me/mark-all-read
- GET /users, /users/me

Config:
- New env vars: MAILGUN_API_KEY, MAILGUN_DOMAIN, MAILGUN_FROM,
  MAILGUN_REGION, APP_PUBLIC_URL, APP_PATH_PREFIX (used to build
  email links). Plumbed into docker-compose.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 13:28:22 -04:00

109 lines
3.1 KiB
Python

"""In-app notification endpoints."""
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import select, func
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.middleware.auth import get_current_user
from app.models.notification import Notification
from app.schemas.approval import NotificationOut
router = APIRouter()
def _to_out(n: Notification) -> NotificationOut:
return NotificationOut(
id=n.id,
type=n.type.value,
title=n.title,
body=n.body,
related_opportunity_id=n.related_opportunity_id,
related_approval_id=n.related_approval_id,
link_path=n.link_path,
read=n.read,
created_at=n.created_at,
read_at=n.read_at,
)
@router.get("/me", response_model=list[NotificationOut])
async def list_my_notifications(
unread_only: bool = False,
db: AsyncSession = Depends(get_db),
current=Depends(get_current_user),
):
user_id = current.get("user_id")
if user_id is None:
raise HTTPException(status_code=401, detail="No user context")
query = select(Notification).where(Notification.user_id == user_id)
if unread_only:
query = query.where(Notification.read == False)
query = query.order_by(Notification.created_at.desc()).limit(50)
result = await db.execute(query)
return [_to_out(n) for n in result.scalars().all()]
@router.get("/me/unread-count")
async def unread_count(
db: AsyncSession = Depends(get_db),
current=Depends(get_current_user),
):
user_id = current.get("user_id")
if user_id is None:
raise HTTPException(status_code=401, detail="No user context")
result = await db.execute(
select(func.count(Notification.id)).where(
Notification.user_id == user_id,
Notification.read == False,
)
)
return {"count": int(result.scalar() or 0)}
@router.put("/{notification_id}/read", response_model=NotificationOut)
async def mark_read(
notification_id: int,
db: AsyncSession = Depends(get_db),
current=Depends(get_current_user),
):
user_id = current.get("user_id")
result = await db.execute(
select(Notification).where(
Notification.id == notification_id,
Notification.user_id == user_id,
)
)
n = result.scalar_one_or_none()
if n is None:
raise HTTPException(status_code=404, detail="Notification not found")
if not n.read:
n.read = True
n.read_at = datetime.utcnow()
await db.commit()
await db.refresh(n)
return _to_out(n)
@router.post("/me/mark-all-read")
async def mark_all_read(
db: AsyncSession = Depends(get_db),
current=Depends(get_current_user),
):
user_id = current.get("user_id")
result = await db.execute(
select(Notification).where(
Notification.user_id == user_id,
Notification.read == False,
)
)
now = datetime.utcnow()
count = 0
for n in result.scalars().all():
n.read = True
n.read_at = now
count += 1
await db.commit()
return {"marked_read": count}