feat(book): add the Book domain — notes, tasks, decisions, questions, rejected (T-1)

The Office (workspace D-64..D-72) needs a store for Notes, Tasks,
Decisions, Questions and Rejected options. D-68 puts it here, in
core-api's own database, behind its own auth.

Migration 005 lands the whole schema now, by hand (down_revision="004";
the chain has a hash-named revision between 003 and 004, confirmed
before trusting the numbering): book_records (kind, status validated
per kind in the service, scope work/personal, a generated tsvector
search column + GIN index), book_tags, book_record_links, book_attachments
(table only, no file handling yet), and book_events — append-only via a
database trigger that raises on UPDATE and DELETE for every role,
including the owning application.

Routes this round (D-70's core verbs), every one behind
require_caller("office", "user"), writes with identity_required=True,
every change writing a book_events row: add, show, list (the nine
views), find (full-text), change, close (task done/dropped; question
resolved via a `resolves` link into `settled`; decision superseded via
a `supersedes` link into `superseded`), link/unlink, tag/untag, history.

Append, remind, reschedule, undo, purge, redact, pin, check, snooze,
archive/trash/restore, pause/resume, due, next, batch calls and
idempotency keys are explicitly not this round — the schema already
carries what they need.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-10-19 11:14:46 +02:00
co-authored by Claude Sonnet 5
parent 3f77cc2926
commit 1166f18b53
11 changed files with 2034 additions and 0 deletions
+9
View File
@@ -0,0 +1,9 @@
"""
Book Domain
The household's Notes, Tasks, Decisions, Questions and Rejected options
(workspace D-68, D-69, D-70). Served under /book/*.
"""
from src.domains.book.controller import book_controller
__all__ = ["book_controller"]
+316
View File
@@ -0,0 +1,316 @@
"""
Book Controller
The core Book verbs (D-70): add, show, list, find, change, close,
link/unlink, tag/untag, history. Every route requires a caller (office or
user); writes additionally require an identity (T-1).
"""
import uuid
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Query
from src.domains.auth.callers import Caller, require_caller
from src.domains.book.schemas import (
BookCloseRequest,
BookEventResponse,
BookHistoryResponse,
BookLinkRequest,
BookLinkResponse,
BookRecordCreate,
BookRecordDetailResponse,
BookRecordListResponse,
BookRecordResponse,
BookRecordUpdate,
BookTagRequest,
ListView,
)
from src.domains.book.service import BookError, get_book_service
from src.shared.base import BaseController
from src.shared.database import get_async_session
from src.shared.logging import get_logger
from sqlalchemy.ext.asyncio import AsyncSession
logger = get_logger(__name__)
require_book_reader = require_caller("office", "user")
require_book_writer = require_caller("office", "user", identity_required=True)
def _record_response(record, tags) -> BookRecordResponse:
schema = BookRecordResponse.model_validate(record, from_attributes=True)
return schema.model_copy(update={"tags": tags})
def _link_response(link) -> BookLinkResponse:
return BookLinkResponse.model_validate(link, from_attributes=True)
def _detail_response(record, tags, links, backlinks) -> BookRecordDetailResponse:
base = BookRecordResponse.model_validate(record, from_attributes=True).model_dump()
base["tags"] = tags
return BookRecordDetailResponse(
**base,
links=[_link_response(link) for link in links],
backlinks=[_link_response(link) for link in backlinks],
)
def _event_response(event) -> BookEventResponse:
return BookEventResponse.model_validate(event, from_attributes=True)
class BookController(BaseController):
"""Controller for the Book domain."""
def __init__(self):
super().__init__(prefix="/book", tags=["Book"])
def create_router(self) -> APIRouter:
router = APIRouter(prefix=self.prefix, tags=self.tags)
service = get_book_service()
# =====================================================================
# add
# =====================================================================
@router.post(
"/records",
response_model=BookRecordResponse,
status_code=201,
summary="Add a record to the Book",
)
async def add_record(
data: BookRecordCreate,
caller: Caller = Depends(require_book_writer),
session: AsyncSession = Depends(get_async_session),
):
try:
record, tags = await service.add_record(session, data, caller)
except BookError as error:
raise HTTPException(status_code=error.status_code, detail={"code": error.code}) from error
return _record_response(record, tags)
# =====================================================================
# list
# =====================================================================
@router.get(
"/records",
response_model=BookRecordListResponse,
summary="List records",
)
async def list_records(
view: Optional[ListView] = Query(None, description="inbox, today, upcoming, waiting, someday, pinned, archive, trash, done"),
tag: Optional[str] = Query(None),
scope: Optional[str] = Query(None),
parent: Optional[uuid.UUID] = Query(None),
limit: int = Query(50, ge=1, le=200),
offset: int = Query(0, ge=0),
caller: Caller = Depends(require_book_reader),
session: AsyncSession = Depends(get_async_session),
):
results, total = await service.list_records(
session, view=view, tag=tag, scope=scope, parent=parent, limit=limit, offset=offset
)
return BookRecordListResponse(
records=[_record_response(record, tags) for record, tags in results],
total=total,
limit=limit,
offset=offset,
)
# =====================================================================
# find — declared before /{record_id} so "find" is never parsed as an id
# =====================================================================
@router.get(
"/records/find",
response_model=BookRecordListResponse,
summary="Full-text search the Book",
)
async def find_records(
q: str = Query(..., min_length=1),
limit: int = Query(50, ge=1, le=200),
offset: int = Query(0, ge=0),
caller: Caller = Depends(require_book_reader),
session: AsyncSession = Depends(get_async_session),
):
results, total = await service.find_records(session, q=q, limit=limit, offset=offset)
return BookRecordListResponse(
records=[_record_response(record, tags) for record, tags in results],
total=total,
limit=limit,
offset=offset,
)
# =====================================================================
# show
# =====================================================================
@router.get(
"/records/{record_id}",
response_model=BookRecordDetailResponse,
summary="Show a record, its links and its tags",
)
async def show_record(
record_id: uuid.UUID,
caller: Caller = Depends(require_book_reader),
session: AsyncSession = Depends(get_async_session),
):
try:
record, tags, links, backlinks = await service.show_record(session, record_id)
except BookError as error:
raise HTTPException(status_code=error.status_code, detail={"code": error.code}) from error
return _detail_response(record, tags, links, backlinks)
# =====================================================================
# change
# =====================================================================
@router.patch(
"/records/{record_id}",
response_model=BookRecordResponse,
summary="Change a record",
)
async def change_record(
record_id: uuid.UUID,
data: BookRecordUpdate,
caller: Caller = Depends(require_book_writer),
session: AsyncSession = Depends(get_async_session),
):
try:
record, tags = await service.change_record(session, record_id, data, caller)
except BookError as error:
raise HTTPException(status_code=error.status_code, detail={"code": error.code}) from error
return _record_response(record, tags)
# =====================================================================
# close
# =====================================================================
@router.post(
"/records/{record_id}/close",
response_model=BookRecordResponse,
summary="Close a record (task done/dropped, question resolved, decision superseded)",
)
async def close_record(
record_id: uuid.UUID,
data: BookCloseRequest,
caller: Caller = Depends(require_book_writer),
session: AsyncSession = Depends(get_async_session),
):
try:
record, tags = await service.close_record(session, record_id, data, caller)
except BookError as error:
raise HTTPException(status_code=error.status_code, detail={"code": error.code}) from error
return _record_response(record, tags)
# =====================================================================
# link / unlink
# =====================================================================
@router.post(
"/records/{record_id}/link",
response_model=BookRecordResponse,
summary="Link a record to another",
)
async def link_record(
record_id: uuid.UUID,
data: BookLinkRequest,
caller: Caller = Depends(require_book_writer),
session: AsyncSession = Depends(get_async_session),
):
try:
record, tags = await service.link_records(
session, record_id, data.to_id, data.relation, caller
)
except BookError as error:
raise HTTPException(status_code=error.status_code, detail={"code": error.code}) from error
return _record_response(record, tags)
@router.post(
"/records/{record_id}/unlink",
response_model=BookRecordResponse,
summary="Remove a link between two records",
)
async def unlink_record(
record_id: uuid.UUID,
data: BookLinkRequest,
caller: Caller = Depends(require_book_writer),
session: AsyncSession = Depends(get_async_session),
):
try:
record, tags = await service.unlink_records(
session, record_id, data.to_id, data.relation, caller
)
except BookError as error:
raise HTTPException(status_code=error.status_code, detail={"code": error.code}) from error
return _record_response(record, tags)
# =====================================================================
# tag / untag
# =====================================================================
@router.post(
"/records/{record_id}/tag",
response_model=BookRecordResponse,
summary="Tag a record",
)
async def tag_record(
record_id: uuid.UUID,
data: BookTagRequest,
caller: Caller = Depends(require_book_writer),
session: AsyncSession = Depends(get_async_session),
):
try:
record, tags = await service.tag_record(session, record_id, data.tag, caller)
except BookError as error:
raise HTTPException(status_code=error.status_code, detail={"code": error.code}) from error
return _record_response(record, tags)
@router.post(
"/records/{record_id}/untag",
response_model=BookRecordResponse,
summary="Remove a tag from a record",
)
async def untag_record(
record_id: uuid.UUID,
data: BookTagRequest,
caller: Caller = Depends(require_book_writer),
session: AsyncSession = Depends(get_async_session),
):
try:
record, tags = await service.untag_record(session, record_id, data.tag, caller)
except BookError as error:
raise HTTPException(status_code=error.status_code, detail={"code": error.code}) from error
return _record_response(record, tags)
# =====================================================================
# history
# =====================================================================
@router.get(
"/records/{record_id}/history",
response_model=BookHistoryResponse,
summary="The append-only event history for a record",
)
async def record_history(
record_id: uuid.UUID,
caller: Caller = Depends(require_book_reader),
session: AsyncSession = Depends(get_async_session),
):
try:
events = await service.get_history(session, record_id)
except BookError as error:
raise HTTPException(status_code=error.status_code, detail={"code": error.code}) from error
return BookHistoryResponse(
record_id=record_id,
events=[_event_response(event) for event in events],
)
return router
# Create controller instance
book_controller = BookController()
+248
View File
@@ -0,0 +1,248 @@
"""
Book Domain Models
The Book holds Notes, Tasks, Decisions, Questions and Rejected options for
the Office (workspace D-68, D-69, D-70). SQLAlchemy 2.0 Mapped/mapped_column
style, timestamptz, following src/domains/auth/models.py.
Table prefix is `book_` (D-68). `book_events` is append-only — migration 005
adds a trigger that raises on UPDATE and DELETE, enforced at the database,
not here.
"""
import uuid
from datetime import datetime
from typing import Optional
from sqlalchemy import (
Boolean,
Computed,
DateTime,
ForeignKey,
Integer,
String,
Text,
UniqueConstraint,
func,
)
from sqlalchemy.dialects.postgresql import JSONB, TSVECTOR, UUID
from sqlalchemy.orm import Mapped, mapped_column
from src.shared.database import Base
# Kinds and their valid statuses. Enforced in the service layer (per kind),
# not as a database CHECK — the valid status set depends on the kind, which
# a single-column CHECK cannot express without a trigger.
RECORD_KINDS = ("note", "task", "decision", "question", "rejected")
STATUSES_BY_KIND = {
"note": {"active"},
"task": {"inbox", "open", "waiting", "someday", "done", "dropped"},
"decision": {"proposed", "accepted", "superseded"},
"question": {"open", "settled", "dropped"},
"rejected": {"rejected"},
}
DEFAULT_STATUS_BY_KIND = {
"note": "active",
"task": "inbox",
"decision": "proposed",
"question": "open",
"rejected": "rejected",
}
SCOPES = ("work", "personal")
DEFAULT_SCOPE = "personal"
LINK_RELATIONS = ("parent", "blocks", "resolves", "supersedes", "related")
class BookRecord(Base):
"""A Note, Task, Decision, Question or Rejected option."""
__tablename__ = "book_records"
__table_args__ = (
UniqueConstraint("source", "external_id", name="uq_book_records_source_external_id"),
)
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
primary_key=True,
default=uuid.uuid4,
)
kind: Mapped[str] = mapped_column(String(20), nullable=False, index=True)
title: Mapped[Optional[str]] = mapped_column(String(500), nullable=True)
body: Mapped[str] = mapped_column(Text, nullable=False, default="")
checklist: Mapped[list] = mapped_column(JSONB, nullable=False, default=list)
status: Mapped[str] = mapped_column(String(20), nullable=False, index=True)
scope: Mapped[str] = mapped_column(String(10), nullable=False, default=DEFAULT_SCOPE)
priority: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
pinned: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
energy: Mapped[Optional[str]] = mapped_column(String(10), nullable=True)
estimate_minutes: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
next_step: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
start_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
due_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True, index=True)
all_day: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
remind_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
recurrence: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
recurrence_mode: Mapped[Optional[str]] = mapped_column(String(20), nullable=True)
remind_to: Mapped[Optional[str]] = mapped_column(String(63), nullable=True)
last_touched_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
server_default=func.now(),
nullable=False,
)
archived_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
trashed_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
paused_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
paused_statuses: Mapped[Optional[dict]] = mapped_column(JSONB, nullable=True)
source: Mapped[Optional[str]] = mapped_column(String(50), nullable=True)
external_id: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
colour: Mapped[Optional[str]] = mapped_column(String(20), nullable=True)
created_by: Mapped[str] = mapped_column(String(63), nullable=False)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
server_default=func.now(),
nullable=False,
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
server_default=func.now(),
onupdate=func.now(),
nullable=False,
)
# Generated by migration 005 (`to_tsvector('simple', ...)`, GIN-indexed).
# 'simple' because the household writes mixed Dutch and English — a
# language-specific config would stem one of the two badly. `Computed`
# mirrors the migration's SQL so SQLAlchemy knows this column is
# database-generated and excludes it from INSERT/UPDATE — without it,
# the ORM tries to write NULL into a GENERATED ALWAYS column and
# Postgres rejects the insert outright.
search: Mapped[Optional[str]] = mapped_column(
TSVECTOR,
Computed("to_tsvector('simple', coalesce(title, '') || ' ' || body)", persisted=True),
nullable=True,
)
# Deliberately no `tags` relationship here. The service always queries
# book_tags explicitly (`BookService._tags_for`) rather than traversing
# an ORM relationship — partly because async SQLAlchemy can't lazy-load
# on an unloaded attribute without a greenlet error, but mainly because
# a relationship named `tags` collides with the response schema's
# `tags: List[str]` field: `BookRecordResponse.model_validate(record,
# from_attributes=True)` would read the relationship (a list of BookTag
# ORM objects) instead of the list of strings the controller builds
# separately, and fail validation. Cascade delete is handled by the
# column's own `ondelete="CASCADE"`, so the relationship bought nothing.
def __repr__(self) -> str:
return f"<BookRecord {self.kind} {self.id}>"
class BookTag(Base):
"""A free tag on a record. `work`/`personal` live on `scope`, not here."""
__tablename__ = "book_tags"
record_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("book_records.id", ondelete="CASCADE"),
primary_key=True,
)
tag: Mapped[str] = mapped_column(String(63), primary_key=True)
def __repr__(self) -> str:
return f"<BookTag {self.tag} on {self.record_id}>"
class BookRecordLink(Base):
"""A directed relation between two records."""
__tablename__ = "book_record_links"
from_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("book_records.id", ondelete="CASCADE"),
primary_key=True,
)
relation: Mapped[str] = mapped_column(String(20), primary_key=True)
to_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("book_records.id", ondelete="CASCADE"),
primary_key=True,
)
created_by: Mapped[str] = mapped_column(String(63), nullable=False)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
server_default=func.now(),
nullable=False,
)
def __repr__(self) -> str:
return f"<BookRecordLink {self.from_id} {self.relation} {self.to_id}>"
class BookAttachment(Base):
"""A file attached to a record. Table only — no file handling yet."""
__tablename__ = "book_attachments"
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
primary_key=True,
default=uuid.uuid4,
)
record_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("book_records.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
path: Mapped[str] = mapped_column(String(1000), nullable=False)
mime: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
size: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
server_default=func.now(),
nullable=False,
)
def __repr__(self) -> str:
return f"<BookAttachment {self.path} on {self.record_id}>"
class BookEvent(Base):
"""
An append-only audit row for every change to a record.
Migration 005 adds a trigger that raises on UPDATE and DELETE against
this table — enforced for every role, the owning application included.
`redact` (D-70 rule 4) is the one sanctioned exception, and it is a
later round; nothing here weakens the trigger.
"""
__tablename__ = "book_events"
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
primary_key=True,
default=uuid.uuid4,
)
record_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("book_records.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
server_default=func.now(),
nullable=False,
)
identity: Mapped[str] = mapped_column(String(63), nullable=False)
client: Mapped[Optional[str]] = mapped_column(String(63), nullable=True)
action: Mapped[str] = mapped_column(String(20), nullable=False)
diff: Mapped[dict] = mapped_column(JSONB, nullable=False, default=dict)
def __repr__(self) -> str:
return f"<BookEvent {self.action} on {self.record_id} by {self.identity}>"
+190
View File
@@ -0,0 +1,190 @@
"""
Book Domain Schemas
Pydantic request/response payloads for the Book (D-68, D-70). This round
ships `add`, `show`, `list`, `find`, `change`, `close`, `link`/`unlink`,
`tag`/`untag`, `history` — the verbs the Office needs next round are not
schematized here (dossier: append, remind, reschedule, undo, purge, redact,
pin, check, snooze, archive/trash/restore, pause/resume, due, next, batch,
idempotency keys).
"""
import uuid
from datetime import datetime
from typing import Any, List, Literal, Optional
from pydantic import Field
from src.shared.base import BaseSchema
RecordKind = Literal["note", "task", "decision", "question", "rejected"]
RecordScope = Literal["work", "personal"]
RecordEnergy = Literal["low", "medium", "high"]
RecurrenceMode = Literal["schedule", "after_completion"]
LinkRelation = Literal["parent", "blocks", "resolves", "supersedes", "related"]
ListView = Literal[
"inbox", "today", "upcoming", "waiting", "someday",
"pinned", "archive", "trash", "done",
]
class BookChecklistItem(BaseSchema):
"""One line of a record's checklist."""
text: str
checked: bool = False
position: int = 0
class BookRecordCreate(BaseSchema):
"""`add` — kind defaults to a Note so text-only capture costs nothing (D-72)."""
kind: RecordKind = "note"
title: Optional[str] = Field(None, max_length=500)
body: str = ""
checklist: List[BookChecklistItem] = Field(default_factory=list)
status: Optional[str] = None
scope: RecordScope = "personal"
priority: Optional[int] = None
pinned: bool = False
energy: Optional[RecordEnergy] = None
estimate_minutes: Optional[int] = None
next_step: Optional[str] = None
start_at: Optional[datetime] = None
due_at: Optional[datetime] = None
all_day: bool = False
remind_at: Optional[datetime] = None
recurrence: Optional[str] = None
recurrence_mode: Optional[RecurrenceMode] = None
remind_to: Optional[str] = None
source: Optional[str] = None
external_id: Optional[str] = None
colour: Optional[str] = None
tags: List[str] = Field(default_factory=list)
parent: Optional[uuid.UUID] = Field(
None, description="Convenience: also create a `parent` link to this record"
)
class BookRecordUpdate(BaseSchema):
"""`change` — every field optional; only what is set is applied."""
kind: Optional[RecordKind] = None
title: Optional[str] = Field(None, max_length=500)
body: Optional[str] = None
checklist: Optional[List[BookChecklistItem]] = None
status: Optional[str] = None
scope: Optional[RecordScope] = None
priority: Optional[int] = None
pinned: Optional[bool] = None
energy: Optional[RecordEnergy] = None
estimate_minutes: Optional[int] = None
next_step: Optional[str] = None
start_at: Optional[datetime] = None
due_at: Optional[datetime] = None
all_day: Optional[bool] = None
remind_at: Optional[datetime] = None
recurrence: Optional[str] = None
recurrence_mode: Optional[RecurrenceMode] = None
remind_to: Optional[str] = None
source: Optional[str] = None
external_id: Optional[str] = None
colour: Optional[str] = None
class BookCloseRequest(BaseSchema):
"""
`close` — the field that applies depends on the record's kind:
- task: `outcome` ("done" or "dropped")
- question: `resolved_by` (the Decision id that resolves it); sets `settled`
- decision: `superseded_by` (the new Decision id); the old one becomes `superseded`
"""
outcome: Optional[Literal["done", "dropped"]] = None
resolved_by: Optional[uuid.UUID] = None
superseded_by: Optional[uuid.UUID] = None
class BookLinkRequest(BaseSchema):
"""`link` / `unlink` — direction is from the record in the path to `to_id`."""
to_id: uuid.UUID
relation: LinkRelation
class BookTagRequest(BaseSchema):
"""`tag` / `untag`."""
tag: str = Field(..., min_length=1, max_length=63)
class BookLinkResponse(BaseSchema):
from_id: uuid.UUID
relation: str
to_id: uuid.UUID
created_by: str
created_at: datetime
class BookRecordResponse(BaseSchema):
"""Every write returns the stored record (D-70 rule 7)."""
id: uuid.UUID
kind: str
title: Optional[str]
body: str
checklist: List[dict] = Field(default_factory=list)
status: str
scope: str
priority: Optional[int]
pinned: bool
energy: Optional[str]
estimate_minutes: Optional[int]
next_step: Optional[str]
start_at: Optional[datetime]
due_at: Optional[datetime]
all_day: bool
remind_at: Optional[datetime]
recurrence: Optional[str]
recurrence_mode: Optional[str]
remind_to: Optional[str]
last_touched_at: datetime
archived_at: Optional[datetime]
trashed_at: Optional[datetime]
paused_at: Optional[datetime]
paused_statuses: Optional[dict]
source: Optional[str]
external_id: Optional[str]
colour: Optional[str]
created_by: str
created_at: datetime
updated_at: datetime
tags: List[str] = Field(default_factory=list)
class BookRecordDetailResponse(BookRecordResponse):
"""`show` — the record plus its outgoing links and backlinks."""
links: List[BookLinkResponse] = Field(default_factory=list)
backlinks: List[BookLinkResponse] = Field(default_factory=list)
class BookRecordListResponse(BaseSchema):
records: List[BookRecordResponse]
total: int
limit: int
offset: int
class BookEventResponse(BaseSchema):
id: uuid.UUID
record_id: uuid.UUID
at: datetime
identity: str
client: Optional[str]
action: str
diff: dict
class BookHistoryResponse(BaseSchema):
record_id: uuid.UUID
events: List[BookEventResponse]
+500
View File
@@ -0,0 +1,500 @@
"""
Book Domain Service
Business logic for the Book — Notes, Tasks, Decisions, Questions and
Rejected options (D-68, D-70). Every mutating method stamps a `book_events`
row via `_write_event`; the table itself is append-only (migration 005's
trigger), so this module never updates or deletes an event.
Methods take `session: AsyncSession` per call rather than storing it on
`self`, following `src/domains/dashboard/service.py`.
"""
from datetime import datetime, timezone
from typing import List, Optional, Tuple
from zoneinfo import ZoneInfo
from sqlalchemy import delete, func, select
from sqlalchemy.ext.asyncio import AsyncSession
from src.domains.auth.callers import Caller
from src.domains.book.models import (
DEFAULT_STATUS_BY_KIND,
STATUSES_BY_KIND,
BookEvent,
BookRecord,
BookRecordLink,
BookTag,
)
from src.domains.book.schemas import BookCloseRequest, BookRecordCreate, BookRecordUpdate
from src.shared.config import get_settings
from src.shared.logging import get_logger
logger = get_logger(__name__)
class BookError(Exception):
"""Raised for a Book-domain failure; the controller maps it to a response."""
def __init__(self, code: str, status_code: int = 400):
self.code = code
self.status_code = status_code
super().__init__(code)
class BookService:
"""Service for Book operations."""
# =========================================================================
# Internal helpers
# =========================================================================
async def _get_or_404(self, session: AsyncSession, record_id) -> BookRecord:
record = await session.get(BookRecord, record_id)
if record is None:
raise BookError("not_found", 404)
return record
async def _tags_for(self, session: AsyncSession, record_id) -> List[str]:
stmt = select(BookTag.tag).where(BookTag.record_id == record_id).order_by(BookTag.tag)
result = await session.execute(stmt)
return list(result.scalars().all())
async def _write_event(
self,
session: AsyncSession,
record_id,
caller: Caller,
action: str,
diff: dict,
) -> None:
event = BookEvent(
record_id=record_id,
identity=caller.identity,
client=caller.client,
action=action,
diff=diff,
)
session.add(event)
await session.flush()
def _validate_status(self, kind: str, status: Optional[str]) -> str:
"""Resolve and validate a status for `kind`; defaults when unset."""
resolved = status or DEFAULT_STATUS_BY_KIND[kind]
if resolved not in STATUSES_BY_KIND[kind]:
raise BookError("invalid_status", 400)
return resolved
def _touch(self, record: BookRecord) -> None:
record.last_touched_at = datetime.now(timezone.utc)
# =========================================================================
# add / show / list / find
# =========================================================================
async def add_record(
self,
session: AsyncSession,
data: BookRecordCreate,
caller: Caller,
) -> Tuple[BookRecord, List[str]]:
"""`add` — text-only input lands an unfiled Note (D-72)."""
status = self._validate_status(data.kind, data.status)
record = BookRecord(
kind=data.kind,
title=data.title,
body=data.body,
checklist=[item.model_dump() for item in data.checklist],
status=status,
scope=data.scope,
priority=data.priority,
pinned=data.pinned,
energy=data.energy,
estimate_minutes=data.estimate_minutes,
next_step=data.next_step,
start_at=data.start_at,
due_at=data.due_at,
all_day=data.all_day,
remind_at=data.remind_at,
recurrence=data.recurrence,
recurrence_mode=data.recurrence_mode,
remind_to=data.remind_to,
source=data.source,
external_id=data.external_id,
colour=data.colour,
created_by=caller.identity,
)
session.add(record)
await session.flush() # assign record.id
tag_names = list(dict.fromkeys(data.tags)) # de-dup, keep order
for tag in tag_names:
session.add(BookTag(record_id=record.id, tag=tag))
if data.parent is not None:
session.add(BookRecordLink(
from_id=record.id,
relation="parent",
to_id=data.parent,
created_by=caller.identity,
))
await self._write_event(session, record.id, caller, "add", {"kind": data.kind, "status": status})
await session.commit()
await session.refresh(record)
logger.info(f"Book record added: kind={data.kind} id={record.id} by={caller.identity}")
return record, tag_names
async def show_record(
self,
session: AsyncSession,
record_id,
) -> Tuple[BookRecord, List[str], List[BookRecordLink], List[BookRecordLink]]:
"""`show` — the record, its tags, outgoing links and backlinks."""
record = await self._get_or_404(session, record_id)
tags = await self._tags_for(session, record_id)
links_stmt = select(BookRecordLink).where(BookRecordLink.from_id == record_id)
links = list((await session.execute(links_stmt)).scalars().all())
backlinks_stmt = select(BookRecordLink).where(BookRecordLink.to_id == record_id)
backlinks = list((await session.execute(backlinks_stmt)).scalars().all())
return record, tags, links, backlinks
async def list_records(
self,
session: AsyncSession,
view: Optional[str] = None,
tag: Optional[str] = None,
scope: Optional[str] = None,
parent: Optional[object] = None,
limit: int = 50,
offset: int = 0,
) -> Tuple[List[Tuple[BookRecord, List[str]]], int]:
"""
`list` — the nine views of D-70. Status-named views (`inbox`,
`waiting`, `someday`, `done`) filter on the matching task status and
simply return nothing for kinds that never carry it. `today` and
`upcoming` bucket on `due_at` in `book_timezone`; richer due/overdue
handling is `due`/`next`, both next round.
"""
conditions = []
if view in ("inbox", "waiting", "someday", "done"):
conditions.append(BookRecord.status == view)
elif view == "pinned":
conditions.append(BookRecord.pinned.is_(True))
elif view == "archive":
conditions.append(BookRecord.archived_at.isnot(None))
elif view == "trash":
conditions.append(BookRecord.trashed_at.isnot(None))
elif view in ("today", "upcoming"):
tz = ZoneInfo(get_settings().book_timezone)
local_now = datetime.now(tz)
day_start = local_now.replace(hour=0, minute=0, second=0, microsecond=0)
day_end = day_start.replace(hour=23, minute=59, second=59, microsecond=999999)
conditions.append(BookRecord.due_at.isnot(None))
if view == "today":
conditions.append(BookRecord.due_at >= day_start)
conditions.append(BookRecord.due_at <= day_end)
else:
conditions.append(BookRecord.due_at > day_end)
if view not in ("archive", "trash"):
conditions.append(BookRecord.archived_at.is_(None))
conditions.append(BookRecord.trashed_at.is_(None))
if scope is not None:
conditions.append(BookRecord.scope == scope)
if parent is not None:
parent_subquery = (
select(BookRecordLink.from_id)
.where(BookRecordLink.relation == "parent", BookRecordLink.to_id == parent)
)
conditions.append(BookRecord.id.in_(parent_subquery))
if tag is not None:
tag_subquery = select(BookTag.record_id).where(BookTag.tag == tag)
conditions.append(BookRecord.id.in_(tag_subquery))
base_query = select(BookRecord).where(*conditions)
count_query = select(func.count()).select_from(base_query.subquery())
total = (await session.execute(count_query)).scalar() or 0
query = (
base_query
.order_by(BookRecord.last_touched_at.desc())
.offset(offset)
.limit(limit)
)
records = list((await session.execute(query)).scalars().all())
results = []
for record in records:
tags = await self._tags_for(session, record.id)
results.append((record, tags))
return results, total
async def find_records(
self,
session: AsyncSession,
q: str,
limit: int = 50,
offset: int = 0,
) -> Tuple[List[Tuple[BookRecord, List[str]]], int]:
"""`find` — full-text search over title and body."""
tsquery = func.websearch_to_tsquery("simple", q)
base_query = select(BookRecord).where(BookRecord.search.op("@@")(tsquery))
count_query = select(func.count()).select_from(base_query.subquery())
total = (await session.execute(count_query)).scalar() or 0
query = base_query.order_by(BookRecord.updated_at.desc()).offset(offset).limit(limit)
records = list((await session.execute(query)).scalars().all())
results = []
for record in records:
tags = await self._tags_for(session, record.id)
results.append((record, tags))
return results, total
# =========================================================================
# change / close
# =========================================================================
async def change_record(
self,
session: AsyncSession,
record_id,
data: BookRecordUpdate,
caller: Caller,
) -> Tuple[BookRecord, List[str]]:
"""`change` — a partial update; only fields set on the request move."""
record = await self._get_or_404(session, record_id)
update_data = data.model_dump(exclude_unset=True)
diff: dict = {}
new_kind = update_data.get("kind", record.kind)
if "status" in update_data or "kind" in update_data:
resolved_status = self._validate_status(new_kind, update_data.get("status"))
update_data["status"] = resolved_status
if "checklist" in update_data:
update_data["checklist"] = [
item if isinstance(item, dict) else item.model_dump()
for item in update_data["checklist"]
]
for field, value in update_data.items():
before = getattr(record, field)
if before != value:
diff[field] = {"from": str(before), "to": str(value)}
setattr(record, field, value)
self._touch(record)
if diff:
await self._write_event(session, record_id, caller, "change", diff)
await session.commit()
await session.refresh(record)
tags = await self._tags_for(session, record_id)
logger.info(f"Book record changed: id={record_id} by={caller.identity} fields={list(diff)}")
return record, tags
async def close_record(
self,
session: AsyncSession,
record_id,
data: BookCloseRequest,
caller: Caller,
) -> Tuple[BookRecord, List[str]]:
"""
`close` — a Task is done or dropped; a Question is resolved into a
Decision (`resolves` link, status `settled`); a Decision is
superseded by a newer one (`supersedes` link, status `superseded`).
"""
record = await self._get_or_404(session, record_id)
diff: dict = {}
if record.kind == "task":
if data.outcome is None:
raise BookError("invalid_status", 400)
record.status = data.outcome
diff = {"status": data.outcome}
elif record.kind == "question":
if data.resolved_by is None:
raise BookError("invalid_status", 400)
decision = await self._get_or_404(session, data.resolved_by)
if decision.kind != "decision":
raise BookError("invalid_status", 400)
session.add(BookRecordLink(
from_id=decision.id,
relation="resolves",
to_id=record.id,
created_by=caller.identity,
))
record.status = "settled"
diff = {"status": "settled", "resolved_by": str(decision.id)}
elif record.kind == "decision":
if data.superseded_by is None:
raise BookError("invalid_status", 400)
new_decision = await self._get_or_404(session, data.superseded_by)
if new_decision.kind != "decision":
raise BookError("invalid_status", 400)
session.add(BookRecordLink(
from_id=new_decision.id,
relation="supersedes",
to_id=record.id,
created_by=caller.identity,
))
record.status = "superseded"
diff = {"status": "superseded", "superseded_by": str(new_decision.id)}
else:
# Notes and Rejected options have no `close` transition.
raise BookError("invalid_status", 400)
self._touch(record)
await self._write_event(session, record_id, caller, "close", diff)
await session.commit()
await session.refresh(record)
tags = await self._tags_for(session, record_id)
logger.info(f"Book record closed: id={record_id} by={caller.identity} diff={diff}")
return record, tags
# =========================================================================
# link / unlink
# =========================================================================
async def link_records(
self,
session: AsyncSession,
record_id,
to_id,
relation: str,
caller: Caller,
) -> Tuple[BookRecord, List[str]]:
record = await self._get_or_404(session, record_id)
await self._get_or_404(session, to_id) # the target must exist
session.add(BookRecordLink(
from_id=record_id,
relation=relation,
to_id=to_id,
created_by=caller.identity,
))
await self._write_event(
session, record_id, caller, "link", {"relation": relation, "to_id": str(to_id)}
)
await session.commit()
await session.refresh(record)
tags = await self._tags_for(session, record_id)
return record, tags
async def unlink_records(
self,
session: AsyncSession,
record_id,
to_id,
relation: str,
caller: Caller,
) -> Tuple[BookRecord, List[str]]:
record = await self._get_or_404(session, record_id)
stmt = delete(BookRecordLink).where(
BookRecordLink.from_id == record_id,
BookRecordLink.relation == relation,
BookRecordLink.to_id == to_id,
)
result = await session.execute(stmt)
if result.rowcount == 0:
raise BookError("not_found", 404)
await self._write_event(
session, record_id, caller, "unlink", {"relation": relation, "to_id": str(to_id)}
)
await session.commit()
await session.refresh(record)
tags = await self._tags_for(session, record_id)
return record, tags
# =========================================================================
# tag / untag
# =========================================================================
async def tag_record(
self,
session: AsyncSession,
record_id,
tag: str,
caller: Caller,
) -> Tuple[BookRecord, List[str]]:
record = await self._get_or_404(session, record_id)
existing = await session.get(BookTag, (record_id, tag))
if existing is None:
session.add(BookTag(record_id=record_id, tag=tag))
await self._write_event(session, record_id, caller, "tag", {"tag": tag})
await session.commit()
tags = await self._tags_for(session, record_id)
return record, tags
async def untag_record(
self,
session: AsyncSession,
record_id,
tag: str,
caller: Caller,
) -> Tuple[BookRecord, List[str]]:
record = await self._get_or_404(session, record_id)
stmt = delete(BookTag).where(BookTag.record_id == record_id, BookTag.tag == tag)
result = await session.execute(stmt)
if result.rowcount > 0:
await self._write_event(session, record_id, caller, "untag", {"tag": tag})
await session.commit()
tags = await self._tags_for(session, record_id)
return record, tags
# =========================================================================
# history
# =========================================================================
async def get_history(self, session: AsyncSession, record_id, limit: int = 100) -> List[BookEvent]:
await self._get_or_404(session, record_id)
stmt = (
select(BookEvent)
.where(BookEvent.record_id == record_id)
.order_by(BookEvent.at.desc())
.limit(limit)
)
return list((await session.execute(stmt)).scalars().all())
# Singleton instance
_book_service: Optional[BookService] = None
def get_book_service() -> BookService:
"""Get singleton Book service instance"""
global _book_service
if _book_service is None:
_book_service = BookService()
return _book_service
+2
View File
@@ -19,6 +19,7 @@ from src.domains.infrastructure import infrastructure_controller
from src.domains.housekeeping import housekeeping_controller
from src.domains.static import static_controller
from src.domains.dashboard import dashboard_controller
from src.domains.book import book_controller
# Initialize settings
settings = get_settings()
@@ -106,6 +107,7 @@ app.include_router(infrastructure_controller.router) # /infrastructure
app.include_router(housekeeping_controller.router) # /housekeeping/*
app.include_router(static_controller.router) # /static/*
app.include_router(dashboard_controller.router) # /dashboard/*
app.include_router(book_controller.router) # /book/*
# Global exception handler