Spaces:
Sleeping
Sleeping
feat: profile photo maps to user_document_links, enhanced initial health check for supabase buckets, added smart fallback with force_provider support
Browse files- docs/DOCUMENT_MANAGEMENT.md +13 -7
- src/app/api/v1/documents.py +92 -6
- src/app/core/health_checks.py +7 -5
- src/app/main.py +4 -1
- src/app/services/media_service.py +79 -25
- tests/integration/test_document_upload.js +155 -20
docs/DOCUMENT_MANAGEMENT.md
CHANGED
|
@@ -34,13 +34,19 @@ Return document metadata to user
|
|
| 34 |
|
| 35 |
## Storage Routing Rules
|
| 36 |
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
|
| 40 |
-
|
|
| 41 |
-
| `
|
| 42 |
-
| `
|
| 43 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 44 |
|
| 45 |
## Database Schema
|
| 46 |
|
|
|
|
| 34 |
|
| 35 |
## Storage Routing Rules
|
| 36 |
|
| 37 |
+
The system automatically routes files based on optimization, not capability:
|
| 38 |
+
|
| 39 |
+
| File Type | Default Provider | Reason | Alternative |
|
| 40 |
+
|-----------|-----------------|--------|-------------|
|
| 41 |
+
| `image/*` | **Cloudinary** | CDN delivery, auto-optimization, transformations | Supabase can also store images |
|
| 42 |
+
| `video/*` | **Cloudinary** | Streaming, transcoding, adaptive bitrate | Supabase can also store videos |
|
| 43 |
+
| `application/pdf` | **Supabase** | Cost-effective, simple storage | N/A |
|
| 44 |
+
| `application/*` | **Supabase** | General documents (DOCX, XLSX, etc.) | N/A |
|
| 45 |
+
| Other | **Supabase** | Fallback for all other file types | N/A |
|
| 46 |
+
|
| 47 |
+
**Note**: Both providers can technically store any file type. The routing is based on optimization:
|
| 48 |
+
- **Cloudinary**: Best for media that needs CDN delivery and transformations
|
| 49 |
+
- **Supabase**: Best for documents and general file storage
|
| 50 |
|
| 51 |
## Database Schema
|
| 52 |
|
src/app/api/v1/documents.py
CHANGED
|
@@ -10,6 +10,7 @@ from uuid import UUID
|
|
| 10 |
from app.api.deps import get_db, get_current_active_user
|
| 11 |
from app.models.user import User
|
| 12 |
from app.models.document import Document
|
|
|
|
| 13 |
from app.schemas.document import (
|
| 14 |
DocumentResponse,
|
| 15 |
DocumentListResponse,
|
|
@@ -18,6 +19,7 @@ from app.schemas.document import (
|
|
| 18 |
)
|
| 19 |
from app.services.media_service import StorageService
|
| 20 |
from app.services.audit_service import AuditService
|
|
|
|
| 21 |
import logging
|
| 22 |
import json
|
| 23 |
|
|
@@ -39,18 +41,25 @@ async def upload_document(
|
|
| 39 |
description: Optional[str] = Form(None),
|
| 40 |
tags: Optional[str] = Form("[]"), # JSON string of array
|
| 41 |
is_public: bool = Form(False),
|
|
|
|
| 42 |
request: Request = None,
|
| 43 |
current_user: User = Depends(get_current_active_user),
|
| 44 |
db: Session = Depends(get_db)
|
| 45 |
):
|
| 46 |
"""
|
| 47 |
-
Universal document upload endpoint
|
| 48 |
|
| 49 |
-
|
| 50 |
-
-
|
| 51 |
-
-
|
| 52 |
|
| 53 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 54 |
"""
|
| 55 |
try:
|
| 56 |
# Parse tags from JSON string
|
|
@@ -63,6 +72,13 @@ async def upload_document(
|
|
| 63 |
detail="No file provided"
|
| 64 |
)
|
| 65 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 66 |
# Upload file
|
| 67 |
document = await StorageService.upload_file(
|
| 68 |
file=file,
|
|
@@ -74,9 +90,35 @@ async def upload_document(
|
|
| 74 |
tags=tags_list,
|
| 75 |
is_public=is_public,
|
| 76 |
uploaded_by_user_id=current_user.id,
|
| 77 |
-
db=db
|
|
|
|
| 78 |
)
|
| 79 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 80 |
# Audit log
|
| 81 |
AuditService.log_action(
|
| 82 |
db=db,
|
|
@@ -322,3 +364,47 @@ async def get_user_documents(
|
|
| 322 |
current_user=current_user,
|
| 323 |
db=db
|
| 324 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 10 |
from app.api.deps import get_db, get_current_active_user
|
| 11 |
from app.models.user import User
|
| 12 |
from app.models.document import Document
|
| 13 |
+
from app.models.user_document_link import UserDocumentLink
|
| 14 |
from app.schemas.document import (
|
| 15 |
DocumentResponse,
|
| 16 |
DocumentListResponse,
|
|
|
|
| 19 |
)
|
| 20 |
from app.services.media_service import StorageService
|
| 21 |
from app.services.audit_service import AuditService
|
| 22 |
+
from datetime import datetime
|
| 23 |
import logging
|
| 24 |
import json
|
| 25 |
|
|
|
|
| 41 |
description: Optional[str] = Form(None),
|
| 42 |
tags: Optional[str] = Form("[]"), # JSON string of array
|
| 43 |
is_public: bool = Form(False),
|
| 44 |
+
force_provider: Optional[str] = Form(None), # Optional: 'cloudinary' or 'supabase'
|
| 45 |
request: Request = None,
|
| 46 |
current_user: User = Depends(get_current_active_user),
|
| 47 |
db: Session = Depends(get_db)
|
| 48 |
):
|
| 49 |
"""
|
| 50 |
+
Universal document upload endpoint with smart fallback
|
| 51 |
|
| 52 |
+
Default routing:
|
| 53 |
+
- Images/videos → Cloudinary (with Supabase fallback)
|
| 54 |
+
- Documents → Supabase Storage
|
| 55 |
|
| 56 |
+
Features:
|
| 57 |
+
- Automatic fallback if primary provider fails
|
| 58 |
+
- Optional provider override via force_provider parameter
|
| 59 |
+
- Supports all entity types: user, project, ticket, client, contractor, etc.
|
| 60 |
+
|
| 61 |
+
Parameters:
|
| 62 |
+
- force_provider: Optional override ('cloudinary' or 'supabase')
|
| 63 |
"""
|
| 64 |
try:
|
| 65 |
# Parse tags from JSON string
|
|
|
|
| 72 |
detail="No file provided"
|
| 73 |
)
|
| 74 |
|
| 75 |
+
# Validate force_provider if provided
|
| 76 |
+
if force_provider and force_provider not in ['cloudinary', 'supabase']:
|
| 77 |
+
raise HTTPException(
|
| 78 |
+
status_code=status.HTTP_400_BAD_REQUEST,
|
| 79 |
+
detail="force_provider must be 'cloudinary' or 'supabase'"
|
| 80 |
+
)
|
| 81 |
+
|
| 82 |
# Upload file
|
| 83 |
document = await StorageService.upload_file(
|
| 84 |
file=file,
|
|
|
|
| 90 |
tags=tags_list,
|
| 91 |
is_public=is_public,
|
| 92 |
uploaded_by_user_id=current_user.id,
|
| 93 |
+
db=db,
|
| 94 |
+
force_provider=force_provider
|
| 95 |
)
|
| 96 |
|
| 97 |
+
# Create user_document_link for user documents
|
| 98 |
+
if entity_type == 'user':
|
| 99 |
+
# Check if link already exists for this document type
|
| 100 |
+
existing_link = db.query(UserDocumentLink).filter(
|
| 101 |
+
UserDocumentLink.user_id == entity_id,
|
| 102 |
+
UserDocumentLink.document_link_type == document_type
|
| 103 |
+
).first()
|
| 104 |
+
|
| 105 |
+
if existing_link:
|
| 106 |
+
# Update existing link to point to new document
|
| 107 |
+
existing_link.document_id = document.id
|
| 108 |
+
existing_link.updated_at = datetime.utcnow().isoformat()
|
| 109 |
+
else:
|
| 110 |
+
# Create new link
|
| 111 |
+
doc_link = UserDocumentLink(
|
| 112 |
+
user_id=entity_id,
|
| 113 |
+
document_id=document.id,
|
| 114 |
+
document_link_type=document_type,
|
| 115 |
+
notes=description
|
| 116 |
+
)
|
| 117 |
+
db.add(doc_link)
|
| 118 |
+
|
| 119 |
+
db.commit()
|
| 120 |
+
logger.info(f"Created user_document_link for {document_type}")
|
| 121 |
+
|
| 122 |
# Audit log
|
| 123 |
AuditService.log_action(
|
| 124 |
db=db,
|
|
|
|
| 364 |
current_user=current_user,
|
| 365 |
db=db
|
| 366 |
)
|
| 367 |
+
|
| 368 |
+
|
| 369 |
+
@router.get("/users/{user_id}/profile-photo", response_model=Optional[DocumentResponse])
|
| 370 |
+
async def get_user_profile_photo(
|
| 371 |
+
user_id: UUID,
|
| 372 |
+
current_user: User = Depends(get_current_active_user),
|
| 373 |
+
db: Session = Depends(get_db)
|
| 374 |
+
):
|
| 375 |
+
"""
|
| 376 |
+
Get user's profile photo (convenience endpoint)
|
| 377 |
+
|
| 378 |
+
Uses user_document_links for fast lookup
|
| 379 |
+
"""
|
| 380 |
+
# Get profile photo link
|
| 381 |
+
doc_link = db.query(UserDocumentLink).filter(
|
| 382 |
+
UserDocumentLink.user_id == user_id,
|
| 383 |
+
UserDocumentLink.document_link_type == 'profile_photo'
|
| 384 |
+
).first()
|
| 385 |
+
|
| 386 |
+
if not doc_link:
|
| 387 |
+
return None
|
| 388 |
+
|
| 389 |
+
# Get document
|
| 390 |
+
document = db.query(Document).filter(
|
| 391 |
+
Document.id == doc_link.document_id,
|
| 392 |
+
Document.deleted_at == None
|
| 393 |
+
).first()
|
| 394 |
+
|
| 395 |
+
if not document:
|
| 396 |
+
return None
|
| 397 |
+
|
| 398 |
+
response = DocumentResponse.from_orm(document)
|
| 399 |
+
|
| 400 |
+
# Add uploader info
|
| 401 |
+
if document.uploaded_by_user_id:
|
| 402 |
+
uploader = db.query(User).filter(User.id == document.uploaded_by_user_id).first()
|
| 403 |
+
if uploader:
|
| 404 |
+
response.uploader = UploaderInfo(
|
| 405 |
+
id=uploader.id,
|
| 406 |
+
name=uploader.name,
|
| 407 |
+
email=uploader.email
|
| 408 |
+
)
|
| 409 |
+
|
| 410 |
+
return response
|
src/app/core/health_checks.py
CHANGED
|
@@ -113,7 +113,7 @@ def check_supabase_storage() -> Dict[str, Any]:
|
|
| 113 |
Returns:
|
| 114 |
dict: Status information
|
| 115 |
"""
|
| 116 |
-
if not all([settings.SUPABASE_URL, settings.
|
| 117 |
return {
|
| 118 |
"configured": False,
|
| 119 |
"status": "Not configured",
|
|
@@ -123,8 +123,8 @@ def check_supabase_storage() -> Dict[str, Any]:
|
|
| 123 |
try:
|
| 124 |
from supabase import create_client
|
| 125 |
|
| 126 |
-
# Create Supabase client
|
| 127 |
-
supabase = create_client(settings.SUPABASE_URL, settings.
|
| 128 |
|
| 129 |
# Test by listing buckets (read-only operation)
|
| 130 |
buckets = supabase.storage.list_buckets()
|
|
@@ -132,14 +132,16 @@ def check_supabase_storage() -> Dict[str, Any]:
|
|
| 132 |
return {
|
| 133 |
"configured": True,
|
| 134 |
"status": "Connected",
|
| 135 |
-
"buckets": len(buckets),
|
| 136 |
-
"
|
|
|
|
| 137 |
}
|
| 138 |
|
| 139 |
except Exception as e:
|
| 140 |
return {
|
| 141 |
"configured": True,
|
| 142 |
"status": "Failed",
|
|
|
|
| 143 |
"message": f"Connection failed: {str(e)}"
|
| 144 |
}
|
| 145 |
|
|
|
|
| 113 |
Returns:
|
| 114 |
dict: Status information
|
| 115 |
"""
|
| 116 |
+
if not all([settings.SUPABASE_URL, settings.SUPABASE_SERVICE_KEY]):
|
| 117 |
return {
|
| 118 |
"configured": False,
|
| 119 |
"status": "Not configured",
|
|
|
|
| 123 |
try:
|
| 124 |
from supabase import create_client
|
| 125 |
|
| 126 |
+
# Create Supabase client with service role key (has permission to list buckets)
|
| 127 |
+
supabase = create_client(settings.SUPABASE_URL, settings.SUPABASE_SERVICE_KEY)
|
| 128 |
|
| 129 |
# Test by listing buckets (read-only operation)
|
| 130 |
buckets = supabase.storage.list_buckets()
|
|
|
|
| 132 |
return {
|
| 133 |
"configured": True,
|
| 134 |
"status": "Connected",
|
| 135 |
+
"buckets": len(buckets) if buckets else 0,
|
| 136 |
+
"bucket_names": [b.get('name') for b in buckets] if buckets else [],
|
| 137 |
+
"message": f"Supabase Storage accessible ({len(buckets) if buckets else 0} buckets)"
|
| 138 |
}
|
| 139 |
|
| 140 |
except Exception as e:
|
| 141 |
return {
|
| 142 |
"configured": True,
|
| 143 |
"status": "Failed",
|
| 144 |
+
"buckets": 0,
|
| 145 |
"message": f"Connection failed: {str(e)}"
|
| 146 |
}
|
| 147 |
|
src/app/main.py
CHANGED
|
@@ -156,7 +156,10 @@ async def startup_event():
|
|
| 156 |
if storage["configured"]:
|
| 157 |
logger.info(f" Supabase Storage: {storage['status']}")
|
| 158 |
if storage["status"] == "Connected":
|
| 159 |
-
|
|
|
|
|
|
|
|
|
|
| 160 |
else:
|
| 161 |
logger.info(f" Note: {storage['message']}")
|
| 162 |
|
|
|
|
| 156 |
if storage["configured"]:
|
| 157 |
logger.info(f" Supabase Storage: {storage['status']}")
|
| 158 |
if storage["status"] == "Connected":
|
| 159 |
+
bucket_count = storage.get('buckets', 0)
|
| 160 |
+
logger.info(f" Buckets: {bucket_count}")
|
| 161 |
+
if bucket_count > 0 and storage.get('bucket_names'):
|
| 162 |
+
logger.info(f" Names: {', '.join(storage['bucket_names'])}")
|
| 163 |
else:
|
| 164 |
logger.info(f" Note: {storage['message']}")
|
| 165 |
|
src/app/services/media_service.py
CHANGED
|
@@ -17,19 +17,32 @@ logger = logging.getLogger(__name__)
|
|
| 17 |
|
| 18 |
|
| 19 |
class StorageService:
|
| 20 |
-
"""Universal storage service that routes to appropriate provider"""
|
| 21 |
|
| 22 |
@staticmethod
|
| 23 |
-
def determine_provider(file_type: str) -> str:
|
| 24 |
"""
|
| 25 |
-
Determine storage provider based on file type
|
| 26 |
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 30 |
- Documents (application/pdf, docx, etc.) → Supabase
|
| 31 |
- Everything else → Supabase
|
|
|
|
|
|
|
|
|
|
| 32 |
"""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 33 |
if file_type.startswith('image/') or file_type.startswith('video/'):
|
| 34 |
return 'cloudinary'
|
| 35 |
return 'supabase'
|
|
@@ -45,16 +58,18 @@ class StorageService:
|
|
| 45 |
tags: list,
|
| 46 |
is_public: bool,
|
| 47 |
uploaded_by_user_id: UUID,
|
| 48 |
-
db: Session
|
|
|
|
| 49 |
) -> Document:
|
| 50 |
"""
|
| 51 |
-
Universal file upload handler
|
| 52 |
|
| 53 |
Workflow:
|
| 54 |
-
1. Determine storage provider based on file type
|
| 55 |
-
2.
|
| 56 |
-
3.
|
| 57 |
-
4.
|
|
|
|
| 58 |
|
| 59 |
Args:
|
| 60 |
file: The file to upload
|
|
@@ -67,31 +82,70 @@ class StorageService:
|
|
| 67 |
is_public: Whether document is public
|
| 68 |
uploaded_by_user_id: ID of user uploading
|
| 69 |
db: Database session
|
|
|
|
| 70 |
|
| 71 |
Returns:
|
| 72 |
Document object
|
| 73 |
|
| 74 |
Raises:
|
| 75 |
-
HTTPException: If
|
| 76 |
"""
|
| 77 |
try:
|
| 78 |
-
# Determine provider
|
| 79 |
-
provider = StorageService.determine_provider(file.content_type)
|
| 80 |
|
| 81 |
logger.info(f"Uploading {file.filename} ({file.content_type}) to {provider}")
|
| 82 |
|
| 83 |
-
#
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 84 |
if provider == 'cloudinary':
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 94 |
else:
|
|
|
|
| 95 |
upload_result = await SupabaseStorageService.upload(
|
| 96 |
file=file,
|
| 97 |
entity_type=entity_type,
|
|
|
|
| 17 |
|
| 18 |
|
| 19 |
class StorageService:
|
| 20 |
+
"""Universal storage service that routes to appropriate provider with fallback support"""
|
| 21 |
|
| 22 |
@staticmethod
|
| 23 |
+
def determine_provider(file_type: str, force_provider: str = None) -> str:
|
| 24 |
"""
|
| 25 |
+
Determine storage provider based on file type with optional override
|
| 26 |
|
| 27 |
+
Args:
|
| 28 |
+
file_type: MIME type of the file
|
| 29 |
+
force_provider: Optional override ('cloudinary' or 'supabase')
|
| 30 |
+
|
| 31 |
+
Rules (when no override):
|
| 32 |
+
- Images (image/*) → Cloudinary (optimized for media)
|
| 33 |
+
- Videos (video/*) → Cloudinary (optimized for media)
|
| 34 |
- Documents (application/pdf, docx, etc.) → Supabase
|
| 35 |
- Everything else → Supabase
|
| 36 |
+
|
| 37 |
+
Returns:
|
| 38 |
+
'cloudinary' or 'supabase'
|
| 39 |
"""
|
| 40 |
+
# Allow manual override
|
| 41 |
+
if force_provider in ['cloudinary', 'supabase']:
|
| 42 |
+
logger.info(f"Using forced provider: {force_provider}")
|
| 43 |
+
return force_provider
|
| 44 |
+
|
| 45 |
+
# Default routing based on file type
|
| 46 |
if file_type.startswith('image/') or file_type.startswith('video/'):
|
| 47 |
return 'cloudinary'
|
| 48 |
return 'supabase'
|
|
|
|
| 58 |
tags: list,
|
| 59 |
is_public: bool,
|
| 60 |
uploaded_by_user_id: UUID,
|
| 61 |
+
db: Session,
|
| 62 |
+
force_provider: str = None
|
| 63 |
) -> Document:
|
| 64 |
"""
|
| 65 |
+
Universal file upload handler with automatic fallback
|
| 66 |
|
| 67 |
Workflow:
|
| 68 |
+
1. Determine storage provider based on file type (or use override)
|
| 69 |
+
2. Try to upload to primary provider
|
| 70 |
+
3. If primary fails, automatically fallback to alternative provider
|
| 71 |
+
4. Create document record in database
|
| 72 |
+
5. Return document object
|
| 73 |
|
| 74 |
Args:
|
| 75 |
file: The file to upload
|
|
|
|
| 82 |
is_public: Whether document is public
|
| 83 |
uploaded_by_user_id: ID of user uploading
|
| 84 |
db: Database session
|
| 85 |
+
force_provider: Optional provider override ('cloudinary' or 'supabase')
|
| 86 |
|
| 87 |
Returns:
|
| 88 |
Document object
|
| 89 |
|
| 90 |
Raises:
|
| 91 |
+
HTTPException: If both providers fail
|
| 92 |
"""
|
| 93 |
try:
|
| 94 |
+
# Determine primary provider
|
| 95 |
+
provider = StorageService.determine_provider(file.content_type, force_provider)
|
| 96 |
|
| 97 |
logger.info(f"Uploading {file.filename} ({file.content_type}) to {provider}")
|
| 98 |
|
| 99 |
+
# Try primary provider with fallback
|
| 100 |
+
upload_result = None
|
| 101 |
+
file_url = None
|
| 102 |
+
file_size = None
|
| 103 |
+
additional_metadata = {}
|
| 104 |
+
|
| 105 |
if provider == 'cloudinary':
|
| 106 |
+
try:
|
| 107 |
+
upload_result = await CloudinaryService.upload(
|
| 108 |
+
file=file,
|
| 109 |
+
entity_type=entity_type,
|
| 110 |
+
entity_id=str(entity_id),
|
| 111 |
+
document_type=document_type
|
| 112 |
+
)
|
| 113 |
+
file_url = upload_result['secure_url']
|
| 114 |
+
file_size = upload_result.get('bytes')
|
| 115 |
+
additional_metadata = upload_result
|
| 116 |
+
|
| 117 |
+
except Exception as cloudinary_error:
|
| 118 |
+
logger.warning(f"Cloudinary upload failed: {cloudinary_error}")
|
| 119 |
+
logger.info("Falling back to Supabase Storage...")
|
| 120 |
+
|
| 121 |
+
# Fallback to Supabase
|
| 122 |
+
try:
|
| 123 |
+
# Reset file pointer for retry
|
| 124 |
+
await file.seek(0)
|
| 125 |
+
|
| 126 |
+
upload_result = await SupabaseStorageService.upload(
|
| 127 |
+
file=file,
|
| 128 |
+
entity_type=entity_type,
|
| 129 |
+
entity_id=str(entity_id),
|
| 130 |
+
document_type=document_type
|
| 131 |
+
)
|
| 132 |
+
file_url = upload_result['public_url']
|
| 133 |
+
file_size = upload_result.get('size')
|
| 134 |
+
additional_metadata = upload_result
|
| 135 |
+
additional_metadata['fallback'] = True
|
| 136 |
+
additional_metadata['primary_provider_error'] = str(cloudinary_error)
|
| 137 |
+
provider = 'supabase' # Update provider to reflect actual storage
|
| 138 |
+
|
| 139 |
+
logger.info("Successfully uploaded to Supabase (fallback)")
|
| 140 |
+
|
| 141 |
+
except Exception as supabase_error:
|
| 142 |
+
logger.error(f"Supabase fallback also failed: {supabase_error}")
|
| 143 |
+
raise HTTPException(
|
| 144 |
+
status_code=500,
|
| 145 |
+
detail=f"Both storage providers failed. Cloudinary: {cloudinary_error}. Supabase: {supabase_error}"
|
| 146 |
+
)
|
| 147 |
else:
|
| 148 |
+
# Primary is Supabase
|
| 149 |
upload_result = await SupabaseStorageService.upload(
|
| 150 |
file=file,
|
| 151 |
entity_type=entity_type,
|
tests/integration/test_document_upload.js
CHANGED
|
@@ -2,12 +2,13 @@
|
|
| 2 |
|
| 3 |
/**
|
| 4 |
* Document Upload Test
|
| 5 |
-
* Tests universal document management system
|
| 6 |
*/
|
| 7 |
|
| 8 |
const https = require('https');
|
| 9 |
const fs = require('fs');
|
| 10 |
const path = require('path');
|
|
|
|
| 11 |
|
| 12 |
const BASE_URL = 'https://kamau1-swiftops-backend.hf.space';
|
| 13 |
const ADMIN_EMAIL = 'lewis.kamau421@gmail.com';
|
|
@@ -18,6 +19,7 @@ const TEST_IMAGE_PATH = path.join(__dirname, '../images/lesley.jpg');
|
|
| 18 |
let adminToken = null;
|
| 19 |
let lesleyUserId = null;
|
| 20 |
let documentId = null;
|
|
|
|
| 21 |
|
| 22 |
const colors = {
|
| 23 |
reset: '\x1b[0m',
|
|
@@ -172,39 +174,126 @@ async function step2_GetLesleyUserId() {
|
|
| 172 |
}
|
| 173 |
}
|
| 174 |
|
| 175 |
-
async function
|
| 176 |
-
|
|
|
|
| 177 |
|
| 178 |
try {
|
| 179 |
-
|
| 180 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 181 |
|
| 182 |
// Prepare form data
|
| 183 |
const formData = {
|
| 184 |
file: {
|
| 185 |
-
filename:
|
| 186 |
-
contentType:
|
| 187 |
-
content:
|
| 188 |
},
|
| 189 |
entity_type: 'user',
|
| 190 |
entity_id: lesleyUserId,
|
| 191 |
-
document_type:
|
| 192 |
-
document_category: 'personal',
|
| 193 |
-
description:
|
| 194 |
-
tags: '["profile", "avatar"]',
|
| 195 |
-
is_public: '
|
|
|
|
| 196 |
};
|
| 197 |
|
| 198 |
const response = await makeMultipartRequest('POST', '/api/v1/documents/upload', formData, adminToken);
|
| 199 |
|
| 200 |
if (response.status === 201) {
|
| 201 |
documentId = response.data.id;
|
| 202 |
-
log('✅
|
| 203 |
log(` Document ID: ${documentId}`);
|
| 204 |
log(` File: ${response.data.file_name}`);
|
| 205 |
-
log(`
|
| 206 |
-
log(`
|
|
|
|
| 207 |
log(` Size: ${(response.data.file_size / 1024).toFixed(2)} KB`);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 208 |
return true;
|
| 209 |
} else {
|
| 210 |
log(`❌ Upload failed: ${response.status}`, 'red');
|
|
@@ -291,10 +380,54 @@ async function step6_UpdateDocumentMetadata() {
|
|
| 291 |
}
|
| 292 |
}
|
| 293 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 294 |
async function runTest() {
|
| 295 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 296 |
log('📁 Document Upload Test', 'blue');
|
| 297 |
-
log(
|
|
|
|
| 298 |
log('='.repeat(70), 'blue');
|
| 299 |
|
| 300 |
const results = [];
|
|
@@ -313,7 +446,7 @@ async function runTest() {
|
|
| 313 |
results.push({ name: 'Get User ID', status: true });
|
| 314 |
|
| 315 |
// Document Tests
|
| 316 |
-
results.push({ name: 'Upload
|
| 317 |
results.push({ name: 'Get Documents', status: await step4_GetDocuments() });
|
| 318 |
results.push({ name: 'Get Single Document', status: await step5_GetSingleDocument() });
|
| 319 |
results.push({ name: 'Update Metadata', status: await step6_UpdateDocumentMetadata() });
|
|
@@ -335,13 +468,15 @@ async function runTest() {
|
|
| 335 |
log(`Total: ${results.length} | Passed: ${passed} | Failed: ${failed}`);
|
| 336 |
|
| 337 |
if (failed === 0) {
|
|
|
|
| 338 |
log('\n🎉 All tests passed!', 'green');
|
| 339 |
log(`\n✨ Document system is working perfectly!`, 'magenta');
|
|
|
|
| 340 |
log(` User ID: ${lesleyUserId}`, 'magenta');
|
| 341 |
if (documentId) {
|
| 342 |
log(` Document ID: ${documentId}`, 'magenta');
|
| 343 |
}
|
| 344 |
-
log(`
|
| 345 |
} else {
|
| 346 |
log(`\n⚠️ ${failed} step(s) failed`, 'red');
|
| 347 |
}
|
|
|
|
| 2 |
|
| 3 |
/**
|
| 4 |
* Document Upload Test
|
| 5 |
+
* Tests universal document management system with storage provider selection
|
| 6 |
*/
|
| 7 |
|
| 8 |
const https = require('https');
|
| 9 |
const fs = require('fs');
|
| 10 |
const path = require('path');
|
| 11 |
+
const readline = require('readline');
|
| 12 |
|
| 13 |
const BASE_URL = 'https://kamau1-swiftops-backend.hf.space';
|
| 14 |
const ADMIN_EMAIL = 'lewis.kamau421@gmail.com';
|
|
|
|
| 19 |
let adminToken = null;
|
| 20 |
let lesleyUserId = null;
|
| 21 |
let documentId = null;
|
| 22 |
+
let selectedProvider = null;
|
| 23 |
|
| 24 |
const colors = {
|
| 25 |
reset: '\x1b[0m',
|
|
|
|
| 174 |
}
|
| 175 |
}
|
| 176 |
|
| 177 |
+
async function step3_UploadDocument() {
|
| 178 |
+
const providerName = selectedProvider === '1' ? 'Supabase' : 'Cloudinary';
|
| 179 |
+
log(`\n📤 Step 3: Upload Document (${providerName})`, 'blue');
|
| 180 |
|
| 181 |
try {
|
| 182 |
+
let fileBuffer, fileName, contentType, documentType, description;
|
| 183 |
+
|
| 184 |
+
if (selectedProvider === '1') {
|
| 185 |
+
// Supabase - Upload a PDF (create a dummy PDF for testing)
|
| 186 |
+
log(' Creating test PDF document...', 'cyan');
|
| 187 |
+
// Simple PDF content (minimal valid PDF)
|
| 188 |
+
const pdfContent = `%PDF-1.4
|
| 189 |
+
1 0 obj
|
| 190 |
+
<<
|
| 191 |
+
/Type /Catalog
|
| 192 |
+
/Pages 2 0 R
|
| 193 |
+
>>
|
| 194 |
+
endobj
|
| 195 |
+
2 0 obj
|
| 196 |
+
<<
|
| 197 |
+
/Type /Pages
|
| 198 |
+
/Kids [3 0 R]
|
| 199 |
+
/Count 1
|
| 200 |
+
>>
|
| 201 |
+
endobj
|
| 202 |
+
3 0 obj
|
| 203 |
+
<<
|
| 204 |
+
/Type /Page
|
| 205 |
+
/Parent 2 0 R
|
| 206 |
+
/Resources <<
|
| 207 |
+
/Font <<
|
| 208 |
+
/F1 <<
|
| 209 |
+
/Type /Font
|
| 210 |
+
/Subtype /Type1
|
| 211 |
+
/BaseFont /Helvetica
|
| 212 |
+
>>
|
| 213 |
+
>>
|
| 214 |
+
>>
|
| 215 |
+
/MediaBox [0 0 612 792]
|
| 216 |
+
/Contents 4 0 R
|
| 217 |
+
>>
|
| 218 |
+
endobj
|
| 219 |
+
4 0 obj
|
| 220 |
+
<<
|
| 221 |
+
/Length 44
|
| 222 |
+
>>
|
| 223 |
+
stream
|
| 224 |
+
BT
|
| 225 |
+
/F1 12 Tf
|
| 226 |
+
100 700 Td
|
| 227 |
+
(Test Document) Tj
|
| 228 |
+
ET
|
| 229 |
+
endstream
|
| 230 |
+
endobj
|
| 231 |
+
xref
|
| 232 |
+
0 5
|
| 233 |
+
0000000000 65535 f
|
| 234 |
+
0000000009 00000 n
|
| 235 |
+
0000000058 00000 n
|
| 236 |
+
0000000115 00000 n
|
| 237 |
+
0000000317 00000 n
|
| 238 |
+
trailer
|
| 239 |
+
<<
|
| 240 |
+
/Size 5
|
| 241 |
+
/Root 1 0 R
|
| 242 |
+
>>
|
| 243 |
+
startxref
|
| 244 |
+
410
|
| 245 |
+
%%EOF`;
|
| 246 |
+
fileBuffer = Buffer.from(pdfContent);
|
| 247 |
+
fileName = 'test_contract.pdf';
|
| 248 |
+
contentType = 'application/pdf';
|
| 249 |
+
documentType = 'contract';
|
| 250 |
+
description = 'Test contract document (Supabase Storage)';
|
| 251 |
+
} else {
|
| 252 |
+
// Cloudinary - Upload the image
|
| 253 |
+
fileBuffer = fs.readFileSync(TEST_IMAGE_PATH);
|
| 254 |
+
fileName = 'lesley.jpg';
|
| 255 |
+
contentType = 'image/jpeg';
|
| 256 |
+
documentType = 'profile_photo';
|
| 257 |
+
description = 'Lesley profile photo (Cloudinary)';
|
| 258 |
+
}
|
| 259 |
|
| 260 |
// Prepare form data
|
| 261 |
const formData = {
|
| 262 |
file: {
|
| 263 |
+
filename: fileName,
|
| 264 |
+
contentType: contentType,
|
| 265 |
+
content: fileBuffer
|
| 266 |
},
|
| 267 |
entity_type: 'user',
|
| 268 |
entity_id: lesleyUserId,
|
| 269 |
+
document_type: documentType,
|
| 270 |
+
document_category: selectedProvider === '1' ? 'legal' : 'personal',
|
| 271 |
+
description: description,
|
| 272 |
+
tags: selectedProvider === '1' ? '["contract", "legal"]' : '["profile", "avatar"]',
|
| 273 |
+
is_public: 'false',
|
| 274 |
+
force_provider: selectedProvider === '1' ? 'supabase' : 'cloudinary'
|
| 275 |
};
|
| 276 |
|
| 277 |
const response = await makeMultipartRequest('POST', '/api/v1/documents/upload', formData, adminToken);
|
| 278 |
|
| 279 |
if (response.status === 201) {
|
| 280 |
documentId = response.data.id;
|
| 281 |
+
log('✅ Document uploaded successfully', 'green');
|
| 282 |
log(` Document ID: ${documentId}`);
|
| 283 |
log(` File: ${response.data.file_name}`);
|
| 284 |
+
log(` Type: ${response.data.document_type}`);
|
| 285 |
+
log(` Storage: ${response.data.storage_provider}`, 'magenta');
|
| 286 |
+
log(` URL: ${response.data.file_url.substring(0, 60)}...`);
|
| 287 |
log(` Size: ${(response.data.file_size / 1024).toFixed(2)} KB`);
|
| 288 |
+
|
| 289 |
+
// Verify correct storage provider
|
| 290 |
+
const expectedProvider = selectedProvider === '1' ? 'supabase' : 'cloudinary';
|
| 291 |
+
if (response.data.storage_provider === expectedProvider) {
|
| 292 |
+
log(` ✓ Routed to correct provider: ${expectedProvider}`, 'green');
|
| 293 |
+
} else {
|
| 294 |
+
log(` ✗ Wrong provider! Expected ${expectedProvider}, got ${response.data.storage_provider}`, 'red');
|
| 295 |
+
}
|
| 296 |
+
|
| 297 |
return true;
|
| 298 |
} else {
|
| 299 |
log(`❌ Upload failed: ${response.status}`, 'red');
|
|
|
|
| 380 |
}
|
| 381 |
}
|
| 382 |
|
| 383 |
+
function promptStorageProvider() {
|
| 384 |
+
return new Promise((resolve) => {
|
| 385 |
+
const rl = readline.createInterface({
|
| 386 |
+
input: process.stdin,
|
| 387 |
+
output: process.stdout
|
| 388 |
+
});
|
| 389 |
+
|
| 390 |
+
console.log('\n' + '='.repeat(70));
|
| 391 |
+
console.log('📦 Storage Provider Selection');
|
| 392 |
+
console.log('='.repeat(70));
|
| 393 |
+
console.log('');
|
| 394 |
+
console.log('Choose storage provider to test:');
|
| 395 |
+
console.log('');
|
| 396 |
+
console.log(' 1️⃣ Supabase Storage');
|
| 397 |
+
console.log(' • General-purpose storage (PDFs, images, any file type)');
|
| 398 |
+
console.log(' • Test with: PDF document');
|
| 399 |
+
console.log('');
|
| 400 |
+
console.log(' 2️⃣ Cloudinary');
|
| 401 |
+
console.log(' • Optimized for images/videos (CDN, transformations)');
|
| 402 |
+
console.log(' • Test with: JPEG image');
|
| 403 |
+
console.log('');
|
| 404 |
+
console.log('Note: Both can store images, but Cloudinary is optimized for media.');
|
| 405 |
+
console.log('');
|
| 406 |
+
|
| 407 |
+
rl.question('Enter your choice (1 or 2): ', (answer) => {
|
| 408 |
+
rl.close();
|
| 409 |
+
const choice = answer.trim();
|
| 410 |
+
if (choice === '1' || choice === '2') {
|
| 411 |
+
resolve(choice);
|
| 412 |
+
} else {
|
| 413 |
+
console.log('\n❌ Invalid choice. Please run again and select 1 or 2.');
|
| 414 |
+
process.exit(1);
|
| 415 |
+
}
|
| 416 |
+
});
|
| 417 |
+
});
|
| 418 |
+
}
|
| 419 |
+
|
| 420 |
async function runTest() {
|
| 421 |
+
// Prompt for storage provider
|
| 422 |
+
selectedProvider = await promptStorageProvider();
|
| 423 |
+
|
| 424 |
+
const providerName = selectedProvider === '1' ? 'Supabase Storage' : 'Cloudinary';
|
| 425 |
+
const fileType = selectedProvider === '1' ? 'PDF document' : 'Image (JPEG)';
|
| 426 |
+
|
| 427 |
+
log('\n' + '='.repeat(70), 'blue');
|
| 428 |
log('📁 Document Upload Test', 'blue');
|
| 429 |
+
log(` Provider: ${providerName}`, 'magenta');
|
| 430 |
+
log(` File Type: ${fileType}`, 'cyan');
|
| 431 |
log('='.repeat(70), 'blue');
|
| 432 |
|
| 433 |
const results = [];
|
|
|
|
| 446 |
results.push({ name: 'Get User ID', status: true });
|
| 447 |
|
| 448 |
// Document Tests
|
| 449 |
+
results.push({ name: 'Upload Document', status: await step3_UploadDocument() });
|
| 450 |
results.push({ name: 'Get Documents', status: await step4_GetDocuments() });
|
| 451 |
results.push({ name: 'Get Single Document', status: await step5_GetSingleDocument() });
|
| 452 |
results.push({ name: 'Update Metadata', status: await step6_UpdateDocumentMetadata() });
|
|
|
|
| 468 |
log(`Total: ${results.length} | Passed: ${passed} | Failed: ${failed}`);
|
| 469 |
|
| 470 |
if (failed === 0) {
|
| 471 |
+
const providerName = selectedProvider === '1' ? 'Supabase Storage' : 'Cloudinary';
|
| 472 |
log('\n🎉 All tests passed!', 'green');
|
| 473 |
log(`\n✨ Document system is working perfectly!`, 'magenta');
|
| 474 |
+
log(` Storage Provider: ${providerName}`, 'magenta');
|
| 475 |
log(` User ID: ${lesleyUserId}`, 'magenta');
|
| 476 |
if (documentId) {
|
| 477 |
log(` Document ID: ${documentId}`, 'magenta');
|
| 478 |
}
|
| 479 |
+
log(`\n💡 Tip: Run the test again and select the other provider!`, 'cyan');
|
| 480 |
} else {
|
| 481 |
log(`\n⚠️ ${failed} step(s) failed`, 'red');
|
| 482 |
}
|