import secrets
import uuid
from datetime import UTC, datetime
from sqlalchemy import (
BigInteger,
CheckConstraint,
DateTime,
ForeignKey,
Index,
Integer,
String,
Text,
func,
)
from sqlalchemy.dialects.postgresql import JSONB, UUID
from sqlalchemy.orm import Mapped, mapped_column, relationship
from .db import Base, gen_uuid7
TZDateTime = DateTime(timezone=True)
def gen_share_token() -> str:
# 128 bits of randomness, URL-safe, 22 chars — the "unguessable" part of public links.
return secrets.token_urlsafe(16)
def utcnow() -> datetime:
return datetime.now(UTC)
class User(Base):
__tablename__ = "users"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=gen_uuid7)
email: Mapped[str] = mapped_column(Text, unique=True, nullable=False)
nickname: Mapped[str] = mapped_column(Text, nullable=False)
password_hash: Mapped[str] = mapped_column(Text, nullable=False)
created_at: Mapped[datetime] = mapped_column(TZDateTime, nullable=False, server_default=func.now())
class Session(Base):
__tablename__ = "sessions"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=gen_uuid7)
user_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
token_hash: Mapped[str] = mapped_column(Text, unique=True, nullable=False) # sha256 hex of the token
client: Mapped[str] = mapped_column(Text, nullable=False) # 'web' | 'extension'
expires_at: Mapped[datetime] = mapped_column(TZDateTime, nullable=False)
created_at: Mapped[datetime] = mapped_column(TZDateTime, nullable=False, default=utcnow)
last_used_at: Mapped[datetime | None] = mapped_column(TZDateTime, nullable=True)
user: Mapped[User] = relationship(lazy="joined")
class Project(Base):
__tablename__ = "projects"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=gen_uuid7)
owner_user_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
name: Mapped[str] = mapped_column(Text, nullable=False)
description: Mapped[str | None] = mapped_column(Text, nullable=True)
share_token: Mapped[str] = mapped_column(Text, unique=True, nullable=False, default=gen_share_token)
created_at: Mapped[datetime] = mapped_column(TZDateTime, nullable=False, server_default=func.now())
class File(Base):
__tablename__ = "files"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=gen_uuid7)
sha256: Mapped[str] = mapped_column(String(64), unique=True, nullable=False)
path: Mapped[str] = mapped_column(Text, nullable=False) # relative to FILES_DIR
mime: Mapped[str] = mapped_column(Text, nullable=False)
size: Mapped[int] = mapped_column(BigInteger, nullable=False)
created_at: Mapped[datetime] = mapped_column(TZDateTime, nullable=False, server_default=func.now())
class Report(Base):
__tablename__ = "reports"
__table_args__ = (CheckConstraint("type IN ('element_note', 'recording')", name="ck_report_type"),)
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=gen_uuid7)
project_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("projects.id", ondelete="CASCADE"), nullable=False)
author_user_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
type: Mapped[str] = mapped_column(Text, nullable=False) # 'element_note' | 'recording'
title: Mapped[str] = mapped_column(Text, nullable=False)
description: Mapped[str | None] = mapped_column(Text, nullable=True)
status: Mapped[str] = mapped_column(Text, nullable=False, default="open") # open|fixed|wont_fix
page_url: Mapped[str | None] = mapped_column(Text, nullable=True)
page_title: Mapped[str | None] = mapped_column(Text, nullable=True)
environment: Mapped[dict] = mapped_column(JSONB, nullable=False, default=dict)
element: Mapped[dict | None] = mapped_column(JSONB, nullable=True) # element_note context
share_token: Mapped[str] = mapped_column(Text, unique=True, nullable=False, default=gen_share_token)
created_at: Mapped[datetime] = mapped_column(TZDateTime, nullable=False, server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(TZDateTime, nullable=False, server_default=func.now(), onupdate=utcnow)
project: Mapped[Project] = relationship(lazy="joined")
author: Mapped[User] = relationship(lazy="joined")
attachments: Mapped[list["Attachment"]] = relationship(
back_populates="report", cascade="all, delete-orphan", lazy="selectin"
)
steps: Mapped[list["RecordingStep"]] = relationship(
back_populates="report", cascade="all, delete-orphan", order_by="RecordingStep.step_index", lazy="selectin"
)
class Attachment(Base):
__tablename__ = "attachments"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=gen_uuid7)
report_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("reports.id", ondelete="CASCADE"), nullable=False)
file_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("files.id"), nullable=False)
kind: Mapped[str] = mapped_column(Text, nullable=False) # screenshot|annotation|document|image|other
filename: Mapped[str] = mapped_column(Text, nullable=False)
mime: Mapped[str] = mapped_column(Text, nullable=False)
size: Mapped[int] = mapped_column(BigInteger, nullable=False)
annotation_shapes: Mapped[list | None] = mapped_column(JSONB, nullable=True)
created_at: Mapped[datetime] = mapped_column(TZDateTime, nullable=False, server_default=func.now())
report: Mapped[Report] = relationship(back_populates="attachments")
file: Mapped[File] = relationship(lazy="joined")
class RecordingStep(Base):
__tablename__ = "recording_steps"
__table_args__ = (Index("ix_recording_steps_report_idx", "report_id", "step_index"),)
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=gen_uuid7)
report_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("reports.id", ondelete="CASCADE"), nullable=False)
step_index: Mapped[int] = mapped_column(Integer, nullable=False)
type: Mapped[str] = mapped_column(Text, nullable=False) # click|input|url_change|navigation|note|screenshot
offset_ms: Mapped[int] = mapped_column(Integer, nullable=False)
data: Mapped[dict] = mapped_column(JSONB, nullable=False, default=dict)
screenshot_attachment_id: Mapped[uuid.UUID | None] = mapped_column(
ForeignKey("attachments.id", ondelete="SET NULL"), nullable=True
)
created_at: Mapped[datetime] = mapped_column(TZDateTime, nullable=False, server_default=func.now())
report: Mapped[Report] = relationship(back_populates="steps")
screenshot: Mapped[Attachment | None] = relationship(lazy="joined")