Initial commit
This commit is contained in:
@@ -0,0 +1,145 @@
|
||||
"""Workspace service: creation, membership, invitation token flow.
|
||||
|
||||
Invitation security contract:
|
||||
- The service generates a random 32-byte token.
|
||||
- Only SHA-256(token) is persisted (token_hash).
|
||||
- The raw token is returned to the caller exactly once and never stored.
|
||||
- Redemption: hash(presented_token) must match stored token_hash.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import secrets
|
||||
from datetime import datetime, timedelta
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
from app.entities.personal_data import PersonalData
|
||||
from app.entities.workspace import Workspace
|
||||
from app.entities.workspace_invitation import WorkspaceInvitation
|
||||
from app.repositories.personal_data_repository import PersonalDataRepository
|
||||
from app.repositories.workspace_repository import (
|
||||
WorkspaceInvitationRepository,
|
||||
WorkspaceRepository,
|
||||
)
|
||||
from app.utils.errors import InvitationError, NotFoundError, ValidationError
|
||||
|
||||
_INVITE_TTL_HOURS = 72
|
||||
|
||||
|
||||
class WorkspaceService:
|
||||
def __init__(
|
||||
self,
|
||||
ws_repo: WorkspaceRepository,
|
||||
inv_repo: WorkspaceInvitationRepository,
|
||||
pd_repo: PersonalDataRepository,
|
||||
) -> None:
|
||||
self._ws = ws_repo
|
||||
self._inv = inv_repo
|
||||
self._pd = pd_repo
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Workspace CRUD
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
async def create_workspace(self, name: str) -> Workspace:
|
||||
ws = Workspace(id=uuid4(), name=name, created_at=datetime.now())
|
||||
return await self._ws.create(ws)
|
||||
|
||||
async def get_workspace(self, workspace_id: UUID) -> Workspace:
|
||||
ws = await self._ws.get_by_id(workspace_id)
|
||||
if ws is None:
|
||||
raise NotFoundError(f"Workspace {workspace_id} not found")
|
||||
return ws
|
||||
|
||||
async def update_workspace(
|
||||
self, workspace_id: UUID, name: str | None = None, planning_goal: str | None = None
|
||||
) -> Workspace:
|
||||
ws = await self.get_workspace(workspace_id)
|
||||
if name is not None:
|
||||
ws.name = name
|
||||
if planning_goal is not None:
|
||||
valid = {"max_time", "max_money", "balanced"}
|
||||
if planning_goal not in valid:
|
||||
raise ValidationError(f"planning_goal must be one of {valid}")
|
||||
ws.planning_goal = planning_goal
|
||||
return await self._ws.update(workspace_id, ws)
|
||||
|
||||
async def list_members(self, workspace_id: UUID) -> list[PersonalData]:
|
||||
"""Return all (non-deleted) parent profiles in the workspace."""
|
||||
return await self._pd.list_by_workspace(workspace_id)
|
||||
|
||||
async def add_placeholder_parent(self, workspace_id: UUID, display_name: str) -> PersonalData:
|
||||
"""Create a parent profile without a login (solo planning for a partner)."""
|
||||
await self.get_workspace(workspace_id) # verify workspace exists
|
||||
pd = PersonalData(
|
||||
uuid=uuid4(),
|
||||
workspace_id=workspace_id,
|
||||
name=display_name,
|
||||
created_at=datetime.now(),
|
||||
)
|
||||
return await self._pd.create(pd)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Invitation flow
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
async def invite_partner(self, workspace_id: UUID, invited_by_user_id: UUID) -> str:
|
||||
"""Generate a single-use invite token. Returns the RAW token (share out-of-band).
|
||||
|
||||
Only the SHA-256 hash is stored. The raw token is lost after this call.
|
||||
"""
|
||||
await self.get_workspace(workspace_id)
|
||||
raw_token = secrets.token_urlsafe(32)
|
||||
token_hash = _sha256(raw_token)
|
||||
invitation = WorkspaceInvitation(
|
||||
id=uuid4(),
|
||||
workspace_id=workspace_id,
|
||||
token_hash=token_hash,
|
||||
invited_by_user_id=invited_by_user_id,
|
||||
status="pending",
|
||||
expires_at=datetime.now() + timedelta(hours=_INVITE_TTL_HOURS),
|
||||
created_at=datetime.now(),
|
||||
)
|
||||
await self._inv.create(invitation)
|
||||
return raw_token
|
||||
|
||||
async def redeem_invitation(
|
||||
self, raw_token: str, personal_data_uuid: UUID
|
||||
) -> WorkspaceInvitation:
|
||||
"""Link personal_data to the workspace and mark invitation accepted."""
|
||||
token_hash = _sha256(raw_token)
|
||||
inv = await self._inv.get_by_token_hash(token_hash)
|
||||
|
||||
if inv is None:
|
||||
raise InvitationError("Invitation not found")
|
||||
if inv.status != "pending":
|
||||
raise InvitationError(f"Invitation is {inv.status}")
|
||||
if datetime.now() > inv.expires_at:
|
||||
inv.status = "expired"
|
||||
await self._inv.update(inv.id, inv)
|
||||
raise InvitationError("Invitation has expired")
|
||||
|
||||
# Link the personal_data to the workspace.
|
||||
pd = await self._pd.get_by_id(personal_data_uuid)
|
||||
if pd is None:
|
||||
raise NotFoundError(f"PersonalData {personal_data_uuid} not found")
|
||||
pd.workspace_id = inv.workspace_id
|
||||
await self._pd.update(pd.uuid, pd)
|
||||
|
||||
inv.status = "accepted"
|
||||
inv.accepted_at = datetime.now()
|
||||
return await self._inv.update(inv.id, inv)
|
||||
|
||||
async def revoke_invitation(self, invitation_id: UUID) -> None:
|
||||
inv = await self._inv.get_by_id(invitation_id)
|
||||
if inv is None:
|
||||
raise NotFoundError(f"Invitation {invitation_id} not found")
|
||||
if inv.status != "pending":
|
||||
raise ValidationError(f"Cannot revoke a {inv.status} invitation")
|
||||
inv.status = "revoked"
|
||||
await self._inv.update(inv.id, inv)
|
||||
|
||||
|
||||
def _sha256(value: str) -> str:
|
||||
return hashlib.sha256(value.encode()).hexdigest()
|
||||
Reference in New Issue
Block a user