create-forgeon 0.3.16 → 0.3.18

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 (109) hide show
  1. package/package.json +1 -1
  2. package/src/cli/add-options.test.mjs +5 -2
  3. package/src/cli/options.test.mjs +1 -0
  4. package/src/cli/prompt-select.test.mjs +1 -0
  5. package/src/core/docs.test.mjs +1 -0
  6. package/src/core/scaffold.test.mjs +1 -0
  7. package/src/core/validate.test.mjs +1 -0
  8. package/src/modules/accounts.mjs +416 -0
  9. package/src/modules/db-prisma.mjs +5 -9
  10. package/src/modules/dependencies.test.mjs +71 -29
  11. package/src/modules/executor.mjs +3 -2
  12. package/src/modules/executor.test.mjs +521 -477
  13. package/src/modules/files-access.mjs +9 -7
  14. package/src/modules/files-image.mjs +9 -7
  15. package/src/modules/files-local.mjs +15 -6
  16. package/src/modules/files-quotas.mjs +8 -6
  17. package/src/modules/files-s3.mjs +17 -6
  18. package/src/modules/files.mjs +21 -21
  19. package/src/modules/idempotency.test.mjs +13 -7
  20. package/src/modules/probes.test.mjs +4 -2
  21. package/src/modules/queue.mjs +9 -6
  22. package/src/modules/rate-limit.mjs +14 -10
  23. package/src/modules/rbac.mjs +12 -11
  24. package/src/modules/registry.mjs +22 -35
  25. package/src/modules/scheduler.mjs +9 -6
  26. package/src/modules/shared/files-runtime-wiring.mjs +81 -0
  27. package/src/modules/shared/patch-utils.mjs +29 -1
  28. package/src/modules/sync-integrations.mjs +102 -422
  29. package/src/modules/sync-integrations.test.mjs +32 -111
  30. package/src/run-add-module.test.mjs +1 -0
  31. package/templates/base/scripts/forgeon-sync-integrations.mjs +65 -325
  32. package/templates/module-fragments/{jwt-auth → accounts}/00_title.md +2 -1
  33. package/templates/module-fragments/{jwt-auth → accounts}/10_overview.md +5 -5
  34. package/templates/module-fragments/accounts/20_scope.md +29 -0
  35. package/templates/module-fragments/accounts/90_status_implemented.md +8 -0
  36. package/templates/module-fragments/accounts/90_status_planned.md +7 -0
  37. package/templates/module-fragments/rbac/30_what_it_adds.md +3 -2
  38. package/templates/module-fragments/rbac/40_how_it_works.md +2 -1
  39. package/templates/module-fragments/rbac/50_how_to_use.md +2 -1
  40. package/templates/module-fragments/swagger/20_scope.md +2 -1
  41. package/templates/module-presets/accounts/apps/api/prisma/migrations/0002_accounts_core/migration.sql +97 -0
  42. package/templates/module-presets/accounts/apps/api/src/accounts/forgeon-accounts-db-prisma.module.ts +17 -0
  43. package/templates/module-presets/accounts/apps/api/src/accounts/prisma-accounts-persistence.store.ts +332 -0
  44. package/templates/module-presets/{jwt-auth/packages/auth-api → accounts/packages/accounts-api}/package.json +5 -5
  45. package/templates/module-presets/accounts/packages/accounts-api/src/accounts-email.port.ts +13 -0
  46. package/templates/module-presets/accounts/packages/accounts-api/src/accounts-persistence.port.ts +67 -0
  47. package/templates/module-presets/accounts/packages/accounts-api/src/accounts-rbac.port.ts +14 -0
  48. package/templates/module-presets/{jwt-auth/packages/auth-api → accounts/packages/accounts-api}/src/auth-config.loader.ts +7 -7
  49. package/templates/module-presets/{jwt-auth/packages/auth-api → accounts/packages/accounts-api}/src/auth-config.service.ts +7 -7
  50. package/templates/module-presets/accounts/packages/accounts-api/src/auth-core.service.ts +318 -0
  51. package/templates/module-presets/{jwt-auth/packages/auth-api → accounts/packages/accounts-api}/src/auth-env.schema.ts +4 -4
  52. package/templates/module-presets/accounts/packages/accounts-api/src/auth-jwt.service.ts +58 -0
  53. package/templates/module-presets/accounts/packages/accounts-api/src/auth-password.service.ts +21 -0
  54. package/templates/module-presets/accounts/packages/accounts-api/src/auth.controller.ts +93 -0
  55. package/templates/module-presets/accounts/packages/accounts-api/src/auth.service.ts +48 -0
  56. package/templates/module-presets/accounts/packages/accounts-api/src/auth.types.ts +17 -0
  57. package/templates/module-presets/accounts/packages/accounts-api/src/dto/change-password.dto.ts +13 -0
  58. package/templates/module-presets/accounts/packages/accounts-api/src/dto/confirm-password-reset.dto.ts +12 -0
  59. package/templates/module-presets/accounts/packages/accounts-api/src/dto/index.ts +10 -0
  60. package/templates/module-presets/{jwt-auth/packages/auth-api → accounts/packages/accounts-api}/src/dto/login.dto.ts +1 -1
  61. package/templates/module-presets/{jwt-auth/packages/auth-api → accounts/packages/accounts-api}/src/dto/refresh.dto.ts +1 -1
  62. package/templates/module-presets/accounts/packages/accounts-api/src/dto/register.dto.ts +23 -0
  63. package/templates/module-presets/accounts/packages/accounts-api/src/dto/request-password-reset.dto.ts +7 -0
  64. package/templates/module-presets/accounts/packages/accounts-api/src/dto/update-user-profile.dto.ts +16 -0
  65. package/templates/module-presets/accounts/packages/accounts-api/src/dto/update-user-settings.dto.ts +16 -0
  66. package/templates/module-presets/accounts/packages/accounts-api/src/dto/update-user.dto.ts +8 -0
  67. package/templates/module-presets/accounts/packages/accounts-api/src/dto/verify-email.dto.ts +8 -0
  68. package/templates/module-presets/accounts/packages/accounts-api/src/forgeon-accounts.module.ts +82 -0
  69. package/templates/module-presets/accounts/packages/accounts-api/src/index.ts +24 -0
  70. package/templates/module-presets/{jwt-auth/packages/auth-api → accounts/packages/accounts-api}/src/jwt.strategy.ts +3 -3
  71. package/templates/module-presets/accounts/packages/accounts-api/src/owner-access.guard.ts +39 -0
  72. package/templates/module-presets/accounts/packages/accounts-api/src/users-config.ts +13 -0
  73. package/templates/module-presets/accounts/packages/accounts-api/src/users.controller.ts +65 -0
  74. package/templates/module-presets/accounts/packages/accounts-api/src/users.service.ts +87 -0
  75. package/templates/module-presets/accounts/packages/accounts-api/src/users.types.ts +65 -0
  76. package/templates/module-presets/{jwt-auth/packages/auth-contracts → accounts/packages/accounts-contracts}/package.json +1 -1
  77. package/templates/module-presets/accounts/packages/accounts-contracts/src/index.ts +119 -0
  78. package/templates/module-presets/db-prisma/apps/api/prisma/seed.ts +40 -19
  79. package/templates/module-presets/files/apps/api/src/files/forgeon-files-db-prisma.module.ts +17 -0
  80. package/templates/module-presets/files/apps/api/src/files/prisma-files-persistence.store.ts +164 -0
  81. package/templates/module-presets/files/packages/files/package.json +1 -2
  82. package/templates/module-presets/files/packages/files/src/files.ports.ts +107 -0
  83. package/templates/module-presets/files/packages/files/src/files.service.ts +81 -395
  84. package/templates/module-presets/files/packages/files/src/forgeon-files.module.ts +126 -2
  85. package/templates/module-presets/files/packages/files/src/index.ts +2 -1
  86. package/templates/module-presets/files-local/packages/files-local/src/forgeon-files-local-storage.module.ts +18 -0
  87. package/templates/module-presets/files-local/packages/files-local/src/index.ts +2 -0
  88. package/templates/module-presets/files-local/packages/files-local/src/local-files-storage.adapter.ts +53 -0
  89. package/templates/module-presets/files-s3/packages/files-s3/src/forgeon-files-s3-storage.module.ts +18 -0
  90. package/templates/module-presets/files-s3/packages/files-s3/src/index.ts +2 -0
  91. package/templates/module-presets/files-s3/packages/files-s3/src/s3-files-storage.adapter.ts +130 -0
  92. package/src/modules/jwt-auth.mjs +0 -271
  93. package/templates/module-fragments/jwt-auth/20_scope.md +0 -19
  94. package/templates/module-fragments/jwt-auth/90_status_implemented.md +0 -8
  95. package/templates/module-fragments/jwt-auth/90_status_planned.md +0 -3
  96. package/templates/module-presets/jwt-auth/apps/api/prisma/migrations/0002_auth_refresh_token_hash/migration.sql +0 -3
  97. package/templates/module-presets/jwt-auth/apps/api/src/auth/prisma-auth-refresh-token.store.ts +0 -36
  98. package/templates/module-presets/jwt-auth/packages/auth-api/src/auth-refresh-token.store.ts +0 -23
  99. package/templates/module-presets/jwt-auth/packages/auth-api/src/auth.controller.ts +0 -71
  100. package/templates/module-presets/jwt-auth/packages/auth-api/src/auth.service.ts +0 -175
  101. package/templates/module-presets/jwt-auth/packages/auth-api/src/auth.types.ts +0 -6
  102. package/templates/module-presets/jwt-auth/packages/auth-api/src/dto/index.ts +0 -2
  103. package/templates/module-presets/jwt-auth/packages/auth-api/src/forgeon-auth.module.ts +0 -47
  104. package/templates/module-presets/jwt-auth/packages/auth-api/src/index.ts +0 -12
  105. package/templates/module-presets/jwt-auth/packages/auth-contracts/src/index.ts +0 -47
  106. /package/templates/module-presets/{jwt-auth/packages/auth-api/src/jwt-auth.guard.ts → accounts/packages/accounts-api/src/access-token.guard.ts} +0 -0
  107. /package/templates/module-presets/{jwt-auth/packages/auth-api → accounts/packages/accounts-api}/src/auth-config.module.ts +0 -0
  108. /package/templates/module-presets/{jwt-auth/packages/auth-api → accounts/packages/accounts-api}/tsconfig.json +0 -0
  109. /package/templates/module-presets/{jwt-auth/packages/auth-contracts → accounts/packages/accounts-contracts}/tsconfig.json +0 -0
@@ -1,41 +1,28 @@
1
- import fs from 'node:fs';
2
- import { promises as fsPromises } from 'node:fs';
3
- import path from 'node:path';
4
1
  import crypto from 'node:crypto';
2
+ import path from 'node:path';
5
3
  import { Readable } from 'node:stream';
6
4
  import {
7
5
  BadRequestException,
8
- InternalServerErrorException,
6
+ Inject,
9
7
  Injectable,
8
+ InternalServerErrorException,
10
9
  NotFoundException,
11
10
  ServiceUnavailableException,
12
11
  } from '@nestjs/common';
13
- import { ConfigService } from '@nestjs/config';
14
- import { PrismaService } from '@forgeon/db-prisma';
15
12
  import { FilesConfigService } from './files-config.service';
13
+ import {
14
+ FILES_PERSISTENCE_PORT,
15
+ FILES_STORAGE_ADAPTER,
16
+ } from './files.ports';
17
+ import type {
18
+ FilesBlobRecord,
19
+ FilesBlobRef,
20
+ FilesPersistencePort,
21
+ FilesRecordAggregate,
22
+ FilesStorageAdapter,
23
+ } from './files.ports';
16
24
  import type { FileRecordDto, FileVariantKey, StoredFileInput } from './files.types';
17
25
 
18
- type S3ModuleLike = {
19
- S3Client: new (config: Record<string, unknown>) => {
20
- send: (command: unknown) => Promise<{
21
- Body?: unknown;
22
- }>;
23
- };
24
- PutObjectCommand: new (input: Record<string, unknown>) => unknown;
25
- GetObjectCommand: new (input: Record<string, unknown>) => unknown;
26
- DeleteObjectCommand: new (input: Record<string, unknown>) => unknown;
27
- };
28
-
29
- type BlobRef = {
30
- id: string;
31
- hash: string;
32
- size: number;
33
- mimeType: string;
34
- storageDriver: string;
35
- storageKey: string;
36
- created: boolean;
37
- };
38
-
39
26
  type PrismaLikeError = {
40
27
  code?: unknown;
41
28
  };
@@ -56,17 +43,11 @@ type PersistedVariant = {
56
43
 
57
44
  @Injectable()
58
45
  export class FilesService {
59
- private s3Client:
60
- | {
61
- send: (command: unknown) => Promise<{
62
- Body?: unknown;
63
- }>;
64
- }
65
- | null = null;
66
-
67
46
  constructor(
68
- private readonly prisma: PrismaService,
69
- private readonly configService: ConfigService,
47
+ @Inject(FILES_PERSISTENCE_PORT)
48
+ private readonly persistence: FilesPersistencePort,
49
+ @Inject(FILES_STORAGE_ADAPTER)
50
+ private readonly storageAdapter: FilesStorageAdapter,
70
51
  private readonly filesConfigService: FilesConfigService,
71
52
  ) {}
72
53
 
@@ -88,19 +69,17 @@ export class FilesService {
88
69
  createdBlobIds.push(originalBlob.id);
89
70
  }
90
71
 
91
- const record = await this.prisma.fileRecord.create({
92
- data: {
93
- publicId: this.generatePublicId(),
94
- storageKey: originalBlob.storageKey,
95
- originalName: input.originalName,
96
- mimeType: preparedOriginal.mimeType,
97
- size: preparedOriginal.size,
98
- storageDriver: originalBlob.storageDriver,
99
- ownerType: input.ownerType ?? 'system',
100
- ownerId: input.ownerId ?? null,
101
- visibility: input.visibility ?? 'private',
102
- createdById: input.createdById ?? null,
103
- },
72
+ const record = await this.persistence.createFileRecord({
73
+ publicId: this.generatePublicId(),
74
+ storageKey: originalBlob.storageKey,
75
+ originalName: input.originalName,
76
+ mimeType: preparedOriginal.mimeType,
77
+ size: preparedOriginal.size,
78
+ storageDriver: originalBlob.storageDriver,
79
+ ownerType: input.ownerType ?? 'system',
80
+ ownerId: input.ownerId ?? null,
81
+ visibility: input.visibility ?? 'private',
82
+ createdById: input.createdById ?? null,
104
83
  });
105
84
  recordId = record.id;
106
85
 
@@ -131,8 +110,8 @@ export class FilesService {
131
110
  persistedVariantBlobIds.push(previewBlob.id);
132
111
  }
133
112
 
134
- await this.prisma.fileVariant.createMany({
135
- data: persistedVariants.map((item, index) => ({
113
+ await this.persistence.createVariants(
114
+ persistedVariants.map((item, index) => ({
136
115
  fileId: record.id,
137
116
  variantKey: item.variantKey,
138
117
  blobId: persistedVariantBlobIds[index],
@@ -140,12 +119,12 @@ export class FilesService {
140
119
  size: item.size,
141
120
  status: item.status,
142
121
  })),
143
- });
122
+ );
144
123
 
145
124
  return this.getByPublicId(record.publicId);
146
125
  } catch (error) {
147
126
  if (recordId) {
148
- await this.prisma.fileRecord.delete({ where: { id: recordId } }).catch(() => undefined);
127
+ await this.persistence.deleteFileRecordById(recordId).catch(() => undefined);
149
128
  }
150
129
  await this.cleanupCreatedBlobs(createdBlobIds);
151
130
  throw error;
@@ -165,33 +144,16 @@ export class FilesService {
165
144
  }
166
145
 
167
146
  async deleteByPublicId(publicId: string): Promise<{ deleted: boolean }> {
168
- const record = await this.prisma.fileRecord.findUnique({
169
- where: { publicId },
170
- include: {
171
- variants: {
172
- select: {
173
- blobId: true,
174
- blob: {
175
- select: {
176
- storageDriver: true,
177
- storageKey: true,
178
- },
179
- },
180
- },
181
- },
182
- },
183
- });
147
+ const record = await this.persistence.findFileRecordForDelete(publicId);
184
148
  if (!record) {
185
149
  throw new NotFoundException('File not found');
186
150
  }
187
151
 
188
- const blobIds = record.variants.map((variant) => variant.blobId);
189
- await this.prisma.fileRecord.delete({
190
- where: { publicId },
191
- });
152
+ const blobIds = (record.variants ?? []).map((variant) => variant.blobId);
153
+ await this.persistence.deleteFileRecordByPublicId(publicId);
192
154
  await this.cleanupReferencedBlobs(blobIds);
193
155
 
194
- if (record.variants.length === 0) {
156
+ if ((record.variants ?? []).length === 0) {
195
157
  await this.deleteStoredContent(record.storageDriver, record.storageKey).catch(() => undefined);
196
158
  }
197
159
 
@@ -199,16 +161,7 @@ export class FilesService {
199
161
  }
200
162
 
201
163
  async getByPublicId(publicId: string): Promise<FileRecordDto> {
202
- const record = await this.prisma.fileRecord.findUnique({
203
- where: { publicId },
204
- include: {
205
- variants: {
206
- select: {
207
- variantKey: true,
208
- },
209
- },
210
- },
211
- });
164
+ const record = await this.persistence.findFileRecordWithVariantKeys(publicId);
212
165
  if (!record) {
213
166
  throw new NotFoundException('File not found');
214
167
  }
@@ -216,23 +169,7 @@ export class FilesService {
216
169
  }
217
170
 
218
171
  async getOwnerUsage(ownerType: string, ownerId: string): Promise<{ filesCount: number; totalBytes: number }> {
219
- const aggregate = await this.prisma.fileRecord.aggregate({
220
- where: {
221
- ownerType,
222
- ownerId,
223
- },
224
- _count: {
225
- _all: true,
226
- },
227
- _sum: {
228
- size: true,
229
- },
230
- });
231
-
232
- return {
233
- filesCount: aggregate._count._all ?? 0,
234
- totalBytes: aggregate._sum.size ?? 0,
235
- };
172
+ return this.persistence.countOwnerUsage(ownerType, ownerId);
236
173
  }
237
174
 
238
175
  async openDownload(publicId: string, variant: FileVariantKey = 'original'): Promise<{
@@ -240,69 +177,36 @@ export class FilesService {
240
177
  mimeType: string;
241
178
  fileName: string;
242
179
  }> {
243
- const record = await this.prisma.fileRecord.findUnique({
244
- where: { publicId },
245
- include: {
246
- variants: {
247
- select: {
248
- variantKey: true,
249
- blob: {
250
- select: {
251
- storageDriver: true,
252
- storageKey: true,
253
- },
254
- },
255
- mimeType: true,
256
- size: true,
257
- },
258
- },
259
- },
260
- });
180
+ const record = await this.persistence.findFileRecordForDownload(publicId);
261
181
  if (!record) {
262
182
  throw new NotFoundException('File not found');
263
183
  }
264
184
 
265
185
  const selectedVariant =
266
- record.variants.find((item) => item.variantKey === variant) ??
186
+ record.variants?.find((item) => item.variantKey === variant) ??
267
187
  (variant === 'original'
268
188
  ? {
269
189
  variantKey: 'original',
190
+ blobId: 'original',
270
191
  blob: {
271
192
  storageDriver: record.storageDriver,
272
193
  storageKey: record.storageKey,
273
194
  },
274
195
  mimeType: record.mimeType,
275
196
  size: record.size,
197
+ status: 'ready',
276
198
  }
277
199
  : null);
278
200
 
279
- if (!selectedVariant) {
201
+ if (!selectedVariant?.blob) {
280
202
  throw new NotFoundException('File variant not found');
281
203
  }
282
204
 
283
- switch (selectedVariant.blob.storageDriver) {
284
- case 'local': {
285
- const absolutePath = path.resolve(process.cwd(), this.resolveLocalRootDir(), selectedVariant.blob.storageKey);
286
- if (!fs.existsSync(absolutePath)) {
287
- throw new NotFoundException('File content not found');
288
- }
289
- return {
290
- stream: fs.createReadStream(absolutePath),
291
- mimeType: selectedVariant.mimeType,
292
- fileName: this.buildVariantFileName(record.originalName, variant, selectedVariant.mimeType),
293
- };
294
- }
295
- case 's3': {
296
- const stream = await this.openS3(selectedVariant.blob.storageKey);
297
- return {
298
- stream,
299
- mimeType: selectedVariant.mimeType,
300
- fileName: this.buildVariantFileName(record.originalName, variant, selectedVariant.mimeType),
301
- };
302
- }
303
- default:
304
- throw new ServiceUnavailableException('Unknown files storage driver');
305
- }
205
+ return {
206
+ stream: await this.openStoredContent(selectedVariant.blob.storageDriver, selectedVariant.blob.storageKey),
207
+ mimeType: selectedVariant.mimeType,
208
+ fileName: this.buildVariantFileName(record.originalName, variant, selectedVariant.mimeType),
209
+ };
306
210
  }
307
211
 
308
212
  async getVariantsProbeStatus(): Promise<{
@@ -349,18 +253,11 @@ export class FilesService {
349
253
  private async store(buffer: Buffer, originalName: string): Promise<{ storageKey: string }> {
350
254
  const extension = path.extname(originalName).toLowerCase();
351
255
  const fileName = `${Date.now()}-${crypto.randomUUID()}${extension}`;
352
- switch (this.filesConfigService.storageDriver) {
353
- case 'local':
354
- return this.storeLocal(buffer, fileName);
355
- case 's3':
356
- return this.storeS3(buffer, fileName);
357
- default:
358
- throw new ServiceUnavailableException('Unknown files storage driver');
359
- }
256
+ return this.storageAdapter.put(buffer, fileName);
360
257
  }
361
258
 
362
- private async getOrCreateBlob(input: PreparedStoredFile, dedupe: boolean): Promise<BlobRef> {
363
- const storageDriver = this.filesConfigService.storageDriver;
259
+ private async getOrCreateBlob(input: PreparedStoredFile, dedupe: boolean): Promise<FilesBlobRef> {
260
+ const storageDriver = this.storageAdapter.driver;
364
261
  const hash = this.computeContentHash(input.buffer);
365
262
 
366
263
  if (dedupe) {
@@ -372,14 +269,12 @@ export class FilesService {
372
269
 
373
270
  const stored = await this.store(input.buffer, input.fileName);
374
271
  try {
375
- const created = await this.prisma.fileBlob.create({
376
- data: {
377
- hash,
378
- size: input.size,
379
- mimeType: input.mimeType,
380
- storageDriver,
381
- storageKey: stored.storageKey,
382
- },
272
+ const created = await this.persistence.createBlob({
273
+ hash,
274
+ size: input.size,
275
+ mimeType: input.mimeType,
276
+ storageDriver,
277
+ storageKey: stored.storageKey,
383
278
  });
384
279
 
385
280
  return {
@@ -425,197 +320,22 @@ export class FilesService {
425
320
  return false;
426
321
  }
427
322
 
428
- private async deleteStoredContent(storageDriver: string, storageKey: string): Promise<void> {
429
- switch (storageDriver) {
430
- case 'local':
431
- await this.deleteLocal(storageKey);
432
- return;
433
- case 's3':
434
- await this.deleteS3(storageKey);
435
- return;
436
- default:
437
- throw new ServiceUnavailableException('Unknown files storage driver');
438
- }
439
- }
440
-
441
- private async storeLocal(buffer: Buffer, storageKey: string): Promise<{ storageKey: string }> {
442
- const rootDir = this.resolveLocalRootDir();
443
- const absoluteRoot = path.resolve(process.cwd(), rootDir);
444
- const absolutePath = path.join(absoluteRoot, storageKey);
445
-
446
- await fsPromises.mkdir(absoluteRoot, { recursive: true });
447
- await fsPromises.writeFile(absolutePath, buffer);
448
-
449
- return { storageKey };
450
- }
451
-
452
- private async storeS3(buffer: Buffer, storageKey: string): Promise<{ storageKey: string }> {
453
- const { PutObjectCommand } = await this.loadS3Module();
454
- const client = await this.getS3Client();
455
- const config = this.resolveS3Config();
456
-
457
- await client.send(
458
- new PutObjectCommand({
459
- Bucket: config.bucket,
460
- Key: storageKey,
461
- Body: buffer,
462
- }),
463
- );
464
-
465
- return { storageKey };
466
- }
467
-
468
- private async openS3(storageKey: string): Promise<Readable> {
469
- const { GetObjectCommand } = await this.loadS3Module();
470
- const client = await this.getS3Client();
471
- const config = this.resolveS3Config();
472
-
473
- const response = await client.send(
474
- new GetObjectCommand({
475
- Bucket: config.bucket,
476
- Key: storageKey,
477
- }),
478
- );
479
- if (!response.Body) {
480
- throw new NotFoundException('File content not found');
481
- }
482
-
483
- return this.toNodeReadable(response.Body);
484
- }
485
-
486
- private async deleteS3(storageKey: string): Promise<void> {
487
- const { DeleteObjectCommand } = await this.loadS3Module();
488
- const client = await this.getS3Client();
489
- const config = this.resolveS3Config();
490
- await client.send(
491
- new DeleteObjectCommand({
492
- Bucket: config.bucket,
493
- Key: storageKey,
494
- }),
495
- );
496
- }
497
-
498
- private async deleteLocal(storageKey: string): Promise<void> {
499
- const rootDir = this.resolveLocalRootDir();
500
- const absoluteRoot = path.resolve(process.cwd(), rootDir);
501
- const absolutePath = path.join(absoluteRoot, storageKey);
502
-
503
- if (!fs.existsSync(absolutePath)) {
504
- return;
505
- }
506
- try {
507
- await fsPromises.unlink(absolutePath);
508
- } catch (error) {
509
- throw new InternalServerErrorException({
510
- message: 'Failed to delete file content',
511
- details: {
512
- storageKey,
513
- reason: error instanceof Error ? error.message : 'unknown',
514
- },
515
- });
516
- }
517
- }
518
-
519
- private resolveLocalRootDir(): string {
520
- const value = this.configService.get<string>('filesLocal.rootDir');
521
- if (!value) {
323
+ private async openStoredContent(storageDriver: string, storageKey: string): Promise<Readable> {
324
+ if (storageDriver !== this.storageAdapter.driver) {
522
325
  throw new ServiceUnavailableException(
523
- 'files-local adapter is not configured. Install/add files-local and ensure FILES_LOCAL_ROOT is set.',
326
+ `File was stored with driver "${storageDriver}", but current adapter is "${this.storageAdapter.driver}".`,
524
327
  );
525
328
  }
526
- return value;
329
+ return this.storageAdapter.open(storageKey);
527
330
  }
528
331
 
529
- private resolveS3Config(): {
530
- providerPreset: 'minio' | 'r2' | 'aws' | 'custom';
531
- bucket: string;
532
- region: string;
533
- endpoint?: string;
534
- accessKeyId: string;
535
- secretAccessKey: string;
536
- forcePathStyle: boolean;
537
- maxAttempts: number;
538
- } {
539
- const providerPreset =
540
- this.configService.get<'minio' | 'r2' | 'aws' | 'custom'>('filesS3.providerPreset') ?? 'minio';
541
- const bucket = this.configService.get<string>('filesS3.bucket');
542
- const region = this.configService.get<string>('filesS3.region');
543
- const endpoint = this.configService.get<string>('filesS3.endpoint');
544
- const accessKeyId = this.configService.get<string>('filesS3.accessKeyId');
545
- const secretAccessKey = this.configService.get<string>('filesS3.secretAccessKey');
546
- const forcePathStyle = this.configService.get<boolean>('filesS3.forcePathStyle');
547
- const maxAttempts = this.configService.get<number>('filesS3.maxAttempts') ?? 3;
548
-
549
- if (!bucket || !region || !accessKeyId || !secretAccessKey) {
550
- throw new ServiceUnavailableException(
551
- 'files-s3 adapter is not configured. Install/add files-s3 and ensure FILES_S3_* env keys are set.',
552
- );
553
- }
554
- if (providerPreset !== 'aws' && !endpoint) {
332
+ private async deleteStoredContent(storageDriver: string, storageKey: string): Promise<void> {
333
+ if (storageDriver !== this.storageAdapter.driver) {
555
334
  throw new ServiceUnavailableException(
556
- `files-s3 adapter endpoint is required for provider preset "${providerPreset}".`,
335
+ `File was stored with driver "${storageDriver}", but current adapter is "${this.storageAdapter.driver}".`,
557
336
  );
558
337
  }
559
-
560
- return {
561
- providerPreset,
562
- bucket,
563
- region,
564
- endpoint,
565
- accessKeyId,
566
- secretAccessKey,
567
- forcePathStyle: forcePathStyle !== false,
568
- maxAttempts,
569
- };
570
- }
571
-
572
- private async getS3Client(): Promise<{
573
- send: (command: unknown) => Promise<{
574
- Body?: unknown;
575
- }>;
576
- }> {
577
- if (this.s3Client) {
578
- return this.s3Client;
579
- }
580
- const { S3Client } = await this.loadS3Module();
581
- const config = this.resolveS3Config();
582
- this.s3Client = new S3Client({
583
- region: config.region,
584
- ...(config.endpoint ? { endpoint: config.endpoint } : {}),
585
- forcePathStyle: config.forcePathStyle,
586
- maxAttempts: config.maxAttempts,
587
- credentials: {
588
- accessKeyId: config.accessKeyId,
589
- secretAccessKey: config.secretAccessKey,
590
- },
591
- });
592
- return this.s3Client;
593
- }
594
-
595
- private toNodeReadable(body: unknown): Readable {
596
- if (body instanceof Readable) {
597
- return body;
598
- }
599
- if (typeof body === 'string' || Buffer.isBuffer(body) || body instanceof Uint8Array) {
600
- return Readable.from(body);
601
- }
602
- if (
603
- typeof body === 'object' &&
604
- body !== null &&
605
- typeof (body as { transformToWebStream?: unknown }).transformToWebStream === 'function'
606
- ) {
607
- return Readable.fromWeb(
608
- (body as { transformToWebStream: () => unknown }).transformToWebStream() as never,
609
- );
610
- }
611
- throw new InternalServerErrorException('Unsupported S3 response body type');
612
- }
613
-
614
- private async loadS3Module(): Promise<S3ModuleLike> {
615
- const importModule = new Function('specifier', 'return import(specifier)') as (
616
- specifier: string,
617
- ) => Promise<S3ModuleLike>;
618
- return importModule('@aws-sdk/client-s3');
338
+ await this.storageAdapter.delete(storageKey);
619
339
  }
620
340
 
621
341
  private generatePublicId(): string {
@@ -633,22 +353,13 @@ export class FilesService {
633
353
  private async cleanupReferencedBlobs(blobIds: string[]): Promise<void> {
634
354
  const uniqueIds = [...new Set(blobIds.filter(Boolean))];
635
355
  for (const blobId of uniqueIds) {
636
- const blob = await this.prisma.fileBlob.findUnique({
637
- where: { id: blobId },
638
- });
356
+ const blob = await this.persistence.findBlobById(blobId);
639
357
  if (!blob) {
640
358
  continue;
641
359
  }
642
360
 
643
- const deleted = await this.prisma.fileBlob.deleteMany({
644
- where: {
645
- id: blob.id,
646
- variants: {
647
- none: {},
648
- },
649
- },
650
- });
651
- if (deleted.count === 0) {
361
+ const deleted = await this.persistence.deleteBlobIfUnreferenced(blob.id);
362
+ if (!deleted) {
652
363
  continue;
653
364
  }
654
365
  await this.deleteStoredContent(blob.storageDriver, blob.storageKey).catch(() => undefined);
@@ -660,27 +371,12 @@ export class FilesService {
660
371
  size: number,
661
372
  mimeType: string,
662
373
  storageDriver: string,
663
- ): Promise<BlobRef | null> {
664
- const existing = await this.prisma.fileBlob.findFirst({
665
- where: {
666
- hash,
667
- size,
668
- mimeType,
669
- storageDriver,
670
- },
671
- });
374
+ ): Promise<FilesBlobRef | null> {
375
+ const existing = await this.persistence.findBlobRef(hash, size, mimeType, storageDriver);
672
376
  if (!existing) {
673
377
  return null;
674
378
  }
675
- return {
676
- id: existing.id,
677
- hash: existing.hash,
678
- size: existing.size,
679
- mimeType: existing.mimeType,
680
- storageDriver: existing.storageDriver,
681
- storageKey: existing.storageKey,
682
- created: false,
683
- };
379
+ return existing;
684
380
  }
685
381
 
686
382
  private isUniqueConstraintError(error: unknown): boolean {
@@ -690,6 +386,13 @@ export class FilesService {
690
386
  return (error as PrismaLikeError).code === 'P2002';
691
387
  }
692
388
 
389
+ protected normalizeFileName(originalName: string, extension: string, suffix?: string): string {
390
+ const parsed = path.parse(originalName);
391
+ const safeExtension = extension.startsWith('.') ? extension : `.${extension}`;
392
+ const base = suffix ? `${parsed.name}-${suffix}` : parsed.name;
393
+ return `${base}${safeExtension}`;
394
+ }
395
+
693
396
  private buildVariantFileName(originalName: string, variant: FileVariantKey, mimeType: string): string {
694
397
  if (variant === 'original') {
695
398
  return originalName;
@@ -709,24 +412,7 @@ export class FilesService {
709
412
  return null;
710
413
  }
711
414
 
712
- private toDto(record: {
713
- id: string;
714
- publicId: string;
715
- storageKey: string;
716
- originalName: string;
717
- mimeType: string;
718
- size: number;
719
- storageDriver: string;
720
- ownerType: string;
721
- ownerId: string | null;
722
- visibility: string;
723
- createdById: string | null;
724
- createdAt: Date;
725
- updatedAt: Date;
726
- variants?: Array<{
727
- variantKey: string;
728
- }>;
729
- }): FileRecordDto {
415
+ private toDto(record: FilesRecordAggregate): FileRecordDto {
730
416
  const availableVariants = new Set<FileVariantKey>(['original']);
731
417
  for (const variant of record.variants ?? []) {
732
418
  if (variant.variantKey === 'original' || variant.variantKey === 'preview') {
@@ -759,4 +445,4 @@ export class FilesService {
759
445
  : `/${this.filesConfigService.publicBasePath}`;
760
446
  return `${basePath}/${publicId}/download`;
761
447
  }
762
- }
448
+ }