"""CalendarService — business-logic layer for calendar operations. REST routes (U2) and agent tools are thin wrappers over this service. The service dispatches to ``db`` functions for persistence and to ``recurrence`` for RRULE expansion. """ from __future__ import annotations import dataclasses import logging import uuid from datetime import datetime, timezone from pathlib import Path import aiosqlite from agentkit.calendar.db import ( DEFAULT_CALENDAR_DB_PATH, add_tag_to_event, delete_event as db_delete_event, get_event as db_get_event, get_invitation as db_get_invitation, insert_event, insert_event_type, insert_invitation, insert_reminder_rule, insert_tag, list_event_types as db_list_event_types, list_events as db_list_events, list_invitations as db_list_invitations, list_reminder_rules_for_type, list_tags as db_list_tags, update_event as db_update_event, update_event_type as db_update_event_type, update_invitation_status, ) from agentkit.calendar.models import ( CalendarEvent, EventType, Invitation, Tag, _now_iso, ) from agentkit.calendar.recurrence import expand_rrule from agentkit.server.auth.models import DEFAULT_AUTH_DB_PATH logger = logging.getLogger(__name__) def _validate_iso(dt_str: str) -> None: """Validate that *dt_str* is a parseable ISO 8601 string.""" if not dt_str: raise ValueError("start_time must be a valid ISO 8601 string") try: datetime.fromisoformat(dt_str) except ValueError: raise ValueError(f"Invalid ISO 8601 format: {dt_str}") def _parse_dt(dt_str: str) -> datetime: """Parse ISO 8601 string to timezone-aware datetime (UTC).""" dt = datetime.fromisoformat(dt_str) if dt.tzinfo is None: dt = dt.replace(tzinfo=timezone.utc) return dt def _format_dt(dt: datetime) -> str: """Format datetime as ISO 8601 UTC string.""" return dt.astimezone(timezone.utc).isoformat() class CalendarService: """Create, query, and manage calendar events, types, tags, and invitations. Mirrors ``DocumentService``: ``__init__`` stores a db_path, async methods delegate to ``calendar.db`` functions. RRULE expansion is handled here so routes and tools get a flat list of occurrences. """ def __init__( self, db_path: str | Path | None = None, auth_db_path: str | Path | None = None, ) -> None: self.db_path = Path(db_path) if db_path is not None else DEFAULT_CALENDAR_DB_PATH self.auth_db_path = Path(auth_db_path) if auth_db_path is not None else DEFAULT_AUTH_DB_PATH # ------------------------------------------------------------------ # Event CRUD # ------------------------------------------------------------------ async def create_event( self, user_id: str, title: str, start_time: str, end_time: str, description: str = "", location: str = "", is_all_day: bool = False, event_type_id: str | None = None, rrule: str | None = None, source: str = "manual", is_invited: bool = False, conversation_id: str | None = None, tag_ids: list[str] | None = None, ) -> CalendarEvent: """Create a calendar event with UUID, timestamps, tags, and cloned reminders.""" _validate_iso(start_time) now = _now_iso() event = CalendarEvent( id=uuid.uuid4().hex, user_id=user_id, title=title, description=description, start_time=start_time, end_time=end_time, is_all_day=is_all_day, location=location, event_type_id=event_type_id, rrule=rrule, source=source, is_invited=is_invited, conversation_id=conversation_id, last_modified=now, created_at=now, ) await insert_event(event, self.db_path) # Link tags if provided — skip any that don't belong to the user if tag_ids: user_tags = await self.list_tags(user_id) user_tag_ids = {t.id for t in user_tags} for tag_id in tag_ids: if tag_id not in user_tag_ids: logger.debug("Skipping tag %s — not owned by user %s", tag_id, user_id) continue await add_tag_to_event(event.id, tag_id, self.db_path) # Clone type-level default reminder rules to the event — skip if type # doesn't belong to the user if event_type_id: user_types = await self.list_event_types(user_id) user_type_ids = {t.id for t in user_types} if event_type_id not in user_type_ids: logger.debug( "Skipping event_type %s — not owned by user %s", event_type_id, user_id ) else: type_rules = await list_reminder_rules_for_type(event_type_id, self.db_path) for rule in type_rules: cloned = dataclasses.replace( rule, id=uuid.uuid4().hex, event_id=event.id, event_type_id=None, ) await insert_reminder_rule(cloned, self.db_path) logger.info(f"Created event {event.id} ({title}) for user {user_id}") return event async def get_event(self, event_id: str) -> CalendarEvent | None: """Return a single event by id, or None.""" return await db_get_event(event_id, self.db_path) async def list_events( self, user_id: str, start: str | None = None, end: str | None = None, event_type_id: str | None = None, tag_id: str | None = None, ) -> list[CalendarEvent]: """List events for a user, expanding recurring events within [start, end]. Non-recurring events are filtered by date range manually (not in the DB query) so that recurring events whose first occurrence falls outside the range are still included and expanded. """ # Fetch all events for the user with type/tag filters — no date filter # at the DB level so recurring events are not excluded. events = await db_list_events( user_id, event_type_id=event_type_id, tag_id=tag_id, db_path=self.db_path, ) result: list[CalendarEvent] = [] for event in events: if event.rrule: # Expand recurring event within [start, end] range try: occurrences = expand_rrule( event.rrule, event.start_time, range_start=start, range_end=end, ) except ValueError: # ponytail: malformed RRULE → fall back to single occurrence # so one bad event doesn't crash the whole list. Upgrade # path: surface a validation error at create_event time. logger.warning( "Malformed RRULE %r for event %s; falling back to start_time", event.rrule, event.id, ) occurrences = [event.start_time] for occ_start_str in occurrences: occ = self._make_occurrence(event, occ_start_str) result.append(occ) else: # Non-recurring: filter by date range manually if _is_in_range(event.start_time, start, end): result.append(event) # Sort by start_time for consistent ordering result.sort(key=lambda e: e.start_time) return result def _make_occurrence(self, event: CalendarEvent, occ_start_str: str) -> CalendarEvent: """Create a copy of *event* with start/end times shifted to the occurrence.""" occ_start_dt = _parse_dt(occ_start_str) original_start = _parse_dt(event.start_time) original_end = _parse_dt(event.end_time) duration = original_end - original_start occ_end_dt = occ_start_dt + duration return dataclasses.replace( event, start_time=_format_dt(occ_start_dt), end_time=_format_dt(occ_end_dt), ) async def update_event(self, event_id: str, fields: dict) -> bool: """Update specific fields of an event. Auto-updates last_modified.""" fields = {**fields, "last_modified": _now_iso()} return await db_update_event(event_id, fields, self.db_path) async def delete_event(self, event_id: str) -> bool: """Delete an event and its dependent rows.""" return await db_delete_event(event_id, self.db_path) # ------------------------------------------------------------------ # Event Type CRUD # ------------------------------------------------------------------ async def list_event_types(self, user_id: str) -> list[EventType]: """List all event types for a user.""" return await db_list_event_types(user_id, self.db_path) async def get_event_type(self, type_id: str) -> EventType | None: """Return a single event type by id, or None.""" async with aiosqlite.connect(str(self.db_path)) as db: db.row_factory = aiosqlite.Row cursor = await db.execute( "SELECT * FROM calendar_event_types WHERE id = ?", (type_id,), ) row = await cursor.fetchone() if row is None: return None return EventType( id=row["id"], user_id=row["user_id"], name=row["name"], color=row["color"], is_default=bool(row["is_default"]), ) async def create_event_type( self, user_id: str, name: str, color: str = "#4A90D9", ) -> EventType: """Create a new event type.""" et = EventType( id=uuid.uuid4().hex, user_id=user_id, name=name, color=color, ) await insert_event_type(et, self.db_path) return et async def update_event_type(self, type_id: str, fields: dict) -> bool: """Update specific fields of an event type.""" return await db_update_event_type(type_id, fields, self.db_path) # ------------------------------------------------------------------ # Tag CRUD # ------------------------------------------------------------------ async def list_tags(self, user_id: str) -> list[Tag]: """List all tags for a user.""" return await db_list_tags(user_id, self.db_path) async def create_tag(self, user_id: str, name: str) -> Tag: """Create a new tag.""" tag = Tag( id=uuid.uuid4().hex, user_id=user_id, name=name, ) await insert_tag(tag, self.db_path) return tag # ------------------------------------------------------------------ # Invitation CRUD # ------------------------------------------------------------------ async def create_invitation( self, event_id: str, inviter_user_id: str, invitee_email: str, ) -> Invitation: """Create an invitation with status='pending'.""" invitation = Invitation( id=uuid.uuid4().hex, event_id=event_id, inviter_user_id=inviter_user_id, invitee_email=invitee_email, status="pending", ) await insert_invitation(invitation, self.db_path) return invitation async def respond_to_invitation(self, invitation_id: str, status: str) -> bool: """Update invitation status and set responded_at.""" return await update_invitation_status( invitation_id, status, _now_iso(), self.db_path, ) async def get_invitation(self, invitation_id: str) -> Invitation | None: """Return a single invitation by id, or None.""" return await db_get_invitation(invitation_id, self.db_path) async def list_invitations(self, invitee_email: str) -> list[Invitation]: """List all invitations for an invitee email.""" return await db_list_invitations(invitee_email, self.db_path) # ------------------------------------------------------------------ # User search (auth DB) # ------------------------------------------------------------------ async def search_users(self, q: str) -> list[dict]: """Search users by username or email. Returns top 10 matches. Only ``username`` and ``email`` are returned — never user_id or password fields (G5/A3 — least-privilege user search). """ escaped_q = q.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_") pattern = f"%{escaped_q}%" async with aiosqlite.connect(str(self.auth_db_path)) as db: db.row_factory = aiosqlite.Row cursor = await db.execute( "SELECT username, email FROM users " "WHERE username LIKE ? ESCAPE '\\' OR email LIKE ? ESCAPE '\\' LIMIT 10", (pattern, pattern), ) rows = await cursor.fetchall() return [{"username": row["username"], "email": row["email"]} for row in rows] async def get_user_email(self, user_id: str) -> str | None: """Look up a user's email from the auth DB by user_id.""" async with aiosqlite.connect(str(self.auth_db_path)) as db: db.row_factory = aiosqlite.Row cursor = await db.execute( "SELECT email FROM users WHERE id = ?", (user_id,), ) row = await cursor.fetchone() return row["email"] if row else None def _is_in_range(dt_str: str, start: str | None, end: str | None) -> bool: """Check if *dt_str* falls within the half-open range [start, end).""" dt = _parse_dt(dt_str) if start is not None: if dt < _parse_dt(start): return False if end is not None: if dt >= _parse_dt(end): return False return True