Files
bvandeusenandClaude Opus 4.8 04e3ab20cf M0: backend skeleton — Quart factory, async DB, auth, ACL spine, migrations
Foundation & Identity backend for ThoughtSync:
- Quart app factory (create_app) with /api/health + SPA history-fallback
- async SQLAlchemy 2.0 + asyncpg engine/session (lazy; boots without a DB)
- native email+password auth via signed-cookie session (register/login/logout/me
  + login_required guard); bcrypt password hashing (72-byte safe)
- multi-user sharing-ACL spine (rule 47): users, groups, group_members, and a
  polymorphic shares table + visible_to_user() SQL predicate (owner OR direct
  share OR group share) that M1's notes will scope through
- Alembic async env (adapted from family pattern) + 0001 foundation migration
- DB-free unit tests (app/health/auth-guard, password roundtrip, ACL compile)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FRgehjoz7Yv8LkUfADxACm
2026-07-19 13:09:37 -04:00

81 lines
3.6 KiB
Python

"""foundation: users, groups, group_members, shares
Revision ID: 0001
Revises:
Create Date: 2026-07-19
The M0 identity + sharing-ACL spine. No notes yet (those arrive in M1); the
shares table is polymorphic so notes reuse it without a schema retrofit.
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects.postgresql import CITEXT, UUID
revision = "0001"
down_revision = None
branch_labels = None
depends_on = None
def upgrade() -> None:
op.execute("CREATE EXTENSION IF NOT EXISTS citext")
op.create_table(
"users",
sa.Column("id", UUID(as_uuid=True), primary_key=True),
sa.Column("email", CITEXT(), nullable=False, unique=True),
sa.Column("email_verified", sa.Boolean(), nullable=False, server_default=sa.false()),
sa.Column("password_hash", sa.Text(), nullable=True),
sa.Column("display_name", sa.Text(), nullable=False),
sa.Column("avatar_path", sa.Text(), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
)
op.create_index("ix_users_email", "users", ["email"])
op.create_table(
"groups",
sa.Column("id", UUID(as_uuid=True), primary_key=True),
sa.Column("name", sa.Text(), nullable=False),
sa.Column("owner_id", UUID(as_uuid=True), sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
)
op.create_table(
"group_members",
sa.Column("id", UUID(as_uuid=True), primary_key=True),
sa.Column("group_id", UUID(as_uuid=True), sa.ForeignKey("groups.id", ondelete="CASCADE"), nullable=False),
sa.Column("user_id", UUID(as_uuid=True), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False),
sa.Column("role", sa.Text(), nullable=False, server_default="member"),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
sa.UniqueConstraint("group_id", "user_id", name="uq_group_members_group_user"),
)
op.create_index("ix_group_members_user", "group_members", ["user_id"])
op.create_table(
"shares",
sa.Column("id", UUID(as_uuid=True), primary_key=True),
sa.Column("resource_type", sa.Text(), nullable=False),
sa.Column("resource_id", UUID(as_uuid=True), nullable=False),
sa.Column("shared_with_user_id", UUID(as_uuid=True), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=True),
sa.Column("shared_with_group_id", UUID(as_uuid=True), sa.ForeignKey("groups.id", ondelete="CASCADE"), nullable=True),
sa.Column("permission", sa.Text(), nullable=False, server_default="view"),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
sa.CheckConstraint(
"(shared_with_user_id IS NOT NULL) <> (shared_with_group_id IS NOT NULL)",
name="ck_shares_one_target",
),
)
op.create_index("ix_shares_resource", "shares", ["resource_type", "resource_id"])
def downgrade() -> None:
op.drop_index("ix_shares_resource", table_name="shares")
op.drop_table("shares")
op.drop_index("ix_group_members_user", table_name="group_members")
op.drop_table("group_members")
op.drop_table("groups")
op.drop_index("ix_users_email", table_name="users")
op.drop_table("users")
# citext extension is left installed — it may be in use elsewhere.