@modern-admin/feature-upload 0.1.0

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 (66) hide show
  1. package/dist/graphql.d.ts +36 -0
  2. package/dist/graphql.d.ts.map +1 -0
  3. package/dist/graphql.js +141 -0
  4. package/dist/graphql.js.map +1 -0
  5. package/dist/index.d.ts +7 -0
  6. package/dist/index.d.ts.map +1 -0
  7. package/dist/index.js +12 -0
  8. package/dist/index.js.map +1 -0
  9. package/dist/nest/index.d.ts +3 -0
  10. package/dist/nest/index.d.ts.map +1 -0
  11. package/dist/nest/index.js +4 -0
  12. package/dist/nest/index.js.map +1 -0
  13. package/dist/nest/upload-sweeper.service.d.ts +32 -0
  14. package/dist/nest/upload-sweeper.service.d.ts.map +1 -0
  15. package/dist/nest/upload-sweeper.service.js +68 -0
  16. package/dist/nest/upload-sweeper.service.js.map +1 -0
  17. package/dist/nest/upload.controller.d.ts +51 -0
  18. package/dist/nest/upload.controller.d.ts.map +1 -0
  19. package/dist/nest/upload.controller.js +220 -0
  20. package/dist/nest/upload.controller.js.map +1 -0
  21. package/dist/nest/upload.module.d.ts +28 -0
  22. package/dist/nest/upload.module.d.ts.map +1 -0
  23. package/dist/nest/upload.module.js +51 -0
  24. package/dist/nest/upload.module.js.map +1 -0
  25. package/dist/nest/upload.tokens.d.ts +23 -0
  26. package/dist/nest/upload.tokens.d.ts.map +1 -0
  27. package/dist/nest/upload.tokens.js +6 -0
  28. package/dist/nest/upload.tokens.js.map +1 -0
  29. package/dist/pending-registry.d.ts +60 -0
  30. package/dist/pending-registry.d.ts.map +1 -0
  31. package/dist/pending-registry.js +106 -0
  32. package/dist/pending-registry.js.map +1 -0
  33. package/dist/providers/local.d.ts +36 -0
  34. package/dist/providers/local.d.ts.map +1 -0
  35. package/dist/providers/local.js +44 -0
  36. package/dist/providers/local.js.map +1 -0
  37. package/dist/providers/s3.d.ts +96 -0
  38. package/dist/providers/s3.d.ts.map +1 -0
  39. package/dist/providers/s3.js +164 -0
  40. package/dist/providers/s3.js.map +1 -0
  41. package/dist/registry.d.ts +27 -0
  42. package/dist/registry.d.ts.map +1 -0
  43. package/dist/registry.js +25 -0
  44. package/dist/registry.js.map +1 -0
  45. package/dist/types.d.ts +130 -0
  46. package/dist/types.d.ts.map +1 -0
  47. package/dist/types.js +9 -0
  48. package/dist/types.js.map +1 -0
  49. package/dist/upload-feature.d.ts +31 -0
  50. package/dist/upload-feature.d.ts.map +1 -0
  51. package/dist/upload-feature.js +170 -0
  52. package/dist/upload-feature.js.map +1 -0
  53. package/package.json +94 -0
  54. package/src/graphql.ts +185 -0
  55. package/src/index.ts +22 -0
  56. package/src/nest/index.ts +4 -0
  57. package/src/nest/upload-sweeper.service.ts +52 -0
  58. package/src/nest/upload.controller.ts +228 -0
  59. package/src/nest/upload.module.ts +44 -0
  60. package/src/nest/upload.tokens.ts +24 -0
  61. package/src/pending-registry.ts +115 -0
  62. package/src/providers/local.ts +63 -0
  63. package/src/providers/s3.ts +226 -0
  64. package/src/registry.ts +39 -0
  65. package/src/types.ts +137 -0
  66. package/src/upload-feature.ts +208 -0
@@ -0,0 +1,63 @@
1
+ /**
2
+ * LocalUploadProvider — stores files on the local filesystem.
3
+ *
4
+ * Files are written to `uploadDir` with a UUID-based filename to avoid
5
+ * collisions. Set `baseUrl` to the public URL prefix where the directory is
6
+ * served as static files (e.g. `'/uploads'` or `'http://localhost:3000/uploads'`).
7
+ *
8
+ * @example
9
+ * new LocalUploadProvider({ uploadDir: './public/uploads', baseUrl: '/uploads' })
10
+ */
11
+
12
+ import { mkdir, writeFile, unlink } from 'node:fs/promises'
13
+ import { join, extname, dirname } from 'node:path'
14
+ import { uuidv7 } from '@modern-admin/core'
15
+ import type { IUploadProvider, UploadedFile } from '../types.js'
16
+
17
+ export interface LocalUploadOptions {
18
+ /**
19
+ * Absolute or process-relative path to the directory where files are stored.
20
+ * Created automatically on first upload.
21
+ */
22
+ uploadDir: string
23
+ /**
24
+ * Public URL prefix (without trailing slash) where uploaded files can be
25
+ * accessed. Used to construct the `url` returned by the upload endpoint and
26
+ * the `urlTemplate` for the frontend.
27
+ *
28
+ * @example '/uploads'
29
+ * @example 'https://static.example.com/uploads'
30
+ */
31
+ baseUrl?: string
32
+ }
33
+
34
+ export class LocalUploadProvider implements IUploadProvider {
35
+ constructor(private readonly options: LocalUploadOptions) {}
36
+
37
+ async upload(file: UploadedFile, key?: string): Promise<string> {
38
+ const resolvedKey = key ?? `${uuidv7()}${extname(file.originalName)}`
39
+ const dest = join(this.options.uploadDir, resolvedKey)
40
+ // Create the full directory tree (handles nested keys like 'avatars/2024/01/uuid.jpg').
41
+ await mkdir(dirname(dest), { recursive: true })
42
+ await writeFile(dest, file.buffer)
43
+ return resolvedKey
44
+ }
45
+
46
+ getUrl(key: string): string {
47
+ const base = this.options.baseUrl ?? '/uploads'
48
+ return `${base}/${key}`
49
+ }
50
+
51
+ async delete(key: string): Promise<void> {
52
+ try {
53
+ await unlink(join(this.options.uploadDir, key))
54
+ } catch (err) {
55
+ if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err
56
+ }
57
+ }
58
+
59
+ urlTemplate(): string {
60
+ const base = this.options.baseUrl ?? '/uploads'
61
+ return `${base}/{key}`
62
+ }
63
+ }
@@ -0,0 +1,226 @@
1
+ /**
2
+ * S3UploadProvider — stores files in an AWS S3 bucket (or any S3-compatible
3
+ * service such as MinIO, Cloudflare R2, DigitalOcean Spaces, etc.).
4
+ *
5
+ * Requires `@aws-sdk/client-s3` to be installed in the host project.
6
+ * For streaming multipart upload of large files, also install `@aws-sdk/lib-storage`.
7
+ * For pre-signed URLs (private buckets), install `@aws-sdk/s3-request-presigner`.
8
+ *
9
+ * @example AWS S3 — public bucket
10
+ * new S3UploadProvider({ bucket: 'my-bucket', region: 'us-east-1', acl: 'public-read' })
11
+ *
12
+ * @example AWS S3 — private bucket with pre-signed URLs (60 min expiry)
13
+ * new S3UploadProvider({ bucket: 'my-bucket', region: 'us-east-1', signed: { expiresIn: 3600 } })
14
+ *
15
+ * @example MinIO / custom endpoint
16
+ * new S3UploadProvider({
17
+ * bucket: 'my-bucket',
18
+ * region: 'us-east-1',
19
+ * endpoint: 'http://localhost:9000',
20
+ * forcePathStyle: true,
21
+ * publicBaseUrl: 'http://localhost:9000/my-bucket',
22
+ * })
23
+ *
24
+ * @example Inject a pre-configured S3Client (share across providers)
25
+ * const s3 = new S3Client({ region: 'us-east-1' })
26
+ * new S3UploadProvider({ bucket: 'my-bucket', region: 'us-east-1' }, s3)
27
+ */
28
+
29
+ import { extname } from 'node:path'
30
+ import { uuidv7 } from '@modern-admin/core'
31
+ import type { IUploadProvider, UploadedFile } from '../types.js'
32
+
33
+ export interface S3UploadOptions {
34
+ /** S3 bucket name. */
35
+ bucket: string
36
+ /** AWS region (e.g. `'us-east-1'`). */
37
+ region: string
38
+ /** AWS access key id. Falls back to environment / credential chain. */
39
+ accessKeyId?: string
40
+ /** AWS secret access key. Falls back to environment / credential chain. */
41
+ secretAccessKey?: string
42
+ /**
43
+ * Custom endpoint for S3-compatible services.
44
+ * @example 'http://localhost:9000'
45
+ * @example 'https://nyc3.digitaloceanspaces.com'
46
+ */
47
+ endpoint?: string
48
+ /** Force path-style URLs (required for some MinIO / custom endpoint setups). */
49
+ forcePathStyle?: boolean
50
+ /** Optional key prefix / "folder". Without trailing slash. */
51
+ prefix?: string
52
+ /**
53
+ * Canned ACL applied to every uploaded object, e.g. `'public-read'`.
54
+ * Omit for private buckets — use `signed` instead.
55
+ */
56
+ acl?: string
57
+ /**
58
+ * Override the public base URL used for `getUrl()` and `urlTemplate()`.
59
+ * Useful when files are served via CloudFront or a custom CDN.
60
+ * Without trailing slash.
61
+ * @example 'https://cdn.example.com'
62
+ */
63
+ publicBaseUrl?: string
64
+ /**
65
+ * Generate pre-signed URLs instead of public URLs.
66
+ * Required for private S3 buckets. Needs `@aws-sdk/s3-request-presigner`.
67
+ *
68
+ * - `true` uses the default expiry (3600 s / 1 hour).
69
+ * - Pass `{ expiresIn: seconds }` to customise.
70
+ *
71
+ * When `signed` is set, `urlTemplate()` is not implemented (each URL is
72
+ * unique and time-limited) and `getUrl()` is async.
73
+ *
74
+ * @example
75
+ * signed: true // 1-hour pre-signed URLs
76
+ * signed: { expiresIn: 60 * 60 * 24 } // 24-hour pre-signed URLs
77
+ */
78
+ signed?: boolean | { expiresIn?: number }
79
+ }
80
+
81
+ export class S3UploadProvider implements IUploadProvider {
82
+
83
+ private _client: any = null
84
+
85
+ constructor(
86
+ private readonly options: S3UploadOptions,
87
+ // Accept a pre-configured S3Client instance (share across providers /
88
+ // inject in tests). When omitted the provider creates its own client.
89
+
90
+ private readonly injectedClient?: any,
91
+ ) {}
92
+
93
+
94
+ private async client(): Promise<any> {
95
+ if (this.injectedClient) return this.injectedClient
96
+ if (this._client) return this._client
97
+ let S3Client: unknown
98
+ try {
99
+ const mod = await import('@aws-sdk/client-s3' as string)
100
+ S3Client = (mod as { S3Client: unknown }).S3Client
101
+ } catch {
102
+ throw new Error(
103
+ '[modern-admin/feature-upload] S3UploadProvider requires @aws-sdk/client-s3. ' +
104
+ 'Install it: bun add @aws-sdk/client-s3',
105
+ )
106
+ }
107
+ const cfg: Record<string, unknown> = {
108
+ region: this.options.region,
109
+ ...(this.options.endpoint ? { endpoint: this.options.endpoint } : {}),
110
+ ...(this.options.forcePathStyle ? { forcePathStyle: true } : {}),
111
+ }
112
+ if (this.options.accessKeyId && this.options.secretAccessKey) {
113
+ cfg.credentials = {
114
+ accessKeyId: this.options.accessKeyId,
115
+ secretAccessKey: this.options.secretAccessKey,
116
+ }
117
+ }
118
+ this._client = new (S3Client as new (cfg: Record<string, unknown>) => unknown)(cfg)
119
+ return this._client
120
+ }
121
+
122
+ async upload(file: UploadedFile, key?: string): Promise<string> {
123
+ const c = await this.client()
124
+ const ext = extname(file.originalName)
125
+ const prefix = this.options.prefix ? `${this.options.prefix}/` : ''
126
+ const resolvedKey = key ?? `${prefix}${uuidv7()}${ext}`
127
+
128
+ // Prefer @aws-sdk/lib-storage for streaming multipart uploads (large files).
129
+ // Fall back to PutObjectCommand if lib-storage is not installed.
130
+ try {
131
+ const libStorage = await import('@aws-sdk/lib-storage' as string)
132
+ const Upload = (libStorage as { Upload: new (i: Record<string, unknown>) => { done(): Promise<unknown> } }).Upload
133
+ const input: Record<string, unknown> = {
134
+ Bucket: this.options.bucket,
135
+ Key: resolvedKey,
136
+ Body: file.buffer,
137
+ ContentType: file.mimeType,
138
+ }
139
+ if (this.options.acl) input.ACL = this.options.acl
140
+ const uploader = new Upload({ client: c, params: input })
141
+ await uploader.done()
142
+ } catch (err) {
143
+ // lib-storage not installed — fall back to PutObjectCommand.
144
+ if ((err as { code?: string }).code === 'ERR_MODULE_NOT_FOUND' ||
145
+ String(err).includes('Cannot find module')) {
146
+ const sdk = await import('@aws-sdk/client-s3' as string)
147
+ const PutObjectCommand = (sdk as { PutObjectCommand: new (i: Record<string, unknown>) => unknown }).PutObjectCommand
148
+ const input: Record<string, unknown> = {
149
+ Bucket: this.options.bucket,
150
+ Key: resolvedKey,
151
+ Body: file.buffer,
152
+ ContentType: file.mimeType,
153
+ ContentLength: file.size,
154
+ }
155
+ if (this.options.acl) input.ACL = this.options.acl
156
+ await (c as { send: (cmd: unknown) => Promise<void> }).send(new PutObjectCommand(input))
157
+ } else {
158
+ throw err
159
+ }
160
+ }
161
+
162
+ return resolvedKey
163
+ }
164
+
165
+ async getUrl(key: string): Promise<string> {
166
+ if (this.options.signed) {
167
+ return this.signedUrl(key)
168
+ }
169
+ return `${this.publicBaseUrl()}/${key}`
170
+ }
171
+
172
+ async delete(key: string): Promise<void> {
173
+ try {
174
+ const sdk = await import('@aws-sdk/client-s3' as string)
175
+ const c = await this.client()
176
+ const DeleteObjectCommand = (sdk as { DeleteObjectCommand: new (i: Record<string, unknown>) => unknown }).DeleteObjectCommand
177
+ await (c as { send: (cmd: unknown) => Promise<void> }).send(
178
+ new DeleteObjectCommand({ Bucket: this.options.bucket, Key: key }),
179
+ )
180
+ } catch {
181
+ // Ignore — file may not exist.
182
+ }
183
+ }
184
+
185
+ /**
186
+ * URL template for the frontend. Only available when `signed` is NOT set
187
+ * (public buckets). For private buckets each URL is unique + time-limited,
188
+ * so the frontend must call the upload endpoint to get a fresh URL.
189
+ */
190
+ urlTemplate(): string | undefined {
191
+ if (this.options.signed) return undefined
192
+ return `${this.publicBaseUrl()}/{key}`
193
+ }
194
+
195
+ private async signedUrl(key: string): Promise<string> {
196
+ let getSignedUrl: unknown
197
+ try {
198
+ const mod = await import('@aws-sdk/s3-request-presigner' as string)
199
+ getSignedUrl = (mod as { getSignedUrl: unknown }).getSignedUrl
200
+ } catch {
201
+ throw new Error(
202
+ '[modern-admin/feature-upload] Signed URLs require @aws-sdk/s3-request-presigner. ' +
203
+ 'Install it: bun add @aws-sdk/s3-request-presigner',
204
+ )
205
+ }
206
+ const sdk = await import('@aws-sdk/client-s3' as string)
207
+ const c = await this.client()
208
+ const GetObjectCommand = (sdk as { GetObjectCommand: new (i: Record<string, unknown>) => unknown }).GetObjectCommand
209
+ const expiresIn =
210
+ typeof this.options.signed === 'object'
211
+ ? (this.options.signed.expiresIn ?? 3600)
212
+ : 3600
213
+
214
+ return (getSignedUrl as (
215
+ client: unknown,
216
+ command: unknown,
217
+ opts: { expiresIn: number },
218
+ ) => Promise<string>)(c, new GetObjectCommand({ Bucket: this.options.bucket, Key: key }), { expiresIn })
219
+ }
220
+
221
+ private publicBaseUrl(): string {
222
+ if (this.options.publicBaseUrl) return this.options.publicBaseUrl
223
+ if (this.options.endpoint) return `${this.options.endpoint}/${this.options.bucket}`
224
+ return `https://${this.options.bucket}.s3.${this.options.region}.amazonaws.com`
225
+ }
226
+ }
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Process-level upload provider registry.
3
+ *
4
+ * `uploadFeature()` registers upload configs here (keyed by a generated id) at
5
+ * feature application time. The NestJS `UploadController` looks up configs by
6
+ * the id stored in the property's `custom.uploadProviderId` field.
7
+ *
8
+ * The registry is intentionally a module-level singleton so it is available
9
+ * before the NestJS DI container is initialised — the same pattern used by
10
+ * `ModernAdminFeatureRegistry` in `@modern-admin/nest`.
11
+ */
12
+
13
+ import type { IUploadProvider } from './types.js'
14
+
15
+ /** Everything stored per upload property registration. */
16
+ export interface RegisteredUploadConfig {
17
+ provider: IUploadProvider
18
+ /** Optional custom key generator (from `UploadPropertyConfig.uploadPath`). */
19
+ uploadPath?: (filename: string) => string
20
+ /** True for multi-file properties — controller will accept N files per request. */
21
+ isArray?: boolean
22
+ }
23
+
24
+ const _registry = new Map<string, RegisteredUploadConfig>()
25
+
26
+ export const UploadProviderRegistry = {
27
+ register(id: string, config: RegisteredUploadConfig): void {
28
+ _registry.set(id, config)
29
+ },
30
+
31
+ get(id: string): RegisteredUploadConfig | undefined {
32
+ return _registry.get(id)
33
+ },
34
+
35
+ /** For test cleanup. */
36
+ clear(): void {
37
+ _registry.clear()
38
+ },
39
+ }
package/src/types.ts ADDED
@@ -0,0 +1,137 @@
1
+ /**
2
+ * Core types for the upload feature plugin.
3
+ *
4
+ * `IUploadProvider` is the single port contract that upload backends must
5
+ * implement. `LocalUploadProvider` and `S3UploadProvider` ship as built-in
6
+ * adapters; custom providers only need to implement these four methods.
7
+ */
8
+
9
+ /** Parsed file data received from a multipart request. */
10
+ export interface UploadedFile {
11
+ /** Original filename from the client. */
12
+ originalName: string
13
+ /** MIME type reported by the client (e.g. 'image/jpeg'). */
14
+ mimeType: string
15
+ /** File size in bytes. */
16
+ size: number
17
+ /** Raw file bytes. */
18
+ buffer: Buffer
19
+ }
20
+
21
+ /** Metadata returned by the upload endpoint. */
22
+ export interface UploadedFileInfo {
23
+ /** Storage key (relative path or object key). Stored in the DB field. */
24
+ key: string
25
+ /** Public URL for browser display / download. */
26
+ url: string
27
+ /** Original filename. */
28
+ name: string
29
+ /** Size in bytes. */
30
+ size: number
31
+ /** MIME type. */
32
+ mimeType: string
33
+ }
34
+
35
+ /**
36
+ * Upload provider port. Implement this interface to add a new storage backend.
37
+ *
38
+ * @example
39
+ * class GcsUploadProvider implements IUploadProvider {
40
+ * async upload(file) { ... }
41
+ * getUrl(key) { ... }
42
+ * async delete(key) { ... }
43
+ * }
44
+ */
45
+ export interface IUploadProvider {
46
+ /**
47
+ * Upload a file and return its storage key.
48
+ * The key is the value that will be persisted in the database field.
49
+ *
50
+ * @param file Parsed file data from the multipart request.
51
+ * @param key Optional pre-computed storage key (from `uploadPath`). When
52
+ * omitted the provider generates a key internally (UUID + extension).
53
+ */
54
+ upload(file: UploadedFile, key?: string): Promise<string>
55
+
56
+ /**
57
+ * Compute the public URL for a stored key. May be sync or async.
58
+ * Used both by the upload endpoint (to return the URL) and by the frontend
59
+ * URL template when `urlTemplate()` is not implemented.
60
+ */
61
+ getUrl(key: string): string | Promise<string>
62
+
63
+ /**
64
+ * Delete the file identified by `key`. Must not throw if the file does not
65
+ * exist (e.g. was already deleted or was never stored).
66
+ */
67
+ delete(key: string): Promise<void>
68
+
69
+ /**
70
+ * Optional URL template string for the frontend to construct display URLs
71
+ * from stored keys without calling the backend.
72
+ * Use `{key}` as the placeholder, e.g. `'https://cdn.example.com/{key}'`.
73
+ * Return `undefined` (or omit the method) when URLs cannot be statically
74
+ * computed (e.g. signed/pre-signed S3 URLs — each URL is unique and time-limited).
75
+ */
76
+ urlTemplate?(): string | undefined
77
+ }
78
+
79
+ /** Per-property upload configuration passed to `uploadFeature()`. */
80
+ export interface UploadPropertyConfig {
81
+ /** Storage provider for this property. */
82
+ provider: IUploadProvider
83
+ /**
84
+ * Allowed MIME type patterns (same syntax as the HTML `accept` attribute).
85
+ * Examples: `['image/*']`, `['image/jpeg', 'application/pdf']`.
86
+ * Enforcement is advisory on the frontend; the controller does not re-validate.
87
+ */
88
+ mimeTypes?: string[]
89
+ /** Maximum upload size in bytes. Advisory (frontend warning only). */
90
+ maxSize?: number
91
+ /**
92
+ * Treat the property as an array of file keys (multi-file upload).
93
+ * When true, the property is stored as `string[]`, the editor allows
94
+ * uploading multiple files (a single multipart request can include several),
95
+ * and the action hooks diff arrays on edit and delete every key on delete.
96
+ */
97
+ isArray?: boolean
98
+ /**
99
+ * Custom storage key generator. Receives the original filename from the
100
+ * client and returns the key to use for storage.
101
+ *
102
+ * Use this to organise files into sub-directories, include the resource
103
+ * name, add timestamps, etc. The provider's internal UUID generator is
104
+ * used when this is omitted.
105
+ *
106
+ * @example
107
+ * // avatars/2024/01/uuid.jpg
108
+ * uploadPath: (filename) => {
109
+ * const ext = filename.split('.').pop()
110
+ * const d = new Date()
111
+ * return `avatars/${d.getFullYear()}/${String(d.getMonth()+1).padStart(2,'0')}/${uuidv7()}.${ext}`
112
+ * }
113
+ *
114
+ * @example
115
+ * // resource-specific prefix
116
+ * uploadPath: (filename) => `products/images/${uuidv7()}-${filename}`
117
+ */
118
+ uploadPath?: (filename: string) => string
119
+ }
120
+
121
+ /** Options passed to `uploadFeature()`. */
122
+ export interface UploadFeatureOptions {
123
+ /**
124
+ * Map from property path to its upload configuration.
125
+ * Each entry makes the property a `type: 'file'` field wired to the
126
+ * specified provider.
127
+ *
128
+ * @example
129
+ * uploadFeature({
130
+ * properties: {
131
+ * avatar: { provider: new LocalUploadProvider({ uploadDir: './uploads', baseUrl: '/uploads' }) },
132
+ * resume: { provider: new S3UploadProvider({ bucket: 'my-bucket', region: 'us-east-1' }), mimeTypes: ['application/pdf'] },
133
+ * },
134
+ * })
135
+ */
136
+ properties: Record<string, UploadPropertyConfig>
137
+ }
@@ -0,0 +1,208 @@
1
+ /**
2
+ * `uploadFeature` — resource plugin that wires file upload into any resource.
3
+ *
4
+ * Returns a `FeatureFn` that, when applied to a resource's `ResourceOptions`,
5
+ * marks the configured properties as `type: 'file'` (with `isArray` when
6
+ * configured), registers the upload providers in `UploadProviderRegistry`, and
7
+ * installs action hooks that:
8
+ *
9
+ * - on `new.after` / `edit.after` — confirm freshly-uploaded keys against
10
+ * `PendingUploadsRegistry` so the orphan sweeper leaves them alone;
11
+ * - on `edit.after` — delete files whose key was replaced or removed
12
+ * (single-value: old !== new; array: keys present in old but missing
13
+ * from new);
14
+ * - on `delete.after` — delete every file referenced by the deleted record.
15
+ *
16
+ * Hooks are **chained**, not replaced: if the incoming `ResourceOptions`
17
+ * already has hooks (e.g. from another feature), the upload hooks are
18
+ * appended so all hooks run in order.
19
+ *
20
+ * @example
21
+ * uploadFeature({
22
+ * properties: {
23
+ * thumbnail: { provider: localProvider, mimeTypes: ['image/*'] },
24
+ * gallery: { provider: localProvider, isArray: true, mimeTypes: ['image/*'] },
25
+ * },
26
+ * })
27
+ */
28
+
29
+ import { uuidv7, type ActionRequest, type ActionResponse, type FeatureFn, type ResourceOptions } from '@modern-admin/core'
30
+ import type { UploadFeatureOptions, UploadPropertyConfig } from './types.js'
31
+ import { UploadProviderRegistry } from './registry.js'
32
+ import { PendingUploadsRegistry } from './pending-registry.js'
33
+
34
+ interface RegisteredProp {
35
+ providerId: string
36
+ config: UploadPropertyConfig
37
+ }
38
+
39
+ // ─── Hook chaining ────────────────────────────────────────────────────────────
40
+
41
+ type HookFn = (
42
+ response: ActionResponse,
43
+ request: ActionRequest,
44
+ context: unknown,
45
+ ) => ActionResponse | Promise<ActionResponse>
46
+
47
+ /** Normalise a hook value (fn | fn[] | undefined) into an array. */
48
+ function toArray(hook: unknown): HookFn[] {
49
+ if (!hook) return []
50
+ return Array.isArray(hook) ? (hook as HookFn[]) : [hook as HookFn]
51
+ }
52
+
53
+ /**
54
+ * Merge `after` hooks from an existing action override with new hooks,
55
+ * producing an array that runs all of them in order.
56
+ * If `existing` already defines `after`, the new hooks are appended.
57
+ */
58
+ function mergeAfterHook(
59
+ existing: Record<string, unknown> | undefined,
60
+ newHook: HookFn,
61
+ ): HookFn[] {
62
+ const existing_after = toArray(existing?.after)
63
+ return [...existing_after, newHook]
64
+ }
65
+
66
+ // ─── Value helpers ────────────────────────────────────────────────────────────
67
+
68
+ /** Return non-empty file keys for the given value (handles single + array). */
69
+ function toKeys(value: unknown): string[] {
70
+ if (value == null || value === '') return []
71
+ if (Array.isArray(value)) {
72
+ return value.flatMap((v) => (v == null || v === '' ? [] : [String(v)]))
73
+ }
74
+ return [String(value)]
75
+ }
76
+
77
+ // ─── Feature function ─────────────────────────────────────────────────────────
78
+
79
+ export function uploadFeature(options: UploadFeatureOptions): FeatureFn {
80
+ // Register configs immediately — before the FeatureFn is called.
81
+ const registered = new Map<string, RegisteredProp>()
82
+
83
+ for (const [propPath, config] of Object.entries(options.properties)) {
84
+ const providerId = `up_${uuidv7().replace(/-/g, '')}`
85
+ UploadProviderRegistry.register(providerId, {
86
+ provider: config.provider,
87
+ uploadPath: config.uploadPath,
88
+ isArray: config.isArray ?? false,
89
+ })
90
+ registered.set(propPath, { providerId, config })
91
+ }
92
+
93
+ return (resourceOptions: ResourceOptions): ResourceOptions => {
94
+ // --- Property overrides ---
95
+ const propOverrides: ResourceOptions['properties'] = {}
96
+ for (const [propPath, { providerId, config }] of registered) {
97
+ // S3 with signed URLs has no static URL template.
98
+ const urlTmpl = config.provider.urlTemplate?.() ?? null
99
+ propOverrides[propPath] = {
100
+ type: 'file',
101
+ ...(config.isArray ? { isArray: true } : {}),
102
+ custom: {
103
+ uploadProviderId: providerId,
104
+ uploadUrlTemplate: urlTmpl,
105
+ uploadMimeTypes: config.mimeTypes ?? null,
106
+ uploadMaxSize: config.maxSize ?? null,
107
+ },
108
+ }
109
+ }
110
+
111
+ // --- Action hooks ---
112
+
113
+ // After new: confirm every freshly-uploaded key against the pending registry.
114
+ const newAfterHook: HookFn = (
115
+ response: ActionResponse,
116
+ _request: ActionRequest,
117
+ _context: unknown,
118
+ ): ActionResponse => {
119
+ const rec = response as { record?: { params?: Record<string, unknown> } }
120
+ const params = rec.record?.params ?? {}
121
+ const newKeys: string[] = []
122
+ for (const propPath of registered.keys()) {
123
+ newKeys.push(...toKeys(params[propPath]))
124
+ }
125
+ if (newKeys.length > 0) PendingUploadsRegistry.confirm(newKeys)
126
+ return response
127
+ }
128
+
129
+ // After edit: delete keys that disappeared and confirm keys that arrived.
130
+ const editAfterHook: HookFn = async (
131
+ response: ActionResponse,
132
+ _request: ActionRequest,
133
+ context: unknown,
134
+ ): Promise<ActionResponse> => {
135
+ const ctx = context as { record?: { get(path: string): unknown } }
136
+ const rec = response as { record?: { params?: Record<string, unknown> } }
137
+ const params = rec.record?.params ?? {}
138
+ for (const [propPath, { config }] of registered) {
139
+ const oldKeys = new Set(toKeys(ctx.record?.get(propPath)))
140
+ const newKeys = toKeys(params[propPath])
141
+ // Confirm new keys (possibly fresh uploads).
142
+ if (newKeys.length > 0) PendingUploadsRegistry.confirm(newKeys)
143
+ // Delete keys that were present before and are gone now.
144
+ const newSet = new Set(newKeys)
145
+ for (const k of oldKeys) {
146
+ if (!newSet.has(k)) {
147
+ try {
148
+ await config.provider.delete(k)
149
+ } catch {
150
+ // Non-fatal — don't break the action response.
151
+ }
152
+ }
153
+ }
154
+ }
155
+ return response
156
+ }
157
+
158
+ // After delete: delete every file referenced by the (now-removed) record.
159
+ const deleteAfterHook: HookFn = async (
160
+ response: ActionResponse,
161
+ _request: ActionRequest,
162
+ context: unknown,
163
+ ): Promise<ActionResponse> => {
164
+ const ctx = context as { record?: { get(path: string): unknown } }
165
+ for (const [propPath, { config }] of registered) {
166
+ const keys = toKeys(ctx.record?.get(propPath))
167
+ for (const k of keys) {
168
+ try {
169
+ await config.provider.delete(k)
170
+ } catch {
171
+ // Non-fatal.
172
+ }
173
+ }
174
+ }
175
+ return response
176
+ }
177
+
178
+ // Retrieve any existing action overrides so we can chain, not replace.
179
+ const existingActions = resourceOptions.actions as Record<string, Record<string, unknown>> | undefined
180
+ const existingNew = existingActions?.['new']
181
+ const existingEdit = existingActions?.['edit']
182
+ const existingDelete = existingActions?.['delete']
183
+
184
+ const actionOverrides = {
185
+ new: {
186
+ ...existingNew,
187
+ after: mergeAfterHook(existingNew, newAfterHook),
188
+ },
189
+ edit: {
190
+ ...existingEdit,
191
+ after: mergeAfterHook(existingEdit, editAfterHook),
192
+ },
193
+ delete: {
194
+ ...existingDelete,
195
+ after: mergeAfterHook(existingDelete, deleteAfterHook),
196
+ },
197
+ } as ResourceOptions['actions']
198
+
199
+ return {
200
+ ...resourceOptions,
201
+ properties: { ...resourceOptions.properties, ...propOverrides },
202
+ actions: {
203
+ ...(resourceOptions.actions ?? {}),
204
+ ...actionOverrides,
205
+ },
206
+ }
207
+ }
208
+ }