"""Soft-delete / trash service — single source of truth for recoverable deletes. A delete stamps `deleted_at` + a shared `deleted_batch_id` on the target row and its descendants (cascade). Restore clears the batch; purge hard-deletes it; the retention cron purges rows older than the configured window. Live reads exclude trashed rows via `alive()`. """ from __future__ import annotations import uuid from datetime import datetime, timezone from sqlalchemy import or_, select, update from fabledassistant.models import async_session from fabledassistant.models.note import Note from fabledassistant.models.event import Event from fabledassistant.models.project import Project from fabledassistant.models.milestone import Milestone from fabledassistant.models.rulebook import Rulebook, RulebookTopic, Rule # entity_type -> Model. Used to resolve which table a trash op targets. _MODEL_FOR = { "note": Note, "task": Note, "event": Event, "project": Project, "milestone": Milestone, "rulebook": Rulebook, "topic": RulebookTopic, "rule": Rule, } def _owner_clause(model, user_id: int): """Boolean expr scoping `model` rows to the ones `user_id` owns. EVERY trash query (exists-check, restore, purge, list, retention sweep) must carry this — a batch_id is a bearer token, so without an owner predicate a leaked/guessed id lets one tenant read, restore, or permanently destroy another's content. Topics and rules carry no user_id of their own; ownership is derived through the parent rulebook (or, for project-scoped rules, the owning project). """ if model is Rulebook: return Rulebook.owner_user_id == user_id if model is RulebookTopic: return RulebookTopic.rulebook_id.in_( select(Rulebook.id).where(Rulebook.owner_user_id == user_id) ) if model is Rule: return or_( Rule.topic_id.in_( select(RulebookTopic.id) .join(Rulebook, RulebookTopic.rulebook_id == Rulebook.id) .where(Rulebook.owner_user_id == user_id) ), Rule.project_id.in_( select(Project.id).where(Project.user_id == user_id) ), ) # Note, Event, Project, Milestone all carry user_id directly. return model.user_id == user_id async def _set(session, model, where, batch, now) -> None: """Stamp deleted_at + batch on live rows matching `where`.""" await session.execute( update(model) .where(*where, model.deleted_at.is_(None)) .values(deleted_at=now, deleted_batch_id=batch) ) async def _exists_alive(session, user_id: int, etype: str, eid: int) -> bool: model = _MODEL_FOR[etype] where = [model.id == eid, model.deleted_at.is_(None), _owner_clause(model, user_id)] return (await session.execute(select(model.id).where(*where))).first() is not None async def _cascade(session, user_id: int, etype: str, eid: int, batch: str, now) -> None: if etype == "project": await _set(session, Note, [Note.user_id == user_id, Note.project_id == eid], batch, now) await _set(session, Milestone, [Milestone.user_id == user_id, Milestone.project_id == eid], batch, now) # Project-scoped rules cascade with the project they're attached to. await _set(session, Rule, [Rule.project_id == eid], batch, now) # Suppressions are pure associations (no deleted_at) — hard-delete # them here so restoring the project doesn't bring stale mutes back. # FK CASCADE would handle a full DELETE on the project row, but the # soft-delete path keeps the project row alive; this guarantees the # rows are gone whether or not the project ever gets purged. from sqlalchemy import delete as _sql_delete from fabledassistant.models.rulebook import ( project_rule_suppressions, project_topic_suppressions, ) await session.execute( _sql_delete(project_rule_suppressions) .where(project_rule_suppressions.c.project_id == eid) ) await session.execute( _sql_delete(project_topic_suppressions) .where(project_topic_suppressions.c.project_id == eid) ) await _set(session, Project, [Project.user_id == user_id, Project.id == eid], batch, now) elif etype == "milestone": await _set(session, Note, [Note.user_id == user_id, Note.milestone_id == eid], batch, now) await _set(session, Milestone, [Milestone.user_id == user_id, Milestone.id == eid], batch, now) elif etype in ("note", "task"): # Stamp the entire sub-task subtree (not just direct children) so a # deeply nested task and all its descendants trash/restore as one batch. ids = [eid] frontier = [eid] while frontier: children = (await session.execute( select(Note.id).where( Note.user_id == user_id, Note.parent_id.in_(frontier), Note.deleted_at.is_(None), ) )).scalars().all() frontier = [c for c in children if c not in ids] ids.extend(frontier) await _set(session, Note, [Note.user_id == user_id, Note.id.in_(ids)], batch, now) elif etype == "event": await _set(session, Event, [Event.user_id == user_id, Event.id == eid], batch, now) elif etype == "rulebook": topic_ids = (await session.execute( select(RulebookTopic.id) .join(Rulebook, RulebookTopic.rulebook_id == Rulebook.id) .where(Rulebook.id == eid, Rulebook.owner_user_id == user_id) )).scalars().all() if topic_ids: await _set(session, Rule, [Rule.topic_id.in_(topic_ids)], batch, now) await _set(session, RulebookTopic, [RulebookTopic.rulebook_id == eid], batch, now) await _set(session, Rulebook, [Rulebook.id == eid, Rulebook.owner_user_id == user_id], batch, now) elif etype == "topic": await _set(session, Rule, [Rule.topic_id == eid], batch, now) await _set(session, RulebookTopic, [RulebookTopic.id == eid], batch, now) elif etype == "rule": await _set(session, Rule, [Rule.id == eid], batch, now) else: raise ValueError(f"unknown entity_type: {etype!r}") async def delete(user_id: int, entity_type: str, entity_id: int) -> str | None: """Soft-delete an entity + its descendants under one batch_id. Returns the batch_id, or None if the entity wasn't found / not owned. """ batch = str(uuid.uuid4()) now = datetime.now(timezone.utc) caldav_event: tuple[str, str] | None = None async with async_session() as session: if not await _exists_alive(session, user_id, entity_type, entity_id): return None # Capture CalDAV linkage before soft-deleting so we can propagate the # deletion to the external server (the row stays present locally). if entity_type == "event": row = (await session.execute( select(Event.caldav_uid, Event.title).where( Event.id == entity_id, Event.user_id == user_id ) )).first() if row and row[0]: caldav_event = (row[0], row[1]) await _cascade(session, user_id, entity_type, entity_id, batch, now) await session.commit() # Without this the soft-delete only hides the event locally and the remote # copy lingers forever (and re-appears on any client syncing that server). if caldav_event: import asyncio from fabledassistant.services.events import _push_delete asyncio.create_task(_push_delete(caldav_event[0], caldav_event[1], user_id)) return batch # All soft-deletable models, and their trash-listing type label. _ALL = [Note, Event, Project, Milestone, Rulebook, RulebookTopic, Rule] _TYPE = { Note: "note", Event: "event", Project: "project", Milestone: "milestone", Rulebook: "rulebook", RulebookTopic: "topic", Rule: "rule", } def alive(stmt, model): """Append a 'not trashed' filter to a select. Use in every live read.""" return stmt.where(model.deleted_at.is_(None)) async def restore(user_id: int, batch_id: str) -> int: """Clear deleted_at for every row in the batch. Returns rows restored.""" n = 0 async with async_session() as session: for model in _ALL: res = await session.execute( update(model) .where(model.deleted_batch_id == batch_id, _owner_clause(model, user_id)) .values(deleted_at=None, deleted_batch_id=None) ) n += res.rowcount or 0 await session.commit() return n async def purge(user_id: int, batch_id: str) -> int: """Hard-delete every row in the batch. Irreversible.""" from sqlalchemy import delete as sql_delete n = 0 async with async_session() as session: for model in _ALL: res = await session.execute( sql_delete(model).where( model.deleted_batch_id == batch_id, _owner_clause(model, user_id) ) ) n += res.rowcount or 0 await session.commit() return n async def list_trash(user_id: int) -> list[dict]: """Trashed rows across all soft-deletable tables, grouped by batch_id.""" batches: dict[str, dict] = {} async with async_session() as session: for model in _ALL: rows = (await session.execute( select(model).where( model.deleted_at.isnot(None), _owner_clause(model, user_id) ) )).scalars().all() for r in rows: grp = batches.setdefault( r.deleted_batch_id, {"batch_id": r.deleted_batch_id, "deleted_at": r.deleted_at.isoformat() if r.deleted_at else None, "items": []}, ) grp["items"].append({ "type": _TYPE[model], "id": r.id, "title": getattr(r, "title", "") or "", }) out = list(batches.values()) for g in out: lead = g["items"][0] g["summary"] = lead["title"] or f"{lead['type']} {lead['id']}" g["count"] = len(g["items"]) out.sort(key=lambda g: g["deleted_at"] or "", reverse=True) return out async def purge_expired(user_id: int, retention_days: int) -> int: """Cron entry: hard-delete THIS user's rows trashed more than retention_days ago. Scoped to one owner so the scheduler can apply each user's own `trash_retention_days` window — a single global sweep would let one user's short window prematurely destroy another's data. retention_days <= 0 disables auto-purge (returns 0 without touching anything). """ from datetime import timedelta from sqlalchemy import delete as sql_delete if retention_days <= 0: return 0 cutoff = datetime.now(timezone.utc) - timedelta(days=retention_days) n = 0 async with async_session() as session: for model in _ALL: res = await session.execute( sql_delete(model).where( model.deleted_at.isnot(None), model.deleted_at < cutoff, _owner_clause(model, user_id), ) ) n += res.rowcount or 0 await session.commit() return n