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 ) # Provenance: the task/feature an issue arose from. Distinct from parent_id # (sub-task hierarchy) — this is "what spawned this". Only meaningful for # issues; nullable for every record. arose_from_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 ) # Note type — 'note' (default) or 'process' (a stored process). Task-ness is # tracked by `status`, not here. (person/place/list entity types removed 2026-07.) note_type: Mapped[str] = mapped_column(Text, default="note", server_default="note") # Task sub-kind — 'work' (default), 'plan', or 'issue' (corrective work). # 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"), Index("ix_notes_arose_from_id", "arose_from_id"), ) @property def is_task(self) -> bool: return self.status is not None 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, "arose_from_id": self.arose_from_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.note_type or "note", "task_kind": self.task_kind, "created_at": self.created_at.isoformat(), "updated_at": self.updated_at.isoformat(), }