kamau1 commited on
Commit
b26895a
·
1 Parent(s): 3e23d36

feat: file naming

Browse files
docs/devlogs/db/logs.json CHANGED
@@ -2,4 +2,9 @@ INSERT INTO "public"."ticket_images" ("id", "ticket_id", "document_id", "image_t
2
 
3
 
4
  and here is the project data that we use to generate the completion form
5
- [{"idx":0,"id":"0ade6bd1-e492-4e25-b681-59f42058d29a","client_id":"a2455244-d87e-4279-9fca-dc067f06b5c3","contractor_id":"1af9fb24-e5bb-40ac-a748-0997580b4c32","title":"Atomio Fttx","description":"Install of testing purposes","service_type":"ftth","primary_manager_id":"c5cf92be-4172-4fe2-af5c-f05d83b3a938","status":"active","planned_start_date":"2025-11-22","planned_end_date":"2026-01-23","actual_start_date":null,"actual_end_date":null,"is_closed":false,"closed_at":null,"closed_by_user_id":null,"platform_billing_plan":null,"is_billable":true,"budget":"{}","inventory_requirements":"{}","photo_requirements":"[{\"type\": \"Speedtest\", \"required\": true, \"max_photos\": 1, \"min_photos\": 1, \"description\": \"speed test collected \"}, {\"type\": \"Airtel network\", \"required\": true, \"max_photos\": 1, \"min_photos\": 1, \"description\": \"strength metrics\"}, {\"type\": \"ODU outdoor image\", \"required\": true, \"max_photos\": 1, \"min_photos\": 1, \"description\": \"take a photo of the odu antenna\"}]","activation_requirements":"[{\"type\": \"text\", \"field\": \"ont_serial\", \"label\": \"ONT\", \"options\": null, \"required\": true}, {\"type\": \"text\", \"field\": \"odu_serial\", \"label\": \"ODU\", \"options\": null, \"required\": true}]","additional_metadata":"{\"setup_finalized_at\": \"2025-11-23T13:47:18.729614\", \"setup_finalized_by\": \"c5cf92be-4172-4fe2-af5c-f05d83b3a938\"}","created_at":"2025-11-20 19:04:04.11638+00","updated_at":"2025-11-24 13:16:22.370045+00","deleted_at":null,"project_type":"customer_service"}]
 
 
 
 
 
 
2
 
3
 
4
  and here is the project data that we use to generate the completion form
5
+ [{"idx":0,"id":"0ade6bd1-e492-4e25-b681-59f42058d29a","client_id":"a2455244-d87e-4279-9fca-dc067f06b5c3","contractor_id":"1af9fb24-e5bb-40ac-a748-0997580b4c32","title":"Atomio Fttx","description":"Install of testing purposes","service_type":"ftth","primary_manager_id":"c5cf92be-4172-4fe2-af5c-f05d83b3a938","status":"active","planned_start_date":"2025-11-22","planned_end_date":"2026-01-23","actual_start_date":null,"actual_end_date":null,"is_closed":false,"closed_at":null,"closed_by_user_id":null,"platform_billing_plan":null,"is_billable":true,"budget":"{}","inventory_requirements":"{}","photo_requirements":"[{\"type\": \"Speedtest\", \"required\": true, \"max_photos\": 1, \"min_photos\": 1, \"description\": \"speed test collected \"}, {\"type\": \"Airtel network\", \"required\": true, \"max_photos\": 1, \"min_photos\": 1, \"description\": \"strength metrics\"}, {\"type\": \"ODU outdoor image\", \"required\": true, \"max_photos\": 1, \"min_photos\": 1, \"description\": \"take a photo of the odu antenna\"}]","activation_requirements":"[{\"type\": \"text\", \"field\": \"ont_serial\", \"label\": \"ONT\", \"options\": null, \"required\": true}, {\"type\": \"text\", \"field\": \"odu_serial\", \"label\": \"ODU\", \"options\": null, \"required\": true}]","additional_metadata":"{\"setup_finalized_at\": \"2025-11-23T13:47:18.729614\", \"setup_finalized_by\": \"c5cf92be-4172-4fe2-af5c-f05d83b3a938\"}","created_at":"2025-11-20 19:04:04.11638+00","updated_at":"2025-11-24 13:16:22.370045+00","deleted_at":null,"project_type":"customer_service"}]
6
+
7
+ Documents system
8
+
9
+ INSERT INTO "public"."documents" ("id", "entity_type", "entity_id", "file_name", "file_type", "file_size", "file_url", "document_type", "document_category", "version", "is_latest_version", "previous_version_id", "description", "tags", "additional_metadata", "uploaded_by_user_id", "is_public", "created_at", "updated_at", "deleted_at", "storage_provider") VALUES ('03b98bf0-00bf-489b-b82e-b5ac8ed93ff1', 'ticket', 'f59b29fc-d0b9-4618-b0d1-889e340da612', 'Screenshot_20251111-113721.png', 'image/png', '435778', 'https://res.cloudinary.com/dnhajmziu/image/upload/v1764500238/file_ejhwnj.png', 'ticket_photo_ODU outdoor image', 'evidence', '1', 'true', null, 'ODU outdoor image photo for ticket Elizabeth Muthoni', '["ticket", "photo", "ODU outdoor image"]', '{"etag": "83d1240488876d014700628e0be9930d", "type": "upload", "bytes": 435778, "width": 720, "format": "png", "height": 1600, "version": 1764500238, "public_id": "file_ejhwnj", "created_at": "2025-11-30T10:57:18Z", "secure_url": "https://res.cloudinary.com/dnhajmziu/image/upload/v1764500238/file_ejhwnj.png", "resource_type": "image"}', '43b778b0-2062-4724-abbb-916a4835a9b0', 'false', '2025-11-30 10:57:18.713582+00', '2025-11-30 10:57:18.713589+00', null, 'cloudinary'), ('28e39514-4ffd-44f3-8a38-3972597264a3', 'ticket', 'f59b29fc-d0b9-4618-b0d1-889e340da612', 'passport 2025-11-27 101849.png', 'image/png', '265110', 'https://res.cloudinary.com/dnhajmziu/image/upload/v1764500237/file_a6bhg3.png', 'ticket_photo_Speedtest', 'evidence', '1', 'true', null, 'Speedtest photo for ticket Elizabeth Muthoni', '["ticket", "photo", "Speedtest"]', '{"etag": "d0c2d78da84c52beb8d77ce9e252c8d8", "type": "upload", "bytes": 265110, "width": 510, "format": "png", "height": 364, "version": 1764500237, "public_id": "file_a6bhg3", "created_at": "2025-11-30T10:57:17Z", "secure_url": "https://res.cloudinary.com/dnhajmziu/image/upload/v1764500237/file_a6bhg3.png", "resource_type": "image"}', '43b778b0-2062-4724-abbb-916a4835a9b0', 'false', '2025-11-30 10:57:18.189524+00', '2025-11-30 10:57:18.189535+00', null, 'cloudinary'), ('e76d848f-640a-4998-9a2c-948d4bc94d77', 'ticket', 'f59b29fc-d0b9-4618-b0d1-889e340da612', 'Gemini_Generated_Image_anof4fanof4fanof-removebg-preview.png', 'image/png', '101003', 'https://res.cloudinary.com/dnhajmziu/image/upload/v1764500236/file_kistjf.png', 'ticket_photo_Airtel network', 'evidence', '1', 'true', null, 'Airtel network photo for ticket Elizabeth Muthoni', '["ticket", "photo", "Airtel network"]', '{"etag": "b15f466735aea70f7c419b7e4f58c29e", "type": "upload", "bytes": 101003, "width": 684, "format": "png", "height": 365, "version": 1764500236, "public_id": "file_kistjf", "created_at": "2025-11-30T10:57:16Z", "secure_url": "https://res.cloudinary.com/dnhajmziu/image/upload/v1764500236/file_kistjf.png", "resource_type": "image"}', '43b778b0-2062-4724-abbb-916a4835a9b0', 'false', '2025-11-30 10:57:17.268748+00', '2025-11-30 10:57:17.268755+00', null, 'cloudinary');
10
+
docs/devlogs/server/runtimeerror.txt CHANGED
@@ -1,137 +1,95 @@
1
- ===== Application Startup at 2025-11-30 10:44:27 =====
2
 
3
  INFO: Started server process [7]
4
  INFO: Waiting for application startup.
5
- INFO: 2025-11-30T10:44:39 - app.main: ============================================================
6
- INFO: 2025-11-30T10:44:39 - app.main: 🚀 SwiftOps API v1.0.0 | PRODUCTION
7
- INFO: 2025-11-30T10:44:39 - app.main: 📊 Dashboard: Enabled
8
- INFO: 2025-11-30T10:44:39 - app.main: ============================================================
9
- INFO: 2025-11-30T10:44:39 - app.main: 📦 Database:
10
- INFO: 2025-11-30T10:44:39 - app.main: ✓ Connected | 44 tables | 6 users
11
- INFO: 2025-11-30T10:44:39 - app.main: 💾 Cache & Sessions:
12
- INFO: 2025-11-30T10:44:40 - app.services.otp_service: ✅ OTP Service initialized with Redis storage
13
- INFO: 2025-11-30T10:44:40 - app.main: ✓ Redis: Connected
14
- INFO: 2025-11-30T10:44:40 - app.main: 🔌 External Services:
15
- INFO: 2025-11-30T10:44:41 - app.main: ✓ Cloudinary: Connected
16
- INFO: 2025-11-30T10:44:41 - app.main: ✓ Resend: Configured
17
- INFO: 2025-11-30T10:44:41 - app.main: ○ WASender: Failed
18
- INFO: 2025-11-30T10:44:41 - app.main: ✓ Supabase: Connected | 6 buckets
19
- INFO: 2025-11-30T10:44:41 - app.main: ============================================================
20
- INFO: 2025-11-30T10:44:41 - app.main: ✅ Startup complete | Ready to serve requests
21
- INFO: 2025-11-30T10:44:41 - app.main: ============================================================
22
  INFO: Application startup complete.
 
 
 
23
  INFO: Uvicorn running on http://0.0.0.0:7860 (Press CTRL+C to quit)
24
- INFO: 2025-11-30T10:44:46 - app.api.deps: Checking active user: 43b778b0-2062-4724-abbb-916a4835a9b0, is_active: True, type: <class 'bool'>
25
- INFO: 2025-11-30T10:44:46 - app.api.deps: User 43b778b0-2062-4724-abbb-916a4835a9b0 is active - proceeding
26
- INFO: 10.16.11.176:44468 - "GET /api/v1/auth/me HTTP/1.1" 200 OK
27
- INFO: 10.16.34.155:33289 - "GET /health HTTP/1.1" 200 OK
28
- INFO: 2025-11-30T10:44:46 - app.api.deps: Checking active user: 43b778b0-2062-4724-abbb-916a4835a9b0, is_active: True, type: <class 'bool'>
29
- INFO: 2025-11-30T10:44:46 - app.api.deps: User 43b778b0-2062-4724-abbb-916a4835a9b0 is active - proceeding
30
- INFO: 10.16.11.176:44468 - "GET /api/v1/auth/me/preferences/available-apps HTTP/1.1" 200 OK
31
- INFO: 2025-11-30T10:44:46 - app.api.deps: Checking active user: 43b778b0-2062-4724-abbb-916a4835a9b0, is_active: True, type: <class 'bool'>
32
- INFO: 2025-11-30T10:44:46 - app.api.deps: User 43b778b0-2062-4724-abbb-916a4835a9b0 is active - proceeding
33
- INFO: 10.16.25.209:26712 - "GET /api/v1/auth/me/preferences HTTP/1.1" 200 OK
34
- INFO: 10.16.25.209:48972 - "GET /api/v1/tickets/f59b29fc-d0b9-4618-b0d1-889e340da612/detail HTTP/1.1" 200 OK
35
- INFO: 10.16.25.209:2106 - "GET /health HTTP/1.1" 200 OK
36
- INFO: 10.16.6.70:56240 - "GET /health HTTP/1.1" 200 OK
37
- INFO: 2025-11-30T10:45:35 - app.api.deps: Checking active user: 43b778b0-2062-4724-abbb-916a4835a9b0, is_active: True, type: <class 'bool'>
38
- INFO: 2025-11-30T10:45:35 - app.api.deps: User 43b778b0-2062-4724-abbb-916a4835a9b0 is active - proceeding
39
- INFO: 10.16.34.155:28174 - "GET /api/v1/auth/me HTTP/1.1" 200 OK
40
- INFO: 10.16.6.70:4951 - "GET /health HTTP/1.1" 200 OK
41
- INFO: 2025-11-30T10:45:35 - app.api.deps: Checking active user: 43b778b0-2062-4724-abbb-916a4835a9b0, is_active: True, type: <class 'bool'>
42
- INFO: 2025-11-30T10:45:35 - app.api.deps: User 43b778b0-2062-4724-abbb-916a4835a9b0 is active - proceeding
43
- INFO: 10.16.34.155:28174 - "GET /api/v1/auth/me/preferences HTTP/1.1" 200 OK
44
- INFO: 2025-11-30T10:45:35 - app.api.deps: Checking active user: 43b778b0-2062-4724-abbb-916a4835a9b0, is_active: True, type: <class 'bool'>
45
- INFO: 2025-11-30T10:45:35 - app.api.deps: User 43b778b0-2062-4724-abbb-916a4835a9b0 is active - proceeding
46
- INFO: 10.16.25.209:63687 - "GET /api/v1/auth/me/preferences/available-apps HTTP/1.1" 200 OK
47
- INFO: 10.16.11.176:12875 - "GET /api/v1/tickets/f59b29fc-d0b9-4618-b0d1-889e340da612/detail HTTP/1.1" 200 OK
48
- INFO: 10.16.18.114:44621 - "GET /health HTTP/1.1" 200 OK
49
- INFO: 10.16.34.155:18720 - "GET /health HTTP/1.1" 200 OK
50
- INFO: 10.16.6.70:37291 - "GET /health HTTP/1.1" 200 OK
51
- INFO: 10.16.6.70:37464 - "GET /health HTTP/1.1" 200 OK
52
- INFO: 10.16.6.70:61632 - "GET /health HTTP/1.1" 200 OK
53
- INFO: 10.16.6.70:49849 - "GET /health HTTP/1.1" 200 OK
54
- INFO: 10.16.18.114:45207 - "GET /health HTTP/1.1" 200 OK
55
- INFO: 10.16.11.176:1595 - "GET /health HTTP/1.1" 200 OK
56
- INFO: 10.16.6.70:20115 - "GET /health HTTP/1.1" 200 OK
57
- INFO: 10.16.18.114:13081 - "GET /health HTTP/1.1" 200 OK
58
- INFO: 10.16.25.209:4876 - "GET /health HTTP/1.1" 200 OK
59
- INFO: 10.16.6.70:4890 - "GET /health HTTP/1.1" 200 OK
60
- INFO: 2025-11-30T10:49:04 - app.api.deps: Checking active user: 43b778b0-2062-4724-abbb-916a4835a9b0, is_active: True, type: <class 'bool'>
61
- INFO: 2025-11-30T10:49:04 - app.api.deps: User 43b778b0-2062-4724-abbb-916a4835a9b0 is active - proceeding
62
- INFO: 10.16.34.155:32608 - "GET /api/v1/auth/me/preferences/available-apps HTTP/1.1" 200 OK
63
- INFO: 10.16.25.209:56255 - "GET /health HTTP/1.1" 200 OK
64
- INFO: 2025-11-30T10:49:04 - app.api.deps: Checking active user: 43b778b0-2062-4724-abbb-916a4835a9b0, is_active: True, type: <class 'bool'>
65
- INFO: 2025-11-30T10:49:04 - app.api.deps: User 43b778b0-2062-4724-abbb-916a4835a9b0 is active - proceeding
66
- INFO: 10.16.11.176:20419 - "GET /api/v1/auth/me/preferences HTTP/1.1" 200 OK
67
- INFO: 10.16.34.155:17206 - "GET /health HTTP/1.1" 200 OK
68
- INFO: 10.16.18.114:24637 - "GET /health HTTP/1.1" 200 OK
69
- INFO: 10.16.34.155:43834 - "GET /health HTTP/1.1" 200 OK
70
- INFO: 10.16.11.176:39270 - "GET / HTTP/1.1" 200 OK
71
- INFO: 10.16.6.70:24221 - "GET /health HTTP/1.1" 200 OK
72
- INFO: 10.16.25.209:58970 - "GET /health HTTP/1.1" 200 OK
73
- INFO: 10.16.11.176:23159 - "GET /health HTTP/1.1" 200 OK
74
- INFO: 10.16.11.176:39192 - "GET /health HTTP/1.1" 200 OK
75
- INFO: 10.16.11.176:19174 - "GET /health HTTP/1.1" 200 OK
76
- INFO: 10.16.11.176:19174 - "GET /health HTTP/1.1" 200 OK
77
- INFO: 10.16.34.155:63357 - "GET /health HTTP/1.1" 200 OK
78
- INFO: 10.16.11.176:31952 - "GET /health HTTP/1.1" 200 OK
79
- INFO: 10.16.11.176:43886 - "GET /health HTTP/1.1" 200 OK
80
- INFO: 10.16.34.155:45381 - "GET /health HTTP/1.1" 200 OK
81
- INFO: 10.16.25.209:23771 - "GET /health HTTP/1.1" 200 OK
82
- INFO: 10.16.25.209:63336 - "GET /health HTTP/1.1" 200 OK
83
- INFO: 10.16.25.209:64141 - "GET /health HTTP/1.1" 200 OK
84
- INFO: 10.16.6.70:48596 - "GET /health HTTP/1.1" 200 OK
85
- INFO: 10.16.6.70:13091 - "GET /health HTTP/1.1" 200 OK
86
- INFO: 10.16.18.114:2584 - "GET /health HTTP/1.1" 200 OK
87
- INFO: 10.16.6.70:49060 - "GET /health HTTP/1.1" 200 OK
88
- INFO: 10.16.11.176:45525 - "GET /health HTTP/1.1" 200 OK
89
- INFO: 10.16.25.209:12389 - "GET /health HTTP/1.1" 200 OK
90
- INFO: 10.16.6.70:8793 - "GET /health HTTP/1.1" 200 OK
91
- INFO: 10.16.25.209:30861 - "GET /health HTTP/1.1" 200 OK
92
- INFO: 10.16.25.209:30861 - "GET /health HTTP/1.1" 200 OK
93
- INFO: 10.16.18.114:22088 - "GET /health HTTP/1.1" 200 OK
94
- INFO: 2025-11-30T10:55:21 - app.api.deps: Checking active user: 43b778b0-2062-4724-abbb-916a4835a9b0, is_active: True, type: <class 'bool'>
95
- INFO: 2025-11-30T10:55:21 - app.api.deps: User 43b778b0-2062-4724-abbb-916a4835a9b0 is active - proceeding
96
- INFO: 10.16.34.155:29232 - "GET /api/v1/auth/me HTTP/1.1" 200 OK
97
- INFO: 2025-11-30T10:55:22 - app.api.deps: Checking active user: 43b778b0-2062-4724-abbb-916a4835a9b0, is_active: True, type: <class 'bool'>
98
- INFO: 2025-11-30T10:55:22 - app.api.deps: User 43b778b0-2062-4724-abbb-916a4835a9b0 is active - proceeding
99
- INFO: 10.16.34.155:29232 - "GET /api/v1/auth/me/preferences HTTP/1.1" 200 OK
100
- INFO: 2025-11-30T10:55:22 - app.api.deps: Checking active user: 43b778b0-2062-4724-abbb-916a4835a9b0, is_active: True, type: <class 'bool'>
101
- INFO: 2025-11-30T10:55:22 - app.api.deps: User 43b778b0-2062-4724-abbb-916a4835a9b0 is active - proceeding
102
- INFO: 10.16.34.155:37709 - "GET /api/v1/auth/me/preferences/available-apps HTTP/1.1" 200 OK
103
- INFO: 10.16.18.114:22088 - "GET /api/v1/tickets/f59b29fc-d0b9-4618-b0d1-889e340da612/detail HTTP/1.1" 200 OK
104
- INFO: 10.16.6.70:17441 - "GET /health HTTP/1.1" 200 OK
105
- INFO: 10.16.18.114:26540 - "POST /api/v1/ticket-assignments/assignments/a82a3824-f4f1-4283-a2e3-8c348dbb28ce/start-journey HTTP/1.1" 200 OK
106
- INFO: 10.16.6.70:14508 - "GET /api/v1/tickets/f59b29fc-d0b9-4618-b0d1-889e340da612/detail HTTP/1.1" 200 OK
107
- INFO: 10.16.11.176:20636 - "POST /api/v1/ticket-assignments/assignments/a82a3824-f4f1-4283-a2e3-8c348dbb28ce/update-location HTTP/1.1" 200 OK
108
- INFO: 10.16.11.176:17733 - "GET /health HTTP/1.1" 200 OK
109
- INFO: 10.16.18.114:40864 - "POST /api/v1/ticket-assignments/assignments/a82a3824-f4f1-4283-a2e3-8c348dbb28ce/update-location HTTP/1.1" 200 OK
110
- INFO: 10.16.34.155:18221 - "POST /api/v1/ticket-assignments/assignments/a82a3824-f4f1-4283-a2e3-8c348dbb28ce/arrived HTTP/1.1" 200 OK
111
- INFO: 10.16.6.70:41146 - "GET /api/v1/tickets/f59b29fc-d0b9-4618-b0d1-889e340da612/detail HTTP/1.1" 200 OK
112
- INFO: 10.16.11.176:54402 - "GET /health HTTP/1.1" 200 OK
113
- INFO: 10.16.34.155:39104 - "GET /api/v1/tickets/f59b29fc-d0b9-4618-b0d1-889e340da612/completion-checklist HTTP/1.1" 200 OK
114
- INFO: 10.16.25.209:18778 - "GET /health HTTP/1.1" 200 OK
115
- INFO: 2025-11-30T10:57:16 - app.services.media_service: Using forced provider: cloudinary
116
- INFO: 2025-11-30T10:57:16 - app.services.media_service: Uploading Gemini_Generated_Image_anof4fanof4fanof-removebg-preview.png (image/png) to cloudinary
117
- INFO: 2025-11-30T10:57:17 - app.integrations.cloudinary: Uploaded file to Cloudinary: https://res.cloudinary.com/dnhajmziu/image/upload/v1764500236/file_kistjf.png
118
- INFO: 2025-11-30T10:57:17 - app.services.media_service: Document created: e76d848f-640a-4998-9a2c-948d4bc94d77 (version 1)
119
- INFO: 2025-11-30T10:57:17 - app.services.ticket_completion_service: Photos updated for ticket f59b29fc-d0b9-4618-b0d1-889e340da612: ['Airtel network']
120
- INFO: 10.16.18.114:36731 - "POST /api/v1/tickets/f59b29fc-d0b9-4618-b0d1-889e340da612/upload-photos HTTP/1.1" 200 OK
121
- INFO: 2025-11-30T10:57:17 - app.services.media_service: Using forced provider: cloudinary
122
- INFO: 2025-11-30T10:57:17 - app.services.media_service: Uploading passport 2025-11-27 101849.png (image/png) to cloudinary
123
- INFO: 2025-11-30T10:57:18 - app.integrations.cloudinary: Uploaded file to Cloudinary: https://res.cloudinary.com/dnhajmziu/image/upload/v1764500237/file_a6bhg3.png
124
- INFO: 2025-11-30T10:57:18 - app.services.media_service: Document created: 28e39514-4ffd-44f3-8a38-3972597264a3 (version 1)
125
- INFO: 2025-11-30T10:57:18 - app.services.ticket_completion_service: Photos updated for ticket f59b29fc-d0b9-4618-b0d1-889e340da612: ['Speedtest']
126
- INFO: 10.16.34.155:33974 - "POST /api/v1/tickets/f59b29fc-d0b9-4618-b0d1-889e340da612/upload-photos HTTP/1.1" 200 OK
127
- INFO: 2025-11-30T10:57:18 - app.services.media_service: Using forced provider: cloudinary
128
- INFO: 2025-11-30T10:57:18 - app.services.media_service: Uploading Screenshot_20251111-113721.png (image/png) to cloudinary
129
- INFO: 2025-11-30T10:57:18 - app.integrations.cloudinary: Uploaded file to Cloudinary: https://res.cloudinary.com/dnhajmziu/image/upload/v1764500238/file_ejhwnj.png
130
- INFO: 2025-11-30T10:57:18 - app.services.media_service: Document created: 03b98bf0-00bf-489b-b82e-b5ac8ed93ff1 (version 1)
131
- INFO: 2025-11-30T10:57:18 - app.services.ticket_completion_service: Photos updated for ticket f59b29fc-d0b9-4618-b0d1-889e340da612: ['ODU outdoor image']
132
- INFO: 10.16.11.176:21259 - "POST /api/v1/tickets/f59b29fc-d0b9-4618-b0d1-889e340da612/upload-photos HTTP/1.1" 200 OK
133
- INFO: 10.16.11.176:21259 - "GET /api/v1/tickets/f59b29fc-d0b9-4618-b0d1-889e340da612/completion-checklist HTTP/1.1" 200 OK
134
- INFO: 10.16.6.70:11342 - "GET /health HTTP/1.1" 200 OK
135
- INFO: 10.16.6.70:42258 - "POST /api/v1/tickets/f59b29fc-d0b9-4618-b0d1-889e340da612/activation-data HTTP/1.1" 404 Not Found
136
- INFO: 10.16.11.176:27978 - "GET /health HTTP/1.1" 200 OK
137
-
 
1
+ ===== Application Startup at 2025-11-30 11:44:53 =====
2
 
3
  INFO: Started server process [7]
4
  INFO: Waiting for application startup.
5
+ INFO: 2025-11-30T11:45:08 - app.main: ============================================================
6
+ INFO: 2025-11-30T11:45:08 - app.main: 🚀 SwiftOps API v1.0.0 | PRODUCTION
7
+ INFO: 2025-11-30T11:45:08 - app.main: 📊 Dashboard: Enabled
8
+ INFO: 2025-11-30T11:45:08 - app.main: ============================================================
9
+ INFO: 2025-11-30T11:45:08 - app.main: 📦 Database:
10
+ INFO: 2025-11-30T11:45:08 - app.main: ✓ Connected | 44 tables | 6 users
11
+ INFO: 2025-11-30T11:45:08 - app.main: 💾 Cache & Sessions:
12
+ INFO: 2025-11-30T11:45:09 - app.services.otp_service: ✅ OTP Service initialized with Redis storage
13
+ INFO: 2025-11-30T11:45:09 - app.main: ✓ Redis: Connected
14
+ INFO: 2025-11-30T11:45:09 - app.main: 🔌 External Services:
15
+ INFO: 2025-11-30T11:45:10 - app.main: ✓ Cloudinary: Connected
16
+ INFO: 2025-11-30T11:45:10 - app.main: ✓ Resend: Configured
17
+ INFO: 2025-11-30T11:45:10 - app.main: ○ WASender: Failed
18
+ INFO: 2025-11-30T11:45:10 - app.main: ✓ Supabase: Connected | 6 buckets
 
 
 
19
  INFO: Application startup complete.
20
+ INFO: 2025-11-30T11:45:10 - app.main: ============================================================
21
+ INFO: 2025-11-30T11:45:10 - app.main: ✅ Startup complete | Ready to serve requests
22
+ INFO: 2025-11-30T11:45:10 - app.main: ============================================================
23
  INFO: Uvicorn running on http://0.0.0.0:7860 (Press CTRL+C to quit)
24
+ INFO: 10.16.11.176:29788 - "GET /health HTTP/1.1" 200 OK
25
+ INFO: 10.16.6.70:65359 - "GET /health HTTP/1.1" 200 OK
26
+ INFO: 2025-11-30T11:45:23 - app.api.deps: Checking active user: 43b778b0-2062-4724-abbb-916a4835a9b0, is_active: True, type: <class 'bool'>
27
+ INFO: 2025-11-30T11:45:23 - app.api.deps: User 43b778b0-2062-4724-abbb-916a4835a9b0 is active - proceeding
28
+ INFO: 10.16.11.176:21546 - "GET /api/v1/auth/me HTTP/1.1" 200 OK
29
+ INFO: 2025-11-30T11:45:24 - app.api.deps: Checking active user: 43b778b0-2062-4724-abbb-916a4835a9b0, is_active: True, type: <class 'bool'>
30
+ INFO: 2025-11-30T11:45:24 - app.api.deps: User 43b778b0-2062-4724-abbb-916a4835a9b0 is active - proceeding
31
+ INFO: 10.16.11.176:21546 - "GET /api/v1/auth/me/preferences HTTP/1.1" 200 OK
32
+ INFO: 2025-11-30T11:45:25 - app.api.deps: Checking active user: 43b778b0-2062-4724-abbb-916a4835a9b0, is_active: True, type: <class 'bool'>
33
+ INFO: 2025-11-30T11:45:25 - app.api.deps: User 43b778b0-2062-4724-abbb-916a4835a9b0 is active - proceeding
34
+ INFO: 10.16.25.209:44475 - "GET /api/v1/auth/me/preferences/available-apps HTTP/1.1" 200 OK
35
+ INFO: 10.16.18.114:8849 - "GET /api/v1/tickets/f59b29fc-d0b9-4618-b0d1-889e340da612/detail HTTP/1.1" 500 Internal Server Error
36
+ ERROR: Exception in ASGI application
37
+ Traceback (most recent call last):
38
+ File "/usr/local/lib/python3.11/site-packages/uvicorn/protocols/http/httptools_impl.py", line 426, in run_asgi
39
+ result = await app( # type: ignore[func-returns-value]
40
+ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
41
+ File "/usr/local/lib/python3.11/site-packages/uvicorn/middleware/proxy_headers.py", line 84, in __call__
42
+ return await self.app(scope, receive, send)
43
+ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
44
+ File "/usr/local/lib/python3.11/site-packages/fastapi/applications.py", line 1106, in __call__
45
+ await super().__call__(scope, receive, send)
46
+ File "/usr/local/lib/python3.11/site-packages/starlette/applications.py", line 122, in __call__
47
+ await self.middleware_stack(scope, receive, send)
48
+ File "/usr/local/lib/python3.11/site-packages/starlette/middleware/errors.py", line 184, in __call__
49
+ raise exc
50
+ File "/usr/local/lib/python3.11/site-packages/starlette/middleware/errors.py", line 162, in __call__
51
+ await self.app(scope, receive, _send)
52
+ File "/usr/local/lib/python3.11/site-packages/starlette/middleware/cors.py", line 91, in __call__
53
+ await self.simple_response(scope, receive, send, request_headers=headers)
54
+ File "/usr/local/lib/python3.11/site-packages/starlette/middleware/cors.py", line 146, in simple_response
55
+ await self.app(scope, receive, send)
56
+ File "/usr/local/lib/python3.11/site-packages/starlette/middleware/exceptions.py", line 79, in __call__
57
+ raise exc
58
+ File "/usr/local/lib/python3.11/site-packages/starlette/middleware/exceptions.py", line 68, in __call__
59
+ await self.app(scope, receive, sender)
60
+ File "/usr/local/lib/python3.11/site-packages/fastapi/middleware/asyncexitstack.py", line 20, in __call__
61
+ raise e
62
+ File "/usr/local/lib/python3.11/site-packages/fastapi/middleware/asyncexitstack.py", line 17, in __call__
63
+ await self.app(scope, receive, send)
64
+ File "/usr/local/lib/python3.11/site-packages/starlette/routing.py", line 718, in __call__
65
+ await route.handle(scope, receive, send)
66
+ File "/usr/local/lib/python3.11/site-packages/starlette/routing.py", line 276, in handle
67
+ await self.app(scope, receive, send)
68
+ File "/usr/local/lib/python3.11/site-packages/starlette/routing.py", line 66, in app
69
+ response = await func(request)
70
+ ^^^^^^^^^^^^^^^^^^^
71
+ File "/usr/local/lib/python3.11/site-packages/fastapi/routing.py", line 274, in app
72
+ raw_response = await run_endpoint_function(
73
+ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^
74
+ File "/usr/local/lib/python3.11/site-packages/fastapi/routing.py", line 193, in run_endpoint_function
75
+ return await run_in_threadpool(dependant.call, **values)
76
+ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
77
+ File "/usr/local/lib/python3.11/site-packages/starlette/concurrency.py", line 41, in run_in_threadpool
78
+ return await anyio.to_thread.run_sync(func, *args)
79
+ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
80
+ File "/usr/local/lib/python3.11/site-packages/anyio/to_thread.py", line 33, in run_sync
81
+ return await get_asynclib().run_sync_in_worker_thread(
82
+ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
83
+ File "/usr/local/lib/python3.11/site-packages/anyio/_backends/_asyncio.py", line 877, in run_sync_in_worker_thread
84
+ return await future
85
+ ^^^^^^^^^^^^
86
+ File "/usr/local/lib/python3.11/site-packages/anyio/_backends/_asyncio.py", line 807, in run
87
+ result = context.run(func, *args)
88
+ ^^^^^^^^^^^^^^^^^^^^^^^^
89
+ File "/app/src/app/api/v1/tickets.py", line 660, in get_ticket_detail
90
+ response["images"] = [{
91
+ ^^
92
+ File "/app/src/app/api/v1/tickets.py", line 662, in <listcomp>
93
+ "image_url": img.image_url,
94
+ ^^^^^^^^^^^^^
95
+ AttributeError: 'TicketImage' object has no attribute 'image_url'
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
src/app/api/v1/tickets.py CHANGED
@@ -651,18 +651,21 @@ def get_ticket_detail(
651
  "created_at": exp.created_at.isoformat() if exp.created_at else None
652
  } for exp in expenses]
653
 
654
- # Get images
655
- images = db.query(TicketImage).filter(
 
 
656
  TicketImage.ticket_id == ticket_id,
657
  TicketImage.deleted_at.is_(None)
658
  ).order_by(TicketImage.created_at.desc()).all()
659
 
660
  response["images"] = [{
661
  "id": str(img.id),
662
- "image_url": img.image_url,
663
  "image_type": img.image_type,
664
- "caption": img.caption,
665
- "uploaded_by_user_id": str(img.uploaded_by_user_id) if img.uploaded_by_user_id else None,
 
666
  "created_at": img.created_at.isoformat() if img.created_at else None
667
  } for img in images]
668
 
 
651
  "created_at": exp.created_at.isoformat() if exp.created_at else None
652
  } for exp in expenses]
653
 
654
+ # Get images with document relationship
655
+ images = db.query(TicketImage).options(
656
+ joinedload(TicketImage.document)
657
+ ).filter(
658
  TicketImage.ticket_id == ticket_id,
659
  TicketImage.deleted_at.is_(None)
660
  ).order_by(TicketImage.created_at.desc()).all()
661
 
662
  response["images"] = [{
663
  "id": str(img.id),
664
+ "image_url": img.document.file_url if img.document else None,
665
  "image_type": img.image_type,
666
+ "description": img.description,
667
+ "captured_at": img.captured_at.isoformat() if img.captured_at else None,
668
+ "uploaded_by_user_id": str(img.captured_by_user_id) if img.captured_by_user_id else None,
669
  "created_at": img.created_at.isoformat() if img.created_at else None
670
  } for img in images]
671
 
src/app/integrations/cloudinary.py CHANGED
@@ -58,7 +58,8 @@ class CloudinaryService:
58
  file: UploadFile,
59
  entity_type: str,
60
  entity_id: str,
61
- document_type: str
 
62
  ) -> Dict[str, Any]:
63
  """
64
  Upload file to Cloudinary
@@ -68,6 +69,7 @@ class CloudinaryService:
68
  entity_type: Type of entity (user, ticket, etc.)
69
  entity_id: ID of the entity
70
  document_type: Type of document (profile_photo, ticket_image, etc.)
 
71
 
72
  Returns:
73
  Dict containing upload response with secure_url, public_id, etc.
@@ -91,16 +93,29 @@ class CloudinaryService:
91
  # Get folder path
92
  folder = CloudinaryService.get_folder_path(entity_type)
93
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
94
  # Upload to Cloudinary
95
- # Using asset_folder for organization and letting Cloudinary generate unique public_id
96
  upload_result = cloudinary.uploader.upload(
97
  file_content,
98
- asset_folder=folder, # Where it appears in Media Library
99
- use_filename=True,
100
- unique_filename=True, # Prevents collisions
101
- overwrite=False,
102
- resource_type=resource_type,
103
- tags=[entity_type, document_type, str(entity_id)] # For searching
104
  )
105
 
106
  logger.info(f"Uploaded file to Cloudinary: {upload_result.get('secure_url')}")
 
58
  file: UploadFile,
59
  entity_type: str,
60
  entity_id: str,
61
+ document_type: str,
62
+ contextual_filename: str = None
63
  ) -> Dict[str, Any]:
64
  """
65
  Upload file to Cloudinary
 
69
  entity_type: Type of entity (user, ticket, etc.)
70
  entity_id: ID of the entity
71
  document_type: Type of document (profile_photo, ticket_image, etc.)
72
+ contextual_filename: Optional contextual filename (without extension)
73
 
74
  Returns:
75
  Dict containing upload response with secure_url, public_id, etc.
 
93
  # Get folder path
94
  folder = CloudinaryService.get_folder_path(entity_type)
95
 
96
+ # Prepare upload options
97
+ upload_options = {
98
+ 'asset_folder': folder, # Where it appears in Media Library
99
+ 'overwrite': False,
100
+ 'resource_type': resource_type,
101
+ 'tags': [entity_type, document_type, str(entity_id)] # For searching
102
+ }
103
+
104
+ # Use contextual filename if provided, otherwise use original with unique suffix
105
+ if contextual_filename:
106
+ # Use contextual filename as public_id (Cloudinary will add folder prefix)
107
+ upload_options['public_id'] = contextual_filename
108
+ upload_options['use_filename'] = False
109
+ upload_options['unique_filename'] = False
110
+ else:
111
+ # Fallback to original filename with unique suffix
112
+ upload_options['use_filename'] = True
113
+ upload_options['unique_filename'] = True
114
+
115
  # Upload to Cloudinary
 
116
  upload_result = cloudinary.uploader.upload(
117
  file_content,
118
+ **upload_options
 
 
 
 
 
119
  )
120
 
121
  logger.info(f"Uploaded file to Cloudinary: {upload_result.get('secure_url')}")
src/app/integrations/supabase.py CHANGED
@@ -44,17 +44,19 @@ class SupabaseStorageService:
44
  def get_file_path(entity_type: str, entity_id: str, file_name: str) -> str:
45
  """
46
  Generate file path in bucket
47
- Format: {entity_type}/{entity_id}/{unique_id}_{filename}
 
 
48
  """
49
- unique_id = str(uuid.uuid4())[:8]
50
- return f"{entity_type}/{entity_id}/{unique_id}_{file_name}"
51
 
52
  @staticmethod
53
  async def upload(
54
  file: UploadFile,
55
  entity_type: str,
56
  entity_id: str,
57
- document_type: str
 
58
  ) -> Dict[str, Any]:
59
  """
60
  Upload file to Supabase Storage
@@ -64,6 +66,7 @@ class SupabaseStorageService:
64
  entity_type: Type of entity (user, ticket, etc.)
65
  entity_id: ID of the entity
66
  document_type: Type of document
 
67
 
68
  Returns:
69
  Dict containing upload response with public URL
@@ -77,9 +80,12 @@ class SupabaseStorageService:
77
  # Read file content
78
  file_content = await file.read()
79
 
 
 
 
80
  # Get bucket and file path
81
  bucket_name = SupabaseStorageService.get_bucket_name(entity_type)
82
- file_path = SupabaseStorageService.get_file_path(entity_type, entity_id, file.filename)
83
 
84
  # Upload to Supabase Storage
85
  response = client.storage.from_(bucket_name).upload(
 
44
  def get_file_path(entity_type: str, entity_id: str, file_name: str) -> str:
45
  """
46
  Generate file path in bucket
47
+ Format: {entity_type}/{entity_id}/{filename}
48
+
49
+ Note: filename should already be contextual and unique from FileNamingService
50
  """
51
+ return f"{entity_type}/{entity_id}/{file_name}"
 
52
 
53
  @staticmethod
54
  async def upload(
55
  file: UploadFile,
56
  entity_type: str,
57
  entity_id: str,
58
+ document_type: str,
59
+ contextual_filename: str = None
60
  ) -> Dict[str, Any]:
61
  """
62
  Upload file to Supabase Storage
 
66
  entity_type: Type of entity (user, ticket, etc.)
67
  entity_id: ID of the entity
68
  document_type: Type of document
69
+ contextual_filename: Optional contextual filename to use instead of original
70
 
71
  Returns:
72
  Dict containing upload response with public URL
 
80
  # Read file content
81
  file_content = await file.read()
82
 
83
+ # Use contextual filename if provided, otherwise use original
84
+ filename_to_use = contextual_filename if contextual_filename else file.filename
85
+
86
  # Get bucket and file path
87
  bucket_name = SupabaseStorageService.get_bucket_name(entity_type)
88
+ file_path = SupabaseStorageService.get_file_path(entity_type, entity_id, filename_to_use)
89
 
90
  # Upload to Supabase Storage
91
  response = client.storage.from_(bucket_name).upload(
src/app/services/media_service.py CHANGED
@@ -4,14 +4,16 @@ Routes uploads to appropriate storage provider based on file type
4
  """
5
  from fastapi import UploadFile, HTTPException
6
  from sqlalchemy.orm import Session
7
- from typing import Dict, Any
8
  from uuid import UUID
9
  import logging
 
10
 
11
  from app.integrations.cloudinary import CloudinaryService
12
  from app.integrations.supabase import SupabaseStorageService
13
  from app.models.document import Document
14
  from app.models.user import User
 
15
 
16
  logger = logging.getLogger(__name__)
17
 
@@ -60,7 +62,8 @@ class StorageService:
60
  uploaded_by_user_id: UUID,
61
  db: Session,
62
  force_provider: str = None,
63
- enable_versioning: bool = True
 
64
  ) -> Document:
65
  """
66
  Universal file upload handler with automatic fallback
@@ -92,10 +95,22 @@ class StorageService:
92
  HTTPException: If both providers fail
93
  """
94
  try:
 
 
 
 
 
 
 
 
 
 
 
 
95
  # Determine primary provider
96
  provider = StorageService.determine_provider(file.content_type, force_provider)
97
 
98
- logger.info(f"Uploading {file.filename} ({file.content_type}) to {provider}")
99
 
100
  # Try primary provider with fallback
101
  upload_result = None
@@ -109,7 +124,8 @@ class StorageService:
109
  file=file,
110
  entity_type=entity_type,
111
  entity_id=str(entity_id),
112
- document_type=document_type
 
113
  )
114
  file_url = upload_result['secure_url']
115
  file_size = upload_result.get('bytes')
@@ -128,7 +144,8 @@ class StorageService:
128
  file=file,
129
  entity_type=entity_type,
130
  entity_id=str(entity_id),
131
- document_type=document_type
 
132
  )
133
  file_url = upload_result['public_url']
134
  file_size = upload_result.get('size')
@@ -151,7 +168,8 @@ class StorageService:
151
  file=file,
152
  entity_type=entity_type,
153
  entity_id=str(entity_id),
154
- document_type=document_type
 
155
  )
156
  file_url = upload_result['public_url']
157
  file_size = upload_result.get('size')
@@ -178,11 +196,11 @@ class StorageService:
178
  version_number = existing_doc.version + 1
179
  logger.info(f"Creating version {version_number} (previous: {existing_doc.id})")
180
 
181
- # Create document record
182
  document = Document(
183
  entity_type=entity_type,
184
  entity_id=entity_id,
185
- file_name=file.filename,
186
  file_type=file.content_type,
187
  file_size=file_size,
188
  file_url=file_url,
@@ -191,7 +209,11 @@ class StorageService:
191
  document_category=document_category,
192
  description=description,
193
  tags=tags,
194
- additional_metadata=additional_metadata,
 
 
 
 
195
  uploaded_by_user_id=uploaded_by_user_id,
196
  is_public=is_public,
197
  version=version_number,
 
4
  """
5
  from fastapi import UploadFile, HTTPException
6
  from sqlalchemy.orm import Session
7
+ from typing import Dict, Any, Optional
8
  from uuid import UUID
9
  import logging
10
+ from pathlib import Path
11
 
12
  from app.integrations.cloudinary import CloudinaryService
13
  from app.integrations.supabase import SupabaseStorageService
14
  from app.models.document import Document
15
  from app.models.user import User
16
+ from app.utils.file_naming import FileNamingService
17
 
18
  logger = logging.getLogger(__name__)
19
 
 
62
  uploaded_by_user_id: UUID,
63
  db: Session,
64
  force_provider: str = None,
65
+ enable_versioning: bool = True,
66
+ additional_context: Optional[str] = None
67
  ) -> Document:
68
  """
69
  Universal file upload handler with automatic fallback
 
95
  HTTPException: If both providers fail
96
  """
97
  try:
98
+ # Generate contextual filename (without extension)
99
+ contextual_filename = FileNamingService.generate_contextual_filename(
100
+ entity_type=entity_type,
101
+ entity_id=entity_id,
102
+ document_type=document_type,
103
+ original_filename=file.filename,
104
+ additional_context=additional_context
105
+ )
106
+
107
+ # Remove extension for Cloudinary public_id (Cloudinary adds it back)
108
+ contextual_filename_no_ext = Path(contextual_filename).stem
109
+
110
  # Determine primary provider
111
  provider = StorageService.determine_provider(file.content_type, force_provider)
112
 
113
+ logger.info(f"Uploading {file.filename} as {contextual_filename} to {provider}")
114
 
115
  # Try primary provider with fallback
116
  upload_result = None
 
124
  file=file,
125
  entity_type=entity_type,
126
  entity_id=str(entity_id),
127
+ document_type=document_type,
128
+ contextual_filename=contextual_filename_no_ext
129
  )
130
  file_url = upload_result['secure_url']
131
  file_size = upload_result.get('bytes')
 
144
  file=file,
145
  entity_type=entity_type,
146
  entity_id=str(entity_id),
147
+ document_type=document_type,
148
+ contextual_filename=contextual_filename
149
  )
150
  file_url = upload_result['public_url']
151
  file_size = upload_result.get('size')
 
168
  file=file,
169
  entity_type=entity_type,
170
  entity_id=str(entity_id),
171
+ document_type=document_type,
172
+ contextual_filename=contextual_filename
173
  )
174
  file_url = upload_result['public_url']
175
  file_size = upload_result.get('size')
 
196
  version_number = existing_doc.version + 1
197
  logger.info(f"Creating version {version_number} (previous: {existing_doc.id})")
198
 
199
+ # Create document record with contextual filename
200
  document = Document(
201
  entity_type=entity_type,
202
  entity_id=entity_id,
203
+ file_name=contextual_filename, # Store contextual name, not original
204
  file_type=file.content_type,
205
  file_size=file_size,
206
  file_url=file_url,
 
209
  document_category=document_category,
210
  description=description,
211
  tags=tags,
212
+ additional_metadata={
213
+ **additional_metadata,
214
+ 'original_filename': file.filename, # Preserve original name
215
+ 'contextual_filename': contextual_filename
216
+ },
217
  uploaded_by_user_id=uploaded_by_user_id,
218
  is_public=is_public,
219
  version=version_number,
src/app/services/ticket_completion_service.py CHANGED
@@ -239,7 +239,7 @@ class TicketCompletionService:
239
  # Upload photos using media_service.py (routes to Cloudinary for images)
240
  for photo_type, files in photos.items():
241
  for file in files:
242
- # Upload via universal storage service
243
  document = await StorageService.upload_file(
244
  file=file,
245
  entity_type="ticket",
@@ -252,7 +252,8 @@ class TicketCompletionService:
252
  uploaded_by_user_id=uploaded_by_user_id,
253
  db=db,
254
  force_provider="cloudinary", # Force Cloudinary for photos
255
- enable_versioning=False
 
256
  )
257
 
258
  # Create ticket_image record linking ticket to document
 
239
  # Upload photos using media_service.py (routes to Cloudinary for images)
240
  for photo_type, files in photos.items():
241
  for file in files:
242
+ # Upload via universal storage service with contextual naming
243
  document = await StorageService.upload_file(
244
  file=file,
245
  entity_type="ticket",
 
252
  uploaded_by_user_id=uploaded_by_user_id,
253
  db=db,
254
  force_provider="cloudinary", # Force Cloudinary for photos
255
+ enable_versioning=False,
256
+ additional_context=photo_type # Add photo type to filename
257
  )
258
 
259
  # Create ticket_image record linking ticket to document
src/app/utils/file_naming.py ADDED
@@ -0,0 +1,211 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ File Naming Utility
3
+
4
+ Generates contextual, self-documenting filenames for object storage.
5
+ Makes files easily identifiable even if database is lost.
6
+
7
+ Naming Pattern:
8
+ {entity_type}_{entity_short_id}_{document_type}_{timestamp}_{original_name}
9
+
10
+ Examples:
11
+ - ticket_8f08ad14_completion_photo_speedtest_20251130_105718.png
12
+ - user_43b778b0_profile_photo_20251130_105718.jpg
13
+ - expense_decafa10_receipt_20251130_105718.pdf
14
+ - project_0ade6bd1_contract_20251130_105718.pdf
15
+ """
16
+
17
+ from datetime import datetime
18
+ from uuid import UUID
19
+ import re
20
+ from pathlib import Path
21
+
22
+
23
+ class FileNamingService:
24
+ """Service for generating contextual filenames"""
25
+
26
+ @staticmethod
27
+ def sanitize_filename(filename: str) -> str:
28
+ """
29
+ Sanitize filename to be filesystem and URL safe
30
+
31
+ - Remove special characters
32
+ - Replace spaces with underscores
33
+ - Lowercase
34
+ - Keep only alphanumeric, underscore, hyphen, dot
35
+ """
36
+ # Get filename without extension
37
+ stem = Path(filename).stem
38
+ ext = Path(filename).suffix
39
+
40
+ # Remove special characters, keep alphanumeric, underscore, hyphen
41
+ stem = re.sub(r'[^\w\-]', '_', stem)
42
+
43
+ # Replace multiple underscores with single
44
+ stem = re.sub(r'_+', '_', stem)
45
+
46
+ # Lowercase and strip
47
+ stem = stem.lower().strip('_')
48
+
49
+ # Limit length to 50 chars
50
+ if len(stem) > 50:
51
+ stem = stem[:50]
52
+
53
+ return f"{stem}{ext.lower()}"
54
+
55
+ @staticmethod
56
+ def get_short_id(entity_id: UUID) -> str:
57
+ """
58
+ Get short version of UUID for filename (first 8 chars)
59
+
60
+ Example: 8f08ad14-df8b-4780-84e7-0d45e133f2a6 -> 8f08ad14
61
+ """
62
+ return str(entity_id).split('-')[0]
63
+
64
+ @staticmethod
65
+ def get_timestamp() -> str:
66
+ """
67
+ Get timestamp for filename in format: YYYYMMDD_HHMMSS
68
+
69
+ Example: 20251130_105718
70
+ """
71
+ return datetime.utcnow().strftime('%Y%m%d_%H%M%S')
72
+
73
+ @staticmethod
74
+ def generate_contextual_filename(
75
+ entity_type: str,
76
+ entity_id: UUID,
77
+ document_type: str,
78
+ original_filename: str,
79
+ additional_context: str = None
80
+ ) -> str:
81
+ """
82
+ Generate contextual, self-documenting filename
83
+
84
+ Args:
85
+ entity_type: Type of entity (ticket, user, expense, etc.)
86
+ entity_id: UUID of the entity
87
+ document_type: Type of document (completion_photo, receipt, profile_photo, etc.)
88
+ original_filename: Original uploaded filename
89
+ additional_context: Optional additional context (photo_type, etc.)
90
+
91
+ Returns:
92
+ Contextual filename
93
+
94
+ Examples:
95
+ ticket_8f08ad14_completion_photo_speedtest_20251130_105718.png
96
+ user_43b778b0_profile_photo_20251130_105718.jpg
97
+ expense_decafa10_receipt_fuel_20251130_105718.pdf
98
+ """
99
+ # Get components
100
+ short_id = FileNamingService.get_short_id(entity_id)
101
+ timestamp = FileNamingService.get_timestamp()
102
+ sanitized_original = FileNamingService.sanitize_filename(original_filename)
103
+
104
+ # Remove extension from original for now
105
+ original_stem = Path(sanitized_original).stem
106
+ extension = Path(sanitized_original).suffix
107
+
108
+ # Build filename parts
109
+ parts = [
110
+ entity_type.lower(),
111
+ short_id,
112
+ document_type.lower().replace(' ', '_')
113
+ ]
114
+
115
+ # Add additional context if provided
116
+ if additional_context:
117
+ sanitized_context = re.sub(r'[^\w\-]', '_', additional_context.lower())
118
+ parts.append(sanitized_context)
119
+
120
+ # Add timestamp
121
+ parts.append(timestamp)
122
+
123
+ # Add original filename (without extension) if it's meaningful
124
+ # Skip if it's generic like "image", "photo", "file", "screenshot"
125
+ generic_names = ['image', 'photo', 'file', 'screenshot', 'document', 'img', 'pic']
126
+ if original_stem and original_stem.lower() not in generic_names:
127
+ # Limit original name to 30 chars
128
+ if len(original_stem) > 30:
129
+ original_stem = original_stem[:30]
130
+ parts.append(original_stem)
131
+
132
+ # Join parts and add extension
133
+ filename = '_'.join(parts) + extension
134
+
135
+ return filename
136
+
137
+ @staticmethod
138
+ def generate_ticket_photo_filename(
139
+ ticket_id: UUID,
140
+ photo_type: str,
141
+ original_filename: str
142
+ ) -> str:
143
+ """
144
+ Generate filename for ticket completion photos
145
+
146
+ Example: ticket_8f08ad14_completion_photo_speedtest_20251130_105718.png
147
+ """
148
+ return FileNamingService.generate_contextual_filename(
149
+ entity_type='ticket',
150
+ entity_id=ticket_id,
151
+ document_type='completion_photo',
152
+ original_filename=original_filename,
153
+ additional_context=photo_type
154
+ )
155
+
156
+ @staticmethod
157
+ def generate_expense_receipt_filename(
158
+ expense_id: UUID,
159
+ expense_category: str,
160
+ original_filename: str
161
+ ) -> str:
162
+ """
163
+ Generate filename for expense receipts
164
+
165
+ Example: expense_decafa10_receipt_fuel_20251130_105718.pdf
166
+ """
167
+ return FileNamingService.generate_contextual_filename(
168
+ entity_type='expense',
169
+ entity_id=expense_id,
170
+ document_type='receipt',
171
+ original_filename=original_filename,
172
+ additional_context=expense_category
173
+ )
174
+
175
+ @staticmethod
176
+ def generate_user_document_filename(
177
+ user_id: UUID,
178
+ document_type: str,
179
+ original_filename: str
180
+ ) -> str:
181
+ """
182
+ Generate filename for user documents
183
+
184
+ Examples:
185
+ user_43b778b0_profile_photo_20251130_105718.jpg
186
+ user_43b778b0_id_document_20251130_105718.pdf
187
+ """
188
+ return FileNamingService.generate_contextual_filename(
189
+ entity_type='user',
190
+ entity_id=user_id,
191
+ document_type=document_type,
192
+ original_filename=original_filename
193
+ )
194
+
195
+ @staticmethod
196
+ def generate_project_document_filename(
197
+ project_id: UUID,
198
+ document_type: str,
199
+ original_filename: str
200
+ ) -> str:
201
+ """
202
+ Generate filename for project documents
203
+
204
+ Example: project_0ade6bd1_contract_20251130_105718.pdf
205
+ """
206
+ return FileNamingService.generate_contextual_filename(
207
+ entity_type='project',
208
+ entity_id=project_id,
209
+ document_type=document_type,
210
+ original_filename=original_filename
211
+ )