kamau1 commited on
Commit
6490f91
·
1 Parent(s): f95d630

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 CHANGED
@@ -34,13 +34,19 @@ Return document metadata to user
34
 
35
  ## Storage Routing Rules
36
 
37
- | File Type | Storage Provider | Folder/Bucket |
38
- |-----------|-----------------|---------------|
39
- | `image/*` | Cloudinary | `/swiftops/users/`, `/swiftops/tickets/`, etc. |
40
- | `video/*` | Cloudinary | `/swiftops/users/`, `/swiftops/tickets/`, etc. |
41
- | `application/pdf` | Supabase | `documents-users`, `documents-tickets`, etc. |
42
- | `application/*` | Supabase | `documents-users`, `documents-tickets`, etc. |
43
- | Other | Supabase | `documents-general` |
 
 
 
 
 
 
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
- Automatically routes to:
50
- - Cloudinary for images and videos
51
- - Supabase Storage for documents (PDF, DOCX, etc.)
52
 
53
- Supports all entity types: user, project, ticket, client, contractor, etc.
 
 
 
 
 
 
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.SUPABASE_KEY]):
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.SUPABASE_KEY)
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
- "message": f"Supabase Storage accessible ({len(buckets)} buckets)"
 
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
- logger.info(f" Buckets: {storage.get('buckets', 0)}")
 
 
 
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
- Rules:
28
- - Images (image/*) Cloudinary
29
- - Videos (video/*) Cloudinary
 
 
 
 
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. Upload to appropriate provider
56
- 3. Create document record in database
57
- 4. Return document object
 
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 upload fails
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
- # Upload to appropriate provider
 
 
 
 
 
84
  if provider == 'cloudinary':
85
- upload_result = await CloudinaryService.upload(
86
- file=file,
87
- entity_type=entity_type,
88
- entity_id=str(entity_id),
89
- document_type=document_type
90
- )
91
- file_url = upload_result['secure_url']
92
- file_size = upload_result.get('bytes')
93
- additional_metadata = upload_result
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 step3_UploadProfilePhoto() {
176
- log('\n📸 Step 3: Upload Profile Photo', 'blue');
 
177
 
178
  try {
179
- // Read test image
180
- const imageBuffer = fs.readFileSync(TEST_IMAGE_PATH);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
181
 
182
  // Prepare form data
183
  const formData = {
184
  file: {
185
- filename: 'lesley.jpg',
186
- contentType: 'image/jpeg',
187
- content: imageBuffer
188
  },
189
  entity_type: 'user',
190
  entity_id: lesleyUserId,
191
- document_type: 'profile_photo',
192
- document_category: 'personal',
193
- description: 'Lesley profile photo',
194
- tags: '["profile", "avatar"]',
195
- is_public: 'true'
 
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('✅ Profile photo uploaded', 'green');
203
  log(` Document ID: ${documentId}`);
204
  log(` File: ${response.data.file_name}`);
205
- log(` Storage: ${response.data.storage_provider}`);
206
- log(` URL: ${response.data.file_url.substring(0, 50)}...`);
 
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
- log('='.repeat(70), 'blue');
 
 
 
 
 
 
296
  log('📁 Document Upload Test', 'blue');
297
- log(' Testing: Universal document management system', 'cyan');
 
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 Profile Photo', status: await step3_UploadProfilePhoto() });
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(` Test image: ${TEST_IMAGE_PATH}`, 'magenta');
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
  }