import enum from datetime import date, datetime from sqlalchemy import Date, DateTime, ForeignKey, Index, Integer, Text from sqlalchemy.dialects.postgresql import ARRAY, JSONB from sqlalchemy.orm import Mapped, mapped_column from scribe.models import Base from scribe.models.base import TimestampMixin, SoftDeleteMixin class TaskStatus(str, enum.Enum): todo = "todo" in_progress = "in_progress" done = "done" cancelled = "cancelled" class TaskPriority(str, enum.Enum): none = "none" low = "low" medium = "medium" high = "high" class Note(Base, TimestampMixin, SoftDeleteMixin): __tablename__ = "notes" id: Mapped[int] = mapped_column(primary_key=True) user_id: Mapped[int | None] = mapped_column( Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=True ) title: Mapped[str] = mapped_column(Text, default="") body: Mapped[str] = mapped_column(Text, default="") description: Mapped[str | None] = mapped_column(Text, nullable=True) consolidated_at: Mapped[datetime | None] = mapped_column( DateTime(timezone=True), nullable=True ) tags: Mapped[list[str]] = mapped_column(ARRAY(Text), default=list) parent_id: Mapped[int | None] = mapped_column( Integer, ForeignKey("notes.id", ondelete="SET NULL"), nullable=True ) project_id: Mapped[int | None] = mapped_column( Integer, ForeignKey("projects.id", ondelete="SET NULL"), nullable=True ) milestone_id: Mapped[int | None] = mapped_column( Integer, ForeignKey("milestones.id", ondelete="SET NULL"), nullable=True ) status: Mapped[str | None] = mapped_column(Text, nullable=True) priority: Mapped[str | None] = mapped_column(Text, nullable=True) due_date: Mapped[date | None] = mapped_column(Date, nullable=True) started_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) completed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) recurrence_rule: Mapped[dict | None] = mapped_column(JSONB, nullable=True) recurrence_next_spawn_at: Mapped[datetime | None] = mapped_column( DateTime(timezone=True), nullable=True ) # Entity type — 'note' (default), 'person', 'place', 'list' note_type: Mapped[str] = mapped_column(Text, default="note", server_default="note") # Structured metadata for entity types (person/place/list) # Named 'entity_meta' to avoid collision with SQLAlchemy's reserved 'metadata' attribute entity_meta: Mapped[dict | None] = mapped_column("metadata", JSONB, nullable=True) # Task sub-kind — 'work' (default) or 'plan'. Only meaningful when the note # is a task (status is not None); ordinary notes keep the 'work' default and # ignore it. Orthogonal to note_type (which is the note/entity axis). task_kind: Mapped[str] = mapped_column(Text, default="work", server_default="work") __table_args__ = ( Index("ix_notes_tags", "tags", postgresql_using="gin"), Index("ix_notes_status", "status"), Index("ix_notes_title", "title"), Index("ix_notes_user_id", "user_id"), Index("ix_notes_project_id", "project_id"), Index("ix_notes_milestone_id", "milestone_id"), Index("ix_notes_note_type", "note_type"), ) @property def is_task(self) -> bool: return self.status is not None @property def entity_type(self) -> str: """Normalised type: 'note', 'person', 'place', or 'list'.""" return self.note_type or "note" def to_dict(self) -> dict: return { "id": self.id, "title": self.title, "body": self.body, "description": self.description, "consolidated_at": ( self.consolidated_at.isoformat() if self.consolidated_at else None ), "tags": self.tags or [], "parent_id": self.parent_id, "project_id": self.project_id, "milestone_id": self.milestone_id, "status": self.status, "priority": self.priority, "due_date": self.due_date.isoformat() if self.due_date else None, "started_at": self.started_at.isoformat() if self.started_at else None, "completed_at": self.completed_at.isoformat() if self.completed_at else None, "recurrence_rule": self.recurrence_rule, "recurrence_next_spawn_at": ( self.recurrence_next_spawn_at.isoformat() if self.recurrence_next_spawn_at else None ), "is_task": self.is_task, "note_type": self.entity_type, "task_kind": self.task_kind, "metadata": self.entity_meta or {}, "created_at": self.created_at.isoformat(), "updated_at": self.updated_at.isoformat(), }