Harden the API and the data layer after a security and performance audit
Security - Every route now requires a login and refuses ids outside the caller's workspace with 404: plan, leave-wishes, day-overrides and vacation-days were fully anonymous; every other router trusted client-supplied workspace/child/parent ids. Scraper sessions are bound to their owner. - Invitations can no longer be created for a foreign workspace, and a redeemer who is already a member (or a full workspace) is refused. - Production refuses the default JWT secret; API docs are hidden in production; a failed migration aborts startup instead of serving. - Login and register no longer reveal whether an email exists; inactive accounts are rejected; /auth/refresh is rate limited. - The FK scraper writes debug artefacts only when explicitly enabled, logs no scraped values or page text, clears the BankID QR as soon as it is scanned and purges finished sessions after five minutes. - Real FK page captures are removed from the tree (history rewrite follows); the parser tests use a synthetic fixture. Performance and correctness - Plan apply/sync run inside one locked transaction with batched writes instead of one commit per row, so a failure mid-batch leaves nothing half-applied and concurrent syncs no longer corrupt each other. - SQLite runs in WAL mode with a busy timeout; 14 indexes cover the filters that were full scans; argon2 hashing runs off the event loop; responses are gzipped. - Frontend: a 401 now refreshes the token once and retries, so sessions no longer die silently after 15 minutes; every data hook discards stale responses; logout clears state even when the server call fails. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CsXCCcDfmV4Sps4ZQSvNEY
This commit is contained in:
@@ -92,13 +92,17 @@ class AnalyticsService:
|
||||
return resolve_age4_savable_cap(planning_mode, len(members))
|
||||
|
||||
async def get_days_left_at_age_4(
|
||||
self, personal_data_uuid: UUID, child_id: UUID
|
||||
self, personal_data_uuid: UUID, child_id: UUID, workspace_id: UUID | None = None
|
||||
) -> Age4Projection:
|
||||
"""Compute the headline projection for a single parent+child pair."""
|
||||
"""Compute the headline projection for a single parent+child pair.
|
||||
|
||||
`workspace_id`, when given, must match the child's own — a foreign
|
||||
workspace's child 404s exactly like a nonexistent one.
|
||||
"""
|
||||
rules = settings.rules
|
||||
|
||||
child = await self._children.get_by_id(child_id)
|
||||
if child is None:
|
||||
if child is None or (workspace_id is not None and child.workspace_id != workspace_id):
|
||||
raise NotFoundError(f"Child {child_id} not found")
|
||||
|
||||
# Compute the 4th birthday with leap-year-safe arithmetic.
|
||||
|
||||
@@ -11,6 +11,7 @@ TODO (post-MVP): password reset + email verification flows are not implemented.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
@@ -23,12 +24,19 @@ from app.entities.personal_data import PersonalData
|
||||
from app.entities.user import User
|
||||
from app.entities.workspace import Workspace
|
||||
from app.repositories.personal_data_repository import PersonalDataRepository
|
||||
from app.repositories.unit_of_work import NoOpUnitOfWork, UnitOfWork
|
||||
from app.repositories.user_repository import UserRepository
|
||||
from app.repositories.workspace_repository import WorkspaceRepository
|
||||
from app.utils.errors import AuthError, ConflictError, UnauthorizedError
|
||||
|
||||
_ph = PasswordHasher()
|
||||
|
||||
# Precomputed at import time so a login against an unknown email still pays the
|
||||
# cost of an argon2 verify (against a hash nobody's password can match) — this
|
||||
# keeps the response-time profile of "unknown email" indistinguishable from
|
||||
# "known email, wrong password" and closes the user-enumeration timing oracle.
|
||||
_DUMMY_HASH = _ph.hash(uuid4().hex)
|
||||
|
||||
|
||||
class AuthService:
|
||||
def __init__(
|
||||
@@ -36,10 +44,16 @@ class AuthService:
|
||||
user_repo: UserRepository,
|
||||
personal_data_repo: PersonalDataRepository,
|
||||
workspace_repo: WorkspaceRepository,
|
||||
uow: UnitOfWork | None = None,
|
||||
) -> None:
|
||||
self._users = user_repo
|
||||
self._personal_data = personal_data_repo
|
||||
self._workspaces = workspace_repo
|
||||
# Defaults to a no-op so pre-existing direct-construction call sites
|
||||
# (scripts/seed_demo.py, test fixtures) keep today's per-write-commit
|
||||
# behavior; `app.dependencies.get_auth_service` always wires a real
|
||||
# `SQLiteUnitOfWork`.
|
||||
self._uow = uow or NoOpUnitOfWork()
|
||||
|
||||
async def register(
|
||||
self, email: str, password: str, display_name: str
|
||||
@@ -47,45 +61,66 @@ class AuthService:
|
||||
"""Create User + personal workspace + PersonalData. Raises ConflictError if email taken."""
|
||||
existing = await self._users.get_by_email(email)
|
||||
if existing is not None:
|
||||
raise ConflictError(f"Email already registered: {email}")
|
||||
raise ConflictError("Email already registered")
|
||||
|
||||
# Create a personal workspace for the registering user.
|
||||
workspace = Workspace(
|
||||
id=uuid4(),
|
||||
name=f"{display_name}s planering",
|
||||
created_at=datetime.now(),
|
||||
)
|
||||
workspace = await self._workspaces.create(workspace)
|
||||
# Hash before opening the unit of work: argon2 is CPU work
|
||||
# (offloaded via to_thread), not a DB write — no reason to hold the
|
||||
# write lock for it.
|
||||
hashed_password = await asyncio.to_thread(_ph.hash, password)
|
||||
|
||||
pd = PersonalData(
|
||||
uuid=uuid4(),
|
||||
workspace_id=workspace.id,
|
||||
name=display_name,
|
||||
created_at=datetime.now(),
|
||||
)
|
||||
pd = await self._personal_data.create(pd)
|
||||
# Workspace + PersonalData + User must land together: a crash between
|
||||
# any two of these would otherwise leave an orphaned workspace, a
|
||||
# personal_data row with no login, or (with the FK ON DELETE CASCADE)
|
||||
# a user pointing at nothing.
|
||||
async with self._uow():
|
||||
# Create a personal workspace for the registering user.
|
||||
workspace = Workspace(
|
||||
id=uuid4(),
|
||||
name=f"{display_name}s planering",
|
||||
created_at=datetime.now(),
|
||||
)
|
||||
workspace = await self._workspaces.create(workspace)
|
||||
|
||||
user = User(
|
||||
id=uuid4(),
|
||||
personal_data_uuid=pd.uuid,
|
||||
email=email,
|
||||
hashed_password=_ph.hash(password),
|
||||
created_at=datetime.now(),
|
||||
)
|
||||
user = await self._users.create(user)
|
||||
pd = PersonalData(
|
||||
uuid=uuid4(),
|
||||
workspace_id=workspace.id,
|
||||
name=display_name,
|
||||
created_at=datetime.now(),
|
||||
)
|
||||
pd = await self._personal_data.create(pd)
|
||||
|
||||
user = User(
|
||||
id=uuid4(),
|
||||
personal_data_uuid=pd.uuid,
|
||||
email=email,
|
||||
hashed_password=hashed_password,
|
||||
created_at=datetime.now(),
|
||||
)
|
||||
user = await self._users.create(user)
|
||||
return user, pd
|
||||
|
||||
async def login(self, email: str, password: str) -> User:
|
||||
"""Verify credentials. Raises AuthError on wrong email or password."""
|
||||
"""Verify credentials. Raises AuthError on wrong email/password/inactive account.
|
||||
|
||||
Always uses a generic message so unknown-email and wrong-password are
|
||||
indistinguishable, and always runs an argon2 verify (real or dummy) so
|
||||
the two cases take comparable time.
|
||||
"""
|
||||
user = await self._users.get_by_email(email)
|
||||
if user is None:
|
||||
try:
|
||||
await asyncio.to_thread(_ph.verify, _DUMMY_HASH, password)
|
||||
except VerifyMismatchError:
|
||||
pass
|
||||
raise AuthError("Invalid email or password")
|
||||
try:
|
||||
_ph.verify(user.hashed_password, password)
|
||||
await asyncio.to_thread(_ph.verify, user.hashed_password, password)
|
||||
except VerifyMismatchError as exc:
|
||||
raise AuthError("Invalid email or password") from exc
|
||||
if not user.is_active:
|
||||
raise UnauthorizedError("Account is inactive")
|
||||
if _ph.check_needs_rehash(user.hashed_password):
|
||||
user.hashed_password = _ph.hash(password)
|
||||
user.hashed_password = await asyncio.to_thread(_ph.hash, password)
|
||||
await self._users.update(user.id, user)
|
||||
return user
|
||||
|
||||
@@ -117,6 +152,8 @@ class AuthService:
|
||||
user = await self._users.get_by_id(user_id)
|
||||
if user is None:
|
||||
raise UnauthorizedError("User not found")
|
||||
if not user.is_active:
|
||||
raise UnauthorizedError("Account is inactive")
|
||||
return user
|
||||
|
||||
async def refresh_access_token(self, refresh_token: str) -> str:
|
||||
|
||||
@@ -37,10 +37,12 @@ class ChildService:
|
||||
)
|
||||
return await self._repo.create(entity)
|
||||
|
||||
async def get(self, child_id: UUID) -> Child:
|
||||
"""Get a child by ID."""
|
||||
async def get(self, child_id: UUID, workspace_id: UUID | None = None) -> Child:
|
||||
"""Get a child by ID. `workspace_id`, when given, must match the
|
||||
child's own — a foreign workspace's child 404s exactly like a
|
||||
nonexistent one."""
|
||||
entity = await self._repo.get_by_id(child_id)
|
||||
if entity is None:
|
||||
if entity is None or (workspace_id is not None and entity.workspace_id != workspace_id):
|
||||
raise NotFoundError(f"Child {child_id} not found")
|
||||
return entity
|
||||
|
||||
@@ -55,9 +57,10 @@ class ChildService:
|
||||
birth_date=None,
|
||||
preschool_start_date=None,
|
||||
preschool_planning_days_per_year: int | None = None,
|
||||
workspace_id: UUID | None = None,
|
||||
) -> Child:
|
||||
"""Update a child (partial update)."""
|
||||
entity = await self.get(child_id)
|
||||
entity = await self.get(child_id, workspace_id)
|
||||
|
||||
if name is not None:
|
||||
entity.name = name
|
||||
@@ -78,9 +81,9 @@ class ChildService:
|
||||
|
||||
return await self._repo.update(child_id, entity)
|
||||
|
||||
async def delete(self, child_id: UUID) -> None:
|
||||
async def delete(self, child_id: UUID, workspace_id: UUID | None = None) -> None:
|
||||
"""Delete a child."""
|
||||
await self.get(child_id) # raises NotFoundError if missing
|
||||
await self.get(child_id, workspace_id) # raises NotFoundError if missing/foreign
|
||||
await self._repo.delete(child_id)
|
||||
|
||||
|
||||
|
||||
@@ -49,16 +49,17 @@ class DayOverrideService:
|
||||
)
|
||||
return await self._repo.create(entity)
|
||||
|
||||
async def get(self, day_override_id: UUID) -> DayOverride:
|
||||
async def get(self, day_override_id: UUID, workspace_id: UUID | None = None) -> DayOverride:
|
||||
entity = await self._repo.get_by_id(day_override_id)
|
||||
if entity is None:
|
||||
if entity is None or (workspace_id is not None and entity.workspace_id != workspace_id):
|
||||
raise NotFoundError(f"DayOverride {day_override_id} not found")
|
||||
return entity
|
||||
|
||||
async def list_for_workspace(self, workspace_id: UUID) -> list[DayOverride]:
|
||||
return await self._repo.list_by_workspace(workspace_id)
|
||||
|
||||
async def delete(self, day_override_id: UUID) -> None:
|
||||
async def delete(self, day_override_id: UUID, workspace_id: UUID | None = None) -> None:
|
||||
await self.get(day_override_id, workspace_id) # raises NotFoundError if missing/foreign
|
||||
await self._repo.delete(day_override_id)
|
||||
|
||||
async def get_effective_map(self, workspace_id: UUID) -> dict[date, bool]:
|
||||
|
||||
@@ -89,9 +89,15 @@ class LeaveWishService:
|
||||
)
|
||||
return await self._repo.create(entity)
|
||||
|
||||
async def get(self, leave_wish_id: UUID) -> LeaveWish:
|
||||
async def get(self, leave_wish_id: UUID, workspace_id: UUID | None = None) -> LeaveWish:
|
||||
"""`workspace_id`, when given, must match the entity's own — callers
|
||||
that already know the caller's tenant (routers) pass it so a foreign
|
||||
workspace's wish 404s exactly like a nonexistent one; internal
|
||||
callers that don't need that check (e.g. `update`'s own re-reads
|
||||
with no tenant context) leave it `None`.
|
||||
"""
|
||||
entity = await self._repo.get_by_id(leave_wish_id)
|
||||
if entity is None:
|
||||
if entity is None or (workspace_id is not None and entity.workspace_id != workspace_id):
|
||||
raise NotFoundError(f"LeaveWish {leave_wish_id} not found")
|
||||
return entity
|
||||
|
||||
@@ -101,6 +107,7 @@ class LeaveWishService:
|
||||
async def update(
|
||||
self,
|
||||
leave_wish_id: UUID,
|
||||
workspace_id: UUID | None = None,
|
||||
child_id: UUID | None = None,
|
||||
name: str | None = None,
|
||||
name_message_key: str | None = None,
|
||||
@@ -138,7 +145,7 @@ class LeaveWishService:
|
||||
`_clear_target_days` resets a day-granular wish back to being sized
|
||||
by `target_weeks` alone.
|
||||
"""
|
||||
entity = await self.get(leave_wish_id)
|
||||
entity = await self.get(leave_wish_id, workspace_id)
|
||||
original_name = entity.name
|
||||
|
||||
if _clear_child_id:
|
||||
@@ -202,7 +209,7 @@ class LeaveWishService:
|
||||
)
|
||||
return await self._repo.update(leave_wish_id, entity)
|
||||
|
||||
async def delete(self, leave_wish_id: UUID) -> None:
|
||||
async def delete(self, leave_wish_id: UUID, workspace_id: UUID | None = None) -> None:
|
||||
"""Delete a wish and every `planned_leave_day` row it produced.
|
||||
|
||||
Under the calendar-direct-manipulation UX a period *is* its wish: the
|
||||
@@ -224,7 +231,7 @@ class LeaveWishService:
|
||||
deletes every day in the band regardless of `is_pinned` with no
|
||||
special-casing.
|
||||
"""
|
||||
await self.get(leave_wish_id) # raises NotFoundError if missing
|
||||
await self.get(leave_wish_id, workspace_id) # raises NotFoundError if missing/foreign
|
||||
await self._planned_leave_repo.delete_by_wish(leave_wish_id)
|
||||
await self._repo.delete(leave_wish_id)
|
||||
|
||||
|
||||
@@ -21,9 +21,9 @@ class PersonalDataService:
|
||||
def __init__(self, repo: PersonalDataRepository) -> None:
|
||||
self._repo = repo
|
||||
|
||||
async def get(self, personal_data_uuid: UUID) -> PersonalData:
|
||||
async def get(self, personal_data_uuid: UUID, workspace_id: UUID | None = None) -> PersonalData:
|
||||
entity = await self._repo.get_by_id(personal_data_uuid)
|
||||
if entity is None:
|
||||
if entity is None or (workspace_id is not None and entity.workspace_id != workspace_id):
|
||||
raise NotFoundError(f"PersonalData {personal_data_uuid} not found")
|
||||
return entity
|
||||
|
||||
@@ -33,9 +33,10 @@ class PersonalDataService:
|
||||
salary_type: str | None = None,
|
||||
employment_status: str | None = None,
|
||||
min_vacation_days_per_year: int | None = None,
|
||||
workspace_id: UUID | None = None,
|
||||
) -> PersonalData:
|
||||
"""Partial update. Raises ValidationError/NotFoundError."""
|
||||
entity = await self.get(personal_data_uuid)
|
||||
entity = await self.get(personal_data_uuid, workspace_id)
|
||||
|
||||
if salary_type is not None:
|
||||
entity.salary_type = salary_type
|
||||
|
||||
@@ -10,6 +10,7 @@ from app.config import settings
|
||||
from app.entities.planned_leave import PlannedLeaveDay
|
||||
from app.repositories.parental_leave_repository import ParentalLeaveRepository
|
||||
from app.repositories.planned_leave_repository import PlannedLeaveRepository
|
||||
from app.repositories.unit_of_work import NoOpUnitOfWork, UnitOfWork
|
||||
from app.repositories.vacation_days_repository import VacationDaysRepository
|
||||
from app.services.allocation_service import PlanningEngineService, PlanProposal
|
||||
from app.services.analytics_service import AnalyticsService
|
||||
@@ -140,6 +141,7 @@ class PlanningService:
|
||||
parental_leave_repo: ParentalLeaveRepository,
|
||||
vacation_repo: VacationDaysRepository,
|
||||
planning_engine_service: PlanningEngineService | None = None,
|
||||
uow: UnitOfWork | None = None,
|
||||
) -> None:
|
||||
self._repo = repo
|
||||
self._analytics = analytics_service
|
||||
@@ -150,6 +152,11 @@ class PlanningService:
|
||||
# ever use the per-day CRUD/`apply_proposed_days` paths don't need to
|
||||
# wire up a `PlanningEngineService` they'll never use.
|
||||
self._engine = planning_engine_service
|
||||
# Defaults to a no-op so pre-existing direct-construction call sites
|
||||
# (scripts/seed_demo.py, test fixtures) keep today's per-write-commit
|
||||
# behavior; `app.dependencies.get_planning_service` always wires a
|
||||
# real `SQLiteUnitOfWork`.
|
||||
self._uow = uow or NoOpUnitOfWork()
|
||||
|
||||
async def add_planned_day(
|
||||
self,
|
||||
@@ -200,11 +207,13 @@ class PlanningService:
|
||||
)
|
||||
return await self._repo.create(entity)
|
||||
|
||||
async def remove_planned_day(self, planned_leave_day_id: UUID) -> None:
|
||||
async def remove_planned_day(
|
||||
self, planned_leave_day_id: UUID, workspace_id: UUID | None = None
|
||||
) -> None:
|
||||
"""Remove a planned leave day."""
|
||||
entity = await self._repo.get_by_id(planned_leave_day_id)
|
||||
if entity is None:
|
||||
raise NotFoundError(f"PlannedLeaveDay {planned_leave_day_id} not found")
|
||||
await self.get(
|
||||
planned_leave_day_id, workspace_id
|
||||
) # raises NotFoundError if missing/foreign
|
||||
await self._repo.delete(planned_leave_day_id)
|
||||
|
||||
async def list_by_workspace(self, workspace_id: UUID) -> list[PlannedLeaveDay]:
|
||||
@@ -226,10 +235,14 @@ class PlanningService:
|
||||
to_date=end_date,
|
||||
)
|
||||
|
||||
async def get(self, planned_leave_day_id: UUID) -> PlannedLeaveDay:
|
||||
"""Get a planned day by ID."""
|
||||
async def get(
|
||||
self, planned_leave_day_id: UUID, workspace_id: UUID | None = None
|
||||
) -> PlannedLeaveDay:
|
||||
"""Get a planned day by ID. `workspace_id`, when given, must match the
|
||||
row's own — a foreign workspace's row 404s exactly like a nonexistent
|
||||
one."""
|
||||
entity = await self._repo.get_by_id(planned_leave_day_id)
|
||||
if entity is None:
|
||||
if entity is None or (workspace_id is not None and entity.workspace_id != workspace_id):
|
||||
raise NotFoundError(f"PlannedLeaveDay {planned_leave_day_id} not found")
|
||||
return entity
|
||||
|
||||
@@ -244,11 +257,10 @@ class PlanningService:
|
||||
is_dubbeldag: bool | None = None,
|
||||
wish_id: UUID | None = None,
|
||||
is_pinned: bool | None = None,
|
||||
workspace_id: UUID | None = None,
|
||||
) -> PlannedLeaveDay:
|
||||
"""Update a planned day (partial update)."""
|
||||
entity = await self._repo.get_by_id(planned_leave_day_id)
|
||||
if entity is None:
|
||||
raise NotFoundError(f"PlannedLeaveDay {planned_leave_day_id} not found")
|
||||
entity = await self.get(planned_leave_day_id, workspace_id)
|
||||
|
||||
if day_type is not None:
|
||||
entity.day_type = day_type
|
||||
@@ -314,10 +326,16 @@ class PlanningService:
|
||||
Validates the entire batch up front (day types, per-day dubbeldagar
|
||||
window, batch-wide dubbeldagar quota, child eligibility window,
|
||||
remaining sjukpenning/lagsta balance, credited for anything in
|
||||
`delete_keys`) and only writes if everything passes — SQLite gives us
|
||||
no cross-request transaction here, so this explicit validate-then-
|
||||
write split is what keeps a rejection from ever leaving partial
|
||||
writes behind.
|
||||
`delete_keys`) and only writes if everything passes. Stages 2-4 below
|
||||
(the classification read through the final write) run inside one
|
||||
`self._uow()` unit of work — a `BEGIN IMMEDIATE` transaction on the
|
||||
shared connection, serialized against any other unit of work via a
|
||||
per-connection lock (see `app.db.connection.unit_of_work`). That lock
|
||||
+ transaction is what actually keeps a rejection, a crash mid-batch,
|
||||
or a concurrent apply/sync on the same workspace from ever leaving
|
||||
partial writes behind; the up-front validate-then-write split alone
|
||||
only ever protected against a *rejected* batch writing anything, not
|
||||
a crashed or interleaved one.
|
||||
"""
|
||||
delete_keys = delete_keys or set()
|
||||
|
||||
@@ -379,149 +397,161 @@ class PlanningService:
|
||||
if not unique_days and not delete_keys:
|
||||
return PlanApplyResult(accepted=True, skipped_free_count=skipped_free_count)
|
||||
|
||||
# -- stage 2: classify against existing rows (collision detection) --
|
||||
by_child: dict[UUID, list[date]] = {}
|
||||
for d in unique_days:
|
||||
by_child.setdefault(d.child_id, []).append(d.date)
|
||||
for child_id, _parent_uuid, day in delete_keys:
|
||||
by_child.setdefault(child_id, []).append(day)
|
||||
# Stages 2-4 run inside one unit of work: a concurrent apply/sync for
|
||||
# this same workspace blocks at `BEGIN IMMEDIATE` (inside `self._uow()`)
|
||||
# until this call commits or rolls back, then re-reads `existing_by_key`
|
||||
# against the now-committed state instead of racing this call's reads.
|
||||
async with self._uow():
|
||||
# -- stage 2: classify against existing rows (collision detection) --
|
||||
by_child: dict[UUID, list[date]] = {}
|
||||
for d in unique_days:
|
||||
by_child.setdefault(d.child_id, []).append(d.date)
|
||||
for child_id, _parent_uuid, day in delete_keys:
|
||||
by_child.setdefault(child_id, []).append(day)
|
||||
|
||||
existing_by_key: dict[tuple[UUID, UUID, date], PlannedLeaveDay] = {}
|
||||
for child_id, dates in by_child.items():
|
||||
existing_rows = await self._repo.list_by_workspace_child(
|
||||
workspace_id=workspace_id,
|
||||
child_id=child_id,
|
||||
from_date=min(dates),
|
||||
to_date=max(dates),
|
||||
)
|
||||
for row in existing_rows:
|
||||
existing_by_key[(row.child_id, row.parent_personal_data_uuid, row.date)] = row
|
||||
|
||||
# Rows named by `delete_keys` that actually exist — a key with no
|
||||
# matching row (already gone) is silently ignored.
|
||||
delete_rows: list[PlannedLeaveDay] = [
|
||||
existing_by_key[key] for key in delete_keys if key in existing_by_key
|
||||
]
|
||||
|
||||
to_create: list[ProposedDayInput] = []
|
||||
to_replace: list[tuple[PlannedLeaveDay, ProposedDayInput]] = []
|
||||
to_update_metadata: list[tuple[PlannedLeaveDay, ProposedDayInput]] = []
|
||||
collisions: list[DayCollision] = []
|
||||
skipped_identical_count = 0
|
||||
|
||||
for d in unique_days:
|
||||
key = (d.child_id, d.parent_personal_data_uuid, d.date)
|
||||
existing = existing_by_key.get(key)
|
||||
if existing is None:
|
||||
to_create.append(d)
|
||||
continue
|
||||
if existing.day_type == d.day_type and existing.is_dubbeldag == d.is_dubbeldag:
|
||||
skipped_identical_count += 1
|
||||
# Identical-vs-collision is (and stays) keyed on
|
||||
# day_type/is_dubbeldag only. A row whose only difference is
|
||||
# `wish_id`/`is_pinned` is never a collision requiring
|
||||
# `replace=True` — it's a plain metadata update, written
|
||||
# below without touching `skipped_identical_count` or
|
||||
# `collisions`.
|
||||
if existing.wish_id != d.wish_id or existing.is_pinned != d.is_pinned:
|
||||
to_update_metadata.append((existing, d))
|
||||
continue
|
||||
collisions.append(
|
||||
DayCollision(
|
||||
date=d.date,
|
||||
parent_personal_data_uuid=d.parent_personal_data_uuid,
|
||||
child_id=d.child_id,
|
||||
existing_day_type=existing.day_type,
|
||||
proposed_day_type=d.day_type,
|
||||
existing_by_key: dict[tuple[UUID, UUID, date], PlannedLeaveDay] = {}
|
||||
for child_id, dates in by_child.items():
|
||||
existing_rows = await self._repo.list_by_workspace_child(
|
||||
workspace_id=workspace_id,
|
||||
child_id=child_id,
|
||||
from_date=min(dates),
|
||||
to_date=max(dates),
|
||||
)
|
||||
)
|
||||
if replace:
|
||||
to_replace.append((existing, d))
|
||||
for row in existing_rows:
|
||||
existing_by_key[(row.child_id, row.parent_personal_data_uuid, row.date)] = row
|
||||
|
||||
if collisions and not replace:
|
||||
return PlanApplyResult(
|
||||
accepted=False,
|
||||
collisions=collisions,
|
||||
rejections=[
|
||||
Rejection(
|
||||
message_key="planApply.rejection.collisionsNeedReplace",
|
||||
data={"count": len(collisions)},
|
||||
# Rows named by `delete_keys` that actually exist — a key with no
|
||||
# matching row (already gone) is silently ignored.
|
||||
delete_rows: list[PlannedLeaveDay] = [
|
||||
existing_by_key[key] for key in delete_keys if key in existing_by_key
|
||||
]
|
||||
|
||||
to_create: list[ProposedDayInput] = []
|
||||
to_replace: list[tuple[PlannedLeaveDay, ProposedDayInput]] = []
|
||||
to_update_metadata: list[tuple[PlannedLeaveDay, ProposedDayInput]] = []
|
||||
collisions: list[DayCollision] = []
|
||||
skipped_identical_count = 0
|
||||
|
||||
for d in unique_days:
|
||||
key = (d.child_id, d.parent_personal_data_uuid, d.date)
|
||||
existing = existing_by_key.get(key)
|
||||
if existing is None:
|
||||
to_create.append(d)
|
||||
continue
|
||||
if existing.day_type == d.day_type and existing.is_dubbeldag == d.is_dubbeldag:
|
||||
skipped_identical_count += 1
|
||||
# Identical-vs-collision is (and stays) keyed on
|
||||
# day_type/is_dubbeldag only. A row whose only difference is
|
||||
# `wish_id`/`is_pinned` is never a collision requiring
|
||||
# `replace=True` — it's a plain metadata update, written
|
||||
# below without touching `skipped_identical_count` or
|
||||
# `collisions`.
|
||||
if existing.wish_id != d.wish_id or existing.is_pinned != d.is_pinned:
|
||||
to_update_metadata.append((existing, d))
|
||||
continue
|
||||
collisions.append(
|
||||
DayCollision(
|
||||
date=d.date,
|
||||
parent_personal_data_uuid=d.parent_personal_data_uuid,
|
||||
child_id=d.child_id,
|
||||
existing_day_type=existing.day_type,
|
||||
proposed_day_type=d.day_type,
|
||||
)
|
||||
],
|
||||
)
|
||||
if replace:
|
||||
to_replace.append((existing, d))
|
||||
|
||||
if collisions and not replace:
|
||||
return PlanApplyResult(
|
||||
accepted=False,
|
||||
collisions=collisions,
|
||||
rejections=[
|
||||
Rejection(
|
||||
message_key="planApply.rejection.collisionsNeedReplace",
|
||||
data={"count": len(collisions)},
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
pending = to_create + [new for _, new in to_replace]
|
||||
if not pending and not to_update_metadata and not delete_rows:
|
||||
# Nothing left to write — either an empty batch or a fully
|
||||
# idempotent re-apply.
|
||||
return PlanApplyResult(
|
||||
accepted=True,
|
||||
skipped_identical_count=skipped_identical_count,
|
||||
skipped_free_count=skipped_free_count,
|
||||
)
|
||||
|
||||
# -- stage 3: domain validation on the batch that would actually write
|
||||
#
|
||||
# `delete_rows` is threaded into both checks below so a row this same
|
||||
# call is about to delete is credited back to its tier/quota BEFORE
|
||||
# the new demand is checked against what's left — the bug this
|
||||
# `delete_keys` parameter exists to fix was validating against a
|
||||
# pre-write snapshot that hadn't yet had that capacity freed.
|
||||
domain_errors: list[str] = []
|
||||
domain_errors.extend(
|
||||
await self._validate_dubbeldagar_batch(pending, to_replace, delete_rows)
|
||||
)
|
||||
domain_errors.extend(await self._validate_eligible_range(pending))
|
||||
domain_errors.extend(
|
||||
await self._validate_remaining_balance(pending, to_replace, delete_rows)
|
||||
)
|
||||
domain_errors.extend(
|
||||
await self._validate_remaining_vacation_balance(pending, to_replace, delete_rows)
|
||||
)
|
||||
|
||||
pending = to_create + [new for _, new in to_replace]
|
||||
if not pending and not to_update_metadata and not delete_rows:
|
||||
# Nothing left to write — either an empty batch or a fully
|
||||
# idempotent re-apply.
|
||||
if domain_errors:
|
||||
return PlanApplyResult(
|
||||
accepted=False, collisions=collisions, rejections=domain_errors
|
||||
)
|
||||
|
||||
# -- stage 4: write, batched (H1: one executemany per operation
|
||||
# instead of one commit per row) --------------------------------
|
||||
delete_ids = [old.id for old, _ in to_replace] + [row.id for row in delete_rows]
|
||||
if delete_ids:
|
||||
await self._repo.delete_many(delete_ids)
|
||||
|
||||
now = datetime.now()
|
||||
to_insert: list[PlannedLeaveDay] = [
|
||||
PlannedLeaveDay(
|
||||
id=uuid4(),
|
||||
workspace_id=workspace_id,
|
||||
child_id=d.child_id,
|
||||
parent_personal_data_uuid=d.parent_personal_data_uuid,
|
||||
date=d.date,
|
||||
day_type=d.day_type,
|
||||
is_dubbeldag=d.is_dubbeldag,
|
||||
wish_id=d.wish_id,
|
||||
is_pinned=d.is_pinned,
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
for d in pending
|
||||
]
|
||||
if to_insert:
|
||||
await self._repo.create_many(to_insert)
|
||||
created = to_insert
|
||||
|
||||
if to_update_metadata:
|
||||
metadata_pairs: list[tuple[UUID, UUID | None, bool]] = []
|
||||
for existing, d in to_update_metadata:
|
||||
existing.wish_id = d.wish_id
|
||||
existing.is_pinned = d.is_pinned
|
||||
metadata_pairs.append((existing.id, d.wish_id, d.is_pinned))
|
||||
await self._repo.update_metadata_many(metadata_pairs)
|
||||
|
||||
return PlanApplyResult(
|
||||
accepted=True,
|
||||
created=created,
|
||||
created_count=len(to_create),
|
||||
skipped_identical_count=skipped_identical_count,
|
||||
skipped_free_count=skipped_free_count,
|
||||
replaced_count=len(to_replace),
|
||||
deleted_count=len(delete_rows),
|
||||
)
|
||||
|
||||
# -- stage 3: domain validation on the batch that would actually write
|
||||
#
|
||||
# `delete_rows` is threaded into both checks below so a row this same
|
||||
# call is about to delete is credited back to its tier/quota BEFORE
|
||||
# the new demand is checked against what's left — the bug this
|
||||
# `delete_keys` parameter exists to fix was validating against a
|
||||
# pre-write snapshot that hadn't yet had that capacity freed.
|
||||
domain_errors: list[str] = []
|
||||
domain_errors.extend(
|
||||
await self._validate_dubbeldagar_batch(pending, to_replace, delete_rows)
|
||||
)
|
||||
domain_errors.extend(await self._validate_eligible_range(pending))
|
||||
domain_errors.extend(
|
||||
await self._validate_remaining_balance(pending, to_replace, delete_rows)
|
||||
)
|
||||
domain_errors.extend(
|
||||
await self._validate_remaining_vacation_balance(pending, to_replace, delete_rows)
|
||||
)
|
||||
|
||||
if domain_errors:
|
||||
return PlanApplyResult(accepted=False, collisions=collisions, rejections=domain_errors)
|
||||
|
||||
# -- stage 4: write --------------------------------------------------
|
||||
for old, _ in to_replace:
|
||||
await self._repo.delete(old.id)
|
||||
|
||||
for row in delete_rows:
|
||||
await self._repo.delete(row.id)
|
||||
|
||||
now = datetime.now()
|
||||
created: list[PlannedLeaveDay] = []
|
||||
for d in pending:
|
||||
entity = PlannedLeaveDay(
|
||||
id=uuid4(),
|
||||
workspace_id=workspace_id,
|
||||
child_id=d.child_id,
|
||||
parent_personal_data_uuid=d.parent_personal_data_uuid,
|
||||
date=d.date,
|
||||
day_type=d.day_type,
|
||||
is_dubbeldag=d.is_dubbeldag,
|
||||
wish_id=d.wish_id,
|
||||
is_pinned=d.is_pinned,
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
)
|
||||
created.append(await self._repo.create(entity))
|
||||
|
||||
for existing, d in to_update_metadata:
|
||||
existing.wish_id = d.wish_id
|
||||
existing.is_pinned = d.is_pinned
|
||||
await self._repo.update(existing.id, existing)
|
||||
|
||||
return PlanApplyResult(
|
||||
accepted=True,
|
||||
created=created,
|
||||
created_count=len(to_create),
|
||||
skipped_identical_count=skipped_identical_count,
|
||||
skipped_free_count=skipped_free_count,
|
||||
replaced_count=len(to_replace),
|
||||
deleted_count=len(delete_rows),
|
||||
)
|
||||
|
||||
# -- sync (POST /api/plan/sync) ---------------------------------------
|
||||
#
|
||||
# Recomputes and persists a workspace's plan in one step, then reports
|
||||
@@ -561,126 +591,135 @@ class PlanningService:
|
||||
if self._engine is None:
|
||||
raise ValidationError("PlanningService.sync_plan requires a PlanningEngineService")
|
||||
|
||||
before = await self._repo.list(workspace_id=workspace_id)
|
||||
before_by_key: dict[tuple[UUID, UUID, date], PlannedLeaveDay] = {
|
||||
(d.child_id, d.parent_personal_data_uuid, d.date): d for d in before
|
||||
}
|
||||
pinned_keys = {key for key, d in before_by_key.items() if d.is_pinned}
|
||||
# One unit of work spans the pre-write snapshot through the post-write
|
||||
# re-read, not just `apply_proposed_days`'s own writes — otherwise a
|
||||
# concurrent sync could commit between this call's `before` read and
|
||||
# its (nested, no-op-passthrough — see `unit_of_work`'s docstring)
|
||||
# write, and this call's `changes` diff would be computed against a
|
||||
# snapshot that was already stale by the time it read `before`.
|
||||
async with self._uow():
|
||||
before = await self._repo.list(workspace_id=workspace_id)
|
||||
before_by_key: dict[tuple[UUID, UUID, date], PlannedLeaveDay] = {
|
||||
(d.child_id, d.parent_personal_data_uuid, d.date): d for d in before
|
||||
}
|
||||
pinned_keys = {key for key, d in before_by_key.items() if d.is_pinned}
|
||||
|
||||
proposal = await self._engine.propose(workspace_id, today=today, only_pinned_existing=True)
|
||||
|
||||
batch = [
|
||||
ProposedDayInput(
|
||||
date=d.date,
|
||||
parent_personal_data_uuid=d.parent_personal_data_uuid,
|
||||
child_id=d.child_id,
|
||||
day_type=d.day_type,
|
||||
is_dubbeldag=d.is_dubbeldag,
|
||||
wish_id=d.wish_id,
|
||||
is_pinned=False,
|
||||
proposal = await self._engine.propose(
|
||||
workspace_id, today=today, only_pinned_existing=True
|
||||
)
|
||||
for d in proposal.days
|
||||
if d.day_type != "free"
|
||||
and d.child_id is not None
|
||||
# Defense-in-depth only: the allocator never proposes anything
|
||||
# for an already-occupied key (pinned rows stay in
|
||||
# `existing_planned` — see `only_pinned_existing` above), so this
|
||||
# should never actually filter anything out.
|
||||
and (d.child_id, d.parent_personal_data_uuid, d.date) not in pinned_keys
|
||||
]
|
||||
|
||||
batch_keys = {(d.child_id, d.parent_personal_data_uuid, d.date) for d in batch}
|
||||
# Rows the freshly-computed plan no longer accounts for at all:
|
||||
# unpinned, wish-attributed, and absent from this run's batch — e.g.
|
||||
# the wish was deleted, or its window moved elsewhere. Passed as
|
||||
# `delete_keys` so `apply_proposed_days` validates the deletions
|
||||
# together with the creates/replaces (see the comment above this
|
||||
# method) rather than deleting them itself afterwards.
|
||||
stale_keys = {
|
||||
key
|
||||
for key, row in before_by_key.items()
|
||||
if not row.is_pinned and row.wish_id is not None and key not in batch_keys
|
||||
}
|
||||
batch = [
|
||||
ProposedDayInput(
|
||||
date=d.date,
|
||||
parent_personal_data_uuid=d.parent_personal_data_uuid,
|
||||
child_id=d.child_id,
|
||||
day_type=d.day_type,
|
||||
is_dubbeldag=d.is_dubbeldag,
|
||||
wish_id=d.wish_id,
|
||||
is_pinned=False,
|
||||
)
|
||||
for d in proposal.days
|
||||
if d.day_type != "free"
|
||||
and d.child_id is not None
|
||||
# Defense-in-depth only: the allocator never proposes anything
|
||||
# for an already-occupied key (pinned rows stay in
|
||||
# `existing_planned` — see `only_pinned_existing` above), so this
|
||||
# should never actually filter anything out.
|
||||
and (d.child_id, d.parent_personal_data_uuid, d.date) not in pinned_keys
|
||||
]
|
||||
|
||||
apply_result = await self.apply_proposed_days(
|
||||
workspace_id=workspace_id, days=batch, replace=True, delete_keys=stale_keys
|
||||
)
|
||||
|
||||
changes: list[PlanChange] = []
|
||||
if apply_result.accepted:
|
||||
after = await self._repo.list(workspace_id=workspace_id)
|
||||
after_by_key: dict[tuple[UUID, UUID, date], PlannedLeaveDay] = {
|
||||
(d.child_id, d.parent_personal_data_uuid, d.date): d for d in after
|
||||
batch_keys = {(d.child_id, d.parent_personal_data_uuid, d.date) for d in batch}
|
||||
# Rows the freshly-computed plan no longer accounts for at all:
|
||||
# unpinned, wish-attributed, and absent from this run's batch — e.g.
|
||||
# the wish was deleted, or its window moved elsewhere. Passed as
|
||||
# `delete_keys` so `apply_proposed_days` validates the deletions
|
||||
# together with the creates/replaces (see the comment above this
|
||||
# method) rather than deleting them itself afterwards.
|
||||
stale_keys = {
|
||||
key
|
||||
for key, row in before_by_key.items()
|
||||
if not row.is_pinned and row.wish_id is not None and key not in batch_keys
|
||||
}
|
||||
|
||||
for key in sorted(
|
||||
set(before_by_key) | set(after_by_key),
|
||||
key=lambda k: (k[2], str(k[0]), str(k[1])),
|
||||
):
|
||||
child_id, parent_uuid, d = key
|
||||
old = before_by_key.get(key)
|
||||
new = after_by_key.get(key)
|
||||
if old is None and new is not None:
|
||||
changes.append(
|
||||
PlanChange(
|
||||
kind="added",
|
||||
date=d,
|
||||
parent_personal_data_uuid=parent_uuid,
|
||||
child_id=child_id,
|
||||
old_day_type=None,
|
||||
new_day_type=new.day_type,
|
||||
old_is_dubbeldag=None,
|
||||
new_is_dubbeldag=new.is_dubbeldag,
|
||||
old_wish_id=None,
|
||||
new_wish_id=new.wish_id,
|
||||
)
|
||||
)
|
||||
elif old is not None and new is None:
|
||||
changes.append(
|
||||
PlanChange(
|
||||
kind="removed",
|
||||
date=d,
|
||||
parent_personal_data_uuid=parent_uuid,
|
||||
child_id=child_id,
|
||||
old_day_type=old.day_type,
|
||||
new_day_type=None,
|
||||
old_is_dubbeldag=old.is_dubbeldag,
|
||||
new_is_dubbeldag=None,
|
||||
old_wish_id=old.wish_id,
|
||||
new_wish_id=None,
|
||||
)
|
||||
)
|
||||
elif old is not None and new is not None:
|
||||
if old.day_type != new.day_type or old.is_dubbeldag != new.is_dubbeldag:
|
||||
apply_result = await self.apply_proposed_days(
|
||||
workspace_id=workspace_id, days=batch, replace=True, delete_keys=stale_keys
|
||||
)
|
||||
|
||||
changes: list[PlanChange] = []
|
||||
if apply_result.accepted:
|
||||
after = await self._repo.list(workspace_id=workspace_id)
|
||||
after_by_key: dict[tuple[UUID, UUID, date], PlannedLeaveDay] = {
|
||||
(d.child_id, d.parent_personal_data_uuid, d.date): d for d in after
|
||||
}
|
||||
|
||||
for key in sorted(
|
||||
set(before_by_key) | set(after_by_key),
|
||||
key=lambda k: (k[2], str(k[0]), str(k[1])),
|
||||
):
|
||||
child_id, parent_uuid, d = key
|
||||
old = before_by_key.get(key)
|
||||
new = after_by_key.get(key)
|
||||
if old is None and new is not None:
|
||||
changes.append(
|
||||
PlanChange(
|
||||
kind="retyped",
|
||||
kind="added",
|
||||
date=d,
|
||||
parent_personal_data_uuid=parent_uuid,
|
||||
child_id=child_id,
|
||||
old_day_type=None,
|
||||
new_day_type=new.day_type,
|
||||
old_is_dubbeldag=None,
|
||||
new_is_dubbeldag=new.is_dubbeldag,
|
||||
old_wish_id=None,
|
||||
new_wish_id=new.wish_id,
|
||||
)
|
||||
)
|
||||
elif old is not None and new is None:
|
||||
changes.append(
|
||||
PlanChange(
|
||||
kind="removed",
|
||||
date=d,
|
||||
parent_personal_data_uuid=parent_uuid,
|
||||
child_id=child_id,
|
||||
old_day_type=old.day_type,
|
||||
new_day_type=new.day_type,
|
||||
new_day_type=None,
|
||||
old_is_dubbeldag=old.is_dubbeldag,
|
||||
new_is_dubbeldag=new.is_dubbeldag,
|
||||
new_is_dubbeldag=None,
|
||||
old_wish_id=old.wish_id,
|
||||
new_wish_id=new.wish_id,
|
||||
new_wish_id=None,
|
||||
)
|
||||
)
|
||||
elif old.wish_id != new.wish_id:
|
||||
changes.append(
|
||||
PlanChange(
|
||||
kind="reattributed",
|
||||
date=d,
|
||||
parent_personal_data_uuid=parent_uuid,
|
||||
child_id=child_id,
|
||||
old_day_type=old.day_type,
|
||||
new_day_type=new.day_type,
|
||||
old_is_dubbeldag=old.is_dubbeldag,
|
||||
new_is_dubbeldag=new.is_dubbeldag,
|
||||
old_wish_id=old.wish_id,
|
||||
new_wish_id=new.wish_id,
|
||||
elif old is not None and new is not None:
|
||||
if old.day_type != new.day_type or old.is_dubbeldag != new.is_dubbeldag:
|
||||
changes.append(
|
||||
PlanChange(
|
||||
kind="retyped",
|
||||
date=d,
|
||||
parent_personal_data_uuid=parent_uuid,
|
||||
child_id=child_id,
|
||||
old_day_type=old.day_type,
|
||||
new_day_type=new.day_type,
|
||||
old_is_dubbeldag=old.is_dubbeldag,
|
||||
new_is_dubbeldag=new.is_dubbeldag,
|
||||
old_wish_id=old.wish_id,
|
||||
new_wish_id=new.wish_id,
|
||||
)
|
||||
)
|
||||
elif old.wish_id != new.wish_id:
|
||||
changes.append(
|
||||
PlanChange(
|
||||
kind="reattributed",
|
||||
date=d,
|
||||
parent_personal_data_uuid=parent_uuid,
|
||||
child_id=child_id,
|
||||
old_day_type=old.day_type,
|
||||
new_day_type=new.day_type,
|
||||
old_is_dubbeldag=old.is_dubbeldag,
|
||||
new_is_dubbeldag=new.is_dubbeldag,
|
||||
old_wish_id=old.wish_id,
|
||||
new_wish_id=new.wish_id,
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
return PlanSyncResult(proposal=proposal, apply_result=apply_result, changes=changes)
|
||||
|
||||
|
||||
@@ -51,9 +51,9 @@ class VacationDaysService:
|
||||
)
|
||||
return await self._repo.create(entity)
|
||||
|
||||
async def get(self, vacation_days_id: UUID) -> VacationDays:
|
||||
async def get(self, vacation_days_id: UUID, workspace_id: UUID | None = None) -> VacationDays:
|
||||
entity = await self._repo.get_by_id(vacation_days_id)
|
||||
if entity is None:
|
||||
if entity is None or (workspace_id is not None and entity.workspace_id != workspace_id):
|
||||
raise NotFoundError(f"VacationDays {vacation_days_id} not found")
|
||||
return entity
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ from app.repositories.leave_wish_repository import LeaveWishRepository
|
||||
from app.repositories.parental_leave_repository import ParentalLeaveRepository
|
||||
from app.repositories.personal_data_repository import PersonalDataRepository
|
||||
from app.repositories.planned_leave_repository import PlannedLeaveRepository
|
||||
from app.repositories.unit_of_work import NoOpUnitOfWork, UnitOfWork
|
||||
from app.repositories.user_repository import UserRepository
|
||||
from app.repositories.vacation_days_repository import VacationDaysRepository
|
||||
from app.repositories.workspace_repository import (
|
||||
@@ -43,6 +44,7 @@ class WorkspaceService:
|
||||
planned_leave_repo: PlannedLeaveRepository,
|
||||
parental_leave_repo: ParentalLeaveRepository,
|
||||
vacation_days_repo: VacationDaysRepository,
|
||||
uow: UnitOfWork | None = None,
|
||||
) -> None:
|
||||
self._ws = ws_repo
|
||||
self._inv = inv_repo
|
||||
@@ -52,6 +54,11 @@ class WorkspaceService:
|
||||
self._planned_leave = planned_leave_repo
|
||||
self._parental_leave = parental_leave_repo
|
||||
self._vacation_days = vacation_days_repo
|
||||
# Defaults to a no-op so pre-existing direct-construction call sites
|
||||
# (scripts/seed_demo.py, test fixtures) keep today's per-write-commit
|
||||
# behavior; `app.dependencies.get_workspace_service` always wires a
|
||||
# real `SQLiteUnitOfWork`.
|
||||
self._uow = uow or NoOpUnitOfWork()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Workspace CRUD
|
||||
@@ -264,16 +271,30 @@ class WorkspaceService:
|
||||
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)
|
||||
if pd.workspace_id == inv.workspace_id:
|
||||
raise InvitationError("You already belong to this workspace")
|
||||
|
||||
async def revoke_invitation(self, invitation_id: UUID) -> None:
|
||||
existing_members = await self._pd.list_by_workspace(inv.workspace_id)
|
||||
if len(existing_members) >= 2:
|
||||
raise InvitationError("This workspace already has its maximum number of members")
|
||||
|
||||
# Both writes must land together: a crash between them would leave
|
||||
# the redeemer either moved to the new workspace with a still-pending
|
||||
# (re-usable) invitation, or stuck in their old workspace with an
|
||||
# invitation marked accepted.
|
||||
async with self._uow():
|
||||
pd.workspace_id = inv.workspace_id
|
||||
await self._pd.update(pd.uuid, pd)
|
||||
|
||||
inv.status = "accepted"
|
||||
inv.accepted_at = datetime.now()
|
||||
inv = await self._inv.update(inv.id, inv)
|
||||
return inv
|
||||
|
||||
async def revoke_invitation(self, invitation_id: UUID, workspace_id: UUID) -> None:
|
||||
inv = await self._inv.get_by_id(invitation_id)
|
||||
if inv is None:
|
||||
if inv is None or inv.workspace_id != workspace_id:
|
||||
raise NotFoundError(f"Invitation {invitation_id} not found")
|
||||
if inv.status != "pending":
|
||||
raise ValidationError(f"Cannot revoke a {inv.status} invitation")
|
||||
|
||||
Reference in New Issue
Block a user