fischer-agentkit/src/agentkit/calendar/db.py

945 lines
35 KiB
Python

"""SQLite persistence for calendar data.
Follows the aiosqlite bare-connection pattern from ``documents/db.py``:
no SQLAlchemy session injection, just ``async with aiosqlite.connect(...)``.
All timestamps are ISO 8601 UTC (see KTD-11).
"""
from __future__ import annotations
import json
import logging
import os
from collections.abc import Mapping
from pathlib import Path
import aiosqlite
from agentkit.calendar.models import (
CalendarEvent,
EventType,
ExternalCalendarConfig,
Invitation,
ReminderDelivery,
ReminderRule,
Tag,
)
logger = logging.getLogger(__name__)
_PROJECT_ROOT = Path(__file__).parents[3]
DEFAULT_CALENDAR_DB_PATH = Path(
os.environ.get("AGENTKIT_CALENDAR_DB", _PROJECT_ROOT / "data" / "calendar.db")
)
_SCHEMA_SQL = """
CREATE TABLE IF NOT EXISTS calendar_event_types (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
name TEXT NOT NULL,
color TEXT NOT NULL DEFAULT '#4A90D9',
is_default INTEGER NOT NULL DEFAULT 0
);
CREATE INDEX IF NOT EXISTS idx_event_types_user
ON calendar_event_types(user_id);
CREATE TABLE IF NOT EXISTS calendar_tags (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
name TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_tags_user
ON calendar_tags(user_id);
CREATE TABLE IF NOT EXISTS calendar_events (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
title TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '',
start_time TEXT NOT NULL,
end_time TEXT NOT NULL,
is_all_day INTEGER NOT NULL DEFAULT 0,
location TEXT NOT NULL DEFAULT '',
event_type_id TEXT,
rrule TEXT,
source TEXT NOT NULL DEFAULT 'manual',
is_invited INTEGER NOT NULL DEFAULT 0,
conversation_id TEXT,
external_id TEXT,
external_provider TEXT,
last_modified TEXT NOT NULL,
created_at TEXT NOT NULL,
FOREIGN KEY (event_type_id) REFERENCES calendar_event_types(id)
);
CREATE INDEX IF NOT EXISTS idx_events_user
ON calendar_events(user_id);
CREATE INDEX IF NOT EXISTS idx_events_start_time
ON calendar_events(start_time);
CREATE INDEX IF NOT EXISTS idx_events_external
ON calendar_events(external_id, external_provider);
CREATE TABLE IF NOT EXISTS calendar_event_tags (
event_id TEXT NOT NULL,
tag_id TEXT NOT NULL,
PRIMARY KEY (event_id, tag_id),
FOREIGN KEY (event_id) REFERENCES calendar_events(id) ON DELETE CASCADE,
FOREIGN KEY (tag_id) REFERENCES calendar_tags(id)
);
CREATE TABLE IF NOT EXISTS calendar_reminder_rules (
id TEXT PRIMARY KEY,
event_id TEXT,
event_type_id TEXT,
offset_minutes INTEGER NOT NULL DEFAULT -15,
channels TEXT NOT NULL DEFAULT '["client"]',
FOREIGN KEY (event_id) REFERENCES calendar_events(id) ON DELETE CASCADE,
FOREIGN KEY (event_type_id) REFERENCES calendar_event_types(id)
);
CREATE TABLE IF NOT EXISTS calendar_reminder_deliveries (
id TEXT PRIMARY KEY,
reminder_rule_id TEXT NOT NULL,
event_id TEXT NOT NULL,
scheduled_time TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'pending',
channel TEXT NOT NULL DEFAULT 'client',
attempts INTEGER NOT NULL DEFAULT 0,
last_error TEXT,
-- ponytail: ON DELETE CASCADE ensures deliveries are removed when their
-- reminder_rule is cascade-deleted (e.g. event deletion). Existing DBs
-- created before this change need ALTER TABLE or DB recreation.
FOREIGN KEY (reminder_rule_id) REFERENCES calendar_reminder_rules(id) ON DELETE CASCADE,
FOREIGN KEY (event_id) REFERENCES calendar_events(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_deliveries_status
ON calendar_reminder_deliveries(status, scheduled_time);
CREATE TABLE IF NOT EXISTS calendar_external_configs (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
provider TEXT NOT NULL,
credentials TEXT NOT NULL DEFAULT '',
sync_frequency INTEGER NOT NULL DEFAULT 30,
sync_scope TEXT NOT NULL DEFAULT '[]',
last_sync TEXT,
sync_token TEXT
);
CREATE INDEX IF NOT EXISTS idx_external_configs_user
ON calendar_external_configs(user_id);
CREATE TABLE IF NOT EXISTS calendar_invitations (
id TEXT PRIMARY KEY,
event_id TEXT NOT NULL,
inviter_user_id TEXT NOT NULL,
invitee_email TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'pending',
responded_at TEXT,
FOREIGN KEY (event_id) REFERENCES calendar_events(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_invitations_email
ON calendar_invitations(invitee_email, status);
"""
async def init_calendar_db(db_path: str | Path | None = None) -> Path:
"""Create all calendar tables if they do not exist. Idempotent."""
path = Path(db_path) if db_path is not None else DEFAULT_CALENDAR_DB_PATH
path.parent.mkdir(parents=True, exist_ok=True)
async with aiosqlite.connect(str(path)) as db:
db.row_factory = aiosqlite.Row
await db.execute("PRAGMA journal_mode=WAL")
await db.execute("PRAGMA busy_timeout = 5000")
await db.execute("PRAGMA foreign_keys = ON")
await db.executescript(_SCHEMA_SQL)
await db.commit()
logger.info(f"Calendar DB initialized at {path}")
return path
# ---------------------------------------------------------------------------
# Row → dataclass converters
# ---------------------------------------------------------------------------
def _row_to_event_type(row: aiosqlite.Row | Mapping[str, object]) -> EventType:
return EventType(
id=row["id"],
user_id=row["user_id"],
name=row["name"],
color=row["color"],
is_default=bool(row["is_default"]),
)
def _row_to_tag(row: aiosqlite.Row | Mapping[str, object]) -> Tag:
return Tag(id=row["id"], user_id=row["user_id"], name=row["name"])
def _row_to_event(row: aiosqlite.Row | Mapping[str, object]) -> CalendarEvent:
return CalendarEvent(
id=row["id"],
user_id=row["user_id"],
title=row["title"],
description=row["description"],
start_time=row["start_time"],
end_time=row["end_time"],
is_all_day=bool(row["is_all_day"]),
location=row["location"],
event_type_id=row["event_type_id"],
rrule=row["rrule"],
source=row["source"],
is_invited=bool(row["is_invited"]),
conversation_id=row["conversation_id"],
external_id=row["external_id"],
external_provider=row["external_provider"],
last_modified=row["last_modified"],
created_at=row["created_at"],
)
def _row_to_reminder_rule(row: aiosqlite.Row | Mapping[str, object]) -> ReminderRule:
return ReminderRule(
id=row["id"],
event_id=row["event_id"],
event_type_id=row["event_type_id"],
offset_minutes=row["offset_minutes"],
channels=json.loads(row["channels"]),
)
def _row_to_reminder_delivery(row: aiosqlite.Row | Mapping[str, object]) -> ReminderDelivery:
return ReminderDelivery(
id=row["id"],
reminder_rule_id=row["reminder_rule_id"],
event_id=row["event_id"],
scheduled_time=row["scheduled_time"],
status=row["status"],
channel=row["channel"],
attempts=row["attempts"],
last_error=row["last_error"],
)
def _row_to_external_config(row: aiosqlite.Row | Mapping[str, object]) -> ExternalCalendarConfig:
return ExternalCalendarConfig(
id=row["id"],
user_id=row["user_id"],
provider=row["provider"],
credentials=row["credentials"],
sync_frequency=row["sync_frequency"],
sync_scope=json.loads(row["sync_scope"]),
last_sync=row["last_sync"],
sync_token=row["sync_token"],
)
def _row_to_invitation(row: aiosqlite.Row | Mapping[str, object]) -> Invitation:
return Invitation(
id=row["id"],
event_id=row["event_id"],
inviter_user_id=row["inviter_user_id"],
invitee_email=row["invitee_email"],
status=row["status"],
responded_at=row["responded_at"],
)
# ---------------------------------------------------------------------------
# Event CRUD
# ---------------------------------------------------------------------------
async def insert_event(event: CalendarEvent, db_path: str | Path | None = None) -> None:
"""Insert a calendar event."""
path = Path(db_path) if db_path is not None else DEFAULT_CALENDAR_DB_PATH
async with aiosqlite.connect(str(path)) as db:
await db.execute("PRAGMA journal_mode=WAL")
await db.execute("PRAGMA busy_timeout = 5000")
await db.execute("PRAGMA foreign_keys = ON")
await db.execute(
"INSERT INTO calendar_events (id, user_id, title, description, "
"start_time, end_time, is_all_day, location, event_type_id, rrule, "
"source, is_invited, conversation_id, external_id, external_provider, "
"last_modified, created_at) "
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
(
event.id,
event.user_id,
event.title,
event.description,
event.start_time,
event.end_time,
int(event.is_all_day),
event.location,
event.event_type_id,
event.rrule,
event.source,
int(event.is_invited),
event.conversation_id,
event.external_id,
event.external_provider,
event.last_modified,
event.created_at,
),
)
await db.commit()
async def get_event(event_id: str, db_path: str | Path | None = None) -> CalendarEvent | None:
"""Return a single event by id, or None."""
path = Path(db_path) if db_path is not None else DEFAULT_CALENDAR_DB_PATH
async with aiosqlite.connect(str(path)) as db:
await db.execute("PRAGMA journal_mode=WAL")
await db.execute("PRAGMA busy_timeout = 5000")
await db.execute("PRAGMA foreign_keys = ON")
db.row_factory = aiosqlite.Row
cursor = await db.execute("SELECT * FROM calendar_events WHERE id = ?", (event_id,))
row = await cursor.fetchone()
return _row_to_event(row) if row else None
async def get_event_by_external_id(
external_id: str,
external_provider: str,
user_id: str,
db_path: str | Path | None = None,
) -> CalendarEvent | None:
"""Return a single event by (external_id, provider, user_id), or None.
Used by ICS import (U8) to skip duplicate UIDs already imported.
"""
path = Path(db_path) if db_path is not None else DEFAULT_CALENDAR_DB_PATH
async with aiosqlite.connect(str(path)) as db:
await db.execute("PRAGMA journal_mode=WAL")
await db.execute("PRAGMA busy_timeout = 5000")
await db.execute("PRAGMA foreign_keys = ON")
db.row_factory = aiosqlite.Row
cursor = await db.execute(
"SELECT * FROM calendar_events "
"WHERE external_id = ? AND external_provider = ? AND user_id = ?",
(external_id, external_provider, user_id),
)
row = await cursor.fetchone()
return _row_to_event(row) if row else None
async def list_events(
user_id: str,
start: str | None = None,
end: str | None = None,
event_type_id: str | None = None,
tag_id: str | None = None,
db_path: str | Path | None = None,
) -> list[CalendarEvent]:
"""List events for a user with optional filters."""
path = Path(db_path) if db_path is not None else DEFAULT_CALENDAR_DB_PATH
query = "SELECT DISTINCT e.* FROM calendar_events e"
params: list[str] = []
conditions: list[str] = ["e.user_id = ?"]
params.append(user_id)
if start is not None:
conditions.append("e.start_time >= ?")
params.append(start)
if end is not None:
conditions.append("e.start_time < ?")
params.append(end)
if event_type_id is not None:
conditions.append("e.event_type_id = ?")
params.append(event_type_id)
if tag_id is not None:
query += " JOIN calendar_event_tags et ON et.event_id = e.id"
conditions.append("et.tag_id = ?")
params.append(tag_id)
query += " WHERE " + " AND ".join(conditions) + " ORDER BY e.start_time"
async with aiosqlite.connect(str(path)) as db:
await db.execute("PRAGMA journal_mode=WAL")
await db.execute("PRAGMA busy_timeout = 5000")
await db.execute("PRAGMA foreign_keys = ON")
db.row_factory = aiosqlite.Row
cursor = await db.execute(query, tuple(params))
rows = await cursor.fetchall()
return [_row_to_event(row) for row in rows]
async def list_all_events_in_time_range(
start: str, end: str, db_path: str | Path | None = None
) -> list[CalendarEvent]:
"""List all events (across all users) with start_time in [start, end).
Used by ReminderScheduler to scan for events entering the reminder window.
"""
path = Path(db_path) if db_path is not None else DEFAULT_CALENDAR_DB_PATH
async with aiosqlite.connect(str(path)) as db:
await db.execute("PRAGMA journal_mode=WAL")
await db.execute("PRAGMA busy_timeout = 5000")
await db.execute("PRAGMA foreign_keys = ON")
db.row_factory = aiosqlite.Row
cursor = await db.execute(
"SELECT * FROM calendar_events WHERE start_time >= ? AND start_time < ? "
"ORDER BY start_time",
(start, end),
)
rows = await cursor.fetchall()
return [_row_to_event(row) for row in rows]
async def update_event(
event_id: str, fields: dict[str, object], db_path: str | Path | None = None
) -> bool:
"""Update specific fields of an event. Returns True if a row was updated."""
path = Path(db_path) if db_path is not None else DEFAULT_CALENDAR_DB_PATH
# Map dataclass field names to column names, handle bool → int
column_map = {
"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",
"external_id": "external_id",
"external_provider": "external_provider",
"last_modified": "last_modified",
}
set_clauses: list[str] = []
params: list[object] = []
for field_name, value in fields.items():
col = column_map.get(field_name)
if col is None:
continue
if field_name in ("is_all_day", "is_invited"):
value = int(bool(value))
set_clauses.append(f"{col} = ?")
params.append(value)
if not set_clauses:
return False
params.append(event_id)
sql = f"UPDATE calendar_events SET {', '.join(set_clauses)} WHERE id = ?"
async with aiosqlite.connect(str(path)) as db:
await db.execute("PRAGMA journal_mode=WAL")
await db.execute("PRAGMA busy_timeout = 5000")
await db.execute("PRAGMA foreign_keys = ON")
cursor = await db.execute(sql, tuple(params))
await db.commit()
return cursor.rowcount > 0
async def delete_event(event_id: str, db_path: str | Path | None = None) -> bool:
"""Delete an event and its dependent rows. Returns True if deleted."""
path = Path(db_path) if db_path is not None else DEFAULT_CALENDAR_DB_PATH
async with aiosqlite.connect(str(path)) as db:
await db.execute("PRAGMA journal_mode=WAL")
await db.execute("PRAGMA busy_timeout = 5000")
await db.execute("PRAGMA foreign_keys = ON")
# Manual cascade for event_tags (no ON DELETE on junction FK in some SQLite versions)
await db.execute("DELETE FROM calendar_event_tags WHERE event_id = ?", (event_id,))
cursor = await db.execute("DELETE FROM calendar_events WHERE id = ?", (event_id,))
await db.commit()
return cursor.rowcount > 0
# ---------------------------------------------------------------------------
# Event Type CRUD
# ---------------------------------------------------------------------------
async def insert_event_type(et: EventType, db_path: str | Path | None = None) -> None:
path = Path(db_path) if db_path is not None else DEFAULT_CALENDAR_DB_PATH
async with aiosqlite.connect(str(path)) as db:
await db.execute("PRAGMA journal_mode=WAL")
await db.execute("PRAGMA busy_timeout = 5000")
await db.execute("PRAGMA foreign_keys = ON")
await db.execute(
"INSERT INTO calendar_event_types (id, user_id, name, color, is_default) "
"VALUES (?, ?, ?, ?, ?)",
(et.id, et.user_id, et.name, et.color, int(et.is_default)),
)
await db.commit()
async def list_event_types(user_id: str, db_path: str | Path | None = None) -> list[EventType]:
path = Path(db_path) if db_path is not None else DEFAULT_CALENDAR_DB_PATH
async with aiosqlite.connect(str(path)) as db:
await db.execute("PRAGMA journal_mode=WAL")
await db.execute("PRAGMA busy_timeout = 5000")
await db.execute("PRAGMA foreign_keys = ON")
db.row_factory = aiosqlite.Row
cursor = await db.execute(
"SELECT * FROM calendar_event_types WHERE user_id = ? ORDER BY name",
(user_id,),
)
rows = await cursor.fetchall()
return [_row_to_event_type(row) for row in rows]
async def update_event_type(
type_id: str, fields: dict[str, object], db_path: str | Path | None = None
) -> bool:
path = Path(db_path) if db_path is not None else DEFAULT_CALENDAR_DB_PATH
set_clauses: list[str] = []
params: list[object] = []
for field_name, value in fields.items():
if field_name == "name":
set_clauses.append("name = ?")
params.append(value)
elif field_name == "color":
set_clauses.append("color = ?")
params.append(value)
elif field_name == "is_default":
set_clauses.append("is_default = ?")
params.append(int(bool(value)))
if not set_clauses:
return False
params.append(type_id)
sql = f"UPDATE calendar_event_types SET {', '.join(set_clauses)} WHERE id = ?"
async with aiosqlite.connect(str(path)) as db:
await db.execute("PRAGMA journal_mode=WAL")
await db.execute("PRAGMA busy_timeout = 5000")
await db.execute("PRAGMA foreign_keys = ON")
cursor = await db.execute(sql, tuple(params))
await db.commit()
return cursor.rowcount > 0
async def delete_event_type(type_id: str, db_path: str | Path | None = None) -> bool:
path = Path(db_path) if db_path is not None else DEFAULT_CALENDAR_DB_PATH
async with aiosqlite.connect(str(path)) as db:
await db.execute("PRAGMA journal_mode=WAL")
await db.execute("PRAGMA busy_timeout = 5000")
await db.execute("PRAGMA foreign_keys = ON")
cursor = await db.execute("DELETE FROM calendar_event_types WHERE id = ?", (type_id,))
await db.commit()
return cursor.rowcount > 0
# ---------------------------------------------------------------------------
# Tag CRUD
# ---------------------------------------------------------------------------
async def insert_tag(tag: Tag, db_path: str | Path | None = None) -> None:
path = Path(db_path) if db_path is not None else DEFAULT_CALENDAR_DB_PATH
async with aiosqlite.connect(str(path)) as db:
await db.execute("PRAGMA journal_mode=WAL")
await db.execute("PRAGMA busy_timeout = 5000")
await db.execute("PRAGMA foreign_keys = ON")
await db.execute(
"INSERT INTO calendar_tags (id, user_id, name) VALUES (?, ?, ?)",
(tag.id, tag.user_id, tag.name),
)
await db.commit()
async def list_tags(user_id: str, db_path: str | Path | None = None) -> list[Tag]:
path = Path(db_path) if db_path is not None else DEFAULT_CALENDAR_DB_PATH
async with aiosqlite.connect(str(path)) as db:
await db.execute("PRAGMA journal_mode=WAL")
await db.execute("PRAGMA busy_timeout = 5000")
await db.execute("PRAGMA foreign_keys = ON")
db.row_factory = aiosqlite.Row
cursor = await db.execute(
"SELECT * FROM calendar_tags WHERE user_id = ? ORDER BY name",
(user_id,),
)
rows = await cursor.fetchall()
return [_row_to_tag(row) for row in rows]
async def delete_tag(tag_id: str, db_path: str | Path | None = None) -> bool:
path = Path(db_path) if db_path is not None else DEFAULT_CALENDAR_DB_PATH
async with aiosqlite.connect(str(path)) as db:
await db.execute("PRAGMA journal_mode=WAL")
await db.execute("PRAGMA busy_timeout = 5000")
await db.execute("PRAGMA foreign_keys = ON")
await db.execute("DELETE FROM calendar_event_tags WHERE tag_id = ?", (tag_id,))
cursor = await db.execute("DELETE FROM calendar_tags WHERE id = ?", (tag_id,))
await db.commit()
return cursor.rowcount > 0
# ---------------------------------------------------------------------------
# Event-Tag junction
# ---------------------------------------------------------------------------
async def add_tag_to_event(event_id: str, tag_id: str, db_path: str | Path | None = None) -> None:
path = Path(db_path) if db_path is not None else DEFAULT_CALENDAR_DB_PATH
async with aiosqlite.connect(str(path)) as db:
await db.execute("PRAGMA journal_mode=WAL")
await db.execute("PRAGMA busy_timeout = 5000")
await db.execute("PRAGMA foreign_keys = ON")
await db.execute(
"INSERT OR IGNORE INTO calendar_event_tags (event_id, tag_id) VALUES (?, ?)",
(event_id, tag_id),
)
await db.commit()
async def remove_tag_from_event(
event_id: str, tag_id: str, db_path: str | Path | None = None
) -> None:
path = Path(db_path) if db_path is not None else DEFAULT_CALENDAR_DB_PATH
async with aiosqlite.connect(str(path)) as db:
await db.execute("PRAGMA journal_mode=WAL")
await db.execute("PRAGMA busy_timeout = 5000")
await db.execute("PRAGMA foreign_keys = ON")
await db.execute(
"DELETE FROM calendar_event_tags WHERE event_id = ? AND tag_id = ?",
(event_id, tag_id),
)
await db.commit()
async def get_event_tags(event_id: str, db_path: str | Path | None = None) -> list[Tag]:
path = Path(db_path) if db_path is not None else DEFAULT_CALENDAR_DB_PATH
async with aiosqlite.connect(str(path)) as db:
await db.execute("PRAGMA journal_mode=WAL")
await db.execute("PRAGMA busy_timeout = 5000")
await db.execute("PRAGMA foreign_keys = ON")
db.row_factory = aiosqlite.Row
cursor = await db.execute(
"SELECT t.* FROM calendar_tags t "
"JOIN calendar_event_tags et ON et.tag_id = t.id "
"WHERE et.event_id = ?",
(event_id,),
)
rows = await cursor.fetchall()
return [_row_to_tag(row) for row in rows]
# ---------------------------------------------------------------------------
# Reminder Rule CRUD
# ---------------------------------------------------------------------------
async def insert_reminder_rule(rule: ReminderRule, db_path: str | Path | None = None) -> None:
path = Path(db_path) if db_path is not None else DEFAULT_CALENDAR_DB_PATH
async with aiosqlite.connect(str(path)) as db:
await db.execute("PRAGMA journal_mode=WAL")
await db.execute("PRAGMA busy_timeout = 5000")
await db.execute("PRAGMA foreign_keys = ON")
await db.execute(
"INSERT INTO calendar_reminder_rules "
"(id, event_id, event_type_id, offset_minutes, channels) "
"VALUES (?, ?, ?, ?, ?)",
(
rule.id,
rule.event_id,
rule.event_type_id,
rule.offset_minutes,
json.dumps(rule.channels),
),
)
await db.commit()
async def list_reminder_rules_for_event(
event_id: str, db_path: str | Path | None = None
) -> list[ReminderRule]:
path = Path(db_path) if db_path is not None else DEFAULT_CALENDAR_DB_PATH
async with aiosqlite.connect(str(path)) as db:
await db.execute("PRAGMA journal_mode=WAL")
await db.execute("PRAGMA busy_timeout = 5000")
await db.execute("PRAGMA foreign_keys = ON")
db.row_factory = aiosqlite.Row
cursor = await db.execute(
"SELECT * FROM calendar_reminder_rules WHERE event_id = ?",
(event_id,),
)
rows = await cursor.fetchall()
return [_row_to_reminder_rule(row) for row in rows]
async def list_reminder_rules_for_type(
event_type_id: str, db_path: str | Path | None = None
) -> list[ReminderRule]:
path = Path(db_path) if db_path is not None else DEFAULT_CALENDAR_DB_PATH
async with aiosqlite.connect(str(path)) as db:
await db.execute("PRAGMA journal_mode=WAL")
await db.execute("PRAGMA busy_timeout = 5000")
await db.execute("PRAGMA foreign_keys = ON")
db.row_factory = aiosqlite.Row
cursor = await db.execute(
"SELECT * FROM calendar_reminder_rules WHERE event_type_id = ?",
(event_type_id,),
)
rows = await cursor.fetchall()
return [_row_to_reminder_rule(row) for row in rows]
async def delete_reminder_rule(rule_id: str, db_path: str | Path | None = None) -> bool:
path = Path(db_path) if db_path is not None else DEFAULT_CALENDAR_DB_PATH
async with aiosqlite.connect(str(path)) as db:
await db.execute("PRAGMA journal_mode=WAL")
await db.execute("PRAGMA busy_timeout = 5000")
await db.execute("PRAGMA foreign_keys = ON")
cursor = await db.execute("DELETE FROM calendar_reminder_rules WHERE id = ?", (rule_id,))
await db.commit()
return cursor.rowcount > 0
# ---------------------------------------------------------------------------
# Reminder Delivery CRUD
# ---------------------------------------------------------------------------
async def insert_reminder_delivery(
delivery: ReminderDelivery, db_path: str | Path | None = None
) -> None:
path = Path(db_path) if db_path is not None else DEFAULT_CALENDAR_DB_PATH
async with aiosqlite.connect(str(path)) as db:
await db.execute("PRAGMA journal_mode=WAL")
await db.execute("PRAGMA busy_timeout = 5000")
await db.execute("PRAGMA foreign_keys = ON")
await db.execute(
"INSERT INTO calendar_reminder_deliveries "
"(id, reminder_rule_id, event_id, scheduled_time, status, channel, "
"attempts, last_error) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
(
delivery.id,
delivery.reminder_rule_id,
delivery.event_id,
delivery.scheduled_time,
delivery.status,
delivery.channel,
delivery.attempts,
delivery.last_error,
),
)
await db.commit()
async def get_pending_deliveries(
event_id: str,
reminder_rule_id: str,
db_path: str | Path | None = None,
status: str = "sent",
) -> list[ReminderDelivery]:
"""Check idempotency — return existing deliveries for an event+rule.
By default only ``sent`` deliveries are returned, so the scheduler's
idempotency check skips rules that already succeeded. Stuck ``pending``
or ``failed`` deliveries are ignored, allowing retry on the next scan.
"""
path = Path(db_path) if db_path is not None else DEFAULT_CALENDAR_DB_PATH
async with aiosqlite.connect(str(path)) as db:
await db.execute("PRAGMA journal_mode=WAL")
await db.execute("PRAGMA busy_timeout = 5000")
await db.execute("PRAGMA foreign_keys = ON")
db.row_factory = aiosqlite.Row
cursor = await db.execute(
"SELECT * FROM calendar_reminder_deliveries "
"WHERE event_id = ? AND reminder_rule_id = ? AND status = ?",
(event_id, reminder_rule_id, status),
)
rows = await cursor.fetchall()
return [_row_to_reminder_delivery(row) for row in rows]
async def update_delivery_status(
delivery_id: str,
status: str,
last_error: str | None = None,
db_path: str | Path | None = None,
) -> bool:
path = Path(db_path) if db_path is not None else DEFAULT_CALENDAR_DB_PATH
async with aiosqlite.connect(str(path)) as db:
await db.execute("PRAGMA journal_mode=WAL")
await db.execute("PRAGMA busy_timeout = 5000")
await db.execute("PRAGMA foreign_keys = ON")
cursor = await db.execute(
"UPDATE calendar_reminder_deliveries "
"SET status = ?, attempts = attempts + 1, last_error = ? "
"WHERE id = ?",
(status, last_error, delivery_id),
)
await db.commit()
return cursor.rowcount > 0
# ---------------------------------------------------------------------------
# External Calendar Config CRUD
# ---------------------------------------------------------------------------
async def insert_external_config(
config: ExternalCalendarConfig, db_path: str | Path | None = None
) -> None:
path = Path(db_path) if db_path is not None else DEFAULT_CALENDAR_DB_PATH
async with aiosqlite.connect(str(path)) as db:
await db.execute("PRAGMA journal_mode=WAL")
await db.execute("PRAGMA busy_timeout = 5000")
await db.execute("PRAGMA foreign_keys = ON")
await db.execute(
"INSERT INTO calendar_external_configs "
"(id, user_id, provider, credentials, sync_frequency, sync_scope, "
"last_sync, sync_token) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
(
config.id,
config.user_id,
config.provider,
config.credentials,
config.sync_frequency,
json.dumps(config.sync_scope),
config.last_sync,
config.sync_token,
),
)
await db.commit()
async def list_external_configs(
user_id: str, db_path: str | Path | None = None
) -> list[ExternalCalendarConfig]:
path = Path(db_path) if db_path is not None else DEFAULT_CALENDAR_DB_PATH
async with aiosqlite.connect(str(path)) as db:
await db.execute("PRAGMA journal_mode=WAL")
await db.execute("PRAGMA busy_timeout = 5000")
await db.execute("PRAGMA foreign_keys = ON")
db.row_factory = aiosqlite.Row
cursor = await db.execute(
"SELECT * FROM calendar_external_configs WHERE user_id = ?",
(user_id,),
)
rows = await cursor.fetchall()
return [_row_to_external_config(row) for row in rows]
async def update_external_config(
config_id: str, fields: dict[str, object], db_path: str | Path | None = None
) -> bool:
path = Path(db_path) if db_path is not None else DEFAULT_CALENDAR_DB_PATH
set_clauses: list[str] = []
params: list[object] = []
for field_name, value in fields.items():
if field_name == "credentials":
set_clauses.append("credentials = ?")
params.append(value)
elif field_name == "sync_frequency":
set_clauses.append("sync_frequency = ?")
params.append(value)
elif field_name == "sync_scope":
set_clauses.append("sync_scope = ?")
params.append(json.dumps(value))
elif field_name == "last_sync":
set_clauses.append("last_sync = ?")
params.append(value)
elif field_name == "sync_token":
set_clauses.append("sync_token = ?")
params.append(value)
if not set_clauses:
return False
params.append(config_id)
sql = f"UPDATE calendar_external_configs SET {', '.join(set_clauses)} WHERE id = ?"
async with aiosqlite.connect(str(path)) as db:
await db.execute("PRAGMA journal_mode=WAL")
await db.execute("PRAGMA busy_timeout = 5000")
await db.execute("PRAGMA foreign_keys = ON")
cursor = await db.execute(sql, tuple(params))
await db.commit()
return cursor.rowcount > 0
async def delete_external_config(config_id: str, db_path: str | Path | None = None) -> bool:
path = Path(db_path) if db_path is not None else DEFAULT_CALENDAR_DB_PATH
async with aiosqlite.connect(str(path)) as db:
await db.execute("PRAGMA journal_mode=WAL")
await db.execute("PRAGMA busy_timeout = 5000")
await db.execute("PRAGMA foreign_keys = ON")
cursor = await db.execute(
"DELETE FROM calendar_external_configs WHERE id = ?", (config_id,)
)
await db.commit()
return cursor.rowcount > 0
# ---------------------------------------------------------------------------
# Invitation CRUD
# ---------------------------------------------------------------------------
async def insert_invitation(invitation: Invitation, db_path: str | Path | None = None) -> None:
path = Path(db_path) if db_path is not None else DEFAULT_CALENDAR_DB_PATH
async with aiosqlite.connect(str(path)) as db:
await db.execute("PRAGMA journal_mode=WAL")
await db.execute("PRAGMA busy_timeout = 5000")
await db.execute("PRAGMA foreign_keys = ON")
await db.execute(
"INSERT INTO calendar_invitations "
"(id, event_id, inviter_user_id, invitee_email, status, responded_at) "
"VALUES (?, ?, ?, ?, ?, ?)",
(
invitation.id,
invitation.event_id,
invitation.inviter_user_id,
invitation.invitee_email,
invitation.status,
invitation.responded_at,
),
)
await db.commit()
async def get_invitation(
invitation_id: str, db_path: str | Path | None = None
) -> Invitation | None:
path = Path(db_path) if db_path is not None else DEFAULT_CALENDAR_DB_PATH
async with aiosqlite.connect(str(path)) as db:
await db.execute("PRAGMA journal_mode=WAL")
await db.execute("PRAGMA busy_timeout = 5000")
await db.execute("PRAGMA foreign_keys = ON")
db.row_factory = aiosqlite.Row
cursor = await db.execute(
"SELECT * FROM calendar_invitations WHERE id = ?", (invitation_id,)
)
row = await cursor.fetchone()
return _row_to_invitation(row) if row else None
async def list_invitations(
invitee_email: str, db_path: str | Path | None = None
) -> list[Invitation]:
path = Path(db_path) if db_path is not None else DEFAULT_CALENDAR_DB_PATH
async with aiosqlite.connect(str(path)) as db:
await db.execute("PRAGMA journal_mode=WAL")
await db.execute("PRAGMA busy_timeout = 5000")
await db.execute("PRAGMA foreign_keys = ON")
db.row_factory = aiosqlite.Row
cursor = await db.execute(
"SELECT * FROM calendar_invitations WHERE invitee_email = ? ORDER BY responded_at DESC",
(invitee_email,),
)
rows = await cursor.fetchall()
return [_row_to_invitation(row) for row in rows]
async def update_invitation_status(
invitation_id: str,
status: str,
responded_at: str,
db_path: str | Path | None = None,
) -> bool:
path = Path(db_path) if db_path is not None else DEFAULT_CALENDAR_DB_PATH
async with aiosqlite.connect(str(path)) as db:
await db.execute("PRAGMA journal_mode=WAL")
await db.execute("PRAGMA busy_timeout = 5000")
await db.execute("PRAGMA foreign_keys = ON")
cursor = await db.execute(
"UPDATE calendar_invitations SET status = ?, responded_at = ? WHERE id = ?",
(status, responded_at, invitation_id),
)
await db.commit()
return cursor.rowcount > 0