oliver-sales-ops-platform/backend/app/api/users.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

43 lines
1.3 KiB
Python

"""User directory endpoints — list users (used by approver picker)."""
from fastapi import APIRouter, Depends
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.middleware.auth import get_current_user
from app.models.user import AppUser
from app.schemas.approval import UserBrief
router = APIRouter()
@router.get("", response_model=list[UserBrief])
async def list_users(
db: AsyncSession = Depends(get_db),
current=Depends(get_current_user),
):
"""Returns all known AppUsers — the directory of potential approvers.
Users land in this table on first authenticated request."""
result = await db.execute(select(AppUser).order_by(AppUser.email))
return [
UserBrief(
id=u.id, email=u.email, name=u.name,
role=u.role.value, last_login=u.last_login,
)
for u in result.scalars().all()
]
@router.get("/me", response_model=UserBrief)
async def me(
db: AsyncSession = Depends(get_db),
current=Depends(get_current_user),
):
"""Current authenticated user's own row."""
result = await db.execute(select(AppUser).where(AppUser.id == current["user_id"]))
u = result.scalar_one()
return UserBrief(
id=u.id, email=u.email, name=u.name,
role=u.role.value, last_login=u.last_login,
)