feat(evidence): add S3 storage service and file_uploads model

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-20 03:15:37 +00:00
parent 4bbdce5123
commit c7d602cfa5
6 changed files with 176 additions and 0 deletions

View File

@@ -0,0 +1,46 @@
"""add file_uploads table
Revision ID: 49150866ae44
Revises: e0d382f083d4
Create Date: 2026-03-20 03:15:10.227797
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '49150866ae44'
down_revision: Union[str, None] = 'e0d382f083d4'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
'file_uploads',
sa.Column('id', sa.UUID(), nullable=False),
sa.Column('account_id', sa.UUID(), nullable=False),
sa.Column('uploaded_by', sa.UUID(), nullable=False),
sa.Column('session_id', sa.UUID(), nullable=True),
sa.Column('filename', sa.String(length=255), nullable=False),
sa.Column('content_type', sa.String(length=100), nullable=False),
sa.Column('size_bytes', sa.Integer(), nullable=False),
sa.Column('storage_key', sa.String(length=500), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(['account_id'], ['accounts.id'], ondelete='CASCADE'),
sa.ForeignKeyConstraint(['session_id'], ['ai_sessions.id'], ondelete='SET NULL'),
sa.ForeignKeyConstraint(['uploaded_by'], ['users.id'], ondelete='SET NULL'),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('storage_key'),
)
op.create_index(op.f('ix_file_uploads_account_id'), 'file_uploads', ['account_id'], unique=False)
op.create_index(op.f('ix_file_uploads_session_id'), 'file_uploads', ['session_id'], unique=False)
def downgrade() -> None:
op.drop_index(op.f('ix_file_uploads_session_id'), table_name='file_uploads')
op.drop_index(op.f('ix_file_uploads_account_id'), table_name='file_uploads')
op.drop_table('file_uploads')

View File

@@ -124,6 +124,13 @@ class Settings(BaseSettings):
"""Check if any AI provider is configured."""
return self.ANTHROPIC_API_KEY is not None or self.GOOGLE_AI_API_KEY is not None
# Object Storage (Railway S3-compatible)
STORAGE_ENDPOINT: str | None = None
STORAGE_ACCESS_KEY: str | None = None
STORAGE_SECRET_KEY: str | None = None
STORAGE_BUCKET_NAME: str = "resolutionflow-uploads"
STORAGE_REGION: str = "us-east-1"
# ConnectWise PSA Integration
# CW_CLIENT_ID is a product-level GUID registered at developer.connectwise.com
# All MSP customers share this single clientId — it identifies ResolutionFlow as the integration

View File

@@ -47,6 +47,7 @@ from .notification_config import NotificationConfig
from .notification_log import NotificationLog
from .notification import Notification
from .psa_activity_log import PsaActivityLog
from .file_upload import FileUpload
__all__ = [
"User",
@@ -108,4 +109,5 @@ __all__ = [
"NotificationLog",
"Notification",
"PsaActivityLog",
"FileUpload",
]

View File

@@ -0,0 +1,32 @@
"""File upload metadata — tracks files stored in S3-compatible object storage."""
import uuid
from datetime import datetime, timezone
from typing import Optional
from sqlalchemy import String, Integer, DateTime, ForeignKey
from sqlalchemy.orm import Mapped, mapped_column
from sqlalchemy.dialects.postgresql import UUID
from app.core.database import Base
class FileUpload(Base):
__tablename__ = "file_uploads"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
account_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("accounts.id", ondelete="CASCADE"), nullable=False, index=True
)
uploaded_by: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"), nullable=False
)
session_id: Mapped[Optional[uuid.UUID]] = mapped_column(
UUID(as_uuid=True), ForeignKey("ai_sessions.id", ondelete="SET NULL"), nullable=True, index=True
)
filename: Mapped[str] = mapped_column(String(255), nullable=False)
content_type: Mapped[str] = mapped_column(String(100), nullable=False)
size_bytes: Mapped[int] = mapped_column(Integer, nullable=False)
storage_key: Mapped[str] = mapped_column(String(500), nullable=False, unique=True)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
)

View File

@@ -0,0 +1,86 @@
"""S3-compatible object storage service for file uploads."""
import logging
import uuid
from io import BytesIO
import boto3
from botocore.config import Config as BotoConfig
from botocore.exceptions import ClientError
from app.core.config import settings
logger = logging.getLogger(__name__)
ALLOWED_IMAGE_TYPES = {"image/png", "image/jpeg", "image/gif", "image/webp"}
ALLOWED_TEXT_TYPES = {"text/plain", "text/csv", "application/octet-stream"}
ALLOWED_TYPES = ALLOWED_IMAGE_TYPES | ALLOWED_TEXT_TYPES
MAX_IMAGE_SIZE = 5 * 1024 * 1024 # 5MB
MAX_TEXT_SIZE = 1 * 1024 * 1024 # 1MB
MAX_FILES_PER_SESSION = 20
MAX_BYTES_PER_SESSION = 50 * 1024 * 1024 # 50MB
PRESIGNED_URL_EXPIRY = 3600 # 1 hour
def _get_client():
"""Get S3 client configured for Railway Object Storage."""
if not settings.STORAGE_ENDPOINT:
raise RuntimeError("Object storage not configured (STORAGE_ENDPOINT missing)")
return boto3.client(
"s3",
endpoint_url=settings.STORAGE_ENDPOINT,
aws_access_key_id=settings.STORAGE_ACCESS_KEY,
aws_secret_access_key=settings.STORAGE_SECRET_KEY,
region_name=settings.STORAGE_REGION,
config=BotoConfig(signature_version="s3v4"),
)
def validate_upload(content_type: str, size_bytes: int) -> str | None:
"""Validate file type and size. Returns error message or None."""
if content_type not in ALLOWED_TYPES:
return f"File type {content_type} not allowed"
max_size = MAX_IMAGE_SIZE if content_type in ALLOWED_IMAGE_TYPES else MAX_TEXT_SIZE
if size_bytes > max_size:
return f"File too large ({size_bytes} bytes, max {max_size})"
return None
async def upload_file(
file_data: bytes,
filename: str,
content_type: str,
account_id: str,
) -> str:
"""Upload file to S3, returns the storage key."""
ext = filename.rsplit(".", 1)[-1] if "." in filename else "bin"
storage_key = f"uploads/{account_id}/{uuid.uuid4()}.{ext}"
client = _get_client()
client.upload_fileobj(
BytesIO(file_data),
settings.STORAGE_BUCKET_NAME,
storage_key,
ExtraArgs={"ContentType": content_type},
)
return storage_key
def get_presigned_url(storage_key: str) -> str:
"""Generate a time-limited presigned URL for downloading a file."""
client = _get_client()
return client.generate_presigned_url(
"get_object",
Params={"Bucket": settings.STORAGE_BUCKET_NAME, "Key": storage_key},
ExpiresIn=PRESIGNED_URL_EXPIRY,
)
async def delete_file(storage_key: str) -> None:
"""Delete a file from S3."""
try:
client = _get_client()
client.delete_object(Bucket=settings.STORAGE_BUCKET_NAME, Key=storage_key)
except ClientError:
logger.warning(f"Failed to delete S3 object: {storage_key}")

View File

@@ -51,3 +51,6 @@ python-dotenv==1.0.1
croniter>=2.0.0
pytz>=2024.1
apscheduler>=3.10.4
# Object Storage
boto3>=1.34.0