feat: deploy infrastructure + disk/drive, calendar, presentation, workspaces, onboarding, demo user

This commit is contained in:
2026-05-19 16:26:26 +03:00
parent 688d043dad
commit 3529c39b22
304 changed files with 18826 additions and 1176 deletions
+6
View File
@@ -12,6 +12,9 @@ from app.api.v1.voice import router as voice_router
from app.api.v1.config import router as config_router
from app.api.v1.feedback import router as feedback_router
from app.api.v1.tariffs import router as tariffs_router
from app.api.v1.workspaces import router as workspaces_router
from app.api.v1.collaboration import router as collaboration_router
from app.api.v1.calendar import router as calendar_router
api_v1_router = APIRouter(prefix="/api/v1")
@@ -25,5 +28,8 @@ api_v1_router.include_router(voice_router, prefix="/voice", tags=["voice"])
api_v1_router.include_router(config_router, prefix="/config", tags=["config"])
api_v1_router.include_router(feedback_router, prefix="/feedback", tags=["feedback"])
api_v1_router.include_router(tariffs_router, prefix="/tariffs", tags=["tariffs"])
api_v1_router.include_router(workspaces_router, prefix="/workspaces", tags=["workspaces"])
api_v1_router.include_router(collaboration_router, prefix="", tags=["collaboration"])
api_v1_router.include_router(calendar_router, prefix="", tags=["calendar"])
__all__ = ["api_v1_router"]
+29
View File
@@ -39,6 +39,8 @@ from app.schemas.admin import (
SystemInfoResponse,
UserAdminUpdate,
)
from app.agents.base import AgentResult
from app.schemas.agent import AgentRunRequest
from app.schemas.bot import BotCommandResponse, BotCommandUpdate
from app.schemas.feedback import FeedbackResponse, FeedbackUpdate
from app.schemas.tariff import TariffPlanCreate, TariffPlanResponse, TariffPlanUpdate, UserSubscriptionResponse
@@ -101,6 +103,7 @@ def _agent_to_response(a: AgentConfig) -> AgentInfoResponse:
description=getattr(a, "description", ""),
is_enabled=a.is_enabled,
version=a.version,
config=a.config,
last_run_at=a.last_run_at,
)
@@ -191,12 +194,38 @@ async def update_agent(
agent.description = body.description
if body.is_enabled is not None:
agent.is_enabled = body.is_enabled
if body.config is not None:
agent.config = body.config
await db.commit()
await db.refresh(agent)
return _agent_to_response(agent)
@router.post("/agents/{agent_name}/run", response_model=AgentResult)
async def run_agent(
agent_name: str,
user: Annotated[User, Depends(get_current_user)],
body: AgentRunRequest | None = None,
):
from app.agents.registry import registry
context = body.model_dump() if body else {}
result = await registry.run_agent(agent_name, context)
return result
@router.post("/agents/run-all", response_model=dict[str, AgentResult])
async def run_all_agents(
user: Annotated[User, Depends(get_current_user)],
body: AgentRunRequest | None = None,
):
from app.agents.registry import registry
context = body.model_dump() if body else {}
return await registry.run_all(context)
# ── Logs ──
@router.get("/logs", response_model=list[LogEntryResponse])
+63
View File
@@ -0,0 +1,63 @@
"""Calendar API routes for VoIdea."""
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.dependencies import get_current_user, get_db
from app.models.user import User
from app.services.calendar_service import CALENDAR_PROVIDERS, CalendarService
from app.services.idea_service import IdeaService
router = APIRouter()
@router.post("/ideas/{idea_id}/calendar")
async def add_idea_to_calendar(
idea_id: str,
user: Annotated[User, Depends(get_current_user)],
db: AsyncSession = Depends(get_db),
):
"""Create a calendar event from an idea."""
idea_svc = IdeaService(db)
idea = await idea_svc.get_by_id(idea_id)
if not idea or str(idea.user_id) != str(user.id):
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Idea not found")
cal_svc = CalendarService(db)
success = await cal_svc.create_idea_event(user, idea.title, idea.content)
if not success:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Calendar not configured. Connect a calendar in Settings > Integrations.",
)
return {"success": True, "message": "Event created in calendar"}
@router.get("/calendar/providers")
async def list_calendar_providers():
return {"providers": list(CALENDAR_PROVIDERS)}
@router.get("/calendar/status")
async def calendar_status(
user: Annotated[User, Depends(get_current_user)],
db: AsyncSession = Depends(get_db),
):
cal_svc = CalendarService(db)
creds = await cal_svc.get_calendar_credentials(user)
return {
"connected": creds is not None,
"provider": user.calendar_provider if hasattr(user, "calendar_provider") else None,
}
@router.post("/calendar/disconnect")
async def disconnect_calendar(
user: Annotated[User, Depends(get_current_user)],
db: AsyncSession = Depends(get_db),
):
cal_svc = CalendarService(db)
await cal_svc.disconnect_calendar(user)
return {"success": True}
+204
View File
@@ -0,0 +1,204 @@
"""Collaboration API routes for VoIdea.
Voting, comments, activity log, and notifications.
"""
from typing import Annotated
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from app.core.dependencies import get_current_user, get_db
from app.models.collaboration import Notification
from app.models.user import User
from app.schemas.collaboration import (
ActivityResponse,
CommentCreate,
CommentResponse,
NotificationResponse,
NotificationUnreadCount,
VoteCount,
VoteResponse,
VoteToggleRequest,
)
from app.services.collaboration_service import CollaborationService
from app.services.idea_service import IdeaService
router = APIRouter()
@router.post("/ideas/{idea_id}/vote", response_model=VoteCount)
async def toggle_vote(
idea_id: str,
body: VoteToggleRequest,
user: Annotated[User, Depends(get_current_user)],
db: AsyncSession = Depends(get_db),
):
svc = CollaborationService(db)
idea_svc = IdeaService(db)
idea = await idea_svc.get_by_id(idea_id)
if not idea or str(idea.user_id) != str(user.id):
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Idea not found")
await svc.toggle_vote(UUID(idea_id), user.id, body.vote)
counts = await svc.get_vote_counts(UUID(idea_id))
user_vote = await svc.get_user_vote(UUID(idea_id), user.id)
return VoteCount(**counts, user_vote=user_vote)
@router.get("/ideas/{idea_id}/votes", response_model=VoteCount)
async def get_votes(
idea_id: str,
user: Annotated[User, Depends(get_current_user)],
db: AsyncSession = Depends(get_db),
):
svc = CollaborationService(db)
idea_svc = IdeaService(db)
idea = await idea_svc.get_by_id(idea_id)
if not idea or str(idea.user_id) != str(user.id):
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Idea not found")
counts = await svc.get_vote_counts(UUID(idea_id))
user_vote = await svc.get_user_vote(UUID(idea_id), user.id)
return VoteCount(**counts, user_vote=user_vote)
# ── Comments ──
@router.get("/ideas/{idea_id}/comments", response_model=list[CommentResponse])
async def list_comments(
idea_id: str,
user: Annotated[User, Depends(get_current_user)],
db: AsyncSession = Depends(get_db),
):
svc = CollaborationService(db)
return await svc.list_comments(UUID(idea_id))
@router.post("/ideas/{idea_id}/comments", response_model=CommentResponse, status_code=status.HTTP_201_CREATED)
async def create_comment(
idea_id: str,
body: CommentCreate,
user: Annotated[User, Depends(get_current_user)],
db: AsyncSession = Depends(get_db),
):
svc = CollaborationService(db)
parent_id = UUID(body.parent_id) if body.parent_id else None
c = await svc.create_comment(UUID(idea_id), user.id, body.content, parent_id)
await svc.log_activity(
UUID(idea_id), user.id, "comment",
{"comment_id": str(c.id), "preview": body.content[:100]},
)
return CommentResponse(
id=str(c.id),
idea_id=str(c.idea_id),
user_id=str(c.user_id),
content=c.content,
parent_id=str(c.parent_id) if c.parent_id else None,
display_name=user.display_name,
avatar_url=user.avatar_url,
created_at=c.created_at,
updated_at=c.updated_at,
)
@router.delete("/ideas/{idea_id}/comments/{comment_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_comment(
idea_id: str,
comment_id: str,
user: Annotated[User, Depends(get_current_user)],
db: AsyncSession = Depends(get_db),
):
svc = CollaborationService(db)
success = await svc.delete_comment(UUID(comment_id), user.id)
if not success:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Comment not found")
# ── Activity ──
@router.get("/ideas/{idea_id}/activity", response_model=list[ActivityResponse])
async def list_activity(
idea_id: str,
user: Annotated[User, Depends(get_current_user)],
db: AsyncSession = Depends(get_db),
limit: int = Query(50, ge=1, le=200),
):
svc = CollaborationService(db)
return await svc.list_activities(UUID(idea_id), limit=limit)
# ── Notifications ──
@router.get("/notifications", response_model=list[NotificationResponse])
async def list_notifications(
user: Annotated[User, Depends(get_current_user)],
db: AsyncSession = Depends(get_db),
unread_only: bool = Query(False),
):
svc = CollaborationService(db)
notifications = await svc.list_notifications(user.id, unread_only=unread_only)
return [
NotificationResponse(
id=str(n.id),
type=n.type,
title=n.title,
body=n.body,
link=n.link,
is_read=n.is_read,
created_at=n.created_at,
)
for n in notifications
]
@router.get("/notifications/unread-count", response_model=NotificationUnreadCount)
async def unread_count(
user: Annotated[User, Depends(get_current_user)],
db: AsyncSession = Depends(get_db),
):
svc = CollaborationService(db)
count = await svc.get_unread_count(user.id)
return NotificationUnreadCount(count=count)
@router.post("/notifications/{notification_id}/read", response_model=NotificationResponse)
async def mark_read(
notification_id: str,
user: Annotated[User, Depends(get_current_user)],
db: AsyncSession = Depends(get_db),
):
svc = CollaborationService(db)
success = await svc.mark_notification_read(UUID(notification_id), user.id)
if not success:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Notification not found")
result = await db.execute(
select(Notification).where(Notification.id == UUID(notification_id))
)
n = result.scalar_one()
return NotificationResponse(
id=str(n.id), type=n.type, title=n.title, body=n.body,
link=n.link, is_read=n.is_read, created_at=n.created_at,
)
@router.post("/notifications/read-all", response_model=NotificationUnreadCount)
async def mark_all_read(
user: Annotated[User, Depends(get_current_user)],
db: AsyncSession = Depends(get_db),
):
svc = CollaborationService(db)
count = await svc.mark_all_read(user.id)
return NotificationUnreadCount(count=count)
+128
View File
@@ -0,0 +1,128 @@
"""Disk/Drive API routes for VoIdea."""
import logging
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, Query, status
from pydantic import BaseModel
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.dependencies import get_current_user, get_db
from app.models.user import User
from app.services.disk_service import DISK_PROVIDERS, DiskService
logger = logging.getLogger(__name__)
router = APIRouter()
class ConnectRequest(BaseModel):
provider: str
code: str
class DisconnectRequest(BaseModel):
provider: str
@router.get("/disk/providers")
async def list_disk_providers():
"""List available disk providers and user's connected ones."""
return {"providers": list(DISK_PROVIDERS)}
@router.get("/disk/status")
async def disk_status(
user: Annotated[User, Depends(get_current_user)],
db: AsyncSession = Depends(get_db),
):
"""Get user's connected disk providers."""
svc = DiskService(db)
providers = await svc.get_connected_providers(user)
return {"connected": providers, "count": len(providers)}
@router.post("/disk/connect")
async def connect_disk(
body: ConnectRequest,
user: Annotated[User, Depends(get_current_user)],
db: AsyncSession = Depends(get_db),
):
"""Exchange OAuth code for tokens and store provider connection."""
if body.provider not in DISK_PROVIDERS:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Unknown provider")
tokens = None
email = ""
if body.provider == "google":
from app.integrations.oauth.google import exchange_code, get_user_info
tokens = await exchange_code(body.code)
if tokens:
info = await get_user_info(tokens.get("access_token", ""))
if info:
email = info.email
elif body.provider == "yandex":
from app.integrations.oauth.yandex import exchange_code, get_user_info
token_result = await exchange_code(body.code)
if token_result:
tokens = {
"access_token": token_result.access_token,
"refresh_token": token_result.refresh_token,
"expires_at": (
token_result.expires_at.isoformat()
if token_result.expires_at else None
),
}
info = await get_user_info(token_result.access_token)
if info:
email = info.email
elif body.provider == "apple":
from app.integrations.oauth.apple import exchange_code
result = await exchange_code(body.code)
if result:
tokens = result
if not tokens:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Failed to exchange authorization code",
)
svc = DiskService(db)
await svc.add_provider(user, body.provider, tokens, email=email)
return {"success": True, "provider": body.provider}
@router.post("/disk/disconnect")
async def disconnect_disk(
body: DisconnectRequest,
user: Annotated[User, Depends(get_current_user)],
db: AsyncSession = Depends(get_db),
):
"""Disconnect a disk provider."""
svc = DiskService(db)
await svc.remove_provider(user, body.provider)
return {"success": True}
@router.get("/disk/oauth-url/{provider}")
async def disk_oauth_url(provider: str):
"""Get OAuth authorize URL for a disk provider."""
if provider not in DISK_PROVIDERS:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Unknown provider")
if provider == "google":
from app.integrations.oauth.google import get_authorize_url
url = await get_authorize_url()
elif provider == "yandex":
from app.integrations.oauth.yandex import get_authorize_url
url = await get_authorize_url()
elif provider == "apple":
from app.integrations.oauth.apple import get_authorize_url
url = await get_authorize_url()
else:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Unknown provider")
return {"url": url}
+96
View File
@@ -14,14 +14,18 @@ from app.models.idea import Idea
from app.models.user import User
from app.schemas.idea import (
AnalysisResultResponse,
FunnelTransitionRequest,
IdeaAnalyzeResponse,
IdeaCreate,
IdeaResponse,
IdeaUpdate,
)
from app.services.analysis_service import AnalysisService
from app.services.collaboration_service import CollaborationService
from app.services.disk_service import DiskService
from app.services.export_service import export_content
from app.services.idea_service import IdeaService
from app.services.presentation_service import PresentationService
router = APIRouter()
@@ -186,6 +190,65 @@ async def export_idea(
)
@router.post("/{idea_id}/presentation")
async def generate_presentation(
idea_id: str,
user: Annotated[User, Depends(get_current_user)],
db: AsyncSession = Depends(get_db),
roles: str = Query("", description="Comma-separated role names (empty=all)"),
destination: str = Query("download", regex="^(download|drive)$"),
drive_provider: str = Query("", description="Specific drive provider (google/yandex/apple)"),
):
"""Generate HTML presentation for an idea.
Args:
destination: "download" = return as file, "drive" = upload to cloud drive
roles: Comma-separated agent roles (empty = all available)
drive_provider: Specific provider to upload to (empty = auto-detect)
"""
idea_svc = IdeaService(db)
idea = await idea_svc.get_by_id(idea_id)
if not idea or str(idea.user_id) != str(user.id):
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Idea not found")
role_list = [r.strip() for r in roles.split(",") if r.strip()] if roles else None
pres_svc = PresentationService(db)
html = await pres_svc.generate(idea_id, roles=role_list, user_name=user.display_name)
if not html:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Idea not found")
filename = f"prezentaciya_{idea.title[:40].replace(' ', '_')}.html"
if destination == "drive":
disk_svc = DiskService(db)
content_bytes = html.encode("utf-8")
if drive_provider:
success = await disk_svc.upload_to_provider(
user, drive_provider, filename, content_bytes
)
if not success:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Failed to upload to {drive_provider}",
)
return {"success": True, "provider": drive_provider, "filename": filename}
else:
result = await disk_svc.upload_to_any(user, filename, content_bytes)
if not result:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="No connected drive. Connect a cloud drive first.",
)
return {"success": True, "provider": result["provider"], "filename": filename}
from fastapi.responses import Response
return Response(
content=html,
media_type="text/html; charset=utf-8",
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
)
@router.post("/{idea_id}/share", response_model=IdeaResponse)
async def toggle_idea_share(
idea_id: str,
@@ -208,6 +271,39 @@ async def toggle_idea_share(
return _idea_to_response(idea)
@router.post("/{idea_id}/funnel", response_model=IdeaResponse)
async def transition_funnel(
idea_id: str,
body: FunnelTransitionRequest,
user: Annotated[User, Depends(get_current_user)],
db: AsyncSession = Depends(get_db),
):
service = IdeaService(db)
idea = await service.get_by_id(idea_id)
if not idea or str(idea.user_id) != str(user.id):
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Idea not found")
target = body.target_status
valid_states = Idea.FUNNEL_STATES
if target not in valid_states:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Invalid funnel status. Valid: {', '.join(valid_states)}",
)
idea.funnel_status = target
await db.commit()
await db.refresh(idea)
collab = CollaborationService(db)
await collab.log_activity(
UUID(idea_id), user.id, "funnel",
{"from": idea.funnel_status, "to": target},
)
return _idea_to_response(idea)
@router.post("/demo", response_model=IdeaResponse, status_code=status.HTTP_201_CREATED)
async def create_demo_idea(
user: Annotated[User, Depends(get_current_user)],
+178
View File
@@ -0,0 +1,178 @@
"""Workspace API routes for VoIdea."""
from typing import Annotated
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.dependencies import get_current_user, get_db
from app.models.user import User
from app.schemas.workspace import (
AddMemberRequest,
MemberResponse,
WorkspaceCreate,
WorkspaceResponse,
WorkspaceUpdate,
)
from app.services.workspace_service import WorkspaceError, WorkspaceLimitError, WorkspaceService
router = APIRouter()
def _ws_to_response(ws, member_count=0) -> WorkspaceResponse:
return WorkspaceResponse(
id=str(ws.id),
name=ws.name,
type=ws.type,
owner_id=str(ws.owner_id),
description=ws.description,
member_count=member_count,
created_at=ws.created_at,
updated_at=ws.updated_at,
)
@router.get("/", response_model=list[WorkspaceResponse])
async def list_workspaces(
user: Annotated[User, Depends(get_current_user)],
db: AsyncSession = Depends(get_db),
):
svc = WorkspaceService(db)
personal = await svc.create_personal(user.id)
workspaces = await svc.list_user_workspaces(user.id)
result = []
for ws in workspaces:
members = await svc.list_members(ws.id)
result.append(_ws_to_response(ws, member_count=len(members)))
return result
@router.post("/", response_model=WorkspaceResponse, status_code=status.HTTP_201_CREATED)
async def create_workspace(
body: WorkspaceCreate,
user: Annotated[User, Depends(get_current_user)],
db: AsyncSession = Depends(get_db),
):
svc = WorkspaceService(db)
try:
if body.type == "personal":
ws = await svc.create_personal(user.id, body.name)
else:
ws = await svc.create_team(body.name, user.id)
members = await svc.list_members(ws.id)
return _ws_to_response(ws, member_count=len(members))
except WorkspaceLimitError as e:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=str(e))
except WorkspaceError as e:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
@router.get("/{workspace_id}", response_model=WorkspaceResponse)
async def get_workspace(
workspace_id: str,
user: Annotated[User, Depends(get_current_user)],
db: AsyncSession = Depends(get_db),
):
svc = WorkspaceService(db)
ws = await svc.get_by_id(UUID(workspace_id))
if not ws:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Workspace not found")
membership = await svc.get_membership(ws.id, user.id)
if not membership:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Not a member")
members = await svc.list_members(ws.id)
return _ws_to_response(ws, member_count=len(members))
@router.patch("/{workspace_id}", response_model=WorkspaceResponse)
async def update_workspace(
workspace_id: str,
body: WorkspaceUpdate,
user: Annotated[User, Depends(get_current_user)],
db: AsyncSession = Depends(get_db),
):
svc = WorkspaceService(db)
try:
ws = await svc.update_workspace(
UUID(workspace_id), user.id, body.model_dump(exclude_none=True)
)
if not ws:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Workspace not found")
members = await svc.list_members(ws.id)
return _ws_to_response(ws, member_count=len(members))
except WorkspaceError as e:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
@router.delete("/{workspace_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_workspace(
workspace_id: str,
user: Annotated[User, Depends(get_current_user)],
db: AsyncSession = Depends(get_db),
):
svc = WorkspaceService(db)
try:
success = await svc.delete_workspace(UUID(workspace_id), user.id)
if not success:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Workspace not found")
except WorkspaceError as e:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
# ── Members ──
@router.get("/{workspace_id}/members", response_model=list[MemberResponse])
async def list_members(
workspace_id: str,
user: Annotated[User, Depends(get_current_user)],
db: AsyncSession = Depends(get_db),
):
svc = WorkspaceService(db)
ws = await svc.get_by_id(UUID(workspace_id))
if not ws:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Workspace not found")
membership = await svc.get_membership(ws.id, user.id)
if not membership:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Not a member")
members = await svc.list_members(ws.id)
return [MemberResponse(**m) for m in members]
@router.post("/{workspace_id}/members", response_model=MemberResponse, status_code=status.HTTP_201_CREATED)
async def add_member(
workspace_id: str,
body: AddMemberRequest,
user: Annotated[User, Depends(get_current_user)],
db: AsyncSession = Depends(get_db),
):
svc = WorkspaceService(db)
try:
member = await svc.add_member(UUID(workspace_id), UUID(body.user_id), body.role)
members = await svc.list_members(ws_id := UUID(workspace_id))
m = next((m for m in members if m["id"] == str(member.id)), None)
return MemberResponse(**m) if m else MemberResponse(
id=str(member.id), user_id=str(member.user_id),
workspace_id=str(member.workspace_id), role=member.role,
joined_at=member.created_at,
)
except WorkspaceLimitError as e:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=str(e))
except WorkspaceError as e:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
@router.delete("/{workspace_id}/members/{member_id}", status_code=status.HTTP_204_NO_CONTENT)
async def remove_member(
workspace_id: str,
member_id: str,
user: Annotated[User, Depends(get_current_user)],
db: AsyncSession = Depends(get_db),
):
svc = WorkspaceService(db)
success = await svc.remove_member(UUID(workspace_id), UUID(member_id))
if not success:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Member not found")