fix part 3

This commit is contained in:
Danilo Reyes
2025-11-02 18:31:46 -06:00
parent 209b6d9f18
commit ff1c29c66a
22 changed files with 2226 additions and 74 deletions

View File

@@ -0,0 +1 @@
/nix/store/92khy67bgrzx85f6052pnw7xrs2jk1v6-source

View File

@@ -0,0 +1 @@
/nix/store/lhn3s31zbiq1syclv0rk94bn5g74750c-source

View File

@@ -0,0 +1 @@
/nix/store/xjjq52iwslhz6lbc621a31v0nfdhr5ks-source

View File

@@ -0,0 +1 @@
/nix/store/zzxxnkdqc6rdycxkylwrs2pg8ahj3cny-source

View File

@@ -0,0 +1 @@
/nix/store/xxizbrvv0ysnp79c429sgsa7g5vwqbr3-nix-shell-env

File diff suppressed because one or more lines are too long

1
.env.example Normal file
View File

@@ -0,0 +1 @@
SECRET_KEY=xM5coyysuo8LZJtNsytgP7OWiKEgHLL75-MGXWzYlxo

2
.gitignore vendored
View File

@@ -98,4 +98,4 @@ frontend/dist/
!.specify/templates/ !.specify/templates/
!.specify/memory/ !.specify/memory/
.direnv/ .direnv/backend/.env

View File

@@ -8,7 +8,7 @@ from sqlalchemy.orm import Session
from app.boards.repository import BoardRepository from app.boards.repository import BoardRepository
from app.boards.schemas import BoardCreate, BoardDetail, BoardSummary, BoardUpdate, ViewportStateUpdate from app.boards.schemas import BoardCreate, BoardDetail, BoardSummary, BoardUpdate, ViewportStateUpdate
from app.core.deps import get_current_user, get_db from app.core.deps import get_current_user, get_db_sync
from app.database.models.user import User from app.database.models.user import User
router = APIRouter(prefix="/boards", tags=["boards"]) router = APIRouter(prefix="/boards", tags=["boards"])
@@ -18,7 +18,7 @@ router = APIRouter(prefix="/boards", tags=["boards"])
def create_board( def create_board(
board_data: BoardCreate, board_data: BoardCreate,
current_user: Annotated[User, Depends(get_current_user)], current_user: Annotated[User, Depends(get_current_user)],
db: Annotated[Session, Depends(get_db)], db: Annotated[Session, Depends(get_db_sync)],
): ):
""" """
Create a new board. Create a new board.
@@ -45,7 +45,7 @@ def create_board(
@router.get("", response_model=dict) @router.get("", response_model=dict)
def list_boards( def list_boards(
current_user: Annotated[User, Depends(get_current_user)], current_user: Annotated[User, Depends(get_current_user)],
db: Annotated[Session, Depends(get_db)], db: Annotated[Session, Depends(get_db_sync)],
limit: Annotated[int, Query(ge=1, le=100)] = 50, limit: Annotated[int, Query(ge=1, le=100)] = 50,
offset: Annotated[int, Query(ge=0)] = 0, offset: Annotated[int, Query(ge=0)] = 0,
): ):
@@ -77,7 +77,7 @@ def list_boards(
def get_board( def get_board(
board_id: UUID, board_id: UUID,
current_user: Annotated[User, Depends(get_current_user)], current_user: Annotated[User, Depends(get_current_user)],
db: Annotated[Session, Depends(get_db)], db: Annotated[Session, Depends(get_db_sync)],
): ):
""" """
Get board details by ID. Get board details by ID.
@@ -111,7 +111,7 @@ def update_board(
board_id: UUID, board_id: UUID,
board_data: BoardUpdate, board_data: BoardUpdate,
current_user: Annotated[User, Depends(get_current_user)], current_user: Annotated[User, Depends(get_current_user)],
db: Annotated[Session, Depends(get_db)], db: Annotated[Session, Depends(get_db_sync)],
): ):
""" """
Update board metadata. Update board metadata.
@@ -157,7 +157,7 @@ def update_viewport(
board_id: UUID, board_id: UUID,
viewport_data: ViewportStateUpdate, viewport_data: ViewportStateUpdate,
current_user: Annotated[User, Depends(get_current_user)], current_user: Annotated[User, Depends(get_current_user)],
db: Annotated[Session, Depends(get_db)], db: Annotated[Session, Depends(get_db_sync)],
): ):
""" """
Update board viewport state only (optimized for frequent updates). Update board viewport state only (optimized for frequent updates).
@@ -198,7 +198,7 @@ def update_viewport(
def delete_board( def delete_board(
board_id: UUID, board_id: UUID,
current_user: Annotated[User, Depends(get_current_user)], current_user: Annotated[User, Depends(get_current_user)],
db: Annotated[Session, Depends(get_db)], db: Annotated[Session, Depends(get_db_sync)],
): ):
""" """
Delete a board (soft delete). Delete a board (soft delete).

View File

@@ -1,6 +1,6 @@
"""Board sharing API endpoints.""" """Board sharing API endpoints."""
from datetime import datetime, timezone from datetime import UTC, datetime
from uuid import UUID from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, status from fastapi import APIRouter, Depends, HTTPException, status
@@ -54,7 +54,7 @@ def validate_share_link(token: str, db: Session, required_permission: str = "vie
) )
# Check expiration # Check expiration
if share_link.expires_at and share_link.expires_at < datetime.now(timezone.utc): if share_link.expires_at and share_link.expires_at < datetime.now(UTC):
raise HTTPException( raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, status_code=status.HTTP_403_FORBIDDEN,
detail="Share link has expired", detail="Share link has expired",
@@ -69,7 +69,7 @@ def validate_share_link(token: str, db: Session, required_permission: str = "vie
# Update access tracking # Update access tracking
share_link.access_count += 1 share_link.access_count += 1
share_link.last_accessed_at = datetime.now(timezone.utc) share_link.last_accessed_at = datetime.now(UTC)
db.commit() db.commit()
return share_link return share_link

View File

@@ -1,6 +1,6 @@
"""JWT token generation and validation.""" """JWT token generation and validation."""
from datetime import datetime, timedelta, timezone from datetime import UTC, datetime, timedelta
from uuid import UUID from uuid import UUID
from jose import JWTError, jwt from jose import JWTError, jwt
@@ -21,11 +21,11 @@ def create_access_token(user_id: UUID, email: str, expires_delta: timedelta | No
Encoded JWT token string Encoded JWT token string
""" """
if expires_delta: if expires_delta:
expire = datetime.now(timezone.utc) + expires_delta expire = datetime.now(UTC) + expires_delta
else: else:
expire = datetime.now(timezone.utc) + timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES) expire = datetime.now(UTC) + timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
to_encode = {"sub": str(user_id), "email": email, "exp": expire, "iat": datetime.now(timezone.utc), "type": "access"} to_encode = {"sub": str(user_id), "email": email, "exp": expire, "iat": datetime.now(UTC), "type": "access"}
encoded_jwt = jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM) encoded_jwt = jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM)
return encoded_jwt return encoded_jwt

View File

@@ -2,7 +2,7 @@
import secrets import secrets
import string import string
from datetime import datetime, timezone from datetime import UTC, datetime
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
@@ -53,12 +53,12 @@ def validate_share_link_token(token: str, db: Session) -> ShareLink | None:
return None return None
# Check expiration # Check expiration
if share_link.expires_at and share_link.expires_at < datetime.now(timezone.utc): if share_link.expires_at and share_link.expires_at < datetime.now(UTC):
return None return None
# Update access tracking # Update access tracking
share_link.access_count += 1 share_link.access_count += 1
share_link.last_accessed_at = datetime.now(timezone.utc) share_link.last_accessed_at = datetime.now(UTC)
db.commit() db.commit()
return share_link return share_link

View File

@@ -5,18 +5,16 @@ from uuid import UUID
from fastapi import Depends, HTTPException, status from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import Session
from app.auth.jwt import decode_access_token
from app.database.models.user import User
from app.database.session import get_db
# For backwards compatibility with synchronous code # For backwards compatibility with synchronous code
from sqlalchemy import create_engine from sqlalchemy import create_engine, select
from sqlalchemy.orm import sessionmaker from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import Session, sessionmaker
from app.auth.jwt import decode_access_token
from app.core.config import settings from app.core.config import settings
from app.database.models.user import User
from app.database.session import get_db
# Sync engine for synchronous endpoints # Sync engine for synchronous endpoints
_sync_engine = create_engine( _sync_engine = create_engine(

View File

@@ -1,10 +1,9 @@
"""Base model for all database models.""" """Base model for all database models."""
from datetime import datetime, timezone
from typing import Any from typing import Any
from uuid import uuid4 from uuid import uuid4
from sqlalchemy import Column, DateTime from sqlalchemy import Column, DateTime, func
from sqlalchemy.dialects.postgresql import UUID from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import DeclarativeBase, declared_attr from sqlalchemy.orm import DeclarativeBase, declared_attr
@@ -22,7 +21,7 @@ class Base(DeclarativeBase):
# Common columns for all models # Common columns for all models
id: Any = Column(UUID(as_uuid=True), primary_key=True, default=uuid4) id: Any = Column(UUID(as_uuid=True), primary_key=True, default=uuid4)
created_at: Any = Column(DateTime, default=lambda: datetime.now(timezone.utc), nullable=False) created_at: Any = Column(DateTime, server_default=func.now(), nullable=False)
def dict(self) -> dict[str, Any]: def dict(self) -> dict[str, Any]:
"""Convert model to dictionary.""" """Convert model to dictionary."""

View File

@@ -1,10 +1,10 @@
"""Board database model.""" """Board database model."""
from datetime import datetime, timezone from datetime import datetime
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from uuid import UUID, uuid4 from uuid import UUID, uuid4
from sqlalchemy import Boolean, DateTime, ForeignKey, String, Text from sqlalchemy import Boolean, DateTime, ForeignKey, String, Text, func
from sqlalchemy.dialects.postgresql import JSONB from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.dialects.postgresql import UUID as PGUUID from sqlalchemy.dialects.postgresql import UUID as PGUUID
from sqlalchemy.orm import Mapped, mapped_column, relationship from sqlalchemy.orm import Mapped, mapped_column, relationship
@@ -42,9 +42,9 @@ class Board(Base):
default=lambda: {"x": 0, "y": 0, "zoom": 1.0, "rotation": 0}, default=lambda: {"x": 0, "y": 0, "zoom": 1.0, "rotation": 0},
) )
created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=lambda: datetime.now(timezone.utc)) created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, server_default=func.now())
updated_at: Mapped[datetime] = mapped_column( updated_at: Mapped[datetime] = mapped_column(
DateTime, nullable=False, default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc) DateTime, nullable=False, server_default=func.now(), onupdate=func.now()
) )
is_deleted: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) is_deleted: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)

View File

@@ -1,10 +1,10 @@
"""BoardImage database model - junction table for boards and images.""" """BoardImage database model - junction table for boards and images."""
from datetime import datetime, timezone from datetime import datetime
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from uuid import UUID, uuid4 from uuid import UUID, uuid4
from sqlalchemy import DateTime, ForeignKey, Integer from sqlalchemy import DateTime, ForeignKey, Integer, func
from sqlalchemy.dialects.postgresql import JSONB from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.dialects.postgresql import UUID as PGUUID from sqlalchemy.dialects.postgresql import UUID as PGUUID
from sqlalchemy.orm import Mapped, mapped_column, relationship from sqlalchemy.orm import Mapped, mapped_column, relationship
@@ -52,9 +52,9 @@ class BoardImage(Base):
PGUUID(as_uuid=True), ForeignKey("groups.id", ondelete="SET NULL"), nullable=True PGUUID(as_uuid=True), ForeignKey("groups.id", ondelete="SET NULL"), nullable=True
) )
created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=lambda: datetime.now(timezone.utc)) created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, server_default=func.now())
updated_at: Mapped[datetime] = mapped_column( updated_at: Mapped[datetime] = mapped_column(
DateTime, nullable=False, default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc) DateTime, nullable=False, server_default=func.now(), onupdate=func.now()
) )
# Relationships # Relationships

View File

@@ -1,9 +1,8 @@
"""Comment model for board annotations.""" """Comment model for board annotations."""
import uuid import uuid
from datetime import datetime, timezone
from sqlalchemy import Boolean, Column, DateTime, ForeignKey, String, Text from sqlalchemy import Boolean, Column, DateTime, ForeignKey, String, Text, func
from sqlalchemy.dialects.postgresql import JSONB, UUID from sqlalchemy.dialects.postgresql import JSONB, UUID
from sqlalchemy.orm import relationship from sqlalchemy.orm import relationship
@@ -21,7 +20,7 @@ class Comment(Base):
author_name = Column(String(100), nullable=False) author_name = Column(String(100), nullable=False)
content = Column(Text, nullable=False) content = Column(Text, nullable=False)
position = Column(JSONB, nullable=True) # Optional canvas position reference position = Column(JSONB, nullable=True) # Optional canvas position reference
created_at = Column(DateTime, nullable=False, default=lambda: datetime.now(timezone.utc)) created_at = Column(DateTime, nullable=False, server_default=func.now())
is_deleted = Column(Boolean, nullable=False, default=False) is_deleted = Column(Boolean, nullable=False, default=False)
# Relationships # Relationships

View File

@@ -1,10 +1,10 @@
"""Group database model.""" """Group database model."""
from datetime import datetime, timezone from datetime import datetime
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from uuid import UUID, uuid4 from uuid import UUID, uuid4
from sqlalchemy import DateTime, ForeignKey, String, Text from sqlalchemy import DateTime, ForeignKey, String, Text, func
from sqlalchemy.dialects.postgresql import UUID as PGUUID from sqlalchemy.dialects.postgresql import UUID as PGUUID
from sqlalchemy.orm import Mapped, mapped_column, relationship from sqlalchemy.orm import Mapped, mapped_column, relationship
@@ -33,9 +33,9 @@ class Group(Base):
color: Mapped[str] = mapped_column(String(7), nullable=False) # Hex color #RRGGBB color: Mapped[str] = mapped_column(String(7), nullable=False) # Hex color #RRGGBB
annotation: Mapped[str | None] = mapped_column(Text, nullable=True) annotation: Mapped[str | None] = mapped_column(Text, nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=lambda: datetime.now(timezone.utc)) created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, server_default=func.now())
updated_at: Mapped[datetime] = mapped_column( updated_at: Mapped[datetime] = mapped_column(
DateTime, nullable=False, default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc) DateTime, nullable=False, server_default=func.now(), onupdate=func.now()
) )
# Relationships # Relationships

View File

@@ -1,10 +1,10 @@
"""Image database model.""" """Image database model."""
from datetime import datetime, timezone from datetime import datetime
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from uuid import UUID, uuid4 from uuid import UUID, uuid4
from sqlalchemy import BigInteger, DateTime, ForeignKey, Integer, String from sqlalchemy import BigInteger, DateTime, ForeignKey, Integer, String, func
from sqlalchemy.dialects.postgresql import JSONB from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.dialects.postgresql import UUID as PGUUID from sqlalchemy.dialects.postgresql import UUID as PGUUID
from sqlalchemy.orm import Mapped, mapped_column, relationship from sqlalchemy.orm import Mapped, mapped_column, relationship
@@ -38,7 +38,7 @@ class Image(Base):
height: Mapped[int] = mapped_column(Integer, nullable=False) height: Mapped[int] = mapped_column(Integer, nullable=False)
image_metadata: Mapped[dict] = mapped_column(JSONB, nullable=False) image_metadata: Mapped[dict] = mapped_column(JSONB, nullable=False)
created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, default=lambda: datetime.now(timezone.utc)) created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, server_default=func.now())
reference_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0) reference_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
# Relationships # Relationships

View File

@@ -1,9 +1,8 @@
"""ShareLink model for board sharing functionality.""" """ShareLink model for board sharing functionality."""
import uuid import uuid
from datetime import datetime, timezone
from sqlalchemy import Boolean, Column, DateTime, ForeignKey, Integer, String from sqlalchemy import Boolean, Column, DateTime, ForeignKey, Integer, String, func
from sqlalchemy.dialects.postgresql import UUID from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import relationship from sqlalchemy.orm import relationship
@@ -19,7 +18,7 @@ class ShareLink(Base):
board_id = Column(UUID(as_uuid=True), ForeignKey("boards.id", ondelete="CASCADE"), nullable=False) board_id = Column(UUID(as_uuid=True), ForeignKey("boards.id", ondelete="CASCADE"), nullable=False)
token = Column(String(64), unique=True, nullable=False, index=True) token = Column(String(64), unique=True, nullable=False, index=True)
permission_level = Column(String(20), nullable=False) # 'view-only' or 'view-comment' permission_level = Column(String(20), nullable=False) # 'view-only' or 'view-comment'
created_at = Column(DateTime, nullable=False, default=lambda: datetime.now(timezone.utc)) created_at = Column(DateTime, nullable=False, server_default=func.now())
expires_at = Column(DateTime, nullable=True) expires_at = Column(DateTime, nullable=True)
last_accessed_at = Column(DateTime, nullable=True) last_accessed_at = Column(DateTime, nullable=True)
access_count = Column(Integer, nullable=False, default=0) access_count = Column(Integer, nullable=False, default=0)

View File

@@ -1,9 +1,8 @@
"""User model for authentication and ownership.""" """User model for authentication and ownership."""
import uuid import uuid
from datetime import datetime, timezone
from sqlalchemy import Boolean, Column, DateTime, String from sqlalchemy import Boolean, Column, DateTime, String, func
from sqlalchemy.dialects.postgresql import UUID from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import relationship from sqlalchemy.orm import relationship
@@ -18,8 +17,8 @@ class User(Base):
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
email = Column(String(255), unique=True, nullable=False, index=True) email = Column(String(255), unique=True, nullable=False, index=True)
password_hash = Column(String(255), nullable=False) password_hash = Column(String(255), nullable=False)
created_at = Column(DateTime, nullable=False, default=lambda: datetime.now(timezone.utc)) created_at = Column(DateTime, nullable=False, server_default=func.now())
updated_at = Column(DateTime, nullable=False, default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc)) updated_at = Column(DateTime, nullable=False, server_default=func.now(), onupdate=func.now())
is_active = Column(Boolean, nullable=False, default=True) is_active = Column(Boolean, nullable=False, default=True)
# Relationships # Relationships

View File

@@ -49,25 +49,17 @@ class ImageRepository:
result = await self.db.execute(select(Image).where(Image.id == image_id)) result = await self.db.execute(select(Image).where(Image.id == image_id))
return result.scalar_one_or_none() return result.scalar_one_or_none()
async def get_user_images( async def get_user_images(self, user_id: UUID, limit: int = 50, offset: int = 0) -> tuple[Sequence[Image], int]:
self, user_id: UUID, limit: int = 50, offset: int = 0
) -> tuple[Sequence[Image], int]:
"""Get all images for a user with pagination.""" """Get all images for a user with pagination."""
from sqlalchemy import func from sqlalchemy import func
# Get total count efficiently # Get total count efficiently
count_result = await self.db.execute( count_result = await self.db.execute(select(func.count(Image.id)).where(Image.user_id == user_id))
select(func.count(Image.id)).where(Image.user_id == user_id)
)
total = count_result.scalar_one() total = count_result.scalar_one()
# Get paginated images # Get paginated images
result = await self.db.execute( result = await self.db.execute(
select(Image) select(Image).where(Image.user_id == user_id).order_by(Image.created_at.desc()).limit(limit).offset(offset)
.where(Image.user_id == user_id)
.order_by(Image.created_at.desc())
.limit(limit)
.offset(offset)
) )
images = result.scalars().all() images = result.scalars().all()
return images, total return images, total
@@ -126,17 +118,14 @@ class ImageRepository:
async def get_board_images(self, board_id: UUID) -> Sequence[BoardImage]: async def get_board_images(self, board_id: UUID) -> Sequence[BoardImage]:
"""Get all images for a board, ordered by z-order.""" """Get all images for a board, ordered by z-order."""
result = await self.db.execute( result = await self.db.execute(
select(BoardImage) select(BoardImage).where(BoardImage.board_id == board_id).order_by(BoardImage.z_order.asc())
.where(BoardImage.board_id == board_id)
.order_by(BoardImage.z_order.asc())
) )
return result.scalars().all() return result.scalars().all()
async def get_board_image(self, board_id: UUID, image_id: UUID) -> BoardImage | None: async def get_board_image(self, board_id: UUID, image_id: UUID) -> BoardImage | None:
"""Get a specific board image.""" """Get a specific board image."""
result = await self.db.execute( result = await self.db.execute(
select(BoardImage) select(BoardImage).where(BoardImage.board_id == board_id, BoardImage.image_id == image_id)
.where(BoardImage.board_id == board_id, BoardImage.image_id == image_id)
) )
return result.scalar_one_or_none() return result.scalar_one_or_none()
@@ -151,7 +140,7 @@ class ImageRepository:
) -> BoardImage | None: ) -> BoardImage | None:
"""Update board image position, transformations, z-order, or group.""" """Update board image position, transformations, z-order, or group."""
board_image = await self.get_board_image(board_id, image_id) board_image = await self.get_board_image(board_id, image_id)
if not board_image: if not board_image:
return None return None