Files
F-r-ldraledigplannerare/backend/app/services/workspace_service.py
T
jacobh2 76b1e712b9 Complete NextSteps roadmap: planning engine UI, calendar cluster, flexible wishes
Works through every numbered item in NextSteps260913.md section 3, plus the
three known verification issues in section 4.1.

Surface what the backend already computed:
- 3.1 render surplus_advice in PlanResults, mirroring the shortfall remedies
- 3.2 surface VacationBalance.is_assumed so assumed statutory years are visible
- 3.3 age-4 expiry given the proposed plan, via a new Age4Projection reusing
  the existing _age4_projections arithmetic (Dashboard stat verified, not rebuilt)

Calendar cluster (one pass over the holiday data):
- new GET /api/calendar/days exposing roda dagar, klamdagar and day overrides,
  paginated four years at a time
- 3.4 shade roda dagar/klamdagar, with R/K badges so colour is not the only signal
- 3.6 click-to-override behind an explicit mode toggle, leaving drag multi-select intact
- 3.7 click month/year to advance
- 3.8 per-year red-days list in Settings, statutory days plus overrides

i18n:
- 3.5 five backend-generated Swedish surfaces converted to message_key + data,
  following the tips_service pattern; old string fields replaced, not kept

Flexible wishes (3.9):
- PLANNING_ENGINE_SPEC section 10: window_mode, candidate search, cost function,
  deterministic tie-break, two-pass ordering, test matrix rows 15-24
- backend: entity, migration 006, _FULL_SCHEMA sync, repository, allocator
- frontend: season-preset toggle in WishEditor, unplaced outcome rendering

Fixes (4.1):
- pin typecheck to the local tsc rather than npx resolving a newer TypeScript
- ignore generated coverage/ and dist/ in the ESLint flat config
- fix the flaky usePlanApply test properly, with waitFor rather than a retry

Tests: backend 361 -> 404, frontend 540 -> 704. Coverage 89.47% -> 96.52% lines.

Not yet verified in a browser.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RXMhc9nT6sNz1SL4j8dznL
2026-09-14 02:33:03 +02:00

155 lines
5.8 KiB
Python

"""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,
planning_mode: 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
if planning_mode is not None:
valid_modes = {"solo", "couple"}
if planning_mode not in valid_modes:
raise ValidationError(f"planning_mode must be one of {valid_modes}")
ws.planning_mode = planning_mode
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()