Skip to content
4 changes: 2 additions & 2 deletions backend/alembic/versions/0003_revoked_token.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
"""revoked_token

Revision ID: 0003
Revises: 0002
Revises: 0002_auth_share
Create Date: 2026-06-22 10:00:00.000000

"""
Expand All @@ -12,7 +12,7 @@

# revision identifiers, used by Alembic.
revision = "0003"
down_revision = "0002"
down_revision = "0002_auth_share"
branch_labels = None
depends_on = None

Expand Down
28 changes: 28 additions & 0 deletions backend/alembic/versions/0004_merge_heads.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
"""merge revoked_token and validate_project_space_fk into a single head

Two migrations branched independently from 0002_auth_share:
- 0003 (revoked_token): creates the revoked_token table
- 0003_validate_project_space_fk: validates a project_space FK
They are unrelated and safe to apply in any order, so this is a no-op merge
that unifies the graph to a single head (``alembic upgrade head`` was ambiguous
with two heads).

Revision ID: 0004_merge_heads
Revises: 0003, 0003_validate_project_space_fk
Create Date: 2026-07-05 00:00:00.000000

"""

# revision identifiers, used by Alembic.
revision = "0004_merge_heads"
down_revision = ("0003", "0003_validate_project_space_fk")
branch_labels = None
depends_on = None


def upgrade() -> None:
"""No schema change; this revision only merges two heads."""


def downgrade() -> None:
"""No schema change; splitting back into two heads requires no work."""
48 changes: 48 additions & 0 deletions backend/alembic/versions/0005_diagram_view.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
"""diagram_view: saved ERD canvas views

Revision ID: 0005_diagram_view
Revises: 0004_merge_heads
Create Date: 2026-07-05

"""

from __future__ import annotations

import sqlalchemy as sa
from alembic import op

revision = "0005_diagram_view"
down_revision = "0004_merge_heads"
branch_labels = None
depends_on = None


def upgrade() -> None:
op.create_table(
"diagram_view",
sa.Column("diagram_view_uuid", sa.Uuid(), primary_key=True, nullable=False),
sa.Column("project_space_uuid", sa.Uuid(), nullable=False),
sa.Column("name", sa.Text(), nullable=False),
sa.Column("layout_json", sa.JSON(), nullable=False),
sa.Column("created_by", sa.Uuid(), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(
["project_space_uuid"],
["project_space.project_space_uuid"],
ondelete="CASCADE",
),
sa.PrimaryKeyConstraint("diagram_view_uuid"),
)
op.create_index(
"ix_diagram_view__project_space_uuid",
"diagram_view",
["project_space_uuid"],
)


def downgrade() -> None:
op.drop_index(
"ix_diagram_view__project_space_uuid", table_name="diagram_view"
)
op.drop_table("diagram_view")
55 changes: 55 additions & 0 deletions backend/alembic/versions/0006_table_annotation.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
"""table_annotation: per-project notes attached to tables by name

Revision ID: 0006_table_annotation
Revises: 0005_diagram_view
Create Date: 2026-07-05

"""

from __future__ import annotations

import sqlalchemy as sa
from alembic import op

revision = "0006_table_annotation"
down_revision = "0005_diagram_view"
branch_labels = None
depends_on = None


def upgrade() -> None:
op.create_table(
"table_annotation",
sa.Column("table_annotation_uuid", sa.Uuid(), primary_key=True, nullable=False),
sa.Column("project_space_uuid", sa.Uuid(), nullable=False),
sa.Column("schema_name", sa.Text(), nullable=False),
sa.Column("relation_name", sa.Text(), nullable=False),
sa.Column("body", sa.Text(), nullable=False),
sa.Column("created_by", sa.Uuid(), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(
["project_space_uuid"],
["project_space.project_space_uuid"],
ondelete="CASCADE",
),
sa.PrimaryKeyConstraint("table_annotation_uuid"),
sa.UniqueConstraint(
"project_space_uuid",
"schema_name",
"relation_name",
name="uq_table_annotation__project_table",
),
)
op.create_index(
"ix_table_annotation__project_space_uuid",
"table_annotation",
["project_space_uuid"],
)


def downgrade() -> None:
op.drop_index(
"ix_table_annotation__project_space_uuid", table_name="table_annotation"
)
op.drop_table("table_annotation")
43 changes: 43 additions & 0 deletions backend/alembic/versions/0007_api_key.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
"""api_key table for programmatic access

Revision ID: 0007_api_key
Revises: 0006_table_annotation
Create Date: 2026-07-06
"""

from __future__ import annotations

import sqlalchemy as sa
from alembic import op
from sqlalchemy.dialects.postgresql import UUID

revision = "0007_api_key"
down_revision = "0006_table_annotation"
branch_labels = None
depends_on = None


def upgrade() -> None:
op.create_table(
"api_key",
sa.Column("api_key_uuid", UUID(as_uuid=True), primary_key=True),
sa.Column(
"user_account_uuid",
UUID(as_uuid=True),
sa.ForeignKey("user_account.user_account_uuid", ondelete="CASCADE"),
nullable=False,
),
sa.Column("key_name", sa.Text(), nullable=False),
sa.Column("key_hash", sa.Text(), nullable=False),
sa.Column("key_prefix", sa.Text(), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("revoked_at", sa.DateTime(timezone=True), nullable=True),
)
op.create_index("ix_api_key__user", "api_key", ["user_account_uuid"])
op.create_index("ix_api_key__hash", "api_key", ["key_hash"], unique=True)


def downgrade() -> None:
op.drop_index("ix_api_key__hash", table_name="api_key")
op.drop_index("ix_api_key__user", table_name="api_key")
op.drop_table("api_key")
139 changes: 139 additions & 0 deletions backend/app/api/annotations.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
from __future__ import annotations

import datetime as dt
import uuid

from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession

from app.auth import CurrentUser, get_current_user
from app.db import get_read_session, get_session
from app.models import TableAnnotation
from app.permissions import require_project_member
from app.schemas import TableAnnotationOut, TableAnnotationUpsertIn

router = APIRouter(prefix="/api/annotations", tags=["annotations"])


def _to_out(ann: TableAnnotation) -> TableAnnotationOut:
return TableAnnotationOut(
table_annotation_uuid=ann.table_annotation_uuid,
schema_name=ann.schema_name,
relation_name=ann.relation_name,
body=ann.body,
created_at=ann.created_at,
updated_at=ann.updated_at,
)


async def _get_authorized_annotation(
session: AsyncSession,
table_annotation_uuid: uuid.UUID,
user: CurrentUser,
minimum_role: str | None = None,
) -> TableAnnotation | None:
"""Fetch an annotation only after project membership has been checked.

Returns ``None`` for both missing and unauthorized so callers can respond
with a uniform 404 (no existence enumeration / IDOR).
"""
project_space_uuid = await session.scalar(
select(TableAnnotation.project_space_uuid).where(
TableAnnotation.table_annotation_uuid == table_annotation_uuid
)
)
if project_space_uuid is None:
return None
try:
await require_project_member(
session,
project_space_uuid,
user.user_account_uuid,
minimum_role=minimum_role,
)
except HTTPException as exc:
if exc.status_code == 403:
return None
raise
return await session.get(TableAnnotation, table_annotation_uuid)


@router.get(
"/by-project/{project_space_uuid}", response_model=list[TableAnnotationOut]
)
async def list_annotations(
project_space_uuid: uuid.UUID,
user: CurrentUser = Depends(get_current_user),
session: AsyncSession = Depends(get_read_session),
) -> list[TableAnnotationOut]:
"""List table annotations for a project."""
await require_project_member(session, project_space_uuid, user.user_account_uuid)
rows = await session.execute(
select(TableAnnotation)
.where(TableAnnotation.project_space_uuid == project_space_uuid)
.order_by(TableAnnotation.schema_name, TableAnnotation.relation_name)
)
return [_to_out(a) for a in rows.scalars().all()]


@router.put(
"/by-project/{project_space_uuid}", response_model=TableAnnotationOut
)
async def upsert_annotation(
project_space_uuid: uuid.UUID,
body: TableAnnotationUpsertIn,
user: CurrentUser = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
) -> TableAnnotationOut:
"""Create or update the note for one table in a project (editor role).

Keyed by (project, schema_name, relation_name); a unique constraint keeps
it to a single note per table.
"""
await require_project_member(
session, project_space_uuid, user.user_account_uuid, minimum_role="editor"
)
now = dt.datetime.now(dt.timezone.utc)
existing = await session.scalar(
select(TableAnnotation).where(
TableAnnotation.project_space_uuid == project_space_uuid,
TableAnnotation.schema_name == body.schema_name,
TableAnnotation.relation_name == body.relation_name,
)
)
if existing is not None:
existing.body = body.body
existing.updated_at = now
ann = existing
else:
ann = TableAnnotation(
table_annotation_uuid=uuid.uuid4(),
project_space_uuid=project_space_uuid,
schema_name=body.schema_name,
relation_name=body.relation_name,
body=body.body,
created_by=user.user_account_uuid,
created_at=now,
updated_at=now,
)
session.add(ann)
await session.commit()
return _to_out(ann)


@router.delete("/{table_annotation_uuid}")
async def delete_annotation(
table_annotation_uuid: uuid.UUID,
user: CurrentUser = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
) -> dict[str, bool]:
"""Delete a table annotation (requires editor membership on its project)."""
ann = await _get_authorized_annotation(
session, table_annotation_uuid, user, minimum_role="editor"
)
if ann is None:
raise HTTPException(status_code=404, detail="annotation not found")
await session.delete(ann)
await session.commit()
return {"ok": True}
Loading