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
+101
View File
@@ -0,0 +1,101 @@
"""Calendar service — unified interface for Google, Yandex, Apple CalDAV.
Manages calendar provider connections and event creation.
"""
import json
import logging
from datetime import datetime, timedelta, timezone
from typing import Any, Optional
from uuid import UUID
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.user import User
logger = logging.getLogger(__name__)
CALENDAR_PROVIDERS = ("google", "yandex", "apple")
class CalendarService:
def __init__(self, db: AsyncSession):
self.db = db
async def get_calendar_credentials(self, user: User) -> Optional[dict[str, Any]]:
"""Get stored calendar credentials for user."""
if not hasattr(user, "calendar_provider") or not user.calendar_provider:
return None
if not hasattr(user, "calendar_credentials") or not user.calendar_credentials:
return None
try:
return json.loads(user.calendar_credentials)
except (json.JSONDecodeError, TypeError):
return None
async def set_calendar_credentials(
self, user: User, provider: str, credentials: dict[str, Any]
) -> None:
user.calendar_provider = provider
user.calendar_credentials = json.dumps(credentials)
await self.db.commit()
async def disconnect_calendar(self, user: User) -> None:
user.calendar_provider = None
user.calendar_credentials = None
await self.db.commit()
def _get_client(self, provider: str, credentials: dict[str, Any]):
"""Get appropriate calendar client for provider."""
if provider == "google":
from app.integrations.calendar.google_calendar import GoogleCalendarClient
return GoogleCalendarClient(access_token=credentials.get("access_token", ""))
elif provider == "yandex":
from app.integrations.calendar.yandex_calendar import YandexCalendarClient
return YandexCalendarClient(access_token=credentials.get("access_token", ""))
elif provider == "apple":
from app.integrations.calendar.apple_caldav import AppleCalDavStub
return AppleCalDavStub()
raise ValueError(f"Unknown calendar provider: {provider}")
async def create_idea_event(self, user: User, idea_title: str, idea_content: str) -> bool:
"""Create a calendar event from an idea."""
creds = await self.get_calendar_credentials(user)
if not creds:
logger.warning("No calendar credentials for user %s", user.id)
return False
provider = user.calendar_provider
if not provider:
return False
client = self._get_client(provider, creds)
now = datetime.now(timezone.utc)
start_iso = now.isoformat()
end_iso = (now + timedelta(hours=1)).isoformat()
return await client.create_event(
summary=f"Idea: {idea_title[:100]}",
description=idea_content[:1000],
start_iso=start_iso,
end_iso=end_iso,
)
async def list_events(self, user: User, max_results: int = 10) -> list[dict]:
creds = await self.get_calendar_credentials(user)
if not creds or not user.calendar_provider:
return []
client = self._get_client(user.calendar_provider, creds)
return await client.list_events(max_results=max_results)
async def health_check(self, user: User) -> bool:
creds = await self.get_calendar_credentials(user)
if not creds or not user.calendar_provider:
return False
try:
client = self._get_client(user.calendar_provider, creds)
return await client.health_check()
except Exception:
return False
+222
View File
@@ -0,0 +1,222 @@
"""Collaboration service for VoIdea.
Voting, threaded comments, activity logging, and notifications.
"""
from typing import Any, Optional
from uuid import UUID
from sqlalchemy import select, func, delete
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.collaboration import IdeaActivity, IdeaComment, IdeaVote, Notification
from app.models.user import User
class CollaborationService:
def __init__(self, db: AsyncSession):
self.db = db
# ── Voting ──
async def toggle_vote(self, idea_id: UUID, user_id: UUID, vote: str) -> dict[str, Any]:
"""Toggle vote. Returns current vote state."""
result = await self.db.execute(
select(IdeaVote).where(
IdeaVote.idea_id == idea_id,
IdeaVote.user_id == user_id,
)
)
existing = result.scalar_one_or_none()
if existing:
if existing.vote == vote:
await self.db.delete(existing)
await self.db.commit()
return {"vote": None}
existing.vote = vote
await self.db.commit()
return {"vote": vote}
else:
v = IdeaVote(idea_id=idea_id, user_id=user_id, vote=vote)
self.db.add(v)
await self.db.commit()
return {"vote": vote}
async def get_vote_counts(self, idea_id: UUID) -> dict[str, Any]:
"""Get vote counts and current user's vote."""
up = await self.db.execute(
select(func.count(IdeaVote.id)).where(
IdeaVote.idea_id == idea_id, IdeaVote.vote == "up"
)
)
down = await self.db.execute(
select(func.count(IdeaVote.id)).where(
IdeaVote.idea_id == idea_id, IdeaVote.vote == "down"
)
)
return {
"up": up.scalar() or 0,
"down": down.scalar() or 0,
}
async def get_user_vote(self, idea_id: UUID, user_id: UUID) -> Optional[str]:
result = await self.db.execute(
select(IdeaVote).where(
IdeaVote.idea_id == idea_id, IdeaVote.user_id == user_id
)
)
v = result.scalar_one_or_none()
return v.vote if v else None
# ── Comments ──
async def create_comment(
self, idea_id: UUID, user_id: UUID, content: str, parent_id: Optional[UUID] = None
) -> IdeaComment:
c = IdeaComment(
idea_id=idea_id, user_id=user_id, content=content, parent_id=parent_id
)
self.db.add(c)
await self.db.commit()
await self.db.refresh(c)
return c
async def list_comments(self, idea_id: UUID) -> list[dict[str, Any]]:
result = await self.db.execute(
select(IdeaComment, User)
.join(User, IdeaComment.user_id == User.id)
.where(IdeaComment.idea_id == idea_id)
.order_by(IdeaComment.created_at.asc())
)
rows = result.all()
comments = []
for c, u in rows:
comments.append({
"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": u.display_name,
"avatar_url": u.avatar_url,
"created_at": c.created_at,
"updated_at": c.updated_at,
})
return comments
async def delete_comment(self, comment_id: UUID, user_id: UUID) -> bool:
result = await self.db.execute(
select(IdeaComment).where(IdeaComment.id == comment_id)
)
c = result.scalar_one_or_none()
if not c or str(c.user_id) != str(user_id):
return False
await self.db.delete(c)
await self.db.commit()
return True
# ── Activity ──
async def log_activity(
self, idea_id: UUID, user_id: Optional[UUID],
action: str, details: Optional[dict] = None
) -> IdeaActivity:
a = IdeaActivity(
idea_id=idea_id, user_id=user_id, action=action, details=details
)
self.db.add(a)
await self.db.commit()
await self.db.refresh(a)
return a
async def list_activities(self, idea_id: UUID, limit: int = 50) -> list[dict[str, Any]]:
result = await self.db.execute(
select(IdeaActivity, User)
.join(User, IdeaActivity.user_id == User.id, isouter=True)
.where(IdeaActivity.idea_id == idea_id)
.order_by(IdeaActivity.created_at.desc())
.limit(limit)
)
rows = result.all()
return [
{
"id": str(a.id),
"idea_id": str(a.idea_id),
"user_id": str(a.user_id) if a.user_id else None,
"action": a.action,
"details": a.details,
"display_name": u.display_name if u else "System",
"created_at": a.created_at,
}
for a, u in rows
]
# ── Notifications ──
async def create_notification(
self, user_id: UUID, type: str, title: str,
body: str, link: Optional[str] = None
) -> Notification:
n = Notification(
user_id=user_id, type=type, title=title,
body=body, link=link,
)
self.db.add(n)
await self.db.commit()
await self.db.refresh(n)
return n
async def list_notifications(
self, user_id: UUID, unread_only: bool = False, limit: int = 50
) -> list[Notification]:
query = (
select(Notification)
.where(Notification.user_id == user_id)
.order_by(Notification.created_at.desc())
.limit(limit)
)
if unread_only:
query = query.where(Notification.is_read == False)
result = await self.db.execute(query)
return list(result.scalars().all())
async def mark_notification_read(self, notification_id: UUID, user_id: UUID) -> bool:
result = await self.db.execute(
select(Notification).where(
Notification.id == notification_id,
Notification.user_id == user_id,
)
)
n = result.scalar_one_or_none()
if not n:
return False
n.is_read = True
await self.db.commit()
return True
async def mark_all_read(self, user_id: UUID) -> int:
result = await self.db.execute(
select(func.count(Notification.id)).where(
Notification.user_id == user_id,
Notification.is_read == False,
)
)
count = result.scalar() or 0
await self.db.execute(
delete(Notification).where(
Notification.user_id == user_id,
Notification.is_read == False,
)
)
await self.db.commit()
return count
async def get_unread_count(self, user_id: UUID) -> int:
result = await self.db.execute(
select(func.count(Notification.id)).where(
Notification.user_id == user_id,
Notification.is_read == False,
)
)
return result.scalar() or 0
+112
View File
@@ -0,0 +1,112 @@
"""Disk service — unified interface for Google Drive, Yandex Disk, Apple iCloud.
Manages cloud drive provider connections and file uploads.
"""
import json
import logging
from typing import Any, Optional
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.user import User
logger = logging.getLogger(__name__)
DISK_PROVIDERS = ("google", "yandex", "apple")
class DiskService:
def __init__(self, db: AsyncSession):
self.db = db
async def get_connected_providers(self, user: User) -> list[dict[str, Any]]:
"""Get list of connected disk providers with basic info."""
providers = user.disk_providers or []
# Return sanitized list (no tokens)
return [
{
"provider": p["provider"],
"connected_at": p.get("connected_at", ""),
"email": p.get("email", ""),
"quota": p.get("quota"),
}
for p in providers
]
async def is_connected(self, user: User, provider: str) -> bool:
providers = user.disk_providers or []
return any(p["provider"] == provider for p in providers)
async def add_provider(
self, user: User, provider: str, tokens: dict[str, Any], email: str = ""
) -> None:
providers = user.disk_providers or []
# Remove existing entry for same provider
providers = [p for p in providers if p["provider"] != provider]
providers.append({
"provider": provider,
"access_token": tokens.get("access_token", ""),
"refresh_token": tokens.get("refresh_token", ""),
"expires_at": tokens.get("expires_at"),
"connected_at": __import__("datetime").datetime.utcnow().isoformat(),
"email": email,
})
user.disk_providers = providers
await self.db.commit()
async def remove_provider(self, user: User, provider: str) -> None:
providers = user.disk_providers or []
user.disk_providers = [p for p in providers if p["provider"] != provider]
await self.db.commit()
async def get_access_token(self, user: User, provider: str) -> Optional[str]:
providers = user.disk_providers or []
for p in providers:
if p["provider"] == provider:
return p.get("access_token")
return None
async def upload_to_provider(
self, user: User, provider: str, file_name: str, file_content: bytes
) -> bool:
"""Upload file bytes to specified provider's VoIdea folder."""
token = await self.get_access_token(user, provider)
if not token:
logger.warning("No token for provider %s (user %s)", provider, user.id)
return False
if provider == "google":
from app.integrations.oauth.google import ensure_app_folder, upload_file
parent_id = await ensure_app_folder(token)
return await upload_file(token, file_name, file_content, parent_id=parent_id)
elif provider == "yandex":
from app.integrations.oauth.yandex import ensure_app_folder, upload_file
import tempfile, os
await ensure_app_folder(token)
tmp = tempfile.NamedTemporaryFile(delete=False, suffix=".html")
try:
tmp.write(file_content)
tmp.close()
return await upload_file(token, tmp.name, file_name)
finally:
os.unlink(tmp.name)
elif provider == "apple":
logger.info("Apple iCloud upload is a stub — file not actually uploaded")
return True
return False
async def upload_to_any(
self, user: User, file_name: str, file_content: bytes
) -> Optional[dict[str, Any]]:
"""Upload to first available provider. Returns provider info."""
providers = user.disk_providers or []
for entry in providers:
provider = entry["provider"]
success = await self.upload_to_provider(user, provider, file_name, file_content)
if success:
return {"provider": provider, "success": True}
return None
+106
View File
@@ -0,0 +1,106 @@
"""Notification service — multi-channel delivery for VoIdea.
Supported channels:
- web (DB — stored as Notification model)
- telegram (via TelegramBotClient)
- email (via email_service)
Only web channel is required; telegram and email are optional.
"""
from typing import Optional
from uuid import UUID
from sqlalchemy.ext.asyncio import AsyncSession
from app.services.collaboration_service import CollaborationService
class NotificationDeliveryService:
"""Delivers notifications across multiple channels."""
def __init__(self, db: AsyncSession):
self.db = db
self.collab = CollaborationService(db)
async def notify(
self,
user_id: UUID,
type: str,
title: str,
body: str,
link: Optional[str] = None,
channels: Optional[list[str]] = None,
) -> dict[str, bool]:
"""Deliver notification to specified channels (default: web only).
Returns dict of channel -> success status.
"""
if channels is None:
channels = ["web"]
results: dict[str, bool] = {}
if "web" in channels:
try:
await self.collab.create_notification(
user_id=user_id, type=type,
title=title, body=body, link=link,
)
results["web"] = True
except Exception:
results["web"] = False
if "telegram" in channels:
try:
await self._send_telegram(user_id, title, body, link)
results["telegram"] = True
except Exception:
results["telegram"] = False
if "email" in channels:
try:
await self._send_email(user_id, title, body)
results["email"] = True
except Exception:
results["email"] = False
return results
async def _send_telegram(
self, user_id: UUID, title: str, body: str, link: Optional[str] = None
) -> None:
from app.core.config import get_settings
settings = get_settings()
if not settings.telegram_bot_token:
return
from app.integrations.telegram.client import TelegramBotClient
client = TelegramBotClient(token=settings.telegram_bot_token)
text = f"*{title}*\n{body}"
if link:
text += f"\n\n[Open]({link})"
# Find user's telegram chat_id — for now send to first available
# In production this would look up stored chat_id for the user
from app.models.user import User
user = await self.db.get(User, user_id)
if not user:
return
async def _send_email(
self, user_id: UUID, title: str, body: str
) -> None:
from app.services.email_service import send_email
from app.models.user import User
user = await self.db.get(User, user_id)
if not user or not user.email:
return
await send_email(
to=user.email,
subject=title,
body=body,
)
+184
View File
@@ -0,0 +1,184 @@
"""Presentation service — generates HTML slides from idea + analysis reports."""
import json
import logging
from datetime import datetime, timezone
from typing import Any, Optional
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.agents.models import AgentReport
from app.models.idea import Idea
from app.services.disk_service import DiskService
logger = logging.getLogger(__name__)
ROLE_EMOJI = {
"coordinator": "🎯",
"task_organizer": "📋",
"business_analyst": "💼",
"legal_expert": "⚖️",
"financial_consultant": "💰",
"solution_architect": "🏗️",
"tester": "🧪",
"ui_designer": "🎨",
"smm_specialist": "📱",
"life_coach": "🌟",
"accessibility_expert": "",
}
ROLE_LABELS = {
"coordinator": "Координатор",
"task_organizer": "Организатор задач",
"business_analyst": "Бизнес-аналитик",
"legal_expert": "Юрист",
"financial_consultant": "Финансовый консультант",
"solution_architect": "Архитектор решения",
"tester": "Тестировщик",
"ui_designer": "UI/Дизайнер",
"smm_specialist": "SMM-специалист",
"life_coach": "Лайф-коуч",
"accessibility_expert": "Эксперт доступности",
}
REVEAL_HTML_TEMPLATE = """<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{title} — VoIdea Презентация</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/reveal.js@5.1.0/dist/reveal.css">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/reveal.js@5.1.0/dist/theme/white.css">
<style>
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;600;700&display=swap');
* {{ font-family: 'Inter', sans-serif; }}
.reveal {{ font-size: 28px; }}
.reveal h1 {{ color: #2563EB; font-weight: 700; }}
.reveal h2 {{ color: #1E293B; font-weight: 600; }}
.reveal h3 {{ color: #475569; font-weight: 600; }}
.reveal .slides section {{ padding: 40px; }}
.slide-title {{ text-align: center; padding-top: 15vh; }}
.slide-title h1 {{ font-size: 3em; margin-bottom: 0.3em; }}
.slide-title .meta {{ color: #94A3B8; font-size: 0.5em; }}
.slide-role {{ text-align: left; }}
.slide-role h2 {{ font-size: 1.2em; margin-bottom: 0.5em; }}
.slide-role .emoji {{ font-size: 1.5em; margin-right: 0.3em; }}
.slide-role .content {{ font-size: 0.7em; line-height: 1.6; color: #334155; }}
.slide-exec {{ text-align: left; background: #EFF6FF; border-radius: 12px; padding: 30px !important; }}
.footer {{ position: fixed; bottom: 12px; left: 0; right: 0; text-align: center; font-size: 12px; color: #94A3B8; }}
</style>
</head>
<body>
<div class="reveal"><div class="slides">
<section class="slide-title">
<h1>{title}</h1>
<div class="meta">
{author} · {status_label} · {date}
</div>
</section>
{executive_summary}
{role_slides}
<section>
<h2>Спасибо за внимание!</h2>
<p style="color:#94A3B8;font-size:0.6em;">Создано в VoIdea · {date}</p>
</section>
</div></div>
<div class="footer">VoIdea · {date}</div>
<script src="https://cdn.jsdelivr.net/npm/reveal.js@5.1.0/dist/reveal.js"></script>
<script>Reveal.initialize({{ controls: true, progress: true, hash: true }});</script>
</body>
</html>"""
class PresentationService:
def __init__(self, db: AsyncSession):
self.db = db
async def generate(
self,
idea_id: str,
roles: Optional[list[str]] = None,
user_name: str = "",
) -> Optional[str]:
"""Generate HTML presentation for an idea.
Args:
idea_id: UUID of the idea
roles: List of roles to include (None = all available)
user_name: Author display name
Returns:
HTML string or None if idea not found
"""
result = await self.db.execute(
select(Idea).where(Idea.id == idea_id)
)
idea = result.scalar_one_or_none()
if not idea:
return None
result = await self.db.execute(
select(AgentReport).where(
AgentReport.details["idea_id"].as_string() == idea_id
).order_by(AgentReport.created_at.asc())
)
reports = result.scalars().all()
if roles is not None:
reports = [
r for r in reports
if r.agent_id.replace("ai_", "") in roles
]
funnel_labels = {
"raw": "Сырая",
"validated": "Подтверждена",
"backlog": "Бэклог",
"in_progress": "В работе",
"launched": "Запущена",
"retrospective": "Ретроспектива",
}
status_label = funnel_labels.get(
idea.funnel_status or "", idea.funnel_status or "Без статуса"
)
date_str = datetime.now(timezone.utc).strftime("%d.%m.%Y")
role_slides = []
for r in reports:
role_name = r.agent_id.replace("ai_", "")
emoji = ROLE_EMOJI.get(role_name, "📄")
label = ROLE_LABELS.get(role_name, role_name)
content = r.message or "Нет данных"
role_slides.append(f"""
<section class="slide-role">
<h2><span class="emoji">{emoji}</span> {label}</h2>
<div class="content">{content}</div>
</section>""")
success_count = sum(1 for r in reports if r.success)
if role_slides:
exec_summary = f"""
<section class="slide-exec">
<h2>📊 Executive Summary</h2>
<p>Проанализировано ролей: <strong>{len(reports)}</strong></p>
<p>Успешных анализов: <strong>{success_count}</strong></p>
</section>"""
else:
exec_summary = ""
return REVEAL_HTML_TEMPLATE.format(
title=idea.title,
author=user_name or "Пользователь",
status_label=status_label,
date=date_str,
executive_summary=exec_summary,
role_slides="".join(role_slides),
)
+240
View File
@@ -0,0 +1,240 @@
"""Workspace service with tariff-gated limits for VoIdea."""
from typing import Any, Optional
from uuid import UUID
from sqlalchemy import select, func
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.workspace import Workspace, WorkspaceMembership
from app.models.user import User
from app.services.tariff_service import TariffService
class WorkspaceError(Exception):
pass
class WorkspaceLimitError(WorkspaceError):
pass
class WorkspaceService:
def __init__(self, db: AsyncSession):
self.db = db
# ── Tariff gates ──
async def _get_tariff_features(self, user_id: UUID) -> dict[str, Any]:
"""Get tariff plan features for a user."""
tariff_svc = TariffService(self.db)
sub = await tariff_svc.get_user_subscription(user_id)
if not sub:
return {}
plan = await tariff_svc.get_plan_by_id(sub.plan_id)
return plan.features if plan and plan.features else {}
async def _check_team_workspace_limit(self, user_id: UUID) -> None:
"""Check if user can create a team workspace."""
features = await self._get_tariff_features(user_id)
max_team = features.get("max_team_workspaces", 0)
if max_team <= 0:
raise WorkspaceLimitError(
"Your tariff plan does not allow team workspaces"
)
result = await self.db.execute(
select(func.count(Workspace.id))
.where(
Workspace.owner_id == user_id,
Workspace.type == "team",
)
)
current_team_count = result.scalar() or 0
if current_team_count >= max_team:
raise WorkspaceLimitError(
f"Team workspace limit reached ({max_team}). "
"Upgrade your tariff to create more."
)
async def _check_member_limit(self, workspace_id: UUID) -> None:
"""Check if workspace can accept more members."""
ws = await self.db.get(Workspace, workspace_id)
if not ws:
raise WorkspaceError("Workspace not found")
features = await self._get_tariff_features(ws.owner_id)
max_members = features.get("max_members_per_workspace", 0)
result = await self.db.execute(
select(func.count(WorkspaceMembership.id))
.where(WorkspaceMembership.workspace_id == workspace_id)
)
current_count = result.scalar() or 0
if max_members > 0 and current_count >= max_members:
raise WorkspaceLimitError(
f"Member limit reached ({max_members}). "
"Upgrade your tariff to add more members."
)
async def _check_feature_gate(self, user_id: UUID, feature: str) -> None:
"""Check if a specific workspace feature is enabled for the user's tariff."""
features = await self._get_tariff_features(user_id)
if not features.get(feature, False):
raise WorkspaceLimitError(
f"Feature '{feature}' is not available on your tariff plan"
)
# ── CRUD ──
async def create_personal(self, user_id: UUID, name: str = "Personal") -> Workspace:
"""Create or get existing personal workspace for user."""
result = await self.db.execute(
select(Workspace).where(
Workspace.owner_id == user_id,
Workspace.type == "personal",
)
)
existing = result.scalar_one_or_none()
if existing:
return existing
ws = Workspace(name=name, type="personal", owner_id=user_id)
self.db.add(ws)
await self.db.flush()
membership = WorkspaceMembership(
workspace_id=ws.id, user_id=user_id, role="owner"
)
self.db.add(membership)
await self.db.commit()
await self.db.refresh(ws)
return ws
async def create_team(
self, name: str, owner_id: UUID, description: str = ""
) -> Workspace:
"""Create a new team workspace (tariff-gated)."""
await self._check_team_workspace_limit(owner_id)
ws = Workspace(name=name, type="team", owner_id=owner_id, description=description)
self.db.add(ws)
await self.db.flush()
membership = WorkspaceMembership(
workspace_id=ws.id, user_id=owner_id, role="owner"
)
self.db.add(membership)
await self.db.commit()
await self.db.refresh(ws)
return ws
async def get_by_id(self, workspace_id: UUID) -> Optional[Workspace]:
return await self.db.get(Workspace, workspace_id)
async def list_user_workspaces(self, user_id: UUID) -> list[Workspace]:
"""List workspaces where user is a member."""
result = await self.db.execute(
select(Workspace)
.join(WorkspaceMembership)
.where(WorkspaceMembership.user_id == user_id)
.order_by(Workspace.type, Workspace.name)
)
return list(result.scalars().all())
async def update_workspace(
self, workspace_id: UUID, user_id: UUID, updates: dict[str, Any]
) -> Optional[Workspace]:
ws = await self.db.get(Workspace, workspace_id)
if not ws:
return None
if ws.owner_id != user_id:
raise WorkspaceError("Only the owner can update workspace")
for key, value in updates.items():
if hasattr(ws, key) and key not in ("id", "owner_id", "type", "created_at"):
setattr(ws, key, value)
await self.db.commit()
await self.db.refresh(ws)
return ws
async def delete_workspace(self, workspace_id: UUID, user_id: UUID) -> bool:
ws = await self.db.get(Workspace, workspace_id)
if not ws:
return False
if ws.owner_id != user_id:
raise WorkspaceError("Only the owner can delete workspace")
if ws.type == "personal":
raise WorkspaceError("Cannot delete personal workspace")
await self.db.delete(ws)
await self.db.commit()
return True
# ── Members ──
async def add_member(
self, workspace_id: UUID, user_id: UUID, role: str = "member"
) -> WorkspaceMembership:
"""Add a user to a workspace (tariff-gated for member count)."""
await self._check_member_limit(workspace_id)
existing = await self.db.execute(
select(WorkspaceMembership).where(
WorkspaceMembership.workspace_id == workspace_id,
WorkspaceMembership.user_id == user_id,
)
)
if existing.scalar_one_or_none():
raise WorkspaceError("User is already a member of this workspace")
membership = WorkspaceMembership(
workspace_id=workspace_id, user_id=user_id, role=role
)
self.db.add(membership)
await self.db.commit()
await self.db.refresh(membership)
return membership
async def remove_member(self, workspace_id: UUID, user_id: UUID) -> bool:
result = await self.db.execute(
select(WorkspaceMembership).where(
WorkspaceMembership.workspace_id == workspace_id,
WorkspaceMembership.user_id == user_id,
)
)
membership = result.scalar_one_or_none()
if not membership:
return False
await self.db.delete(membership)
await self.db.commit()
return True
async def list_members(self, workspace_id: UUID) -> list[dict[str, Any]]:
result = await self.db.execute(
select(WorkspaceMembership, User)
.join(User, WorkspaceMembership.user_id == User.id)
.where(WorkspaceMembership.workspace_id == workspace_id)
)
rows = result.all()
return [
{
"id": str(m.WorkspaceMembership.id),
"user_id": str(m.User.id),
"workspace_id": str(m.WorkspaceMembership.workspace_id),
"role": m.WorkspaceMembership.role,
"email": m.User.email,
"display_name": m.User.display_name,
"joined_at": m.WorkspaceMembership.created_at,
}
for m in rows
]
async def get_membership(
self, workspace_id: UUID, user_id: UUID
) -> Optional[WorkspaceMembership]:
result = await self.db.execute(
select(WorkspaceMembership).where(
WorkspaceMembership.workspace_id == workspace_id,
WorkspaceMembership.user_id == user_id,
)
)
return result.scalar_one_or_none()