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 # The Keep-style palette. Stored as a key string, so the actual tints live in the # frontend and can change without a schema migration. NOTE_COLORS = { "default", "red", "orange", "yellow", "green", "teal", "blue", "purple", "pink", "gray", } 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 ) title: Mapped[str | None] = mapped_column(Text(), nullable=True) # The note's display NAME: explicit title if set, else the first non-empty body # line (see notes.derive_display_title). Persisted + normalized-matched so every # note — even a body-only one — is nameable, searchable, graphable, and # [[wiki-link]]-able without forcing the user to type a title. display_title: Mapped[str] = mapped_column(Text(), nullable=False, server_default="") body: Mapped[str] = mapped_column(Text(), nullable=False, server_default="") color: Mapped[str] = mapped_column(Text(), nullable=False, server_default="default") # 'text' (freeform body) or 'list' (a checklist of note_items). kind: Mapped[str] = mapped_column(Text(), nullable=False, server_default="text") # 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; no push in M3). remind_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), 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), "title": self.title, "display_title": self.display_title, "body": self.body, "color": self.color, "kind": self.kind, "position": self.position, "pinned": self.pinned, "archived": self.archived, "trashed": self.deleted_at is not None, "remind_at": self.remind_at.isoformat() if self.remind_at else None, "created_at": self.created_at.isoformat() if self.created_at else None, "updated_at": self.updated_at.isoformat() if self.updated_at else None, }