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:
@@ -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')
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
32
backend/app/models/file_upload.py
Normal file
32
backend/app/models/file_upload.py
Normal 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)
|
||||
)
|
||||
86
backend/app/services/storage_service.py
Normal file
86
backend/app/services/storage_service.py
Normal 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}")
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user