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:
@@ -1 +1 @@
|
||||
"""API v1 routers."""
|
||||
"""API v1 package."""
|
||||
|
||||
@@ -1,129 +0,0 @@
|
||||
"""Account API endpoints."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.db import get_db
|
||||
from app.core.deps import get_current_user
|
||||
from app.models.account import AccountSize, Industry
|
||||
from app.models.user import User
|
||||
from app.schemas.account import AccountCreate, AccountOut, AccountUpdate
|
||||
from app.services.account_service import (
|
||||
create_account as svc_create_account,
|
||||
)
|
||||
from app.services.account_service import (
|
||||
get_account as svc_get_account,
|
||||
)
|
||||
from app.services.account_service import (
|
||||
list_accounts as svc_list_accounts,
|
||||
)
|
||||
from app.services.account_service import (
|
||||
soft_delete_account as svc_soft_delete_account,
|
||||
)
|
||||
from app.services.account_service import (
|
||||
update_account as svc_update_account,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/accounts", tags=["accounts"])
|
||||
|
||||
|
||||
@router.post(
|
||||
"/",
|
||||
response_model=AccountOut,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
summary="Create a new account",
|
||||
)
|
||||
async def create_account(
|
||||
payload: AccountCreate,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> AccountOut:
|
||||
acc = await svc_create_account(
|
||||
db, payload, org_id=current_user.org_id, owner_id=current_user.id
|
||||
)
|
||||
return AccountOut.model_validate(acc)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/",
|
||||
response_model=list[AccountOut],
|
||||
summary="List accounts (paginated, filterable)",
|
||||
)
|
||||
async def list_accounts(
|
||||
skip: int = Query(0, ge=0),
|
||||
limit: int = Query(20, ge=1, le=100),
|
||||
industry: Industry | None = None,
|
||||
size: AccountSize | None = None,
|
||||
owner_id: int | None = None,
|
||||
q: str | None = None,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> list[AccountOut]:
|
||||
accounts = await svc_list_accounts(
|
||||
db,
|
||||
org_id=current_user.org_id,
|
||||
skip=skip,
|
||||
limit=limit,
|
||||
industry=industry.value if industry else None,
|
||||
size=size.value if size else None,
|
||||
owner_id=owner_id,
|
||||
q=q,
|
||||
)
|
||||
return [AccountOut.model_validate(a) for a in accounts]
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{account_id}",
|
||||
response_model=AccountOut,
|
||||
summary="Get one account with all relations",
|
||||
)
|
||||
async def get_account(
|
||||
account_id: int,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> AccountOut:
|
||||
acc = await svc_get_account(db, account_id, org_id=current_user.org_id)
|
||||
if acc is None:
|
||||
raise HTTPException(status_code=404, detail="Account not found")
|
||||
return AccountOut.model_validate(acc)
|
||||
|
||||
|
||||
@router.patch(
|
||||
"/{account_id}",
|
||||
response_model=AccountOut,
|
||||
summary="Update an account",
|
||||
)
|
||||
async def update_account(
|
||||
account_id: int,
|
||||
payload: AccountUpdate,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> AccountOut:
|
||||
acc = await svc_get_account(db, account_id, org_id=current_user.org_id)
|
||||
if acc is None:
|
||||
raise HTTPException(status_code=404, detail="Account not found")
|
||||
updated = await svc_update_account(db, acc, payload)
|
||||
return AccountOut.model_validate(updated)
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/{account_id}",
|
||||
status_code=status.HTTP_204_NO_CONTENT,
|
||||
response_class=Response,
|
||||
summary="Soft-delete an account",
|
||||
)
|
||||
async def delete_account(
|
||||
account_id: int,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> Response:
|
||||
acc = await svc_get_account(db, account_id, org_id=current_user.org_id)
|
||||
if acc is None:
|
||||
raise HTTPException(status_code=404, detail="Account not found")
|
||||
await svc_soft_delete_account(db, acc)
|
||||
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
|
||||
__all__ = ["router"]
|
||||
@@ -1,164 +0,0 @@
|
||||
"""Activity API endpoints."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.db import get_db
|
||||
from app.core.deps import get_current_user
|
||||
from app.models.activity import ActivityType
|
||||
from app.models.user import User
|
||||
from app.schemas.activity import (
|
||||
ActivityCompleteRequest,
|
||||
ActivityCreate,
|
||||
ActivityOut,
|
||||
ActivityUpdate,
|
||||
)
|
||||
from app.services.activity_service import (
|
||||
NoParentException,
|
||||
)
|
||||
from app.services.activity_service import (
|
||||
complete_activity as svc_complete_activity,
|
||||
)
|
||||
from app.services.activity_service import (
|
||||
create_activity as svc_create_activity,
|
||||
)
|
||||
from app.services.activity_service import (
|
||||
get_activity as svc_get_activity,
|
||||
)
|
||||
from app.services.activity_service import (
|
||||
list_activities as svc_list_activities,
|
||||
)
|
||||
from app.services.activity_service import (
|
||||
soft_delete_activity as svc_soft_delete_activity,
|
||||
)
|
||||
from app.services.activity_service import (
|
||||
update_activity as svc_update_activity,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/activities", tags=["activities"])
|
||||
|
||||
|
||||
@router.post(
|
||||
"/",
|
||||
response_model=ActivityOut,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
summary="Create a new activity (polymorphic parent: account/contact/deal)",
|
||||
)
|
||||
async def create_activity(
|
||||
payload: ActivityCreate,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> ActivityOut:
|
||||
try:
|
||||
activity = await svc_create_activity(
|
||||
db, payload, org_id=current_user.org_id, owner_id=current_user.id
|
||||
)
|
||||
except NoParentException as e:
|
||||
raise HTTPException(status_code=422, detail=str(e)) from e
|
||||
return ActivityOut.model_validate(activity)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/",
|
||||
response_model=list[ActivityOut],
|
||||
summary="List activities (paginated, filterable)",
|
||||
)
|
||||
async def list_activities(
|
||||
skip: int = Query(0, ge=0),
|
||||
limit: int = Query(20, ge=1, le=100),
|
||||
type: ActivityType | None = None,
|
||||
owner_id: int | None = None,
|
||||
overdue: bool | None = None,
|
||||
completed: bool | None = None,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> list[ActivityOut]:
|
||||
activities = await svc_list_activities(
|
||||
db,
|
||||
org_id=current_user.org_id,
|
||||
skip=skip,
|
||||
limit=limit,
|
||||
type=type,
|
||||
owner_id=owner_id,
|
||||
overdue=overdue,
|
||||
completed=completed,
|
||||
)
|
||||
return [ActivityOut.model_validate(a) for a in activities]
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{activity_id}",
|
||||
response_model=ActivityOut,
|
||||
summary="Get one activity",
|
||||
)
|
||||
async def get_activity(
|
||||
activity_id: int,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> ActivityOut:
|
||||
activity = await svc_get_activity(db, activity_id, org_id=current_user.org_id)
|
||||
if activity is None:
|
||||
raise HTTPException(status_code=404, detail="Activity not found")
|
||||
return ActivityOut.model_validate(activity)
|
||||
|
||||
|
||||
@router.patch(
|
||||
"/{activity_id}",
|
||||
response_model=ActivityOut,
|
||||
summary="Update an activity",
|
||||
)
|
||||
async def update_activity(
|
||||
activity_id: int,
|
||||
payload: ActivityUpdate,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> ActivityOut:
|
||||
activity = await svc_get_activity(db, activity_id, org_id=current_user.org_id)
|
||||
if activity is None:
|
||||
raise HTTPException(status_code=404, detail="Activity not found")
|
||||
try:
|
||||
updated = await svc_update_activity(db, activity, payload)
|
||||
except NoParentException as e:
|
||||
raise HTTPException(status_code=422, detail=str(e)) from e
|
||||
return ActivityOut.model_validate(updated)
|
||||
|
||||
|
||||
@router.patch(
|
||||
"/{activity_id}/complete",
|
||||
response_model=ActivityOut,
|
||||
summary="Mark an activity as completed",
|
||||
)
|
||||
async def complete_activity(
|
||||
activity_id: int,
|
||||
payload: ActivityCompleteRequest,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> ActivityOut:
|
||||
activity = await svc_get_activity(db, activity_id, org_id=current_user.org_id)
|
||||
if activity is None:
|
||||
raise HTTPException(status_code=404, detail="Activity not found")
|
||||
updated = await svc_complete_activity(db, activity, payload)
|
||||
return ActivityOut.model_validate(updated)
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/{activity_id}",
|
||||
status_code=status.HTTP_204_NO_CONTENT,
|
||||
response_class=Response,
|
||||
summary="Soft-delete an activity",
|
||||
)
|
||||
async def delete_activity(
|
||||
activity_id: int,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> Response:
|
||||
activity = await svc_get_activity(db, activity_id, org_id=current_user.org_id)
|
||||
if activity is None:
|
||||
raise HTTPException(status_code=404, detail="Activity not found")
|
||||
await svc_soft_delete_activity(db, activity)
|
||||
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
|
||||
__all__ = ["router"]
|
||||
@@ -1,154 +0,0 @@
|
||||
"""Auth API endpoints: register, login, refresh, logout."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from fastapi.security import OAuth2PasswordRequestForm
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.config import get_settings
|
||||
from app.core.db import get_db
|
||||
from app.core.deps import get_current_user
|
||||
from app.models.user import User
|
||||
from app.schemas.auth import (
|
||||
LogoutResponse,
|
||||
RegisterResponse,
|
||||
TokenResponse,
|
||||
UserLoginRequest,
|
||||
UserRegisterRequest,
|
||||
)
|
||||
from app.schemas.user import UserOut
|
||||
from app.services import auth_service
|
||||
|
||||
router = APIRouter(prefix="/auth", tags=["auth"])
|
||||
|
||||
|
||||
@router.post(
|
||||
"/register",
|
||||
response_model=RegisterResponse,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
summary="Bootstrap user registration (only allowed if users table is empty)",
|
||||
)
|
||||
async def register(
|
||||
payload: UserRegisterRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> RegisterResponse:
|
||||
"""Create the first user and a default organization.
|
||||
|
||||
Returns 403 after the first user has been registered.
|
||||
"""
|
||||
try:
|
||||
user, token = await auth_service.register_user(db, payload)
|
||||
except auth_service.BootstrapAlreadyCompleted as e:
|
||||
raise HTTPException(status_code=403, detail=str(e)) from e
|
||||
except auth_service.EmailAlreadyExists as e:
|
||||
raise HTTPException(status_code=409, detail=str(e)) from e
|
||||
|
||||
settings = get_settings()
|
||||
return RegisterResponse(
|
||||
user=UserOut.model_validate(user),
|
||||
access_token=token,
|
||||
token_type="bearer",
|
||||
expires_in=settings.jwt_expiry_seconds,
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/login",
|
||||
response_model=TokenResponse,
|
||||
summary="Login with email + password (form-data or JSON)",
|
||||
)
|
||||
async def login(
|
||||
form_data: OAuth2PasswordRequestForm = Depends(),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> TokenResponse:
|
||||
"""OAuth2-compatible login. `username` field carries the email.
|
||||
|
||||
Returns 401 on invalid credentials — never leaks whether the email exists.
|
||||
"""
|
||||
result = await auth_service.authenticate_user(
|
||||
db, email=form_data.username, password=form_data.password
|
||||
)
|
||||
if result is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid email or password",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
_user, token = result
|
||||
settings = get_settings()
|
||||
return TokenResponse(
|
||||
access_token=token,
|
||||
token_type="bearer",
|
||||
expires_in=settings.jwt_expiry_seconds,
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/login/json",
|
||||
response_model=TokenResponse,
|
||||
summary="Login with JSON body (alternative to form-data)",
|
||||
)
|
||||
async def login_json(
|
||||
payload: UserLoginRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> TokenResponse:
|
||||
"""JSON-body login variant for clients that prefer JSON over form-data."""
|
||||
result = await auth_service.authenticate_user(
|
||||
db, email=payload.email, password=payload.password
|
||||
)
|
||||
if result is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid email or password",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
_user, token = result
|
||||
settings = get_settings()
|
||||
return TokenResponse(
|
||||
access_token=token,
|
||||
token_type="bearer",
|
||||
expires_in=settings.jwt_expiry_seconds,
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/refresh",
|
||||
response_model=TokenResponse,
|
||||
summary="Issue a new JWT for the current user",
|
||||
)
|
||||
async def refresh(
|
||||
current_user: User = Depends(get_current_user),
|
||||
) -> TokenResponse:
|
||||
"""Re-issue a fresh token. v1.1 will add refresh-token rotation; v1 re-signs with the same secret."""
|
||||
settings = get_settings()
|
||||
from app.core.security import create_access_token
|
||||
|
||||
role_str = (
|
||||
current_user.role.value if hasattr(current_user.role, "value") else str(current_user.role)
|
||||
)
|
||||
token = create_access_token(current_user.id, current_user.org_id, role_str)
|
||||
return TokenResponse(
|
||||
access_token=token,
|
||||
token_type="bearer",
|
||||
expires_in=settings.jwt_expiry_seconds,
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/logout",
|
||||
response_model=LogoutResponse,
|
||||
status_code=status.HTTP_200_OK,
|
||||
summary="Logout (client-side token discard)",
|
||||
)
|
||||
async def logout(
|
||||
_current_user: User = Depends(get_current_user),
|
||||
) -> LogoutResponse:
|
||||
"""Stateless logout. Client deletes the token from localStorage.
|
||||
|
||||
The endpoint validates the token (so a stolen token can be detected on logout)
|
||||
but does not maintain a server-side blacklist in v1.
|
||||
"""
|
||||
return LogoutResponse()
|
||||
@@ -1,135 +0,0 @@
|
||||
"""Contact API endpoints."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.db import get_db
|
||||
from app.core.deps import get_current_user
|
||||
from app.models.user import User
|
||||
from app.schemas.contact import ContactCreate, ContactOut, ContactUpdate
|
||||
from app.services.contact_service import (
|
||||
InvalidAccount,
|
||||
)
|
||||
from app.services.contact_service import (
|
||||
create_contact as svc_create_contact,
|
||||
)
|
||||
from app.services.contact_service import (
|
||||
get_contact as svc_get_contact,
|
||||
)
|
||||
from app.services.contact_service import (
|
||||
list_contacts as svc_list_contacts,
|
||||
)
|
||||
from app.services.contact_service import (
|
||||
soft_delete_contact as svc_soft_delete_contact,
|
||||
)
|
||||
from app.services.contact_service import (
|
||||
update_contact as svc_update_contact,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/contacts", tags=["contacts"])
|
||||
|
||||
|
||||
@router.post(
|
||||
"/",
|
||||
response_model=ContactOut,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
summary="Create a new contact",
|
||||
)
|
||||
async def create_contact(
|
||||
payload: ContactCreate,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> ContactOut:
|
||||
try:
|
||||
contact = await svc_create_contact(
|
||||
db, payload, org_id=current_user.org_id, owner_id=current_user.id
|
||||
)
|
||||
except InvalidAccount as e:
|
||||
raise HTTPException(status_code=404, detail=str(e)) from e
|
||||
return ContactOut.model_validate(contact)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/",
|
||||
response_model=list[ContactOut],
|
||||
summary="List contacts (paginated, filterable)",
|
||||
)
|
||||
async def list_contacts(
|
||||
skip: int = Query(0, ge=0),
|
||||
limit: int = Query(20, ge=1, le=100),
|
||||
account_id: int | None = None,
|
||||
owner_id: int | None = None,
|
||||
q: str | None = None,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> list[ContactOut]:
|
||||
contacts = await svc_list_contacts(
|
||||
db,
|
||||
org_id=current_user.org_id,
|
||||
skip=skip,
|
||||
limit=limit,
|
||||
account_id=account_id,
|
||||
owner_id=owner_id,
|
||||
q=q,
|
||||
)
|
||||
return [ContactOut.model_validate(c) for c in contacts]
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{contact_id}",
|
||||
response_model=ContactOut,
|
||||
summary="Get one contact",
|
||||
)
|
||||
async def get_contact(
|
||||
contact_id: int,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> ContactOut:
|
||||
contact = await svc_get_contact(db, contact_id, org_id=current_user.org_id)
|
||||
if contact is None:
|
||||
raise HTTPException(status_code=404, detail="Contact not found")
|
||||
return ContactOut.model_validate(contact)
|
||||
|
||||
|
||||
@router.patch(
|
||||
"/{contact_id}",
|
||||
response_model=ContactOut,
|
||||
summary="Update a contact",
|
||||
)
|
||||
async def update_contact(
|
||||
contact_id: int,
|
||||
payload: ContactUpdate,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> ContactOut:
|
||||
contact = await svc_get_contact(db, contact_id, org_id=current_user.org_id)
|
||||
if contact is None:
|
||||
raise HTTPException(status_code=404, detail="Contact not found")
|
||||
try:
|
||||
updated = await svc_update_contact(db, contact, payload)
|
||||
except InvalidAccount as e:
|
||||
raise HTTPException(status_code=404, detail=str(e)) from e
|
||||
return ContactOut.model_validate(updated)
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/{contact_id}",
|
||||
status_code=status.HTTP_204_NO_CONTENT,
|
||||
response_class=Response,
|
||||
summary="Soft-delete a contact",
|
||||
)
|
||||
async def delete_contact(
|
||||
contact_id: int,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> Response:
|
||||
contact = await svc_get_contact(db, contact_id, org_id=current_user.org_id)
|
||||
if contact is None:
|
||||
raise HTTPException(status_code=404, detail="Contact not found")
|
||||
await svc_soft_delete_contact(db, contact)
|
||||
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
|
||||
__all__ = ["router"]
|
||||
@@ -1,47 +0,0 @@
|
||||
"""Dashboard API endpoints: KPIs and activity feed."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.db import get_db
|
||||
from app.core.deps import get_current_user
|
||||
from app.models.user import User
|
||||
from app.schemas.dashboard import ActivityFeedItem, KPIOut
|
||||
from app.services.dashboard_service import (
|
||||
get_activity_feed,
|
||||
get_kpis,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/dashboard", tags=["dashboard"])
|
||||
|
||||
|
||||
@router.get(
|
||||
"/kpis",
|
||||
response_model=KPIOut,
|
||||
summary="Get dashboard KPIs (open deals, pipeline value, won this month, conversion rate)",
|
||||
)
|
||||
async def get_kpis_endpoint(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> KPIOut:
|
||||
kpis = await get_kpis(db, org_id=current_user.org_id)
|
||||
return KPIOut(**kpis) # type: ignore[arg-type]
|
||||
|
||||
|
||||
@router.get(
|
||||
"/feed",
|
||||
response_model=list[ActivityFeedItem],
|
||||
summary="Get the latest 20 activities (sorted by created_at desc)",
|
||||
)
|
||||
async def get_activity_feed_endpoint(
|
||||
limit: int = 20,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> list[ActivityFeedItem]:
|
||||
activities = await get_activity_feed(db, org_id=current_user.org_id, limit=limit)
|
||||
return [ActivityFeedItem.model_validate(a) for a in activities]
|
||||
|
||||
|
||||
__all__ = ["router"]
|
||||
@@ -1,185 +0,0 @@
|
||||
"""Deal API endpoints."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.db import get_db
|
||||
from app.core.deps import get_current_user
|
||||
from app.models.deal import DealStage
|
||||
from app.models.user import User
|
||||
from app.schemas.deal import (
|
||||
DealCreate,
|
||||
DealOut,
|
||||
DealPipelineOut,
|
||||
DealStageUpdate,
|
||||
DealUpdate,
|
||||
)
|
||||
from app.services.deal_service import (
|
||||
InvalidAccount,
|
||||
get_pipeline,
|
||||
)
|
||||
from app.services.deal_service import (
|
||||
create_deal as svc_create_deal,
|
||||
)
|
||||
from app.services.deal_service import (
|
||||
get_deal as svc_get_deal,
|
||||
)
|
||||
from app.services.deal_service import (
|
||||
list_deals as svc_list_deals,
|
||||
)
|
||||
from app.services.deal_service import (
|
||||
soft_delete_deal as svc_soft_delete_deal,
|
||||
)
|
||||
from app.services.deal_service import (
|
||||
update_deal as svc_update_deal,
|
||||
)
|
||||
from app.services.deal_service import (
|
||||
update_stage as svc_update_stage,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/deals", tags=["deals"])
|
||||
|
||||
|
||||
@router.post(
|
||||
"/",
|
||||
response_model=DealOut,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
summary="Create a new deal",
|
||||
)
|
||||
async def create_deal(
|
||||
payload: DealCreate,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> DealOut:
|
||||
try:
|
||||
deal = await svc_create_deal(
|
||||
db, payload, org_id=current_user.org_id, owner_id=current_user.id
|
||||
)
|
||||
except InvalidAccount as e:
|
||||
raise HTTPException(status_code=404, detail=str(e)) from e
|
||||
return DealOut.model_validate(deal)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/",
|
||||
response_model=list[DealOut],
|
||||
summary="List deals (paginated, filterable)",
|
||||
)
|
||||
async def list_deals(
|
||||
skip: int = Query(0, ge=0),
|
||||
limit: int = Query(20, ge=1, le=100),
|
||||
stage: DealStage | None = None,
|
||||
owner_id: int | None = None,
|
||||
account_id: int | None = None,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> list[DealOut]:
|
||||
deals = await svc_list_deals(
|
||||
db,
|
||||
org_id=current_user.org_id,
|
||||
skip=skip,
|
||||
limit=limit,
|
||||
stage=stage.value if stage else None,
|
||||
owner_id=owner_id,
|
||||
account_id=account_id,
|
||||
)
|
||||
return [DealOut.model_validate(d) for d in deals]
|
||||
|
||||
|
||||
@router.get(
|
||||
"/pipeline",
|
||||
response_model=list[DealPipelineOut],
|
||||
summary="Get pipeline view (deals grouped by stage)",
|
||||
)
|
||||
async def get_pipeline_endpoint(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> list[DealPipelineOut]:
|
||||
pipeline = await get_pipeline(db, org_id=current_user.org_id)
|
||||
out: list[DealPipelineOut] = []
|
||||
for item in pipeline:
|
||||
out.append(
|
||||
DealPipelineOut(
|
||||
stage=DealStage(item["stage"]),
|
||||
count=int(item["count"]),
|
||||
total_value=float(item["total_value"]),
|
||||
)
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{deal_id}",
|
||||
response_model=DealOut,
|
||||
summary="Get one deal",
|
||||
)
|
||||
async def get_deal(
|
||||
deal_id: int,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> DealOut:
|
||||
deal = await svc_get_deal(db, deal_id, org_id=current_user.org_id)
|
||||
if deal is None:
|
||||
raise HTTPException(status_code=404, detail="Deal not found")
|
||||
return DealOut.model_validate(deal)
|
||||
|
||||
|
||||
@router.patch(
|
||||
"/{deal_id}",
|
||||
response_model=DealOut,
|
||||
summary="Update a deal (does NOT change stage)",
|
||||
)
|
||||
async def update_deal(
|
||||
deal_id: int,
|
||||
payload: DealUpdate,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> DealOut:
|
||||
deal = await svc_get_deal(db, deal_id, org_id=current_user.org_id)
|
||||
if deal is None:
|
||||
raise HTTPException(status_code=404, detail="Deal not found")
|
||||
updated = await svc_update_deal(db, deal, payload)
|
||||
return DealOut.model_validate(updated)
|
||||
|
||||
|
||||
@router.patch(
|
||||
"/{deal_id}/stage",
|
||||
response_model=DealOut,
|
||||
summary="Change a deal's stage (creates DealStageHistory entry)",
|
||||
)
|
||||
async def update_deal_stage(
|
||||
deal_id: int,
|
||||
payload: DealStageUpdate,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> DealOut:
|
||||
deal = await svc_get_deal(db, deal_id, org_id=current_user.org_id)
|
||||
if deal is None:
|
||||
raise HTTPException(status_code=404, detail="Deal not found")
|
||||
updated = await svc_update_stage(
|
||||
db, deal, payload.stage, changed_by=current_user.id, reason=payload.reason
|
||||
)
|
||||
return DealOut.model_validate(updated)
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/{deal_id}",
|
||||
status_code=status.HTTP_204_NO_CONTENT,
|
||||
response_class=Response,
|
||||
summary="Soft-delete a deal",
|
||||
)
|
||||
async def delete_deal(
|
||||
deal_id: int,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> Response:
|
||||
deal = await svc_get_deal(db, deal_id, org_id=current_user.org_id)
|
||||
if deal is None:
|
||||
raise HTTPException(status_code=404, detail="Deal not found")
|
||||
await svc_soft_delete_deal(db, deal)
|
||||
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
|
||||
__all__ = ["router"]
|
||||
@@ -1,69 +0,0 @@
|
||||
"""Health check endpoints: /health (root) and /api/v1/health."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app import __version__
|
||||
from app.core.db import get_db
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(tags=["health"])
|
||||
|
||||
|
||||
async def _check_db(db: AsyncSession) -> bool:
|
||||
"""Run SELECT 1 to verify the database connection is alive."""
|
||||
try:
|
||||
result = await db.execute(text("SELECT 1"))
|
||||
result.scalar_one()
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error("Database health check failed: %s", e)
|
||||
return False
|
||||
|
||||
|
||||
@router.get(
|
||||
"/health",
|
||||
summary="Healthcheck (used by Coolify + Kubernetes)",
|
||||
)
|
||||
async def health(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> dict:
|
||||
"""Returns 200 if the API and DB are healthy, 503 if the DB is down."""
|
||||
db_ok = await _check_db(db)
|
||||
if not db_ok:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail={"status": "error", "db": "down"},
|
||||
)
|
||||
return {
|
||||
"status": "ok",
|
||||
"db": "ok",
|
||||
"version": __version__,
|
||||
}
|
||||
|
||||
|
||||
@router.get(
|
||||
"/api/v1/health",
|
||||
summary="Versioned healthcheck (for clients that hit /api/v1/*)",
|
||||
)
|
||||
async def api_v1_health(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> dict:
|
||||
"""Same as /health but under the /api/v1 prefix for versioned routing."""
|
||||
db_ok = await _check_db(db)
|
||||
if not db_ok:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail={"status": "error", "db": "down"},
|
||||
)
|
||||
return {
|
||||
"status": "ok",
|
||||
"db": "ok",
|
||||
"version": __version__,
|
||||
}
|
||||
@@ -1,131 +0,0 @@
|
||||
"""Note API endpoints (R-2: polymorphic parent validation)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.db import get_db
|
||||
from app.core.deps import get_current_user
|
||||
from app.models.note import NoteParentType
|
||||
from app.models.user import User
|
||||
from app.schemas.note import NoteCreate, NoteOut, NoteUpdate
|
||||
from app.services.note_service import (
|
||||
InvalidParent,
|
||||
)
|
||||
from app.services.note_service import (
|
||||
create_note as svc_create_note,
|
||||
)
|
||||
from app.services.note_service import (
|
||||
get_note as svc_get_note,
|
||||
)
|
||||
from app.services.note_service import (
|
||||
list_notes as svc_list_notes,
|
||||
)
|
||||
from app.services.note_service import (
|
||||
soft_delete_note as svc_soft_delete_note,
|
||||
)
|
||||
from app.services.note_service import (
|
||||
update_note as svc_update_note,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/notes", tags=["notes"])
|
||||
|
||||
|
||||
@router.post(
|
||||
"/",
|
||||
response_model=NoteOut,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
summary="Create a new note (R-2: validates parent exists)",
|
||||
)
|
||||
async def create_note(
|
||||
payload: NoteCreate,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> NoteOut:
|
||||
try:
|
||||
note = await svc_create_note(
|
||||
db, payload, org_id=current_user.org_id, author_id=current_user.id
|
||||
)
|
||||
except InvalidParent as e:
|
||||
raise HTTPException(status_code=404, detail=str(e)) from e
|
||||
return NoteOut.model_validate(note)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/",
|
||||
response_model=list[NoteOut],
|
||||
summary="List notes (filterable by parent_type + parent_id)",
|
||||
)
|
||||
async def list_notes(
|
||||
skip: int = Query(0, ge=0),
|
||||
limit: int = Query(20, ge=1, le=100),
|
||||
parent_type: NoteParentType | None = None,
|
||||
parent_id: int | None = None,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> list[NoteOut]:
|
||||
notes = await svc_list_notes(
|
||||
db,
|
||||
org_id=current_user.org_id,
|
||||
skip=skip,
|
||||
limit=limit,
|
||||
parent_type=parent_type.value if parent_type else None,
|
||||
parent_id=parent_id,
|
||||
)
|
||||
return [NoteOut.model_validate(n) for n in notes]
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{note_id}",
|
||||
response_model=NoteOut,
|
||||
summary="Get one note",
|
||||
)
|
||||
async def get_note(
|
||||
note_id: int,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> NoteOut:
|
||||
note = await svc_get_note(db, note_id, org_id=current_user.org_id)
|
||||
if note is None:
|
||||
raise HTTPException(status_code=404, detail="Note not found")
|
||||
return NoteOut.model_validate(note)
|
||||
|
||||
|
||||
@router.patch(
|
||||
"/{note_id}",
|
||||
response_model=NoteOut,
|
||||
summary="Update a note's body",
|
||||
)
|
||||
async def update_note(
|
||||
note_id: int,
|
||||
payload: NoteUpdate,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> NoteOut:
|
||||
note = await svc_get_note(db, note_id, org_id=current_user.org_id)
|
||||
if note is None:
|
||||
raise HTTPException(status_code=404, detail="Note not found")
|
||||
updated = await svc_update_note(db, note, payload)
|
||||
return NoteOut.model_validate(updated)
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/{note_id}",
|
||||
status_code=status.HTTP_204_NO_CONTENT,
|
||||
response_class=Response,
|
||||
summary="Soft-delete a note",
|
||||
)
|
||||
async def delete_note(
|
||||
note_id: int,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> Response:
|
||||
note = await svc_get_note(db, note_id, org_id=current_user.org_id)
|
||||
if note is None:
|
||||
raise HTTPException(status_code=404, detail="Note not found")
|
||||
await svc_soft_delete_note(db, note)
|
||||
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
|
||||
__all__ = ["router"]
|
||||
@@ -1,106 +0,0 @@
|
||||
"""Tag API endpoints (R-2: polymorphic parent validation)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Response, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.db import get_db
|
||||
from app.core.deps import get_current_user
|
||||
from app.models.user import User
|
||||
from app.schemas.tag import TagCreate, TagLinkCreate, TagLinkOut, TagOut
|
||||
from app.services.tag_service import (
|
||||
DuplicateTagLink,
|
||||
InvalidParent,
|
||||
TagNotFound,
|
||||
)
|
||||
from app.services.tag_service import (
|
||||
create_tag as svc_create_tag,
|
||||
)
|
||||
from app.services.tag_service import (
|
||||
link_tag as svc_link_tag,
|
||||
)
|
||||
from app.services.tag_service import (
|
||||
list_tags as svc_list_tags,
|
||||
)
|
||||
from app.services.tag_service import (
|
||||
unlink_tag as svc_unlink_tag,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/tags", tags=["tags"])
|
||||
|
||||
|
||||
@router.post(
|
||||
"/",
|
||||
response_model=TagOut,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
summary="Create a new tag",
|
||||
)
|
||||
async def create_tag(
|
||||
payload: TagCreate,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> TagOut:
|
||||
tag = await svc_create_tag(db, payload, org_id=current_user.org_id)
|
||||
return TagOut.model_validate(tag)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/",
|
||||
response_model=list[TagOut],
|
||||
summary="List all tags in the org",
|
||||
)
|
||||
async def list_tags(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> list[TagOut]:
|
||||
tags = await svc_list_tags(db, org_id=current_user.org_id)
|
||||
return [TagOut.model_validate(t) for t in tags]
|
||||
|
||||
|
||||
@router.post(
|
||||
"/link",
|
||||
response_model=TagLinkOut,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
summary="Link a tag to an entity (R-2: validates parent exists)",
|
||||
)
|
||||
async def link_tag_endpoint(
|
||||
payload: TagLinkCreate,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> TagLinkOut:
|
||||
try:
|
||||
link = await svc_link_tag(db, payload, org_id=current_user.org_id)
|
||||
except InvalidParent as e:
|
||||
raise HTTPException(status_code=404, detail=str(e)) from e
|
||||
except TagNotFound as e:
|
||||
raise HTTPException(status_code=404, detail=str(e)) from e
|
||||
except DuplicateTagLink as e:
|
||||
raise HTTPException(status_code=409, detail=str(e)) from e
|
||||
return TagLinkOut.model_validate(link)
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/link",
|
||||
status_code=status.HTTP_204_NO_CONTENT,
|
||||
response_class=Response,
|
||||
summary="Unlink a tag from an entity",
|
||||
)
|
||||
async def unlink_tag_endpoint(
|
||||
payload: TagLinkCreate,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> Response:
|
||||
deleted = await svc_unlink_tag(
|
||||
db,
|
||||
tag_id=payload.tag_id,
|
||||
parent_type=payload.parent_type,
|
||||
parent_id=payload.parent_id,
|
||||
org_id=current_user.org_id,
|
||||
)
|
||||
if not deleted:
|
||||
raise HTTPException(status_code=404, detail="Tag link not found")
|
||||
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
|
||||
__all__ = ["router"]
|
||||
@@ -1,147 +0,0 @@
|
||||
"""User API endpoints: me, list, create, update, soft-delete."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.db import get_db
|
||||
from app.core.deps import get_current_admin_user, get_current_user
|
||||
from app.models.user import User, UserRole
|
||||
from app.schemas.user import (
|
||||
UserCreateRequest,
|
||||
UserListResponse,
|
||||
UserOut,
|
||||
UserUpdate,
|
||||
)
|
||||
from app.services import user_service
|
||||
|
||||
router = APIRouter(prefix="/users", tags=["users"])
|
||||
|
||||
|
||||
@router.get(
|
||||
"/me",
|
||||
response_model=UserOut,
|
||||
summary="Get the currently authenticated user",
|
||||
)
|
||||
async def get_me(
|
||||
current_user: User = Depends(get_current_user),
|
||||
) -> UserOut:
|
||||
"""Returns the user from the JWT. Used by the frontend for auth checks and profile display."""
|
||||
return UserOut.model_validate(current_user)
|
||||
|
||||
|
||||
@router.patch(
|
||||
"/me",
|
||||
response_model=UserOut,
|
||||
summary="Update own profile (name, avatar, notification prefs)",
|
||||
)
|
||||
async def update_me(
|
||||
payload: UserUpdate,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> UserOut:
|
||||
"""A user can update their own profile, but not their role."""
|
||||
updated = await user_service.update_user_profile(db, current_user, payload, is_admin=False)
|
||||
return UserOut.model_validate(updated)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/",
|
||||
response_model=UserListResponse,
|
||||
summary="List all users in the current org (admin only)",
|
||||
)
|
||||
async def list_users(
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(50, ge=1, le=100),
|
||||
current_user: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> UserListResponse:
|
||||
"""Paginated list of active users. Restricted to admin role."""
|
||||
skip = (page - 1) * page_size
|
||||
users = await user_service.list_users(
|
||||
db, org_id=current_user.org_id, skip=skip, limit=page_size
|
||||
)
|
||||
total = await user_service.count_users_in_org(db, current_user.org_id)
|
||||
return UserListResponse(
|
||||
items=[UserOut.model_validate(u) for u in users],
|
||||
total=total,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/",
|
||||
response_model=UserOut,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
summary="Create a new user in the current org (admin only)",
|
||||
)
|
||||
async def create_user(
|
||||
payload: UserCreateRequest,
|
||||
current_user: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> UserOut:
|
||||
"""Admin creates a new user in the same org."""
|
||||
try:
|
||||
new_user = await user_service.create_user_as_admin(db, payload, org_id=current_user.org_id)
|
||||
except user_service.EmailAlreadyTaken as e:
|
||||
raise HTTPException(status_code=409, detail=str(e)) from e
|
||||
return UserOut.model_validate(new_user)
|
||||
|
||||
|
||||
@router.patch(
|
||||
"/{user_id}",
|
||||
response_model=UserOut,
|
||||
summary="Update a user (admin or self for non-role fields)",
|
||||
)
|
||||
async def update_user(
|
||||
user_id: int,
|
||||
payload: UserUpdate,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> UserOut:
|
||||
"""Admin can update any user (including role); non-admin can update only their own profile fields."""
|
||||
is_admin = (
|
||||
current_user.role == UserRole.admin
|
||||
if hasattr(current_user.role, "__eq__") and not isinstance(current_user.role, str)
|
||||
else str(current_user.role) == UserRole.admin.value
|
||||
)
|
||||
if not is_admin and current_user.id != user_id:
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail="You can only update your own profile",
|
||||
)
|
||||
|
||||
target = await user_service.get_user_by_id(db, user_id)
|
||||
if target is None:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
|
||||
if target.org_id != current_user.org_id:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
|
||||
updated = await user_service.update_user_profile(db, target, payload, is_admin=is_admin)
|
||||
return UserOut.model_validate(updated)
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/{user_id}",
|
||||
status_code=status.HTTP_204_NO_CONTENT,
|
||||
response_class=Response,
|
||||
summary="Soft-delete a user (admin only)",
|
||||
)
|
||||
async def delete_user(
|
||||
user_id: int,
|
||||
current_user: User = Depends(get_current_admin_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> Response:
|
||||
"""Sets deleted_at timestamp. The record stays in the DB for audit purposes."""
|
||||
if current_user.id == user_id:
|
||||
raise HTTPException(status_code=400, detail="You cannot delete your own account")
|
||||
|
||||
target = await user_service.get_user_by_id(db, user_id)
|
||||
if target is None or target.org_id != current_user.org_id:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
|
||||
await user_service.soft_delete_user(db, target)
|
||||
return Response(status_code=status.HTTP_204_NO_CONTENT)
|
||||
Reference in New Issue
Block a user