@opensaas/stack-storage 0.1.1

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.
Files changed (61) hide show
  1. package/.turbo/turbo-build.log +4 -0
  2. package/CHANGELOG.md +11 -0
  3. package/CLAUDE.md +426 -0
  4. package/LICENSE +21 -0
  5. package/README.md +425 -0
  6. package/dist/config/index.d.ts +19 -0
  7. package/dist/config/index.d.ts.map +1 -0
  8. package/dist/config/index.js +25 -0
  9. package/dist/config/index.js.map +1 -0
  10. package/dist/config/types.d.ts +113 -0
  11. package/dist/config/types.d.ts.map +1 -0
  12. package/dist/config/types.js +2 -0
  13. package/dist/config/types.js.map +1 -0
  14. package/dist/fields/index.d.ts +111 -0
  15. package/dist/fields/index.d.ts.map +1 -0
  16. package/dist/fields/index.js +237 -0
  17. package/dist/fields/index.js.map +1 -0
  18. package/dist/index.d.ts +6 -0
  19. package/dist/index.d.ts.map +1 -0
  20. package/dist/index.js +11 -0
  21. package/dist/index.js.map +1 -0
  22. package/dist/providers/index.d.ts +2 -0
  23. package/dist/providers/index.d.ts.map +1 -0
  24. package/dist/providers/index.js +2 -0
  25. package/dist/providers/index.js.map +1 -0
  26. package/dist/providers/local.d.ts +22 -0
  27. package/dist/providers/local.d.ts.map +1 -0
  28. package/dist/providers/local.js +64 -0
  29. package/dist/providers/local.js.map +1 -0
  30. package/dist/runtime/index.d.ts +75 -0
  31. package/dist/runtime/index.d.ts.map +1 -0
  32. package/dist/runtime/index.js +157 -0
  33. package/dist/runtime/index.js.map +1 -0
  34. package/dist/utils/image.d.ts +18 -0
  35. package/dist/utils/image.d.ts.map +1 -0
  36. package/dist/utils/image.js +82 -0
  37. package/dist/utils/image.js.map +1 -0
  38. package/dist/utils/index.d.ts +3 -0
  39. package/dist/utils/index.d.ts.map +1 -0
  40. package/dist/utils/index.js +3 -0
  41. package/dist/utils/index.js.map +1 -0
  42. package/dist/utils/upload.d.ts +56 -0
  43. package/dist/utils/upload.d.ts.map +1 -0
  44. package/dist/utils/upload.js +74 -0
  45. package/dist/utils/upload.js.map +1 -0
  46. package/package.json +50 -0
  47. package/src/config/index.ts +30 -0
  48. package/src/config/types.ts +127 -0
  49. package/src/fields/index.ts +347 -0
  50. package/src/index.ts +14 -0
  51. package/src/providers/index.ts +1 -0
  52. package/src/providers/local.ts +85 -0
  53. package/src/runtime/index.ts +243 -0
  54. package/src/utils/image.ts +111 -0
  55. package/src/utils/index.ts +2 -0
  56. package/src/utils/upload.ts +122 -0
  57. package/tests/image-utils.test.ts +498 -0
  58. package/tests/local-provider.test.ts +349 -0
  59. package/tests/upload-utils.test.ts +313 -0
  60. package/tsconfig.json +9 -0
  61. package/vitest.config.ts +14 -0
@@ -0,0 +1,347 @@
1
+ import type { BaseFieldConfig } from '@opensaas/stack-core'
2
+ import { z } from 'zod'
3
+ import type { ComponentType } from 'react'
4
+ import type { FileMetadata, ImageMetadata, ImageTransformationConfig } from '../config/types.js'
5
+ import type { FileValidationOptions } from '../utils/upload.js'
6
+
7
+ /**
8
+ * File field configuration
9
+ */
10
+ export interface FileFieldConfig extends BaseFieldConfig<FileMetadata | null, FileMetadata | null> {
11
+ type: 'file'
12
+ /** Name of the storage provider from config.storage */
13
+ storage: string
14
+ /** File validation options */
15
+ validation?: FileValidationOptions
16
+ /** Automatically delete file from storage when record is deleted */
17
+ cleanupOnDelete?: boolean
18
+ /** Automatically delete old file from storage when replaced with new file */
19
+ cleanupOnReplace?: boolean
20
+ /** UI options */
21
+ ui?: {
22
+ /** Custom component to use for rendering this field */
23
+ component?: ComponentType<unknown>
24
+ /** Custom field type name for component registry lookup */
25
+ fieldType?: string
26
+ /** Label for the field */
27
+ label?: string
28
+ /** Help text shown below the field */
29
+ helpText?: string
30
+ /** Placeholder text */
31
+ placeholder?: string
32
+ /** Additional UI options passed through to component */
33
+ [key: string]: unknown
34
+ }
35
+ }
36
+
37
+ /**
38
+ * Image field configuration
39
+ */
40
+ export interface ImageFieldConfig
41
+ extends BaseFieldConfig<ImageMetadata | null, ImageMetadata | null> {
42
+ type: 'image'
43
+ /** Name of the storage provider from config.storage */
44
+ storage: string
45
+ /** Image transformations to generate on upload */
46
+ transformations?: Record<string, ImageTransformationConfig>
47
+ /** File validation options */
48
+ validation?: FileValidationOptions
49
+ /** Automatically delete file from storage when record is deleted */
50
+ cleanupOnDelete?: boolean
51
+ /** Automatically delete old file from storage when replaced with new file */
52
+ cleanupOnReplace?: boolean
53
+ /** UI options */
54
+ ui?: {
55
+ /** Custom component to use for rendering this field */
56
+ component?: ComponentType<unknown>
57
+ /** Custom field type name for component registry lookup */
58
+ fieldType?: string
59
+ /** Label for the field */
60
+ label?: string
61
+ /** Help text shown below the field */
62
+ helpText?: string
63
+ /** Placeholder text */
64
+ placeholder?: string
65
+ /** Show image preview */
66
+ showPreview?: boolean
67
+ /** Preview size (width in pixels) */
68
+ previewSize?: number
69
+ /** Additional UI options passed through to component */
70
+ [key: string]: unknown
71
+ }
72
+ }
73
+
74
+ /**
75
+ * Creates a file upload field
76
+ *
77
+ * Uses JSON field backing to store file metadata including filename, URL, size, MIME type, etc.
78
+ *
79
+ * @example
80
+ * ```typescript
81
+ * fields: {
82
+ * resume: file({
83
+ * storage: 'documents',
84
+ * validation: {
85
+ * maxFileSize: 10 * 1024 * 1024, // 10MB
86
+ * acceptedMimeTypes: ['application/pdf']
87
+ * }
88
+ * })
89
+ * }
90
+ * ```
91
+ */
92
+ export function file(options: Omit<FileFieldConfig, 'type'>): FileFieldConfig {
93
+ const fieldConfig: FileFieldConfig = {
94
+ type: 'file',
95
+ ...options,
96
+
97
+ hooks: {
98
+ resolveInput: async ({ inputValue, context, item, fieldName }) => {
99
+ // If null/undefined, return as-is (deletion or no change)
100
+ if (inputValue === null || inputValue === undefined) {
101
+ return inputValue
102
+ }
103
+
104
+ // If already FileMetadata, keep existing (edit mode - no new file uploaded)
105
+ if (typeof inputValue === 'object' && 'filename' in inputValue && 'url' in inputValue) {
106
+ return inputValue as FileMetadata
107
+ }
108
+
109
+ // If File object, upload it
110
+ // Check if it's a File-like object (has arrayBuffer method)
111
+ if (
112
+ typeof inputValue === 'object' &&
113
+ 'arrayBuffer' in inputValue &&
114
+ typeof (inputValue as { arrayBuffer?: unknown }).arrayBuffer === 'function'
115
+ ) {
116
+ // Convert File to buffer
117
+ const fileObj = inputValue as File
118
+ const arrayBuffer = await fileObj.arrayBuffer()
119
+ const buffer = Buffer.from(arrayBuffer)
120
+
121
+ // Upload file using context.storage utilities
122
+ const metadata = (await context.storage.uploadFile(fieldConfig.storage, fileObj, buffer, {
123
+ validation: fieldConfig.validation,
124
+ })) as FileMetadata
125
+
126
+ // If cleanupOnReplace is enabled and there was an old file, delete it
127
+ if (fieldConfig.cleanupOnReplace && item && fieldName) {
128
+ const oldMetadata = item[fieldName] as FileMetadata | null
129
+ if (oldMetadata && oldMetadata.filename) {
130
+ try {
131
+ await context.storage.deleteFile(oldMetadata.storageProvider, oldMetadata.filename)
132
+ } catch (error) {
133
+ // Log error but don't fail the operation
134
+ console.error(`Failed to cleanup old file: ${oldMetadata.filename}`, error)
135
+ }
136
+ }
137
+ }
138
+
139
+ return metadata
140
+ }
141
+
142
+ // Unknown type - return as-is and let validation catch it
143
+ return inputValue
144
+ },
145
+
146
+ afterOperation: async ({ operation, item, fieldName, context }) => {
147
+ // Only cleanup on delete if enabled
148
+ if (operation === 'delete' && fieldConfig.cleanupOnDelete) {
149
+ const fileMetadata = item[fieldName] as FileMetadata | null
150
+
151
+ if (fileMetadata && fileMetadata.filename) {
152
+ try {
153
+ await context.storage.deleteFile(fileMetadata.storageProvider, fileMetadata.filename)
154
+ } catch (error) {
155
+ // Log error but don't fail the operation
156
+ console.error(`Failed to cleanup file on delete: ${fileMetadata.filename}`, error)
157
+ }
158
+ }
159
+ }
160
+ },
161
+ },
162
+
163
+ getZodSchema: (_fieldName: string, _operation: 'create' | 'update') => {
164
+ // File metadata follows the FileMetadata schema
165
+ const fileMetadataSchema = z.object({
166
+ filename: z.string(),
167
+ originalFilename: z.string(),
168
+ url: z.string(), // Accept both absolute URLs and relative paths
169
+ mimeType: z.string(),
170
+ size: z.number(),
171
+ uploadedAt: z.string(),
172
+ storageProvider: z.string(),
173
+ metadata: z.record(z.string(), z.unknown()).optional(),
174
+ })
175
+
176
+ // Allow null or undefined values
177
+ return z.union([fileMetadataSchema, z.null(), z.undefined()])
178
+ },
179
+
180
+ getPrismaType: (_fieldName: string) => {
181
+ // Store as JSON in database
182
+ return { type: 'Json', modifiers: '?' }
183
+ },
184
+
185
+ getTypeScriptType: () => {
186
+ // TypeScript type is FileMetadata | null
187
+ return {
188
+ type: 'import("@opensaas/stack-storage").FileMetadata | null',
189
+ optional: true,
190
+ }
191
+ },
192
+ }
193
+
194
+ return fieldConfig
195
+ }
196
+
197
+ /**
198
+ * Creates an image upload field with optional transformations
199
+ *
200
+ * Uses JSON field backing to store image metadata including dimensions, transformations, etc.
201
+ *
202
+ * @example
203
+ * ```typescript
204
+ * fields: {
205
+ * avatar: image({
206
+ * storage: 'avatars',
207
+ * transformations: {
208
+ * thumbnail: { width: 100, height: 100, fit: 'cover' },
209
+ * profile: { width: 400, height: 400, fit: 'cover' }
210
+ * },
211
+ * validation: {
212
+ * maxFileSize: 5 * 1024 * 1024, // 5MB
213
+ * acceptedMimeTypes: ['image/jpeg', 'image/png', 'image/webp']
214
+ * }
215
+ * })
216
+ * }
217
+ * ```
218
+ */
219
+ export function image(options: Omit<ImageFieldConfig, 'type'>): ImageFieldConfig {
220
+ const fieldConfig: ImageFieldConfig = {
221
+ type: 'image',
222
+ ...options,
223
+
224
+ hooks: {
225
+ resolveInput: async ({ inputValue, context, item, fieldName }) => {
226
+ // If null/undefined, return as-is (deletion or no change)
227
+ if (inputValue === null || inputValue === undefined) {
228
+ return inputValue
229
+ }
230
+
231
+ // If already ImageMetadata, keep existing (edit mode - no new file uploaded)
232
+ if (
233
+ typeof inputValue === 'object' &&
234
+ 'filename' in inputValue &&
235
+ 'url' in inputValue &&
236
+ 'width' in inputValue &&
237
+ 'height' in inputValue
238
+ ) {
239
+ return inputValue as ImageMetadata
240
+ }
241
+
242
+ // If File object, upload it
243
+ // Check if it's a File-like object (has arrayBuffer method)
244
+ if (
245
+ typeof inputValue === 'object' &&
246
+ 'arrayBuffer' in inputValue &&
247
+ typeof (inputValue as { arrayBuffer?: unknown }).arrayBuffer === 'function'
248
+ ) {
249
+ // Convert File to buffer
250
+ const fileObj = inputValue as File
251
+ const arrayBuffer = await fileObj.arrayBuffer()
252
+ const buffer = Buffer.from(arrayBuffer)
253
+
254
+ // Upload image using context.storage utilities
255
+ const metadata = (await context.storage.uploadImage(
256
+ fieldConfig.storage,
257
+ fileObj,
258
+ buffer,
259
+ {
260
+ validation: fieldConfig.validation,
261
+ transformations: fieldConfig.transformations,
262
+ },
263
+ )) as ImageMetadata
264
+
265
+ // If cleanupOnReplace is enabled and there was an old file, delete it
266
+ if (fieldConfig.cleanupOnReplace && item && fieldName) {
267
+ const oldMetadata = item[fieldName] as ImageMetadata | null
268
+ if (oldMetadata && oldMetadata.filename) {
269
+ try {
270
+ await context.storage.deleteImage(oldMetadata)
271
+ } catch (error) {
272
+ // Log error but don't fail the operation
273
+ console.error(`Failed to cleanup old image: ${oldMetadata.filename}`, error)
274
+ }
275
+ }
276
+ }
277
+
278
+ return metadata
279
+ }
280
+
281
+ // Unknown type - return as-is and let validation catch it
282
+ return inputValue
283
+ },
284
+
285
+ afterOperation: async ({ operation, item, fieldName, context }) => {
286
+ // Only cleanup on delete if enabled
287
+ if (operation === 'delete' && fieldConfig.cleanupOnDelete) {
288
+ const imageMetadata = item[fieldName] as ImageMetadata | null
289
+
290
+ if (imageMetadata && imageMetadata.filename) {
291
+ try {
292
+ await context.storage.deleteImage(imageMetadata)
293
+ } catch (error) {
294
+ // Log error but don't fail the operation
295
+ console.error(`Failed to cleanup image on delete: ${imageMetadata.filename}`, error)
296
+ }
297
+ }
298
+ }
299
+ },
300
+ },
301
+
302
+ getZodSchema: (_fieldName: string, _operation: 'create' | 'update') => {
303
+ // Image metadata follows the ImageMetadata schema (extends FileMetadata)
304
+ const imageMetadataSchema = z.object({
305
+ filename: z.string(),
306
+ originalFilename: z.string(),
307
+ url: z.string(), // Accept both absolute URLs and relative paths
308
+ mimeType: z.string(),
309
+ size: z.number(),
310
+ width: z.number(),
311
+ height: z.number(),
312
+ uploadedAt: z.string(),
313
+ storageProvider: z.string(),
314
+ metadata: z.record(z.string(), z.unknown()).optional(),
315
+ transformations: z
316
+ .record(
317
+ z.string(),
318
+ z.object({
319
+ url: z.string(), // Accept both absolute URLs and relative paths
320
+ width: z.number(),
321
+ height: z.number(),
322
+ size: z.number(),
323
+ }),
324
+ )
325
+ .optional(),
326
+ })
327
+
328
+ // Allow null or undefined values
329
+ return z.union([imageMetadataSchema, z.null(), z.undefined()])
330
+ },
331
+
332
+ getPrismaType: (_fieldName: string) => {
333
+ // Store as JSON in database
334
+ return { type: 'Json', modifiers: '?' }
335
+ },
336
+
337
+ getTypeScriptType: () => {
338
+ // TypeScript type is ImageMetadata | null
339
+ return {
340
+ type: 'import("@opensaas/stack-storage").ImageMetadata | null',
341
+ optional: true,
342
+ }
343
+ },
344
+ }
345
+
346
+ return fieldConfig
347
+ }
package/src/index.ts ADDED
@@ -0,0 +1,14 @@
1
+ // Config and types
2
+ export * from './config/index.js'
3
+
4
+ // Field builders
5
+ export * from './fields/index.js'
6
+
7
+ // Storage providers
8
+ export * from './providers/index.js'
9
+
10
+ // Runtime utilities
11
+ export * from './runtime/index.js'
12
+
13
+ // Upload utilities
14
+ export * from './utils/index.js'
@@ -0,0 +1 @@
1
+ export { LocalStorageProvider } from './local.js'
@@ -0,0 +1,85 @@
1
+ import fs from 'node:fs/promises'
2
+ import path from 'node:path'
3
+ import { randomBytes } from 'node:crypto'
4
+ import type {
5
+ StorageProvider,
6
+ UploadOptions,
7
+ UploadResult,
8
+ LocalStorageConfig,
9
+ } from '../config/types.js'
10
+
11
+ /**
12
+ * Local filesystem storage provider
13
+ * Stores files on the local filesystem
14
+ */
15
+ export class LocalStorageProvider implements StorageProvider {
16
+ private config: LocalStorageConfig
17
+
18
+ constructor(config: LocalStorageConfig) {
19
+ this.config = config
20
+ }
21
+
22
+ /**
23
+ * Ensures the upload directory exists
24
+ */
25
+ private async ensureUploadDir(): Promise<void> {
26
+ try {
27
+ await fs.access(this.config.uploadDir)
28
+ } catch {
29
+ await fs.mkdir(this.config.uploadDir, { recursive: true })
30
+ }
31
+ }
32
+
33
+ /**
34
+ * Generates a unique filename if configured
35
+ */
36
+ private generateFilename(originalFilename: string): string {
37
+ if (this.config.generateUniqueFilenames === false) {
38
+ return originalFilename
39
+ }
40
+
41
+ const ext = path.extname(originalFilename)
42
+ const uniqueId = randomBytes(16).toString('hex')
43
+ const timestamp = Date.now()
44
+ return `${timestamp}-${uniqueId}${ext}`
45
+ }
46
+
47
+ async upload(
48
+ file: Buffer | Uint8Array,
49
+ filename: string,
50
+ options?: UploadOptions,
51
+ ): Promise<UploadResult> {
52
+ await this.ensureUploadDir()
53
+
54
+ const generatedFilename = this.generateFilename(filename)
55
+ const filePath = path.join(this.config.uploadDir, generatedFilename)
56
+
57
+ // Write file to disk
58
+ await fs.writeFile(filePath, file)
59
+
60
+ // Get file stats for size
61
+ const stats = await fs.stat(filePath)
62
+
63
+ return {
64
+ filename: generatedFilename,
65
+ url: `${this.config.serveUrl}/${generatedFilename}`,
66
+ size: stats.size,
67
+ contentType: options?.contentType || 'application/octet-stream',
68
+ metadata: options?.metadata,
69
+ }
70
+ }
71
+
72
+ async download(filename: string): Promise<Buffer> {
73
+ const filePath = path.join(this.config.uploadDir, filename)
74
+ return await fs.readFile(filePath)
75
+ }
76
+
77
+ async delete(filename: string): Promise<void> {
78
+ const filePath = path.join(this.config.uploadDir, filename)
79
+ await fs.unlink(filePath)
80
+ }
81
+
82
+ getUrl(filename: string): string {
83
+ return `${this.config.serveUrl}/${filename}`
84
+ }
85
+ }
@@ -0,0 +1,243 @@
1
+ import type { OpenSaasConfig } from '@opensaas/stack-core'
2
+ import type {
3
+ StorageProvider,
4
+ LocalStorageConfig,
5
+ FileMetadata,
6
+ ImageMetadata,
7
+ ImageTransformationConfig,
8
+ } from '../config/types.js'
9
+ import { LocalStorageProvider } from '../providers/local.js'
10
+ import { validateFile, getMimeType, type FileValidationOptions } from '../utils/upload.js'
11
+ import { getImageDimensions, processImageTransformations } from '../utils/image.js'
12
+
13
+ /**
14
+ * Creates a storage provider instance from config
15
+ */
16
+ export function createStorageProvider(
17
+ config: OpenSaasConfig,
18
+ providerName: string,
19
+ ): StorageProvider {
20
+ if (!config.storage || !config.storage[providerName]) {
21
+ throw new Error(`Storage provider '${providerName}' not found in config`)
22
+ }
23
+
24
+ const providerConfig = config.storage[providerName]
25
+
26
+ switch (providerConfig.type) {
27
+ case 'local':
28
+ return new LocalStorageProvider(providerConfig as unknown as LocalStorageConfig)
29
+ default:
30
+ throw new Error(`Unknown storage provider type: ${providerConfig.type}`)
31
+ }
32
+ }
33
+
34
+ /**
35
+ * Options for uploading a file
36
+ */
37
+ export interface UploadFileOptions {
38
+ /** Validation options */
39
+ validation?: FileValidationOptions
40
+ /** Custom metadata */
41
+ metadata?: Record<string, string>
42
+ }
43
+
44
+ /**
45
+ * Options for uploading an image with transformations
46
+ */
47
+ export interface UploadImageOptions extends UploadFileOptions {
48
+ /** Image transformations to apply */
49
+ transformations?: Record<string, ImageTransformationConfig>
50
+ }
51
+
52
+ /**
53
+ * Uploads a file to the specified storage provider
54
+ *
55
+ * @example
56
+ * ```typescript
57
+ * const metadata = await uploadFile(config, 'documents', {
58
+ * file,
59
+ * buffer,
60
+ * validation: {
61
+ * maxFileSize: 10 * 1024 * 1024, // 10MB
62
+ * acceptedMimeTypes: ['application/pdf']
63
+ * }
64
+ * })
65
+ * ```
66
+ */
67
+ export async function uploadFile(
68
+ config: OpenSaasConfig,
69
+ storageProviderName: string,
70
+ data: {
71
+ file: File
72
+ buffer: Buffer
73
+ },
74
+ options?: UploadFileOptions,
75
+ ): Promise<FileMetadata> {
76
+ const { file, buffer } = data
77
+
78
+ // Validate file
79
+ if (options?.validation) {
80
+ const validation = validateFile(
81
+ {
82
+ size: file.size,
83
+ name: file.name,
84
+ type: file.type,
85
+ },
86
+ options.validation,
87
+ )
88
+
89
+ if (!validation.valid) {
90
+ throw new Error(validation.error)
91
+ }
92
+ }
93
+
94
+ // Get storage provider
95
+ const provider = createStorageProvider(config, storageProviderName)
96
+
97
+ // Determine content type
98
+ const contentType = file.type || getMimeType(file.name)
99
+
100
+ // Upload file
101
+ const result = await provider.upload(buffer, file.name, {
102
+ contentType,
103
+ metadata: options?.metadata,
104
+ })
105
+
106
+ // Return metadata
107
+ return {
108
+ filename: result.filename,
109
+ originalFilename: file.name,
110
+ url: result.url,
111
+ mimeType: contentType,
112
+ size: result.size,
113
+ uploadedAt: new Date().toISOString(),
114
+ storageProvider: storageProviderName,
115
+ metadata: result.metadata,
116
+ }
117
+ }
118
+
119
+ /**
120
+ * Uploads an image with optional transformations
121
+ *
122
+ * @example
123
+ * ```typescript
124
+ * const metadata = await uploadImage(config, 'avatars', {
125
+ * file,
126
+ * buffer,
127
+ * validation: {
128
+ * maxFileSize: 5 * 1024 * 1024, // 5MB
129
+ * acceptedMimeTypes: ['image/jpeg', 'image/png', 'image/webp']
130
+ * },
131
+ * transformations: {
132
+ * thumbnail: { width: 100, height: 100, fit: 'cover' },
133
+ * profile: { width: 400, height: 400, fit: 'cover' }
134
+ * }
135
+ * })
136
+ * ```
137
+ */
138
+ export async function uploadImage(
139
+ config: OpenSaasConfig,
140
+ storageProviderName: string,
141
+ data: {
142
+ file: File
143
+ buffer: Buffer
144
+ },
145
+ options?: UploadImageOptions,
146
+ ): Promise<ImageMetadata> {
147
+ const { file, buffer } = data
148
+
149
+ // Validate file
150
+ if (options?.validation) {
151
+ const validation = validateFile(
152
+ {
153
+ size: file.size,
154
+ name: file.name,
155
+ type: file.type,
156
+ },
157
+ options.validation,
158
+ )
159
+
160
+ if (!validation.valid) {
161
+ throw new Error(validation.error)
162
+ }
163
+ }
164
+
165
+ // Get storage provider
166
+ const provider = createStorageProvider(config, storageProviderName)
167
+
168
+ // Determine content type
169
+ const contentType = file.type || getMimeType(file.name)
170
+
171
+ // Get original image dimensions
172
+ const { width, height } = await getImageDimensions(buffer)
173
+
174
+ // Upload original image
175
+ const result = await provider.upload(buffer, file.name, {
176
+ contentType,
177
+ metadata: options?.metadata,
178
+ })
179
+
180
+ // Process transformations if provided
181
+ let transformations:
182
+ | Record<string, { url: string; width: number; height: number; size: number }>
183
+ | undefined
184
+ if (options?.transformations) {
185
+ transformations = await processImageTransformations(
186
+ buffer,
187
+ file.name,
188
+ options.transformations,
189
+ provider,
190
+ contentType,
191
+ )
192
+ }
193
+
194
+ // Return metadata
195
+ return {
196
+ filename: result.filename,
197
+ originalFilename: file.name,
198
+ url: result.url,
199
+ mimeType: contentType,
200
+ size: result.size,
201
+ width,
202
+ height,
203
+ uploadedAt: new Date().toISOString(),
204
+ storageProvider: storageProviderName,
205
+ metadata: result.metadata,
206
+ transformations,
207
+ }
208
+ }
209
+
210
+ /**
211
+ * Deletes a file from storage
212
+ */
213
+ export async function deleteFile(
214
+ config: OpenSaasConfig,
215
+ storageProviderName: string,
216
+ filename: string,
217
+ ): Promise<void> {
218
+ const provider = createStorageProvider(config, storageProviderName)
219
+ await provider.delete(filename)
220
+ }
221
+
222
+ /**
223
+ * Deletes an image and all its transformations from storage
224
+ */
225
+ export async function deleteImage(config: OpenSaasConfig, metadata: ImageMetadata): Promise<void> {
226
+ const provider = createStorageProvider(config, metadata.storageProvider)
227
+
228
+ // Delete original image
229
+ await provider.delete(metadata.filename)
230
+
231
+ // Delete all transformations
232
+ if (metadata.transformations) {
233
+ for (const transformationResult of Object.values(metadata.transformations)) {
234
+ // Extract filename from URL
235
+ const filename = transformationResult.url.split('/').pop()
236
+ if (filename) {
237
+ await provider.delete(filename)
238
+ }
239
+ }
240
+ }
241
+ }
242
+
243
+ export { parseFileFromFormData } from '../utils/upload.js'