From 731d5231ff1e4a8aa8342b72e2bb8a177df90da1 Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Fri, 29 May 2026 22:15:15 +0200 Subject: [PATCH] feat(event_bus): add document.entered_folder event type and payload schema --- .../app/event_bus/events/__init__.py | 5 ++ .../events/document_entered_folder.py | 86 +++++++++++++++++++ .../event_bus/test_document_entered_folder.py | 56 ++++++++++++ 3 files changed, 147 insertions(+) create mode 100644 surfsense_backend/app/event_bus/events/__init__.py create mode 100644 surfsense_backend/app/event_bus/events/document_entered_folder.py create mode 100644 surfsense_backend/tests/unit/event_bus/test_document_entered_folder.py diff --git a/surfsense_backend/app/event_bus/events/__init__.py b/surfsense_backend/app/event_bus/events/__init__.py new file mode 100644 index 000000000..47c0e64c1 --- /dev/null +++ b/surfsense_backend/app/event_bus/events/__init__.py @@ -0,0 +1,5 @@ +"""Domain event type definitions — each in its own module, self-registering at import.""" + +from __future__ import annotations + +from . import document_entered_folder # noqa: F401 diff --git a/surfsense_backend/app/event_bus/events/document_entered_folder.py b/surfsense_backend/app/event_bus/events/document_entered_folder.py new file mode 100644 index 000000000..fc4e2de14 --- /dev/null +++ b/surfsense_backend/app/event_bus/events/document_entered_folder.py @@ -0,0 +1,86 @@ +"""``document.entered_folder``: a document became a member of a folder. + +Fires once per arrival, however the document got there (upload, AI sort, move). +The payload carries the fields a user can filter a trigger on. +""" + +from __future__ import annotations + +from pydantic import BaseModel, ConfigDict, computed_field + +from app.event_bus.catalog import EventType, catalog + +EVENT_TYPE = "document.entered_folder" + + +class DocumentEnteredFolderPayload(BaseModel): + """Snapshot of the document at the moment it entered ``folder_id``. + + ``previous_folder_id`` is the folder it left, or ``None`` for a first + placement. ``is_move`` derives from it and is emitted for filtering. + """ + + model_config = ConfigDict(extra="forbid") + + document_id: int + folder_id: int + previous_folder_id: int | None = None + document_type: str + title: str + connector_id: int | None = None + created_by_id: str | None = None + + @computed_field + @property + def is_move(self) -> bool: + return self.previous_folder_id is not None + + +catalog.register( + EventType( + type=EVENT_TYPE, + description="A document became a member of a folder.", + payload_model=DocumentEnteredFolderPayload, + ) +) + + +def payload_if_entered_folder( + *, + document_id: int, + search_space_id: int, + new_folder_id: int | None, + previous_folder_id: int | None, + folder_id_changed: bool, + status_state: str, + document_type: str, + title: str, + connector_id: int | None, + created_by_id: str | None, +) -> dict | None: + """Return a publish payload if this commit represents a folder arrival, else None. + + ``folder_id_changed`` comes from SQLAlchemy attribute history — it is True + only when ``folder_id`` actually changed in this transaction, preventing + spurious events on unrelated saves. + """ + if not folder_id_changed: + return None + if new_folder_id is None: + return None + if status_state != "ready": + return None + + return { + "event_type": EVENT_TYPE, + "search_space_id": search_space_id, + "payload": { + "document_id": document_id, + "folder_id": new_folder_id, + "previous_folder_id": previous_folder_id, + "document_type": document_type, + "title": title, + "connector_id": connector_id, + "created_by_id": created_by_id, + }, + } diff --git a/surfsense_backend/tests/unit/event_bus/test_document_entered_folder.py b/surfsense_backend/tests/unit/event_bus/test_document_entered_folder.py new file mode 100644 index 000000000..6044b539e --- /dev/null +++ b/surfsense_backend/tests/unit/event_bus/test_document_entered_folder.py @@ -0,0 +1,56 @@ +"""``document.entered_folder`` payload contract + catalog registration.""" + +from __future__ import annotations + +import pytest + +from app.event_bus.catalog import catalog +from app.event_bus.events.document_entered_folder import ( + EVENT_TYPE, + DocumentEnteredFolderPayload, +) + +pytestmark = pytest.mark.unit + + +def _payload(**overrides: object) -> DocumentEnteredFolderPayload: + base: dict[str, object] = { + "document_id": 42, + "folder_id": 7, + "document_type": "FILE", + "title": "Q3 report.pdf", + } + base.update(overrides) + return DocumentEnteredFolderPayload(**base) + + +def test_payload_carries_the_filterable_fields() -> None: + payload = _payload(connector_id=12, created_by_id="abc") + + assert payload.document_id == 42 + assert payload.folder_id == 7 + assert payload.document_type == "FILE" + assert payload.connector_id == 12 + + +def test_first_placement_is_not_a_move() -> None: + """No previous folder (created or AI-sorted into place) → not a move.""" + assert _payload(previous_folder_id=None).is_move is False + + +def test_change_between_folders_is_a_move() -> None: + assert _payload(previous_folder_id=3).is_move is True + + +def test_is_move_is_serialized_for_filtering() -> None: + """Filters match against the dumped payload, so ``is_move`` must appear there.""" + dumped = _payload(previous_folder_id=3).model_dump() + + assert dumped["is_move"] is True + + +def test_event_type_is_registered_in_the_catalog() -> None: + registered = catalog.get(EVENT_TYPE) + + assert registered is not None + assert registered.payload_model is DocumentEnteredFolderPayload