cynx-lib 1.3.34 → 1.3.36

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,393 +1,393 @@
1
- /**
2
- * cynx/utils — Storage Server Client & Connector (storage.js)
3
- *
4
- * Universal client SDK to interact with cynx-storage-server:
5
- * - Upload files (Browser File/Blob or Node.js Buffer/Stream/Blob)
6
- * - Query file metadata & entity attachments
7
- * - Delete files or whole entity file groups
8
- * - Generate public CDN URLs and stream content
9
- *
10
- * Works in Browser, Node.js (18+), Next.js / SSR, and Web Workers.
11
- * Zero external dependencies.
12
- */
13
-
14
- // ─── UTILITIES & HELPERS ────────────────────────────────────────────────────────
15
-
16
- const isNode = typeof process !== 'undefined' && Boolean(process.versions?.node);
17
-
18
- function getDefaultBaseUrl() {
19
- if (isNode && process.env?.STORAGE_SERVER_URL) {
20
- return process.env.STORAGE_SERVER_URL;
21
- }
22
- if (typeof window !== 'undefined' && window.location?.origin) {
23
- return window.location.origin;
24
- }
25
- return '';
26
- }
27
-
28
- function getDefaultApiKey() {
29
- if (isNode && (process.env?.STORAGE_SERVER_API_KEY || process.env?.STORAGE_API_KEY)) {
30
- return process.env.STORAGE_SERVER_API_KEY || process.env.STORAGE_API_KEY;
31
- }
32
- return '';
33
- }
34
-
35
- /**
36
- * Format raw byte numbers into human-readable strings.
37
- * @param {number|string} bytes
38
- * @returns {string} e.g. "12.5 MB"
39
- */
40
- export function formatBytes(bytes) {
41
- const num = Number(bytes) || 0;
42
- if (num === 0) return '0 B';
43
- const k = 1024;
44
- const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
45
- const i = Math.floor(Math.log(num) / Math.log(k));
46
- const val = (num / Math.pow(k, i)).toFixed(2);
47
- return `${val} ${sizes[i]}`;
48
- }
49
-
50
- /**
51
- * Categorize mimeType and filename into categories.
52
- * @param {string} [mimeType='']
53
- * @param {string} [filename='']
54
- * @returns {'image'|'pdf'|'video'|'audio'|'document'|'archive'|'other'}
55
- */
56
- export function getFileTypeCategory(mimeType = '', filename = '') {
57
- const mt = (mimeType || '').toLowerCase();
58
- const fn = (filename || '').toLowerCase();
59
-
60
- if (mt.startsWith('image/') || /\.(jpg|jpeg|png|gif|webp|bmp|svg|avif|ico)$/i.test(fn)) {
61
- return 'image';
62
- }
63
- if (mt === 'application/pdf' || /\.pdf$/i.test(fn)) {
64
- return 'pdf';
65
- }
66
- if (mt.startsWith('video/') || /\.(mp4|webm|mkv|mov|avi|flv|wmv|m4v)$/i.test(fn)) {
67
- return 'video';
68
- }
69
- if (mt.startsWith('audio/') || /\.(mp3|wav|ogg|m4a|aac|flac)$/i.test(fn)) {
70
- return 'audio';
71
- }
72
- if (
73
- mt.includes('word') ||
74
- mt.includes('excel') ||
75
- mt.includes('spreadsheet') ||
76
- mt.includes('presentation') ||
77
- mt.includes('csv') ||
78
- /\.(doc|docx|xls|xlsx|csv|ppt|pptx|txt|rtf|md)$/i.test(fn)
79
- ) {
80
- return 'document';
81
- }
82
- if (mt.includes('zip') || mt.includes('tar') || mt.includes('compressed') || /\.(zip|tar|gz|7z|rar)$/i.test(fn)) {
83
- return 'archive';
84
- }
85
- return 'other';
86
- }
87
-
88
- /**
89
- * Build a public CDN URL for a given file ID.
90
- * @param {string} fileId e.g. "sf_abc123"
91
- * @param {string} [filename] Optional filename suffix for cleaner download names
92
- * @param {string} [baseUrl] Storage server base URL
93
- * @returns {string} CDN URL
94
- */
95
- export function getCdnUrl(fileId, filename = '', baseUrl = '') {
96
- if (!fileId) return '';
97
- const base = (baseUrl || getDefaultBaseUrl() || '').replace(/\/+$/, '');
98
- const cleanId = String(fileId).trim();
99
- const suffix = filename ? `/${encodeURIComponent(filename)}` : '';
100
- return `${base}/cdn/${encodeURIComponent(cleanId)}${suffix}`;
101
- }
102
-
103
- // ─── STORAGE CLIENT CLASS ───────────────────────────────────────────────────────
104
-
105
- export class StorageClient {
106
- /**
107
- * @param {object} [options={}]
108
- * @param {string} [options.baseUrl] Base URL of the storage server (e.g. "https://storage.domain.com" or "http://localhost:5000")
109
- * @param {string} [options.apiKey] API Key with upload/read/delete permissions
110
- * @param {number} [options.timeout=30000] Request timeout in milliseconds
111
- * @param {Function} [options.fetch] Custom fetch implementation (optional)
112
- */
113
- constructor(options = {}) {
114
- this.baseUrl = (options.baseUrl || getDefaultBaseUrl() || '').replace(/\/+$/, '');
115
- this.apiKey = options.apiKey || getDefaultApiKey() || '';
116
- this.timeout = options.timeout || 30000;
117
- this.fetchFn = options.fetch || (typeof globalThis !== 'undefined' ? globalThis.fetch?.bind(globalThis) : null);
118
- }
119
-
120
- /**
121
- * Update configuration options.
122
- * @param {object} options
123
- */
124
- configure(options = {}) {
125
- if (options.baseUrl !== undefined) this.baseUrl = String(options.baseUrl || '').replace(/\/+$/, '');
126
- if (options.apiKey !== undefined) this.apiKey = String(options.apiKey || '');
127
- if (options.timeout !== undefined) this.timeout = Number(options.timeout) || 30000;
128
- if (options.fetch !== undefined) this.fetchFn = options.fetch;
129
- return this;
130
- }
131
-
132
- /**
133
- * Internal HTTP request helper.
134
- * @private
135
- */
136
- async _request(path, { method = 'GET', headers = {}, body, apiKey, signal, timeout } = {}) {
137
- if (!this.fetchFn) {
138
- throw new Error('[cynx/storage] No fetch implementation available in current environment.');
139
- }
140
-
141
- const url = `${this.baseUrl}${path.startsWith('/') ? path : `/${path}`}`;
142
- const key = apiKey !== undefined ? apiKey : this.apiKey;
143
- const reqHeaders = { ...headers };
144
-
145
- if (key) {
146
- reqHeaders['x-api-key'] = key;
147
- }
148
-
149
- const abortCtrl = typeof AbortController !== 'undefined' ? new AbortController() : null;
150
- const reqTimeout = timeout || this.timeout;
151
- let timer = null;
152
-
153
- if (abortCtrl && reqTimeout > 0) {
154
- timer = setTimeout(() => abortCtrl.abort(new Error(`Storage request timeout after ${reqTimeout}ms`)), reqTimeout);
155
- }
156
-
157
- const activeSignal = signal || abortCtrl?.signal;
158
-
159
- try {
160
- const response = await this.fetchFn(url, {
161
- method,
162
- headers: reqHeaders,
163
- body,
164
- signal: activeSignal,
165
- });
166
-
167
- if (timer) clearTimeout(timer);
168
-
169
- if (!response.ok) {
170
- let errData = null;
171
- try {
172
- errData = await response.json();
173
- } catch {
174
- errData = { error: response.statusText || `HTTP ${response.status}` };
175
- }
176
- const error = new Error(errData.error || errData.message || `Storage Error: ${response.status}`);
177
- error.status = response.status;
178
- error.response = errData;
179
- throw error;
180
- }
181
-
182
- return await response.json();
183
- } catch (err) {
184
- if (timer) clearTimeout(timer);
185
- throw err;
186
- }
187
- }
188
-
189
- /**
190
- * Upload one or multiple files to the storage server.
191
- *
192
- * @param {object} params
193
- * @param {File|Blob|Buffer|ArrayBuffer|Array<File|Blob|Buffer|object>} [params.files] File or array of files to upload
194
- * @param {File|Blob|Buffer|ArrayBuffer} [params.file] Single file alias
195
- * @param {string} [params.folder] Target subfolder in cloud storage
196
- * @param {string} [params.entityType] Associated entity type (e.g. 'company', 'employee', 'inspection')
197
- * @param {string|number} [params.entityId] Associated entity ID
198
- * @param {string} [params.apiKey] Override API Key for this upload
199
- * @param {AbortSignal} [params.signal] AbortSignal for cancellation
200
- * @returns {Promise<{ files: Array<object>, file: object|null }>} Formatted upload result
201
- */
202
- async upload({
203
- files,
204
- file,
205
- folder,
206
- entityType,
207
- entityId,
208
- entity_type,
209
- entity_id,
210
- apiKey,
211
- signal,
212
- } = {}) {
213
- if (typeof FormData === 'undefined') {
214
- throw new Error('[cynx/storage] FormData is not available in the current environment.');
215
- }
216
-
217
- const formData = new FormData();
218
- const rawFiles = files ? (Array.isArray(files) ? files : [files]) : file ? [file] : [];
219
-
220
- if (rawFiles.length === 0) {
221
- throw new Error('[cynx/storage] No file provided for upload.');
222
- }
223
-
224
- for (let i = 0; i < rawFiles.length; i++) {
225
- const item = rawFiles[i];
226
- if (!item) continue;
227
-
228
- if (typeof File !== 'undefined' && item instanceof File) {
229
- formData.append('files', item, item.name);
230
- } else if (typeof Blob !== 'undefined' && item instanceof Blob) {
231
- const name = item.name || `file_${Date.now()}_${i}`;
232
- formData.append('files', item, name);
233
- } else if (isNode && (Buffer.isBuffer(item) || item instanceof Uint8Array || item instanceof ArrayBuffer)) {
234
- const name = item.name || item.filename || `file_${Date.now()}_${i}`;
235
- const blob = new Blob([item], { type: item.type || item.mimeType || 'application/octet-stream' });
236
- formData.append('files', blob, name);
237
- } else if (item && typeof item === 'object' && item.data) {
238
- // Wrapped object format: { data, name, type }
239
- const name = item.name || item.filename || `file_${Date.now()}_${i}`;
240
- const blob = item.data instanceof Blob ? item.data : new Blob([item.data], { type: item.type || item.mimeType || 'application/octet-stream' });
241
- formData.append('files', blob, name);
242
- } else {
243
- formData.append('files', item);
244
- }
245
- }
246
-
247
- if (folder) formData.append('folder', folder);
248
- const resolvedEntityType = entityType || entity_type;
249
- const resolvedEntityId = entityId || entity_id;
250
-
251
- if (resolvedEntityType) formData.append('entityType', resolvedEntityType);
252
- if (resolvedEntityId) formData.append('entityId', String(resolvedEntityId));
253
-
254
- const res = await this._request('/api/v1/upload', {
255
- method: 'POST',
256
- body: formData,
257
- apiKey,
258
- signal,
259
- });
260
-
261
- const fileList = res?.files ? res.files : Array.isArray(res) ? res : res ? [res] : [];
262
- return {
263
- files: fileList,
264
- file: fileList[0] || null,
265
- ...res,
266
- };
267
- }
268
-
269
- /**
270
- * Get metadata and CDN URLs for a specific file by its ID.
271
- *
272
- * @param {string} fileId ID of the file (e.g. "sf_abc123")
273
- * @param {object} [options]
274
- * @param {string} [options.apiKey] Override API Key
275
- * @param {AbortSignal} [options.signal] AbortSignal
276
- * @returns {Promise<object>} File metadata record
277
- */
278
- async getFile(fileId, options = {}) {
279
- if (!fileId) throw new Error('[cynx/storage] fileId is required.');
280
- const cleanId = String(fileId).trim().replace(/\.[^/.]+$/, ''); // strip any extension if present
281
- return this._request(`/api/v1/files/${encodeURIComponent(cleanId)}`, {
282
- method: 'GET',
283
- ...options,
284
- });
285
- }
286
-
287
- /**
288
- * Get all files attached to a specific entity.
289
- *
290
- * @param {string} entityType Entity type (e.g. "company", "employee")
291
- * @param {string|number} entityId Entity identifier
292
- * @param {object} [options]
293
- * @param {string} [options.apiKey] Override API Key
294
- * @param {AbortSignal} [options.signal] AbortSignal
295
- * @returns {Promise<Array<object>>} List of file metadata records
296
- */
297
- async getEntityFiles(entityType, entityId, options = {}) {
298
- if (!entityType || entityId === undefined || entityId === null) {
299
- throw new Error('[cynx/storage] entityType and entityId are required.');
300
- }
301
- const res = await this._request(
302
- `/api/v1/entity/${encodeURIComponent(entityType)}/${encodeURIComponent(entityId)}`,
303
- { method: 'GET', ...options }
304
- );
305
- return Array.isArray(res) ? res : [];
306
- }
307
-
308
- /**
309
- * Delete a single file by ID.
310
- *
311
- * @param {string} fileId ID of the file to delete
312
- * @param {object} [options]
313
- * @param {string} [options.apiKey] Override API Key
314
- * @returns {Promise<{ message: string, fileId: string }>}
315
- */
316
- async deleteFile(fileId, options = {}) {
317
- if (!fileId) throw new Error('[cynx/storage] fileId is required.');
318
- const cleanId = String(fileId).trim().replace(/\.[^/.]+$/, '');
319
- return this._request(`/api/v1/files/${encodeURIComponent(cleanId)}`, {
320
- method: 'DELETE',
321
- ...options,
322
- });
323
- }
324
-
325
- /**
326
- * Delete all files belonging to a specific entity.
327
- *
328
- * @param {string} entityType Entity type
329
- * @param {string|number} entityId Entity identifier
330
- * @param {object} [options]
331
- * @param {string} [options.apiKey] Override API Key
332
- * @returns {Promise<{ message: string, deletedCount: number }>}
333
- */
334
- async deleteEntityFiles(entityType, entityId, options = {}) {
335
- if (!entityType || entityId === undefined || entityId === null) {
336
- throw new Error('[cynx/storage] entityType and entityId are required.');
337
- }
338
- return this._request(
339
- `/api/v1/entity/${encodeURIComponent(entityType)}/${encodeURIComponent(entityId)}`,
340
- { method: 'DELETE', ...options }
341
- );
342
- }
343
-
344
- /**
345
- * Generate a public CDN streaming/viewing URL for a file.
346
- *
347
- * @param {string} fileId File ID
348
- * @param {string} [filename] Optional clean filename for browser save/view
349
- * @returns {string} Complete CDN URL
350
- */
351
- getCdnUrl(fileId, filename = '') {
352
- return getCdnUrl(fileId, filename, this.baseUrl);
353
- }
354
-
355
- /**
356
- * Download a file from CDN as a standard Fetch Response.
357
- *
358
- * @param {string} fileId File ID
359
- * @param {object} [options]
360
- * @param {string} [options.filename]
361
- * @param {AbortSignal} [options.signal]
362
- * @returns {Promise<Response>}
363
- */
364
- async download(fileId, options = {}) {
365
- if (!this.fetchFn) {
366
- throw new Error('[cynx/storage] No fetch implementation available.');
367
- }
368
- const url = this.getCdnUrl(fileId, options.filename);
369
- const res = await this.fetchFn(url, { signal: options.signal });
370
- if (!res.ok) {
371
- throw new Error(`Failed to download file: HTTP ${res.status} ${res.statusText}`);
372
- }
373
- return res;
374
- }
375
- }
376
-
377
- /**
378
- * Factory function to create a new StorageClient instance.
379
- *
380
- * @param {object} [options]
381
- * @returns {StorageClient}
382
- */
383
- export function createStorageClient(options = {}) {
384
- return new StorageClient(options);
385
- }
386
-
387
- export default {
388
- StorageClient,
389
- createStorageClient,
390
- getCdnUrl,
391
- getFileTypeCategory,
392
- formatBytes,
393
- };
1
+ /**
2
+ * cynx/utils — Storage Server Client & Connector (storage.js)
3
+ *
4
+ * Universal client SDK to interact with cynx-storage-server:
5
+ * - Upload files (Browser File/Blob or Node.js Buffer/Stream/Blob)
6
+ * - Query file metadata & entity attachments
7
+ * - Delete files or whole entity file groups
8
+ * - Generate public CDN URLs and stream content
9
+ *
10
+ * Works in Browser, Node.js (18+), Next.js / SSR, and Web Workers.
11
+ * Zero external dependencies.
12
+ */
13
+
14
+ // ─── UTILITIES & HELPERS ────────────────────────────────────────────────────────
15
+
16
+ const isNode = typeof process !== 'undefined' && Boolean(process.versions?.node);
17
+
18
+ function getDefaultBaseUrl() {
19
+ if (isNode && process.env?.STORAGE_SERVER_URL) {
20
+ return process.env.STORAGE_SERVER_URL;
21
+ }
22
+ if (typeof window !== 'undefined' && window.location?.origin) {
23
+ return window.location.origin;
24
+ }
25
+ return '';
26
+ }
27
+
28
+ function getDefaultApiKey() {
29
+ if (isNode && (process.env?.STORAGE_SERVER_API_KEY || process.env?.STORAGE_API_KEY)) {
30
+ return process.env.STORAGE_SERVER_API_KEY || process.env.STORAGE_API_KEY;
31
+ }
32
+ return '';
33
+ }
34
+
35
+ /**
36
+ * Format raw byte numbers into human-readable strings.
37
+ * @param {number|string} bytes
38
+ * @returns {string} e.g. "12.5 MB"
39
+ */
40
+ export function formatBytes(bytes) {
41
+ const num = Number(bytes) || 0;
42
+ if (num === 0) return '0 B';
43
+ const k = 1024;
44
+ const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
45
+ const i = Math.floor(Math.log(num) / Math.log(k));
46
+ const val = (num / Math.pow(k, i)).toFixed(2);
47
+ return `${val} ${sizes[i]}`;
48
+ }
49
+
50
+ /**
51
+ * Categorize mimeType and filename into categories.
52
+ * @param {string} [mimeType='']
53
+ * @param {string} [filename='']
54
+ * @returns {'image'|'pdf'|'video'|'audio'|'document'|'archive'|'other'}
55
+ */
56
+ export function getFileTypeCategory(mimeType = '', filename = '') {
57
+ const mt = (mimeType || '').toLowerCase();
58
+ const fn = (filename || '').toLowerCase();
59
+
60
+ if (mt.startsWith('image/') || /\.(jpg|jpeg|png|gif|webp|bmp|svg|avif|ico)$/i.test(fn)) {
61
+ return 'image';
62
+ }
63
+ if (mt === 'application/pdf' || /\.pdf$/i.test(fn)) {
64
+ return 'pdf';
65
+ }
66
+ if (mt.startsWith('video/') || /\.(mp4|webm|mkv|mov|avi|flv|wmv|m4v)$/i.test(fn)) {
67
+ return 'video';
68
+ }
69
+ if (mt.startsWith('audio/') || /\.(mp3|wav|ogg|m4a|aac|flac)$/i.test(fn)) {
70
+ return 'audio';
71
+ }
72
+ if (
73
+ mt.includes('word') ||
74
+ mt.includes('excel') ||
75
+ mt.includes('spreadsheet') ||
76
+ mt.includes('presentation') ||
77
+ mt.includes('csv') ||
78
+ /\.(doc|docx|xls|xlsx|csv|ppt|pptx|txt|rtf|md)$/i.test(fn)
79
+ ) {
80
+ return 'document';
81
+ }
82
+ if (mt.includes('zip') || mt.includes('tar') || mt.includes('compressed') || /\.(zip|tar|gz|7z|rar)$/i.test(fn)) {
83
+ return 'archive';
84
+ }
85
+ return 'other';
86
+ }
87
+
88
+ /**
89
+ * Build a public CDN URL for a given file ID.
90
+ * @param {string} fileId e.g. "sf_abc123"
91
+ * @param {string} [filename] Optional filename suffix for cleaner download names
92
+ * @param {string} [baseUrl] Storage server base URL
93
+ * @returns {string} CDN URL
94
+ */
95
+ export function getCdnUrl(fileId, filename = '', baseUrl = '') {
96
+ if (!fileId) return '';
97
+ const base = (baseUrl || getDefaultBaseUrl() || '').replace(/\/+$/, '');
98
+ const cleanId = String(fileId).trim();
99
+ const suffix = filename ? `/${encodeURIComponent(filename)}` : '';
100
+ return `${base}/cdn/${encodeURIComponent(cleanId)}${suffix}`;
101
+ }
102
+
103
+ // ─── STORAGE CLIENT CLASS ───────────────────────────────────────────────────────
104
+
105
+ export class StorageClient {
106
+ /**
107
+ * @param {object} [options={}]
108
+ * @param {string} [options.baseUrl] Base URL of the storage server (e.g. "https://storage.domain.com" or "http://localhost:5000")
109
+ * @param {string} [options.apiKey] API Key with upload/read/delete permissions
110
+ * @param {number} [options.timeout=30000] Request timeout in milliseconds
111
+ * @param {Function} [options.fetch] Custom fetch implementation (optional)
112
+ */
113
+ constructor(options = {}) {
114
+ this.baseUrl = (options.baseUrl || getDefaultBaseUrl() || '').replace(/\/+$/, '');
115
+ this.apiKey = options.apiKey || getDefaultApiKey() || '';
116
+ this.timeout = options.timeout || 30000;
117
+ this.fetchFn = options.fetch || (typeof globalThis !== 'undefined' ? globalThis.fetch?.bind(globalThis) : null);
118
+ }
119
+
120
+ /**
121
+ * Update configuration options.
122
+ * @param {object} options
123
+ */
124
+ configure(options = {}) {
125
+ if (options.baseUrl !== undefined) this.baseUrl = String(options.baseUrl || '').replace(/\/+$/, '');
126
+ if (options.apiKey !== undefined) this.apiKey = String(options.apiKey || '');
127
+ if (options.timeout !== undefined) this.timeout = Number(options.timeout) || 30000;
128
+ if (options.fetch !== undefined) this.fetchFn = options.fetch;
129
+ return this;
130
+ }
131
+
132
+ /**
133
+ * Internal HTTP request helper.
134
+ * @private
135
+ */
136
+ async _request(path, { method = 'GET', headers = {}, body, apiKey, signal, timeout } = {}) {
137
+ if (!this.fetchFn) {
138
+ throw new Error('[cynx/storage] No fetch implementation available in current environment.');
139
+ }
140
+
141
+ const url = `${this.baseUrl}${path.startsWith('/') ? path : `/${path}`}`;
142
+ const key = apiKey !== undefined ? apiKey : this.apiKey;
143
+ const reqHeaders = { ...headers };
144
+
145
+ if (key) {
146
+ reqHeaders['x-api-key'] = key;
147
+ }
148
+
149
+ const abortCtrl = typeof AbortController !== 'undefined' ? new AbortController() : null;
150
+ const reqTimeout = timeout || this.timeout;
151
+ let timer = null;
152
+
153
+ if (abortCtrl && reqTimeout > 0) {
154
+ timer = setTimeout(() => abortCtrl.abort(new Error(`Storage request timeout after ${reqTimeout}ms`)), reqTimeout);
155
+ }
156
+
157
+ const activeSignal = signal || abortCtrl?.signal;
158
+
159
+ try {
160
+ const response = await this.fetchFn(url, {
161
+ method,
162
+ headers: reqHeaders,
163
+ body,
164
+ signal: activeSignal,
165
+ });
166
+
167
+ if (timer) clearTimeout(timer);
168
+
169
+ if (!response.ok) {
170
+ let errData = null;
171
+ try {
172
+ errData = await response.json();
173
+ } catch {
174
+ errData = { error: response.statusText || `HTTP ${response.status}` };
175
+ }
176
+ const error = new Error(errData.error || errData.message || `Storage Error: ${response.status}`);
177
+ error.status = response.status;
178
+ error.response = errData;
179
+ throw error;
180
+ }
181
+
182
+ return await response.json();
183
+ } catch (err) {
184
+ if (timer) clearTimeout(timer);
185
+ throw err;
186
+ }
187
+ }
188
+
189
+ /**
190
+ * Upload one or multiple files to the storage server.
191
+ *
192
+ * @param {object} params
193
+ * @param {File|Blob|Buffer|ArrayBuffer|Array<File|Blob|Buffer|object>} [params.files] File or array of files to upload
194
+ * @param {File|Blob|Buffer|ArrayBuffer} [params.file] Single file alias
195
+ * @param {string} [params.folder] Target subfolder in cloud storage
196
+ * @param {string} [params.entityType] Associated entity type (e.g. 'company', 'employee', 'inspection')
197
+ * @param {string|number} [params.entityId] Associated entity ID
198
+ * @param {string} [params.apiKey] Override API Key for this upload
199
+ * @param {AbortSignal} [params.signal] AbortSignal for cancellation
200
+ * @returns {Promise<{ files: Array<object>, file: object|null }>} Formatted upload result
201
+ */
202
+ async upload({
203
+ files,
204
+ file,
205
+ folder,
206
+ entityType,
207
+ entityId,
208
+ entity_type,
209
+ entity_id,
210
+ apiKey,
211
+ signal,
212
+ } = {}) {
213
+ if (typeof FormData === 'undefined') {
214
+ throw new Error('[cynx/storage] FormData is not available in the current environment.');
215
+ }
216
+
217
+ const formData = new FormData();
218
+ const rawFiles = files ? (Array.isArray(files) ? files : [files]) : file ? [file] : [];
219
+
220
+ if (rawFiles.length === 0) {
221
+ throw new Error('[cynx/storage] No file provided for upload.');
222
+ }
223
+
224
+ for (let i = 0; i < rawFiles.length; i++) {
225
+ const item = rawFiles[i];
226
+ if (!item) continue;
227
+
228
+ if (typeof File !== 'undefined' && item instanceof File) {
229
+ formData.append('files', item, item.name);
230
+ } else if (typeof Blob !== 'undefined' && item instanceof Blob) {
231
+ const name = item.name || `file_${Date.now()}_${i}`;
232
+ formData.append('files', item, name);
233
+ } else if (isNode && (Buffer.isBuffer(item) || item instanceof Uint8Array || item instanceof ArrayBuffer)) {
234
+ const name = item.name || item.filename || `file_${Date.now()}_${i}`;
235
+ const blob = new Blob([item], { type: item.type || item.mimeType || 'application/octet-stream' });
236
+ formData.append('files', blob, name);
237
+ } else if (item && typeof item === 'object' && item.data) {
238
+ // Wrapped object format: { data, name, type }
239
+ const name = item.name || item.filename || `file_${Date.now()}_${i}`;
240
+ const blob = item.data instanceof Blob ? item.data : new Blob([item.data], { type: item.type || item.mimeType || 'application/octet-stream' });
241
+ formData.append('files', blob, name);
242
+ } else {
243
+ formData.append('files', item);
244
+ }
245
+ }
246
+
247
+ if (folder) formData.append('folder', folder);
248
+ const resolvedEntityType = entityType || entity_type;
249
+ const resolvedEntityId = entityId || entity_id;
250
+
251
+ if (resolvedEntityType) formData.append('entityType', resolvedEntityType);
252
+ if (resolvedEntityId) formData.append('entityId', String(resolvedEntityId));
253
+
254
+ const res = await this._request('/api/v1/upload', {
255
+ method: 'POST',
256
+ body: formData,
257
+ apiKey,
258
+ signal,
259
+ });
260
+
261
+ const fileList = res?.files ? res.files : Array.isArray(res) ? res : res ? [res] : [];
262
+ return {
263
+ files: fileList,
264
+ file: fileList[0] || null,
265
+ ...res,
266
+ };
267
+ }
268
+
269
+ /**
270
+ * Get metadata and CDN URLs for a specific file by its ID.
271
+ *
272
+ * @param {string} fileId ID of the file (e.g. "sf_abc123")
273
+ * @param {object} [options]
274
+ * @param {string} [options.apiKey] Override API Key
275
+ * @param {AbortSignal} [options.signal] AbortSignal
276
+ * @returns {Promise<object>} File metadata record
277
+ */
278
+ async getFile(fileId, options = {}) {
279
+ if (!fileId) throw new Error('[cynx/storage] fileId is required.');
280
+ const cleanId = String(fileId).trim().replace(/\.[^/.]+$/, ''); // strip any extension if present
281
+ return this._request(`/api/v1/files/${encodeURIComponent(cleanId)}`, {
282
+ method: 'GET',
283
+ ...options,
284
+ });
285
+ }
286
+
287
+ /**
288
+ * Get all files attached to a specific entity.
289
+ *
290
+ * @param {string} entityType Entity type (e.g. "company", "employee")
291
+ * @param {string|number} entityId Entity identifier
292
+ * @param {object} [options]
293
+ * @param {string} [options.apiKey] Override API Key
294
+ * @param {AbortSignal} [options.signal] AbortSignal
295
+ * @returns {Promise<Array<object>>} List of file metadata records
296
+ */
297
+ async getEntityFiles(entityType, entityId, options = {}) {
298
+ if (!entityType || entityId === undefined || entityId === null) {
299
+ throw new Error('[cynx/storage] entityType and entityId are required.');
300
+ }
301
+ const res = await this._request(
302
+ `/api/v1/entity/${encodeURIComponent(entityType)}/${encodeURIComponent(entityId)}`,
303
+ { method: 'GET', ...options }
304
+ );
305
+ return Array.isArray(res) ? res : [];
306
+ }
307
+
308
+ /**
309
+ * Delete a single file by ID.
310
+ *
311
+ * @param {string} fileId ID of the file to delete
312
+ * @param {object} [options]
313
+ * @param {string} [options.apiKey] Override API Key
314
+ * @returns {Promise<{ message: string, fileId: string }>}
315
+ */
316
+ async deleteFile(fileId, options = {}) {
317
+ if (!fileId) throw new Error('[cynx/storage] fileId is required.');
318
+ const cleanId = String(fileId).trim().replace(/\.[^/.]+$/, '');
319
+ return this._request(`/api/v1/files/${encodeURIComponent(cleanId)}`, {
320
+ method: 'DELETE',
321
+ ...options,
322
+ });
323
+ }
324
+
325
+ /**
326
+ * Delete all files belonging to a specific entity.
327
+ *
328
+ * @param {string} entityType Entity type
329
+ * @param {string|number} entityId Entity identifier
330
+ * @param {object} [options]
331
+ * @param {string} [options.apiKey] Override API Key
332
+ * @returns {Promise<{ message: string, deletedCount: number }>}
333
+ */
334
+ async deleteEntityFiles(entityType, entityId, options = {}) {
335
+ if (!entityType || entityId === undefined || entityId === null) {
336
+ throw new Error('[cynx/storage] entityType and entityId are required.');
337
+ }
338
+ return this._request(
339
+ `/api/v1/entity/${encodeURIComponent(entityType)}/${encodeURIComponent(entityId)}`,
340
+ { method: 'DELETE', ...options }
341
+ );
342
+ }
343
+
344
+ /**
345
+ * Generate a public CDN streaming/viewing URL for a file.
346
+ *
347
+ * @param {string} fileId File ID
348
+ * @param {string} [filename] Optional clean filename for browser save/view
349
+ * @returns {string} Complete CDN URL
350
+ */
351
+ getCdnUrl(fileId, filename = '') {
352
+ return getCdnUrl(fileId, filename, this.baseUrl);
353
+ }
354
+
355
+ /**
356
+ * Download a file from CDN as a standard Fetch Response.
357
+ *
358
+ * @param {string} fileId File ID
359
+ * @param {object} [options]
360
+ * @param {string} [options.filename]
361
+ * @param {AbortSignal} [options.signal]
362
+ * @returns {Promise<Response>}
363
+ */
364
+ async download(fileId, options = {}) {
365
+ if (!this.fetchFn) {
366
+ throw new Error('[cynx/storage] No fetch implementation available.');
367
+ }
368
+ const url = this.getCdnUrl(fileId, options.filename);
369
+ const res = await this.fetchFn(url, { signal: options.signal });
370
+ if (!res.ok) {
371
+ throw new Error(`Failed to download file: HTTP ${res.status} ${res.statusText}`);
372
+ }
373
+ return res;
374
+ }
375
+ }
376
+
377
+ /**
378
+ * Factory function to create a new StorageClient instance.
379
+ *
380
+ * @param {object} [options]
381
+ * @returns {StorageClient}
382
+ */
383
+ export function createStorageClient(options = {}) {
384
+ return new StorageClient(options);
385
+ }
386
+
387
+ export default {
388
+ StorageClient,
389
+ createStorageClient,
390
+ getCdnUrl,
391
+ getFileTypeCategory,
392
+ formatBytes,
393
+ };