feat: deploy infrastructure + disk/drive, calendar, presentation, workspaces, onboarding, demo user
This commit is contained in:
@@ -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"]
|
||||
|
||||
@@ -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])
|
||||
|
||||
@@ -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}
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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}
|
||||
@@ -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)],
|
||||
|
||||
@@ -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")
|
||||
Reference in New Issue
Block a user