from __future__ import annotations import uuid from datetime import datetime from sqlalchemy import BigInteger, Boolean, DateTime, ForeignKey, Index, Integer, Text, func from sqlalchemy.dialects.postgresql import UUID from sqlalchemy.orm import Mapped, mapped_column from . import Base from ..common import iso class Note(Base): __tablename__ = "notes" __table_args__ = ( # Covers the board queries: a user's notes filtered by trash/archive state, # pinned first. Index("ix_notes_owner_board", "owner_id", "deleted_at", "archived", "pinned"), ) id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) owner_id: Mapped[uuid.UUID] = mapped_column( UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False ) # The note's NAME: its first non-empty body line, else its first checklist item # (see notes.derive_display_title). There is no title field to prefer — a note is # a body plus optional items, and this is simply the first thing written in it. # Persisted so search results and export filenames have something to say, and so # the full-text vector can weight it above the rest of the body. display_title: Mapped[str] = mapped_column(Text(), nullable=False, server_default="") body: Mapped[str] = mapped_column(Text(), nullable=False, server_default="") # Manual drag order (higher = earlier); 0 until the user reorders. position: Mapped[int] = mapped_column(Integer(), nullable=False, server_default="0") pinned: Mapped[bool] = mapped_column(Boolean(), nullable=False, server_default=func.false()) archived: Mapped[bool] = mapped_column(Boolean(), nullable=False, server_default=func.false()) # Soft delete: non-null => in Trash. Restore sets it back to null. deleted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) # Optional reminder time (surfaced in the Reminders view + foreground delivery). remind_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) # Optional recurrence for the reminder: daily | weekly | monthly | yearly (else null). # On "complete", a recurring reminder advances remind_at to its next occurrence. recurrence: Mapped[str | None] = mapped_column(Text(), nullable=True) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now()) updated_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now() ) # Sync (M8): a monotonic per-row revision drawn from sync_revision_seq and assigned # by a DB trigger on every insert/update — the delta cursor native clients pull # against. Nullable in the ORM because the trigger populates it server-side. sync_revision: Mapped[int | None] = mapped_column(BigInteger(), nullable=True) # Hard-delete tombstone: non-null => permanently deleted (content cleared), kept so # offline clients learn the row is gone. Distinct from deleted_at (= recoverable trash). purged_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) def serialize(self) -> dict: return { "id": str(self.id), "display_title": self.display_title, "body": self.body, "position": self.position, "pinned": self.pinned, "archived": self.archived, "trashed": self.deleted_at is not None, # WHEN it was trashed, not just that it was: clients count the retention # window from here to show how long a note has left before it's purged. "deleted_at": iso(self.deleted_at), "remind_at": iso(self.remind_at), "recurrence": self.recurrence, "created_at": iso(self.created_at), "updated_at": iso(self.updated_at), }