servcraft 0.1.0 → 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 (216) hide show
  1. package/.claude/settings.local.json +29 -0
  2. package/.github/CODEOWNERS +18 -0
  3. package/.github/PULL_REQUEST_TEMPLATE.md +46 -0
  4. package/.github/dependabot.yml +59 -0
  5. package/.github/workflows/ci.yml +188 -0
  6. package/.github/workflows/release.yml +195 -0
  7. package/AUDIT.md +602 -0
  8. package/README.md +1070 -1
  9. package/dist/cli/index.cjs +2026 -2168
  10. package/dist/cli/index.cjs.map +1 -1
  11. package/dist/cli/index.js +2026 -2168
  12. package/dist/cli/index.js.map +1 -1
  13. package/dist/index.cjs +595 -616
  14. package/dist/index.cjs.map +1 -1
  15. package/dist/index.d.cts +114 -52
  16. package/dist/index.d.ts +114 -52
  17. package/dist/index.js +595 -616
  18. package/dist/index.js.map +1 -1
  19. package/docs/CLI-001_MULTI_DB_PLAN.md +546 -0
  20. package/docs/DATABASE_MULTI_ORM.md +399 -0
  21. package/docs/PHASE1_BREAKDOWN.md +346 -0
  22. package/docs/PROGRESS.md +550 -0
  23. package/docs/modules/ANALYTICS.md +226 -0
  24. package/docs/modules/API-VERSIONING.md +252 -0
  25. package/docs/modules/AUDIT.md +192 -0
  26. package/docs/modules/AUTH.md +431 -0
  27. package/docs/modules/CACHE.md +346 -0
  28. package/docs/modules/EMAIL.md +254 -0
  29. package/docs/modules/FEATURE-FLAG.md +291 -0
  30. package/docs/modules/I18N.md +294 -0
  31. package/docs/modules/MEDIA-PROCESSING.md +281 -0
  32. package/docs/modules/MFA.md +266 -0
  33. package/docs/modules/NOTIFICATION.md +311 -0
  34. package/docs/modules/OAUTH.md +237 -0
  35. package/docs/modules/PAYMENT.md +804 -0
  36. package/docs/modules/QUEUE.md +540 -0
  37. package/docs/modules/RATE-LIMIT.md +339 -0
  38. package/docs/modules/SEARCH.md +288 -0
  39. package/docs/modules/SECURITY.md +327 -0
  40. package/docs/modules/SESSION.md +382 -0
  41. package/docs/modules/SWAGGER.md +305 -0
  42. package/docs/modules/UPLOAD.md +296 -0
  43. package/docs/modules/USER.md +505 -0
  44. package/docs/modules/VALIDATION.md +294 -0
  45. package/docs/modules/WEBHOOK.md +270 -0
  46. package/docs/modules/WEBSOCKET.md +691 -0
  47. package/package.json +53 -38
  48. package/prisma/schema.prisma +395 -1
  49. package/src/cli/commands/add-module.ts +520 -87
  50. package/src/cli/commands/db.ts +3 -4
  51. package/src/cli/commands/docs.ts +256 -6
  52. package/src/cli/commands/generate.ts +12 -19
  53. package/src/cli/commands/init.ts +384 -214
  54. package/src/cli/index.ts +0 -4
  55. package/src/cli/templates/repository.ts +6 -1
  56. package/src/cli/templates/routes.ts +6 -21
  57. package/src/cli/utils/docs-generator.ts +6 -7
  58. package/src/cli/utils/env-manager.ts +717 -0
  59. package/src/cli/utils/field-parser.ts +16 -7
  60. package/src/cli/utils/interactive-prompt.ts +223 -0
  61. package/src/cli/utils/template-manager.ts +346 -0
  62. package/src/config/database.config.ts +183 -0
  63. package/src/config/env.ts +0 -10
  64. package/src/config/index.ts +0 -14
  65. package/src/core/server.ts +1 -1
  66. package/src/database/adapters/mongoose.adapter.ts +132 -0
  67. package/src/database/adapters/prisma.adapter.ts +118 -0
  68. package/src/database/connection.ts +190 -0
  69. package/src/database/interfaces/database.interface.ts +85 -0
  70. package/src/database/interfaces/index.ts +7 -0
  71. package/src/database/interfaces/repository.interface.ts +129 -0
  72. package/src/database/models/mongoose/index.ts +7 -0
  73. package/src/database/models/mongoose/payment.schema.ts +347 -0
  74. package/src/database/models/mongoose/user.schema.ts +154 -0
  75. package/src/database/prisma.ts +1 -4
  76. package/src/database/redis.ts +101 -0
  77. package/src/database/repositories/mongoose/index.ts +7 -0
  78. package/src/database/repositories/mongoose/payment.repository.ts +380 -0
  79. package/src/database/repositories/mongoose/user.repository.ts +255 -0
  80. package/src/database/seed.ts +6 -1
  81. package/src/index.ts +9 -20
  82. package/src/middleware/security.ts +2 -6
  83. package/src/modules/analytics/analytics.routes.ts +80 -0
  84. package/src/modules/analytics/analytics.service.ts +364 -0
  85. package/src/modules/analytics/index.ts +18 -0
  86. package/src/modules/analytics/types.ts +180 -0
  87. package/src/modules/api-versioning/index.ts +15 -0
  88. package/src/modules/api-versioning/types.ts +86 -0
  89. package/src/modules/api-versioning/versioning.middleware.ts +120 -0
  90. package/src/modules/api-versioning/versioning.routes.ts +54 -0
  91. package/src/modules/api-versioning/versioning.service.ts +189 -0
  92. package/src/modules/audit/audit.repository.ts +206 -0
  93. package/src/modules/audit/audit.service.ts +27 -59
  94. package/src/modules/auth/auth.controller.ts +2 -2
  95. package/src/modules/auth/auth.middleware.ts +3 -9
  96. package/src/modules/auth/auth.routes.ts +10 -107
  97. package/src/modules/auth/auth.service.ts +126 -23
  98. package/src/modules/auth/index.ts +3 -4
  99. package/src/modules/cache/cache.service.ts +367 -0
  100. package/src/modules/cache/index.ts +10 -0
  101. package/src/modules/cache/types.ts +44 -0
  102. package/src/modules/email/email.service.ts +3 -10
  103. package/src/modules/email/templates.ts +2 -8
  104. package/src/modules/feature-flag/feature-flag.repository.ts +303 -0
  105. package/src/modules/feature-flag/feature-flag.routes.ts +247 -0
  106. package/src/modules/feature-flag/feature-flag.service.ts +566 -0
  107. package/src/modules/feature-flag/index.ts +20 -0
  108. package/src/modules/feature-flag/types.ts +192 -0
  109. package/src/modules/i18n/i18n.middleware.ts +186 -0
  110. package/src/modules/i18n/i18n.routes.ts +191 -0
  111. package/src/modules/i18n/i18n.service.ts +456 -0
  112. package/src/modules/i18n/index.ts +18 -0
  113. package/src/modules/i18n/types.ts +118 -0
  114. package/src/modules/media-processing/index.ts +17 -0
  115. package/src/modules/media-processing/media-processing.routes.ts +111 -0
  116. package/src/modules/media-processing/media-processing.service.ts +245 -0
  117. package/src/modules/media-processing/types.ts +156 -0
  118. package/src/modules/mfa/index.ts +20 -0
  119. package/src/modules/mfa/mfa.repository.ts +206 -0
  120. package/src/modules/mfa/mfa.routes.ts +595 -0
  121. package/src/modules/mfa/mfa.service.ts +572 -0
  122. package/src/modules/mfa/totp.ts +150 -0
  123. package/src/modules/mfa/types.ts +57 -0
  124. package/src/modules/notification/index.ts +20 -0
  125. package/src/modules/notification/notification.repository.ts +356 -0
  126. package/src/modules/notification/notification.service.ts +483 -0
  127. package/src/modules/notification/types.ts +119 -0
  128. package/src/modules/oauth/index.ts +20 -0
  129. package/src/modules/oauth/oauth.repository.ts +219 -0
  130. package/src/modules/oauth/oauth.routes.ts +446 -0
  131. package/src/modules/oauth/oauth.service.ts +293 -0
  132. package/src/modules/oauth/providers/apple.provider.ts +250 -0
  133. package/src/modules/oauth/providers/facebook.provider.ts +181 -0
  134. package/src/modules/oauth/providers/github.provider.ts +248 -0
  135. package/src/modules/oauth/providers/google.provider.ts +189 -0
  136. package/src/modules/oauth/providers/twitter.provider.ts +214 -0
  137. package/src/modules/oauth/types.ts +94 -0
  138. package/src/modules/payment/index.ts +19 -0
  139. package/src/modules/payment/payment.repository.ts +733 -0
  140. package/src/modules/payment/payment.routes.ts +390 -0
  141. package/src/modules/payment/payment.service.ts +354 -0
  142. package/src/modules/payment/providers/mobile-money.provider.ts +274 -0
  143. package/src/modules/payment/providers/paypal.provider.ts +190 -0
  144. package/src/modules/payment/providers/stripe.provider.ts +215 -0
  145. package/src/modules/payment/types.ts +140 -0
  146. package/src/modules/queue/cron.ts +438 -0
  147. package/src/modules/queue/index.ts +87 -0
  148. package/src/modules/queue/queue.routes.ts +600 -0
  149. package/src/modules/queue/queue.service.ts +842 -0
  150. package/src/modules/queue/types.ts +222 -0
  151. package/src/modules/queue/workers.ts +366 -0
  152. package/src/modules/rate-limit/index.ts +59 -0
  153. package/src/modules/rate-limit/rate-limit.middleware.ts +134 -0
  154. package/src/modules/rate-limit/rate-limit.routes.ts +269 -0
  155. package/src/modules/rate-limit/rate-limit.service.ts +348 -0
  156. package/src/modules/rate-limit/stores/memory.store.ts +165 -0
  157. package/src/modules/rate-limit/stores/redis.store.ts +322 -0
  158. package/src/modules/rate-limit/types.ts +153 -0
  159. package/src/modules/search/adapters/elasticsearch.adapter.ts +326 -0
  160. package/src/modules/search/adapters/meilisearch.adapter.ts +261 -0
  161. package/src/modules/search/adapters/memory.adapter.ts +278 -0
  162. package/src/modules/search/index.ts +21 -0
  163. package/src/modules/search/search.service.ts +234 -0
  164. package/src/modules/search/types.ts +214 -0
  165. package/src/modules/security/index.ts +40 -0
  166. package/src/modules/security/sanitize.ts +223 -0
  167. package/src/modules/security/security-audit.service.ts +388 -0
  168. package/src/modules/security/security.middleware.ts +398 -0
  169. package/src/modules/session/index.ts +3 -0
  170. package/src/modules/session/session.repository.ts +159 -0
  171. package/src/modules/session/session.service.ts +340 -0
  172. package/src/modules/session/types.ts +38 -0
  173. package/src/modules/swagger/index.ts +7 -1
  174. package/src/modules/swagger/schema-builder.ts +16 -4
  175. package/src/modules/swagger/swagger.service.ts +9 -10
  176. package/src/modules/swagger/types.ts +0 -2
  177. package/src/modules/upload/index.ts +14 -0
  178. package/src/modules/upload/types.ts +83 -0
  179. package/src/modules/upload/upload.repository.ts +199 -0
  180. package/src/modules/upload/upload.routes.ts +311 -0
  181. package/src/modules/upload/upload.service.ts +448 -0
  182. package/src/modules/user/index.ts +3 -3
  183. package/src/modules/user/user.controller.ts +15 -9
  184. package/src/modules/user/user.repository.ts +237 -113
  185. package/src/modules/user/user.routes.ts +39 -164
  186. package/src/modules/user/user.service.ts +4 -3
  187. package/src/modules/validation/validator.ts +12 -17
  188. package/src/modules/webhook/index.ts +91 -0
  189. package/src/modules/webhook/retry.ts +196 -0
  190. package/src/modules/webhook/signature.ts +135 -0
  191. package/src/modules/webhook/types.ts +181 -0
  192. package/src/modules/webhook/webhook.repository.ts +358 -0
  193. package/src/modules/webhook/webhook.routes.ts +442 -0
  194. package/src/modules/webhook/webhook.service.ts +457 -0
  195. package/src/modules/websocket/features.ts +504 -0
  196. package/src/modules/websocket/index.ts +106 -0
  197. package/src/modules/websocket/middlewares.ts +298 -0
  198. package/src/modules/websocket/types.ts +181 -0
  199. package/src/modules/websocket/websocket.service.ts +692 -0
  200. package/src/utils/errors.ts +7 -0
  201. package/src/utils/pagination.ts +4 -1
  202. package/tests/helpers/db-check.ts +79 -0
  203. package/tests/integration/auth-redis.test.ts +94 -0
  204. package/tests/integration/cache-redis.test.ts +387 -0
  205. package/tests/integration/mongoose-repositories.test.ts +410 -0
  206. package/tests/integration/payment-prisma.test.ts +637 -0
  207. package/tests/integration/queue-bullmq.test.ts +417 -0
  208. package/tests/integration/user-prisma.test.ts +441 -0
  209. package/tests/integration/websocket-socketio.test.ts +552 -0
  210. package/tests/setup.ts +11 -9
  211. package/vitest.config.ts +3 -8
  212. package/npm-cache/_cacache/content-v2/sha512/1c/d0/03440d500a0487621aad1d6402978340698976602046db8e24fa03c01ee6c022c69b0582f969042d9442ee876ac35c038e960dd427d1e622fa24b8eb7dba +0 -0
  213. package/npm-cache/_cacache/content-v2/sha512/42/55/28b493ca491833e5aab0e9c3108d29ab3f36c248ca88f45d4630674fce9130959e56ae308797ac2b6328fa7f09a610b9550ed09cb971d039876d293fc69d +0 -0
  214. package/npm-cache/_cacache/content-v2/sha512/e0/12/f360dc9315ee5f17844a0c8c233ee6bf7c30837c4a02ea0d56c61c7f7ab21c0e958e50ed2c57c59f983c762b93056778c9009b2398ffc26def0183999b13 +0 -0
  215. package/npm-cache/_cacache/content-v2/sha512/ed/b0/fae1161902898f4c913c67d7f6cdf6be0665aec3b389b9c4f4f0a101ca1da59badf1b59c4e0030f5223023b8d63cfe501c46a32c20c895d4fb3f11ca2232 +0 -0
  216. package/npm-cache/_cacache/index-v5/58/94/c2cba79e0f16b4c10e95a87e32255741149e8222cc314a476aab67c39cc0 +0 -5
@@ -0,0 +1,448 @@
1
+ import { randomUUID } from 'crypto';
2
+ import fs from 'fs/promises';
3
+ import path from 'path';
4
+ import { logger } from '../../core/logger.js';
5
+ import { BadRequestError, NotFoundError } from '../../utils/errors.js';
6
+ import { prisma } from '../../database/prisma.js';
7
+ import { UploadRepository } from './upload.repository.js';
8
+ import type {
9
+ UploadConfig,
10
+ UploadedFile,
11
+ UploadOptions,
12
+ MultipartFile,
13
+ ImageTransformOptions,
14
+ } from './types.js';
15
+
16
+ const defaultConfig: UploadConfig = {
17
+ provider: 'local',
18
+ maxFileSize: 10 * 1024 * 1024, // 10MB
19
+ allowedMimeTypes: [
20
+ 'image/jpeg',
21
+ 'image/png',
22
+ 'image/gif',
23
+ 'image/webp',
24
+ 'application/pdf',
25
+ 'text/plain',
26
+ 'application/json',
27
+ ],
28
+ local: {
29
+ uploadDir: './uploads',
30
+ publicPath: '/uploads',
31
+ },
32
+ };
33
+
34
+ export class UploadService {
35
+ private config: UploadConfig;
36
+ private repository: UploadRepository;
37
+
38
+ constructor(config: Partial<UploadConfig> = {}) {
39
+ this.config = { ...defaultConfig, ...config };
40
+ this.repository = new UploadRepository(prisma);
41
+ }
42
+
43
+ async upload(file: MultipartFile, options: UploadOptions = {}): Promise<UploadedFile> {
44
+ // Validate mime type
45
+ if (!this.config.allowedMimeTypes.includes(file.mimetype)) {
46
+ throw new BadRequestError(
47
+ `File type not allowed. Allowed types: ${this.config.allowedMimeTypes.join(', ')}`
48
+ );
49
+ }
50
+
51
+ const buffer = await file.toBuffer();
52
+
53
+ // Validate file size
54
+ if (buffer.length > this.config.maxFileSize) {
55
+ throw new BadRequestError(
56
+ `File too large. Maximum size: ${this.formatBytes(this.config.maxFileSize)}`
57
+ );
58
+ }
59
+
60
+ let processedBuffer = buffer;
61
+
62
+ // Apply image transformations if requested
63
+ if (options.transform && this.isImage(file.mimetype)) {
64
+ processedBuffer = await this.transformImage(buffer, options.transform);
65
+ }
66
+
67
+ const uploadedFile = await this.saveFile(file, processedBuffer, options);
68
+
69
+ // Save metadata to database
70
+ const savedFile = await this.repository.create(uploadedFile);
71
+
72
+ logger.info({ fileId: savedFile.id, size: savedFile.size }, 'File uploaded');
73
+ return savedFile;
74
+ }
75
+
76
+ async uploadMultiple(
77
+ uploadFiles: MultipartFile[],
78
+ options: UploadOptions = {}
79
+ ): Promise<UploadedFile[]> {
80
+ const results: UploadedFile[] = [];
81
+ for (const file of uploadFiles) {
82
+ const uploaded = await this.upload(file, options);
83
+ results.push(uploaded);
84
+ }
85
+ return results;
86
+ }
87
+
88
+ async getFile(fileId: string): Promise<UploadedFile | null> {
89
+ return this.repository.getById(fileId);
90
+ }
91
+
92
+ async deleteFile(fileId: string): Promise<void> {
93
+ const file = await this.repository.getById(fileId);
94
+ if (!file) {
95
+ throw new NotFoundError('File');
96
+ }
97
+
98
+ // Delete actual file from storage
99
+ switch (file.provider) {
100
+ case 'local':
101
+ await this.deleteLocal(file.path);
102
+ break;
103
+ case 's3':
104
+ await this.deleteS3(file.path);
105
+ break;
106
+ case 'cloudinary':
107
+ await this.deleteCloudinary(file.path);
108
+ break;
109
+ case 'gcs':
110
+ await this.deleteGCS(file.path);
111
+ break;
112
+ }
113
+
114
+ // Delete metadata from database
115
+ await this.repository.delete(fileId);
116
+ logger.info({ fileId }, 'File deleted');
117
+ }
118
+
119
+ async getSignedUrl(fileId: string, expiresIn = 3600): Promise<string> {
120
+ const file = await this.repository.getById(fileId);
121
+ if (!file) {
122
+ throw new NotFoundError('File');
123
+ }
124
+
125
+ switch (file.provider) {
126
+ case 's3':
127
+ return this.getS3SignedUrl(file.path, expiresIn);
128
+ case 'gcs':
129
+ return this.getGCSSignedUrl(file.path, expiresIn);
130
+ default:
131
+ return file.url;
132
+ }
133
+ }
134
+
135
+ /**
136
+ * Get files by user ID
137
+ */
138
+ async getFilesByUser(
139
+ userId: string,
140
+ options?: { limit?: number; offset?: number }
141
+ ): Promise<UploadedFile[]> {
142
+ return this.repository.getByUserId(userId, options);
143
+ }
144
+
145
+ /**
146
+ * Get total storage used by user
147
+ */
148
+ async getUserStorageUsage(userId: string): Promise<{ totalSize: number; fileCount: number }> {
149
+ const [totalSize, fileCount] = await Promise.all([
150
+ this.repository.getTotalSizeByUser(userId),
151
+ this.repository.countByUser(userId),
152
+ ]);
153
+ return { totalSize, fileCount };
154
+ }
155
+
156
+ /**
157
+ * Delete all files for a user
158
+ */
159
+ async deleteUserFiles(userId: string): Promise<number> {
160
+ const files = await this.repository.getByUserId(userId);
161
+
162
+ // Delete actual files
163
+ for (const file of files) {
164
+ try {
165
+ switch (file.provider) {
166
+ case 'local':
167
+ await this.deleteLocal(file.path);
168
+ break;
169
+ case 's3':
170
+ await this.deleteS3(file.path);
171
+ break;
172
+ case 'cloudinary':
173
+ await this.deleteCloudinary(file.path);
174
+ break;
175
+ case 'gcs':
176
+ await this.deleteGCS(file.path);
177
+ break;
178
+ }
179
+ } catch (error) {
180
+ logger.warn({ fileId: file.id, error }, 'Failed to delete file from storage');
181
+ }
182
+ }
183
+
184
+ // Delete all metadata records
185
+ return this.repository.deleteByUserId(userId);
186
+ }
187
+
188
+ // Private methods
189
+ private async saveFile(
190
+ file: MultipartFile,
191
+ buffer: Buffer,
192
+ options: UploadOptions
193
+ ): Promise<UploadedFile> {
194
+ const id = randomUUID();
195
+ const ext = path.extname(file.filename);
196
+ const filename = options.filename || `${id}${ext}`;
197
+ const folder = options.folder || '';
198
+
199
+ switch (this.config.provider) {
200
+ case 'local':
201
+ return this.saveLocal(id, file, buffer, filename, folder);
202
+ case 's3':
203
+ return this.saveS3(id, file, buffer, filename, folder, options.isPublic);
204
+ case 'cloudinary':
205
+ return this.saveCloudinary(id, file, buffer, filename, folder);
206
+ case 'gcs':
207
+ return this.saveGCS(id, file, buffer, filename, folder, options.isPublic);
208
+ default:
209
+ return this.saveLocal(id, file, buffer, filename, folder);
210
+ }
211
+ }
212
+
213
+ // Local storage
214
+ private async saveLocal(
215
+ id: string,
216
+ file: MultipartFile,
217
+ buffer: Buffer,
218
+ filename: string,
219
+ folder: string
220
+ ): Promise<UploadedFile> {
221
+ const uploadDir = path.join(this.config.local!.uploadDir, folder);
222
+ await fs.mkdir(uploadDir, { recursive: true });
223
+
224
+ const filePath = path.join(uploadDir, filename);
225
+ await fs.writeFile(filePath, buffer);
226
+
227
+ const publicPath = path.join(this.config.local!.publicPath, folder, filename);
228
+
229
+ return {
230
+ id,
231
+ originalName: file.filename,
232
+ filename,
233
+ mimetype: file.mimetype,
234
+ size: buffer.length,
235
+ path: filePath,
236
+ url: publicPath,
237
+ provider: 'local',
238
+ createdAt: new Date(),
239
+ };
240
+ }
241
+
242
+ private async deleteLocal(filePath: string): Promise<void> {
243
+ await fs.unlink(filePath).catch(() => {});
244
+ }
245
+
246
+ // S3 storage
247
+ private async saveS3(
248
+ id: string,
249
+ file: MultipartFile,
250
+ buffer: Buffer,
251
+ filename: string,
252
+ folder: string,
253
+ isPublic = false
254
+ ): Promise<UploadedFile> {
255
+ const config = this.config.s3!;
256
+ const key = folder ? `${folder}/${filename}` : filename;
257
+
258
+ // Using fetch for S3 upload (simplified - in production use @aws-sdk/client-s3)
259
+ const url = `https://${config.bucket}.s3.${config.region}.amazonaws.com/${key}`;
260
+
261
+ const response = await fetch(url, {
262
+ method: 'PUT',
263
+ headers: {
264
+ 'Content-Type': file.mimetype,
265
+ 'Content-Length': buffer.length.toString(),
266
+ 'x-amz-acl': isPublic ? 'public-read' : 'private',
267
+ },
268
+ body: new Uint8Array(buffer),
269
+ });
270
+
271
+ if (!response.ok) {
272
+ throw new Error(`S3 upload failed: ${response.statusText}`);
273
+ }
274
+
275
+ return {
276
+ id,
277
+ originalName: file.filename,
278
+ filename,
279
+ mimetype: file.mimetype,
280
+ size: buffer.length,
281
+ path: key,
282
+ url: isPublic ? url : '',
283
+ provider: 's3',
284
+ bucket: config.bucket,
285
+ createdAt: new Date(),
286
+ };
287
+ }
288
+
289
+ private async deleteS3(key: string): Promise<void> {
290
+ const config = this.config.s3!;
291
+ const url = `https://${config.bucket}.s3.${config.region}.amazonaws.com/${key}`;
292
+ await fetch(url, { method: 'DELETE' });
293
+ }
294
+
295
+ private async getS3SignedUrl(key: string, _expiresIn: number): Promise<string> {
296
+ const config = this.config.s3!;
297
+ // Simplified - in production use @aws-sdk/s3-request-presigner
298
+ return `https://${config.bucket}.s3.${config.region}.amazonaws.com/${key}`;
299
+ }
300
+
301
+ // Cloudinary storage
302
+ private async saveCloudinary(
303
+ id: string,
304
+ file: MultipartFile,
305
+ buffer: Buffer,
306
+ filename: string,
307
+ folder: string
308
+ ): Promise<UploadedFile> {
309
+ const config = this.config.cloudinary!;
310
+ const uploadFolder = config.folder ? `${config.folder}/${folder}` : folder;
311
+
312
+ const formData = new FormData();
313
+ formData.append('file', new Blob([new Uint8Array(buffer)], { type: file.mimetype }));
314
+ formData.append('api_key', config.apiKey);
315
+ formData.append('folder', uploadFolder);
316
+ formData.append('public_id', path.parse(filename).name);
317
+
318
+ const timestamp = Math.floor(Date.now() / 1000);
319
+ formData.append('timestamp', timestamp.toString());
320
+
321
+ // Generate signature (simplified - use cloudinary SDK in production)
322
+ const response = await fetch(
323
+ `https://api.cloudinary.com/v1_1/${config.cloudName}/auto/upload`,
324
+ { method: 'POST', body: formData }
325
+ );
326
+
327
+ const result = (await response.json()) as { secure_url: string; public_id: string };
328
+
329
+ return {
330
+ id,
331
+ originalName: file.filename,
332
+ filename,
333
+ mimetype: file.mimetype,
334
+ size: buffer.length,
335
+ path: result.public_id,
336
+ url: result.secure_url,
337
+ provider: 'cloudinary',
338
+ createdAt: new Date(),
339
+ };
340
+ }
341
+
342
+ private async deleteCloudinary(publicId: string): Promise<void> {
343
+ const config = this.config.cloudinary!;
344
+ const formData = new FormData();
345
+ formData.append('api_key', config.apiKey);
346
+ formData.append('public_id', publicId);
347
+
348
+ await fetch(`https://api.cloudinary.com/v1_1/${config.cloudName}/image/destroy`, {
349
+ method: 'POST',
350
+ body: formData,
351
+ });
352
+ }
353
+
354
+ // GCS storage
355
+ private async saveGCS(
356
+ id: string,
357
+ file: MultipartFile,
358
+ buffer: Buffer,
359
+ filename: string,
360
+ folder: string,
361
+ isPublic = false
362
+ ): Promise<UploadedFile> {
363
+ const config = this.config.gcs!;
364
+ const objectName = folder ? `${folder}/${filename}` : filename;
365
+
366
+ const url = `https://storage.googleapis.com/upload/storage/v1/b/${config.bucket}/o?uploadType=media&name=${objectName}`;
367
+
368
+ const response = await fetch(url, {
369
+ method: 'POST',
370
+ headers: { 'Content-Type': file.mimetype },
371
+ body: new Uint8Array(buffer),
372
+ });
373
+
374
+ if (!response.ok) {
375
+ throw new Error(`GCS upload failed: ${response.statusText}`);
376
+ }
377
+
378
+ const publicUrl = isPublic
379
+ ? `https://storage.googleapis.com/${config.bucket}/${objectName}`
380
+ : '';
381
+
382
+ return {
383
+ id,
384
+ originalName: file.filename,
385
+ filename,
386
+ mimetype: file.mimetype,
387
+ size: buffer.length,
388
+ path: objectName,
389
+ url: publicUrl,
390
+ provider: 'gcs',
391
+ bucket: config.bucket,
392
+ createdAt: new Date(),
393
+ };
394
+ }
395
+
396
+ private async deleteGCS(objectName: string): Promise<void> {
397
+ const config = this.config.gcs!;
398
+ const url = `https://storage.googleapis.com/storage/v1/b/${config.bucket}/o/${encodeURIComponent(objectName)}`;
399
+ await fetch(url, { method: 'DELETE' });
400
+ }
401
+
402
+ private async getGCSSignedUrl(objectName: string, _expiresIn: number): Promise<string> {
403
+ const config = this.config.gcs!;
404
+ return `https://storage.googleapis.com/${config.bucket}/${objectName}`;
405
+ }
406
+
407
+ // Image transformation (simplified - use sharp in production)
408
+ private async transformImage(buffer: Buffer, options: ImageTransformOptions): Promise<Buffer> {
409
+ // This is a placeholder - in production, use the 'sharp' package
410
+ // Example with sharp:
411
+ // import sharp from 'sharp';
412
+ // let image = sharp(buffer);
413
+ // if (options.width || options.height) {
414
+ // image = image.resize(options.width, options.height, { fit: options.fit });
415
+ // }
416
+ // if (options.grayscale) image = image.grayscale();
417
+ // if (options.blur) image = image.blur(options.blur);
418
+ // if (options.format) image = image.toFormat(options.format, { quality: options.quality });
419
+ // return image.toBuffer();
420
+
421
+ logger.debug({ options }, 'Image transformation requested (using placeholder)');
422
+ return buffer;
423
+ }
424
+
425
+ private isImage(mimetype: string): boolean {
426
+ return mimetype.startsWith('image/');
427
+ }
428
+
429
+ private formatBytes(bytes: number): string {
430
+ if (bytes < 1024) return `${bytes} B`;
431
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
432
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
433
+ }
434
+ }
435
+
436
+ let uploadService: UploadService | null = null;
437
+
438
+ export function getUploadService(): UploadService {
439
+ if (!uploadService) {
440
+ uploadService = new UploadService();
441
+ }
442
+ return uploadService;
443
+ }
444
+
445
+ export function createUploadService(config: Partial<UploadConfig>): UploadService {
446
+ uploadService = new UploadService(config);
447
+ return uploadService;
448
+ }
@@ -1,8 +1,8 @@
1
1
  import type { FastifyInstance } from 'fastify';
2
2
  import { logger } from '../../core/logger.js';
3
- import { UserService, createUserService } from './user.service.js';
4
- import { UserController, createUserController } from './user.controller.js';
5
- import { UserRepository, createUserRepository } from './user.repository.js';
3
+ import { createUserService } from './user.service.js';
4
+ import { createUserController } from './user.controller.js';
5
+ import { createUserRepository } from './user.repository.js';
6
6
  import { registerUserRoutes } from './user.routes.js';
7
7
  import type { AuthService } from '../auth/auth.service.js';
8
8
 
@@ -6,6 +6,14 @@ import { parsePaginationParams } from '../../utils/pagination.js';
6
6
  import { validateBody, validateQuery } from '../validation/validator.js';
7
7
  import type { AuthenticatedRequest } from '../auth/types.js';
8
8
  import { ForbiddenError } from '../../utils/errors.js';
9
+ import type { User } from './types.js';
10
+
11
+ // Helper to remove password from user object
12
+ function omitPassword(user: User): Omit<User, 'password'> {
13
+ const { password, ...userData } = user;
14
+ void password; // Explicitly mark as intentionally unused
15
+ return userData;
16
+ }
9
17
 
10
18
  export class UserController {
11
19
  constructor(private userService: UserService) {}
@@ -39,8 +47,7 @@ export class UserController {
39
47
  }
40
48
 
41
49
  // Remove password from response
42
- const { password, ...userData } = user;
43
- success(reply, userData);
50
+ success(reply, omitPassword(user));
44
51
  }
45
52
 
46
53
  async update(
@@ -50,8 +57,7 @@ export class UserController {
50
57
  const data = validateBody(updateUserSchema, request.body);
51
58
  const user = await this.userService.update(request.params.id, data);
52
59
 
53
- const { password, ...userData } = user;
54
- success(reply, userData);
60
+ success(reply, omitPassword(user));
55
61
  }
56
62
 
57
63
  async delete(
@@ -80,7 +86,7 @@ export class UserController {
80
86
  }
81
87
 
82
88
  const user = await this.userService.suspend(request.params.id);
83
- const { password, ...userData } = user;
89
+ const userData = omitPassword(user);
84
90
  success(reply, userData);
85
91
  }
86
92
 
@@ -95,7 +101,7 @@ export class UserController {
95
101
  }
96
102
 
97
103
  const user = await this.userService.ban(request.params.id);
98
- const { password, ...userData } = user;
104
+ const userData = omitPassword(user);
99
105
  success(reply, userData);
100
106
  }
101
107
 
@@ -104,7 +110,7 @@ export class UserController {
104
110
  reply: FastifyReply
105
111
  ): Promise<void> {
106
112
  const user = await this.userService.activate(request.params.id);
107
- const { password, ...userData } = user;
113
+ const userData = omitPassword(user);
108
114
  success(reply, userData);
109
115
  }
110
116
 
@@ -120,7 +126,7 @@ export class UserController {
120
126
  });
121
127
  }
122
128
 
123
- const { password, ...userData } = user;
129
+ const userData = omitPassword(user);
124
130
  success(reply, userData);
125
131
  }
126
132
 
@@ -129,7 +135,7 @@ export class UserController {
129
135
  const data = validateBody(updateProfileSchema, request.body);
130
136
 
131
137
  const user = await this.userService.update(authRequest.user.id, data);
132
- const { password, ...userData } = user;
138
+ const userData = omitPassword(user);
133
139
  success(reply, userData);
134
140
  }
135
141
  }