diff --git a/handlers/login_handlers.py b/handlers/login_handlers.py new file mode 100644 index 0000000..32a1bb6 --- /dev/null +++ b/handlers/login_handlers.py @@ -0,0 +1,15 @@ +from __future__ import annotations + +from sqlalchemy.ext.asyncio import AsyncSession + +from shared.events import register_handler +from shared.models.domain_event import DomainEvent +from glue.services.cart_adoption import adopt_session_for_user + + +async def on_user_logged_in(event: DomainEvent, session: AsyncSession) -> None: + payload = event.payload + await adopt_session_for_user(session, payload["user_id"], payload["session_id"]) + + +register_handler("user.logged_in", on_user_logged_in) diff --git a/handlers/order_handlers.py b/handlers/order_handlers.py new file mode 100644 index 0000000..41016aa --- /dev/null +++ b/handlers/order_handlers.py @@ -0,0 +1,22 @@ +from __future__ import annotations + +import logging + +from sqlalchemy.ext.asyncio import AsyncSession + +from shared.events import register_handler +from shared.models.domain_event import DomainEvent + +log = logging.getLogger(__name__) + + +async def on_order_created(event: DomainEvent, session: AsyncSession) -> None: + log.info("order.created: order_id=%s", event.payload.get("order_id")) + + +async def on_order_paid(event: DomainEvent, session: AsyncSession) -> None: + log.info("order.paid: order_id=%s", event.payload.get("order_id")) + + +register_handler("order.created", on_order_created) +register_handler("order.paid", on_order_paid) diff --git a/services/cart_adoption.py b/services/cart_adoption.py new file mode 100644 index 0000000..84979d0 --- /dev/null +++ b/services/cart_adoption.py @@ -0,0 +1,50 @@ +"""Glue service: adopt anonymous cart items + calendar entries for a logged-in user.""" +from __future__ import annotations + +from sqlalchemy import select, update, func +from sqlalchemy.ext.asyncio import AsyncSession + +from market.models.market import CartItem +from events.models.calendars import CalendarEntry + + +async def adopt_session_for_user( + session: AsyncSession, + user_id: int, + session_id: str, +) -> None: + """Adopt anonymous cart items + calendar entries for a logged-in user.""" + # --- adopt cart items --- + anon_result = await session.execute( + select(CartItem).where( + CartItem.deleted_at.is_(None), + CartItem.user_id.is_(None), + CartItem.session_id == session_id, + ) + ) + anon_items = anon_result.scalars().all() + + if anon_items: + # Soft-delete existing user cart + await session.execute( + update(CartItem) + .where(CartItem.deleted_at.is_(None), CartItem.user_id == user_id) + .values(deleted_at=func.now()) + ) + for ci in anon_items: + ci.user_id = user_id + + # --- adopt calendar entries --- + await session.execute( + update(CalendarEntry) + .where(CalendarEntry.deleted_at.is_(None), CalendarEntry.user_id == user_id) + .values(deleted_at=func.now()) + ) + cal_result = await session.execute( + select(CalendarEntry).where( + CalendarEntry.deleted_at.is_(None), + CalendarEntry.session_id == session_id, + ) + ) + for entry in cal_result.scalars().all(): + entry.user_id = user_id diff --git a/services/order_lifecycle.py b/services/order_lifecycle.py new file mode 100644 index 0000000..720974d --- /dev/null +++ b/services/order_lifecycle.py @@ -0,0 +1,83 @@ +"""Glue service: cross-domain writes for order ↔ calendar-entry bridging. + +These run in the *same* DB transaction as the caller — not event-driven — +because order creation and payment confirmation need immediate consistency. +""" +from __future__ import annotations + +from sqlalchemy import select, update +from sqlalchemy.ext.asyncio import AsyncSession + +from events.models.calendars import CalendarEntry, Calendar + + +async def claim_entries_for_order( + session: AsyncSession, + order_id: int, + user_id: int | None, + session_id: str | None, + page_post_id: int | None = None, +) -> None: + """Mark pending CalendarEntries as 'ordered' and set order_id.""" + filters = [ + CalendarEntry.deleted_at.is_(None), + CalendarEntry.state == "pending", + ] + + if user_id is not None: + filters.append(CalendarEntry.user_id == user_id) + elif session_id is not None: + filters.append(CalendarEntry.session_id == session_id) + + if page_post_id is not None: + cal_ids = select(Calendar.id).where( + Calendar.container_type == "page", + Calendar.container_id == page_post_id, + Calendar.deleted_at.is_(None), + ).scalar_subquery() + filters.append(CalendarEntry.calendar_id.in_(cal_ids)) + + await session.execute( + update(CalendarEntry) + .where(*filters) + .values(state="ordered", order_id=order_id) + ) + + +async def confirm_entries_for_order( + session: AsyncSession, + order_id: int, + user_id: int | None, + session_id: str | None, +) -> None: + """Mark ordered CalendarEntries as 'provisional'. Called when payment confirms.""" + filters = [ + CalendarEntry.deleted_at.is_(None), + CalendarEntry.state == "ordered", + CalendarEntry.order_id == order_id, + ] + + if user_id is not None: + filters.append(CalendarEntry.user_id == user_id) + elif session_id is not None: + filters.append(CalendarEntry.session_id == session_id) + + await session.execute( + update(CalendarEntry) + .where(*filters) + .values(state="provisional") + ) + + +async def get_entries_for_order( + session: AsyncSession, + order_id: int, +) -> list[CalendarEntry]: + """Return CalendarEntries for an order. Replaces Order.calendar_entries relationship.""" + result = await session.execute( + select(CalendarEntry).where( + CalendarEntry.order_id == order_id, + CalendarEntry.deleted_at.is_(None), + ) + ) + return list(result.scalars().all()) diff --git a/setup.py b/setup.py index 18cd5c4..e888d7f 100644 --- a/setup.py +++ b/setup.py @@ -1,3 +1,5 @@ def register_glue_handlers(): """Import handlers to trigger registration. Call at app startup.""" import glue.handlers.container_handlers # noqa: F401 + import glue.handlers.login_handlers # noqa: F401 + import glue.handlers.order_handlers # noqa: F401