luplo.core.ideas ================ .. py:module:: luplo.core.ideas .. autoapi-nested-parse:: CRUD operations for the ``ideas`` table. Ideas are append-only ideation notes attached to a work unit — separate from ``items`` because their lifecycle and intent differ. There is intentionally **no** ``update_idea`` or ``delete_idea`` here: mistakes are recovered via :func:`redact_idea`, which preserves the audit row. The Python API surface is the policy boundary; the database has no triggers enforcing append-only. Direct SQL is still allowed for admin cleanup. Functions --------- .. autoapisummary:: luplo.core.ideas.add_idea luplo.core.ideas.list_ideas luplo.core.ideas.search_ideas luplo.core.ideas.redact_idea luplo.core.ideas.get_idea Module Contents --------------- .. py:function:: add_idea(conn: psycopg.AsyncConnection[Any], *, project_id: str, work_unit_id: str, text: str, created_by: str | None = None, id: str | None = None) -> luplo.core.models.Idea :async: Insert an idea attached to a work unit. Accepts a full UUID or 8+ hex prefix for ``work_unit_id``. The work unit must exist, belong to ``project_id``, and not be ``archived`` or ``abandoned``. ``done`` is allowed (retro notes). Status check + INSERT happen in a single SQL statement — no TOCTOU window between "I checked archived" and "I inserted". :raises ValidationError: empty text, cross-project WU, or WU in a status that rejects new ideas. :raises NotFoundError: ``work_unit_id`` does not resolve to any row. .. py:function:: list_ideas(conn: psycopg.AsyncConnection[Any], *, work_unit_id: str, project_id: str | None = None, limit: int = 100, include_redacted: bool = False) -> list[luplo.core.models.Idea] :async: List ideas for a work unit, newest first. Accepts a full UUID or 8+ hex prefix for ``work_unit_id``. ``project_id`` scopes prefix resolution and is also enforced at the SELECT level so a full UUID from another project returns ``[]`` instead of leaking rows. Redacted rows are excluded by default; ``include_redacted=True`` opts back in (admin / audit flows). .. py:function:: search_ideas(conn: psycopg.AsyncConnection[Any], *, project_id: str, query: str | None = None, tsquery: str | None = None, work_unit_id: str | None = None, author: str | None = None, since: datetime.datetime | None = None, until: datetime.datetime | None = None, include_redacted: bool = False, limit: int = 50) -> list[luplo.core.models.Idea] :async: Full-text search over ideas, project-scoped. Two query modes (mutually exclusive): * ``query`` — simple dialect, glossary-expanded. Same dialect as :func:`luplo.core.search.pipeline.search` for items. * ``tsquery`` — raw ``to_tsquery`` expression. Caller owns synonym coverage and validity. Malformed expressions surface as ``psycopg.errors.SyntaxError`` from Postgres. Filters: ``work_unit_id`` (narrow scope, accepts UUID or hex prefix), ``author`` (actor id), ``since`` / ``until`` (datetime — caller does string parsing). Both ``query`` and ``tsquery`` may be ``None`` for a filter-only search (e.g. "내가 이번 주 적은 아이디어"). Redacted rows excluded unless ``include_redacted=True``. ``include_redacted=True`` cannot be combined with ``query`` / ``tsquery`` — the body is masked at the response layer, but a text predicate on redacted rows would leak via match/no-match (a keyword oracle). Use filter-only mode for audit flows, or fetch raw text via the SaaS-side admin path. .. py:function:: redact_idea(conn: psycopg.AsyncConnection[Any], *, idea_id: str, redacted_by: str, project_id: str | None = None) -> tuple[luplo.core.models.Idea, bool] :async: Mark an idea as redacted (idempotent — no-op if already redacted). Append-only invariant preserved: the row remains, ``text`` is not cleared, and downstream readers filter on ``redacted_at IS NULL`` by default. ``project_id`` (when provided) scopes prefix resolution **and** is enforced in the UPDATE/SELECT predicate so a full UUID from another project cannot mutate or read this row. Ambiguity errors surface only the matched ``id`` values, never the text content. Returns ``(idea, newly_redacted)``: ``newly_redacted=True`` on first transition to redacted, ``False`` on idempotent retry. Callers can skip side-effects (audit, notifications) when ``False``. :raises NotFoundError: when ``idea_id`` does not resolve to an existing row in this project. .. py:function:: get_idea(conn: psycopg.AsyncConnection[Any], idea_id: str, *, project_id: str | None = None) -> luplo.core.models.Idea | None :async: Fetch a single idea by id (or hex prefix). Includes redacted rows. SaaS-side permission checks need to fetch the row before deciding whether redact is allowed — hence this is a separate helper rather than relying on list/search. ``project_id`` scopes prefix resolution **and** is enforced in the SELECT predicate so a full UUID from another project returns ``None`` instead of leaking the row. Ambiguity errors surface only the matched ``id`` values.