servcraft 0.1.0 → 0.1.3

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 (217) hide show
  1. package/.claude/settings.local.json +30 -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/LICENSE +21 -0
  9. package/README.md +1102 -1
  10. package/dist/cli/index.cjs +2026 -2168
  11. package/dist/cli/index.cjs.map +1 -1
  12. package/dist/cli/index.js +2026 -2168
  13. package/dist/cli/index.js.map +1 -1
  14. package/dist/index.cjs +595 -616
  15. package/dist/index.cjs.map +1 -1
  16. package/dist/index.d.cts +114 -52
  17. package/dist/index.d.ts +114 -52
  18. package/dist/index.js +595 -616
  19. package/dist/index.js.map +1 -1
  20. package/docs/CLI-001_MULTI_DB_PLAN.md +546 -0
  21. package/docs/DATABASE_MULTI_ORM.md +399 -0
  22. package/docs/PHASE1_BREAKDOWN.md +346 -0
  23. package/docs/PROGRESS.md +550 -0
  24. package/docs/modules/ANALYTICS.md +226 -0
  25. package/docs/modules/API-VERSIONING.md +252 -0
  26. package/docs/modules/AUDIT.md +192 -0
  27. package/docs/modules/AUTH.md +431 -0
  28. package/docs/modules/CACHE.md +346 -0
  29. package/docs/modules/EMAIL.md +254 -0
  30. package/docs/modules/FEATURE-FLAG.md +291 -0
  31. package/docs/modules/I18N.md +294 -0
  32. package/docs/modules/MEDIA-PROCESSING.md +281 -0
  33. package/docs/modules/MFA.md +266 -0
  34. package/docs/modules/NOTIFICATION.md +311 -0
  35. package/docs/modules/OAUTH.md +237 -0
  36. package/docs/modules/PAYMENT.md +804 -0
  37. package/docs/modules/QUEUE.md +540 -0
  38. package/docs/modules/RATE-LIMIT.md +339 -0
  39. package/docs/modules/SEARCH.md +288 -0
  40. package/docs/modules/SECURITY.md +327 -0
  41. package/docs/modules/SESSION.md +382 -0
  42. package/docs/modules/SWAGGER.md +305 -0
  43. package/docs/modules/UPLOAD.md +296 -0
  44. package/docs/modules/USER.md +505 -0
  45. package/docs/modules/VALIDATION.md +294 -0
  46. package/docs/modules/WEBHOOK.md +270 -0
  47. package/docs/modules/WEBSOCKET.md +691 -0
  48. package/package.json +53 -38
  49. package/prisma/schema.prisma +395 -1
  50. package/src/cli/commands/add-module.ts +520 -87
  51. package/src/cli/commands/db.ts +3 -4
  52. package/src/cli/commands/docs.ts +256 -6
  53. package/src/cli/commands/generate.ts +12 -19
  54. package/src/cli/commands/init.ts +384 -214
  55. package/src/cli/index.ts +0 -4
  56. package/src/cli/templates/repository.ts +6 -1
  57. package/src/cli/templates/routes.ts +6 -21
  58. package/src/cli/utils/docs-generator.ts +6 -7
  59. package/src/cli/utils/env-manager.ts +717 -0
  60. package/src/cli/utils/field-parser.ts +16 -7
  61. package/src/cli/utils/interactive-prompt.ts +223 -0
  62. package/src/cli/utils/template-manager.ts +346 -0
  63. package/src/config/database.config.ts +183 -0
  64. package/src/config/env.ts +0 -10
  65. package/src/config/index.ts +0 -14
  66. package/src/core/server.ts +1 -1
  67. package/src/database/adapters/mongoose.adapter.ts +132 -0
  68. package/src/database/adapters/prisma.adapter.ts +118 -0
  69. package/src/database/connection.ts +190 -0
  70. package/src/database/interfaces/database.interface.ts +85 -0
  71. package/src/database/interfaces/index.ts +7 -0
  72. package/src/database/interfaces/repository.interface.ts +129 -0
  73. package/src/database/models/mongoose/index.ts +7 -0
  74. package/src/database/models/mongoose/payment.schema.ts +347 -0
  75. package/src/database/models/mongoose/user.schema.ts +154 -0
  76. package/src/database/prisma.ts +1 -4
  77. package/src/database/redis.ts +101 -0
  78. package/src/database/repositories/mongoose/index.ts +7 -0
  79. package/src/database/repositories/mongoose/payment.repository.ts +380 -0
  80. package/src/database/repositories/mongoose/user.repository.ts +255 -0
  81. package/src/database/seed.ts +6 -1
  82. package/src/index.ts +9 -20
  83. package/src/middleware/security.ts +2 -6
  84. package/src/modules/analytics/analytics.routes.ts +80 -0
  85. package/src/modules/analytics/analytics.service.ts +364 -0
  86. package/src/modules/analytics/index.ts +18 -0
  87. package/src/modules/analytics/types.ts +180 -0
  88. package/src/modules/api-versioning/index.ts +15 -0
  89. package/src/modules/api-versioning/types.ts +86 -0
  90. package/src/modules/api-versioning/versioning.middleware.ts +120 -0
  91. package/src/modules/api-versioning/versioning.routes.ts +54 -0
  92. package/src/modules/api-versioning/versioning.service.ts +189 -0
  93. package/src/modules/audit/audit.repository.ts +206 -0
  94. package/src/modules/audit/audit.service.ts +27 -59
  95. package/src/modules/auth/auth.controller.ts +2 -2
  96. package/src/modules/auth/auth.middleware.ts +3 -9
  97. package/src/modules/auth/auth.routes.ts +10 -107
  98. package/src/modules/auth/auth.service.ts +126 -23
  99. package/src/modules/auth/index.ts +3 -4
  100. package/src/modules/cache/cache.service.ts +367 -0
  101. package/src/modules/cache/index.ts +10 -0
  102. package/src/modules/cache/types.ts +44 -0
  103. package/src/modules/email/email.service.ts +3 -10
  104. package/src/modules/email/templates.ts +2 -8
  105. package/src/modules/feature-flag/feature-flag.repository.ts +303 -0
  106. package/src/modules/feature-flag/feature-flag.routes.ts +247 -0
  107. package/src/modules/feature-flag/feature-flag.service.ts +566 -0
  108. package/src/modules/feature-flag/index.ts +20 -0
  109. package/src/modules/feature-flag/types.ts +192 -0
  110. package/src/modules/i18n/i18n.middleware.ts +186 -0
  111. package/src/modules/i18n/i18n.routes.ts +191 -0
  112. package/src/modules/i18n/i18n.service.ts +456 -0
  113. package/src/modules/i18n/index.ts +18 -0
  114. package/src/modules/i18n/types.ts +118 -0
  115. package/src/modules/media-processing/index.ts +17 -0
  116. package/src/modules/media-processing/media-processing.routes.ts +111 -0
  117. package/src/modules/media-processing/media-processing.service.ts +245 -0
  118. package/src/modules/media-processing/types.ts +156 -0
  119. package/src/modules/mfa/index.ts +20 -0
  120. package/src/modules/mfa/mfa.repository.ts +206 -0
  121. package/src/modules/mfa/mfa.routes.ts +595 -0
  122. package/src/modules/mfa/mfa.service.ts +572 -0
  123. package/src/modules/mfa/totp.ts +150 -0
  124. package/src/modules/mfa/types.ts +57 -0
  125. package/src/modules/notification/index.ts +20 -0
  126. package/src/modules/notification/notification.repository.ts +356 -0
  127. package/src/modules/notification/notification.service.ts +483 -0
  128. package/src/modules/notification/types.ts +119 -0
  129. package/src/modules/oauth/index.ts +20 -0
  130. package/src/modules/oauth/oauth.repository.ts +219 -0
  131. package/src/modules/oauth/oauth.routes.ts +446 -0
  132. package/src/modules/oauth/oauth.service.ts +293 -0
  133. package/src/modules/oauth/providers/apple.provider.ts +250 -0
  134. package/src/modules/oauth/providers/facebook.provider.ts +181 -0
  135. package/src/modules/oauth/providers/github.provider.ts +248 -0
  136. package/src/modules/oauth/providers/google.provider.ts +189 -0
  137. package/src/modules/oauth/providers/twitter.provider.ts +214 -0
  138. package/src/modules/oauth/types.ts +94 -0
  139. package/src/modules/payment/index.ts +19 -0
  140. package/src/modules/payment/payment.repository.ts +733 -0
  141. package/src/modules/payment/payment.routes.ts +390 -0
  142. package/src/modules/payment/payment.service.ts +354 -0
  143. package/src/modules/payment/providers/mobile-money.provider.ts +274 -0
  144. package/src/modules/payment/providers/paypal.provider.ts +190 -0
  145. package/src/modules/payment/providers/stripe.provider.ts +215 -0
  146. package/src/modules/payment/types.ts +140 -0
  147. package/src/modules/queue/cron.ts +438 -0
  148. package/src/modules/queue/index.ts +87 -0
  149. package/src/modules/queue/queue.routes.ts +600 -0
  150. package/src/modules/queue/queue.service.ts +842 -0
  151. package/src/modules/queue/types.ts +222 -0
  152. package/src/modules/queue/workers.ts +366 -0
  153. package/src/modules/rate-limit/index.ts +59 -0
  154. package/src/modules/rate-limit/rate-limit.middleware.ts +134 -0
  155. package/src/modules/rate-limit/rate-limit.routes.ts +269 -0
  156. package/src/modules/rate-limit/rate-limit.service.ts +348 -0
  157. package/src/modules/rate-limit/stores/memory.store.ts +165 -0
  158. package/src/modules/rate-limit/stores/redis.store.ts +322 -0
  159. package/src/modules/rate-limit/types.ts +153 -0
  160. package/src/modules/search/adapters/elasticsearch.adapter.ts +326 -0
  161. package/src/modules/search/adapters/meilisearch.adapter.ts +261 -0
  162. package/src/modules/search/adapters/memory.adapter.ts +278 -0
  163. package/src/modules/search/index.ts +21 -0
  164. package/src/modules/search/search.service.ts +234 -0
  165. package/src/modules/search/types.ts +214 -0
  166. package/src/modules/security/index.ts +40 -0
  167. package/src/modules/security/sanitize.ts +223 -0
  168. package/src/modules/security/security-audit.service.ts +388 -0
  169. package/src/modules/security/security.middleware.ts +398 -0
  170. package/src/modules/session/index.ts +3 -0
  171. package/src/modules/session/session.repository.ts +159 -0
  172. package/src/modules/session/session.service.ts +340 -0
  173. package/src/modules/session/types.ts +38 -0
  174. package/src/modules/swagger/index.ts +7 -1
  175. package/src/modules/swagger/schema-builder.ts +16 -4
  176. package/src/modules/swagger/swagger.service.ts +9 -10
  177. package/src/modules/swagger/types.ts +0 -2
  178. package/src/modules/upload/index.ts +14 -0
  179. package/src/modules/upload/types.ts +83 -0
  180. package/src/modules/upload/upload.repository.ts +199 -0
  181. package/src/modules/upload/upload.routes.ts +311 -0
  182. package/src/modules/upload/upload.service.ts +448 -0
  183. package/src/modules/user/index.ts +3 -3
  184. package/src/modules/user/user.controller.ts +15 -9
  185. package/src/modules/user/user.repository.ts +237 -113
  186. package/src/modules/user/user.routes.ts +39 -164
  187. package/src/modules/user/user.service.ts +4 -3
  188. package/src/modules/validation/validator.ts +12 -17
  189. package/src/modules/webhook/index.ts +91 -0
  190. package/src/modules/webhook/retry.ts +196 -0
  191. package/src/modules/webhook/signature.ts +135 -0
  192. package/src/modules/webhook/types.ts +181 -0
  193. package/src/modules/webhook/webhook.repository.ts +358 -0
  194. package/src/modules/webhook/webhook.routes.ts +442 -0
  195. package/src/modules/webhook/webhook.service.ts +457 -0
  196. package/src/modules/websocket/features.ts +504 -0
  197. package/src/modules/websocket/index.ts +106 -0
  198. package/src/modules/websocket/middlewares.ts +298 -0
  199. package/src/modules/websocket/types.ts +181 -0
  200. package/src/modules/websocket/websocket.service.ts +692 -0
  201. package/src/utils/errors.ts +7 -0
  202. package/src/utils/pagination.ts +4 -1
  203. package/tests/helpers/db-check.ts +79 -0
  204. package/tests/integration/auth-redis.test.ts +94 -0
  205. package/tests/integration/cache-redis.test.ts +387 -0
  206. package/tests/integration/mongoose-repositories.test.ts +410 -0
  207. package/tests/integration/payment-prisma.test.ts +637 -0
  208. package/tests/integration/queue-bullmq.test.ts +417 -0
  209. package/tests/integration/user-prisma.test.ts +441 -0
  210. package/tests/integration/websocket-socketio.test.ts +552 -0
  211. package/tests/setup.ts +11 -9
  212. package/vitest.config.ts +3 -8
  213. package/npm-cache/_cacache/content-v2/sha512/1c/d0/03440d500a0487621aad1d6402978340698976602046db8e24fa03c01ee6c022c69b0582f969042d9442ee876ac35c038e960dd427d1e622fa24b8eb7dba +0 -0
  214. package/npm-cache/_cacache/content-v2/sha512/42/55/28b493ca491833e5aab0e9c3108d29ab3f36c248ca88f45d4630674fce9130959e56ae308797ac2b6328fa7f09a610b9550ed09cb971d039876d293fc69d +0 -0
  215. package/npm-cache/_cacache/content-v2/sha512/e0/12/f360dc9315ee5f17844a0c8c233ee6bf7c30837c4a02ea0d56c61c7f7ab21c0e958e50ed2c57c59f983c762b93056778c9009b2398ffc26def0183999b13 +0 -0
  216. package/npm-cache/_cacache/content-v2/sha512/ed/b0/fae1161902898f4c913c67d7f6cdf6be0665aec3b389b9c4f4f0a101ca1da59badf1b59c4e0030f5223023b8d63cfe501c46a32c20c895d4fb3f11ca2232 +0 -0
  217. package/npm-cache/_cacache/index-v5/58/94/c2cba79e0f16b4c10e95a87e32255741149e8222cc314a476aab67c39cc0 +0 -5
@@ -0,0 +1,199 @@
1
+ /**
2
+ * Upload Repository
3
+ * Prisma-based persistence for file upload metadata
4
+ */
5
+ import { Prisma } from '@prisma/client';
6
+ import type {
7
+ UploadedFile as PrismaUploadedFile,
8
+ StorageProvider as PrismaProvider,
9
+ PrismaClient,
10
+ } from '@prisma/client';
11
+ import { logger } from '../../core/logger.js';
12
+ import type { UploadedFile, StorageProvider } from './types.js';
13
+
14
+ // Enum mappings (Prisma UPPERCASE ↔ Application lowercase)
15
+ const providerToPrisma: Record<StorageProvider, PrismaProvider> = {
16
+ local: 'LOCAL',
17
+ s3: 'S3',
18
+ cloudinary: 'CLOUDINARY',
19
+ gcs: 'GCS',
20
+ };
21
+
22
+ const providerFromPrisma: Record<PrismaProvider, StorageProvider> = {
23
+ LOCAL: 'local',
24
+ S3: 's3',
25
+ CLOUDINARY: 'cloudinary',
26
+ GCS: 'gcs',
27
+ };
28
+
29
+ export class UploadRepository {
30
+ constructor(private prisma: PrismaClient) {}
31
+
32
+ /**
33
+ * Create file metadata record
34
+ */
35
+ async create(data: Omit<UploadedFile, 'createdAt'>): Promise<UploadedFile> {
36
+ const file = await this.prisma.uploadedFile.create({
37
+ data: {
38
+ id: data.id,
39
+ userId: data.userId,
40
+ originalName: data.originalName,
41
+ filename: data.filename,
42
+ mimetype: data.mimetype,
43
+ size: data.size,
44
+ path: data.path,
45
+ url: data.url,
46
+ provider: providerToPrisma[data.provider],
47
+ bucket: data.bucket,
48
+ metadata: data.metadata as Prisma.InputJsonValue,
49
+ },
50
+ });
51
+
52
+ return this.mapFromPrisma(file);
53
+ }
54
+
55
+ /**
56
+ * Get file by ID
57
+ */
58
+ async getById(id: string): Promise<UploadedFile | null> {
59
+ const file = await this.prisma.uploadedFile.findUnique({
60
+ where: { id },
61
+ });
62
+
63
+ return file ? this.mapFromPrisma(file) : null;
64
+ }
65
+
66
+ /**
67
+ * Get files by user ID
68
+ */
69
+ async getByUserId(
70
+ userId: string,
71
+ options?: { limit?: number; offset?: number }
72
+ ): Promise<UploadedFile[]> {
73
+ const files = await this.prisma.uploadedFile.findMany({
74
+ where: { userId },
75
+ orderBy: { createdAt: 'desc' },
76
+ take: options?.limit || 100,
77
+ skip: options?.offset || 0,
78
+ });
79
+
80
+ return files.map((f) => this.mapFromPrisma(f));
81
+ }
82
+
83
+ /**
84
+ * Get files by provider
85
+ */
86
+ async getByProvider(provider: StorageProvider): Promise<UploadedFile[]> {
87
+ const files = await this.prisma.uploadedFile.findMany({
88
+ where: { provider: providerToPrisma[provider] },
89
+ orderBy: { createdAt: 'desc' },
90
+ });
91
+
92
+ return files.map((f) => this.mapFromPrisma(f));
93
+ }
94
+
95
+ /**
96
+ * Update file metadata
97
+ */
98
+ async update(
99
+ id: string,
100
+ data: Partial<Pick<UploadedFile, 'url' | 'metadata'>>
101
+ ): Promise<UploadedFile | null> {
102
+ try {
103
+ const updateData: Prisma.UploadedFileUpdateInput = {};
104
+
105
+ if (data.url !== undefined) updateData.url = data.url;
106
+ if (data.metadata !== undefined) updateData.metadata = data.metadata as Prisma.InputJsonValue;
107
+
108
+ const file = await this.prisma.uploadedFile.update({
109
+ where: { id },
110
+ data: updateData,
111
+ });
112
+
113
+ return this.mapFromPrisma(file);
114
+ } catch (error) {
115
+ if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === 'P2025') {
116
+ return null;
117
+ }
118
+ throw error;
119
+ }
120
+ }
121
+
122
+ /**
123
+ * Delete file record
124
+ */
125
+ async delete(id: string): Promise<boolean> {
126
+ try {
127
+ await this.prisma.uploadedFile.delete({ where: { id } });
128
+ return true;
129
+ } catch (error) {
130
+ if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === 'P2025') {
131
+ return false;
132
+ }
133
+ throw error;
134
+ }
135
+ }
136
+
137
+ /**
138
+ * Delete files by user ID
139
+ */
140
+ async deleteByUserId(userId: string): Promise<number> {
141
+ const result = await this.prisma.uploadedFile.deleteMany({
142
+ where: { userId },
143
+ });
144
+ return result.count;
145
+ }
146
+
147
+ /**
148
+ * Delete old files
149
+ */
150
+ async deleteOlderThan(date: Date): Promise<number> {
151
+ const result = await this.prisma.uploadedFile.deleteMany({
152
+ where: { createdAt: { lt: date } },
153
+ });
154
+
155
+ logger.info({ count: result.count, olderThan: date }, 'Deleted old file records');
156
+ return result.count;
157
+ }
158
+
159
+ /**
160
+ * Get total storage used by user
161
+ */
162
+ async getTotalSizeByUser(userId: string): Promise<number> {
163
+ const result = await this.prisma.uploadedFile.aggregate({
164
+ where: { userId },
165
+ _sum: { size: true },
166
+ });
167
+
168
+ return result._sum.size || 0;
169
+ }
170
+
171
+ /**
172
+ * Count files by user
173
+ */
174
+ async countByUser(userId: string): Promise<number> {
175
+ return this.prisma.uploadedFile.count({
176
+ where: { userId },
177
+ });
178
+ }
179
+
180
+ /**
181
+ * Map Prisma model to application type
182
+ */
183
+ private mapFromPrisma(prismaFile: PrismaUploadedFile): UploadedFile {
184
+ return {
185
+ id: prismaFile.id,
186
+ userId: prismaFile.userId || undefined,
187
+ originalName: prismaFile.originalName,
188
+ filename: prismaFile.filename,
189
+ mimetype: prismaFile.mimetype,
190
+ size: prismaFile.size,
191
+ path: prismaFile.path,
192
+ url: prismaFile.url,
193
+ provider: providerFromPrisma[prismaFile.provider],
194
+ bucket: prismaFile.bucket || undefined,
195
+ metadata: prismaFile.metadata as Record<string, unknown> | undefined,
196
+ createdAt: prismaFile.createdAt,
197
+ };
198
+ }
199
+ }
@@ -0,0 +1,311 @@
1
+ import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify';
2
+ import type { Readable } from 'stream';
3
+ import type { AuthService } from '../auth/auth.service.js';
4
+ import { createAuthMiddleware } from '../auth/auth.middleware.js';
5
+ import { commonResponses, idParam } from '../swagger/index.js';
6
+ import { getUploadService } from './upload.service.js';
7
+ import type { MultipartFile, ImageTransformOptions } from './types.js';
8
+
9
+ // Extend FastifyRequest for multipart support
10
+ interface MultipartData {
11
+ filename: string;
12
+ mimetype: string;
13
+ encoding: string;
14
+ file: Readable;
15
+ fields: Record<string, { value?: string }>;
16
+ toBuffer: () => Promise<Buffer>;
17
+ }
18
+
19
+ interface MultipartRequest extends FastifyRequest {
20
+ file: () => Promise<MultipartData | undefined>;
21
+ files: () => AsyncIterableIterator<MultipartData>;
22
+ }
23
+
24
+ const uploadTag = 'Uploads';
25
+
26
+ const fileResponse = {
27
+ type: 'object',
28
+ properties: {
29
+ success: { type: 'boolean', example: true },
30
+ data: {
31
+ type: 'object',
32
+ properties: {
33
+ id: { type: 'string', format: 'uuid' },
34
+ originalName: { type: 'string' },
35
+ filename: { type: 'string' },
36
+ mimetype: { type: 'string' },
37
+ size: { type: 'number' },
38
+ url: { type: 'string' },
39
+ provider: { type: 'string' },
40
+ createdAt: { type: 'string', format: 'date-time' },
41
+ },
42
+ },
43
+ },
44
+ };
45
+
46
+ export function registerUploadRoutes(app: FastifyInstance, authService: AuthService): void {
47
+ const authenticate = createAuthMiddleware(authService);
48
+ const uploadService = getUploadService();
49
+
50
+ // Upload single file
51
+ app.post(
52
+ '/upload',
53
+ {
54
+ preHandler: [authenticate],
55
+ schema: {
56
+ tags: [uploadTag],
57
+ summary: 'Upload a file',
58
+ description: 'Upload a single file. Supports images, PDFs, and other allowed file types.',
59
+ security: [{ bearerAuth: [] }],
60
+ consumes: ['multipart/form-data'],
61
+ body: {
62
+ type: 'object',
63
+ properties: {
64
+ file: { type: 'string', format: 'binary' },
65
+ folder: { type: 'string', description: 'Optional folder path' },
66
+ isPublic: { type: 'boolean', default: false },
67
+ },
68
+ },
69
+ response: {
70
+ 201: fileResponse,
71
+ 400: commonResponses.error,
72
+ 401: commonResponses.unauthorized,
73
+ },
74
+ },
75
+ },
76
+ async (request: FastifyRequest, reply: FastifyReply) => {
77
+ const multipartRequest = request as MultipartRequest;
78
+ const data = await multipartRequest.file();
79
+ if (!data) {
80
+ return reply.status(400).send({ success: false, message: 'No file provided' });
81
+ }
82
+
83
+ const file: MultipartFile = {
84
+ filename: data.filename,
85
+ mimetype: data.mimetype,
86
+ encoding: data.encoding,
87
+ file: data.file,
88
+ toBuffer: () => data.toBuffer(),
89
+ };
90
+
91
+ const fields = data.fields as Record<string, { value?: string }>;
92
+ const uploaded = await uploadService.upload(file, {
93
+ folder: fields.folder?.value,
94
+ isPublic: fields.isPublic?.value === 'true',
95
+ });
96
+
97
+ return reply.status(201).send({ success: true, data: uploaded });
98
+ }
99
+ );
100
+
101
+ // Upload multiple files
102
+ app.post(
103
+ '/upload/multiple',
104
+ {
105
+ preHandler: [authenticate],
106
+ schema: {
107
+ tags: [uploadTag],
108
+ summary: 'Upload multiple files',
109
+ security: [{ bearerAuth: [] }],
110
+ consumes: ['multipart/form-data'],
111
+ body: {
112
+ type: 'object',
113
+ properties: {
114
+ files: { type: 'array', items: { type: 'string', format: 'binary' } },
115
+ folder: { type: 'string' },
116
+ },
117
+ },
118
+ response: {
119
+ 201: {
120
+ type: 'object',
121
+ properties: {
122
+ success: { type: 'boolean' },
123
+ data: { type: 'array', items: { type: 'object' } },
124
+ },
125
+ },
126
+ 400: commonResponses.error,
127
+ 401: commonResponses.unauthorized,
128
+ },
129
+ },
130
+ },
131
+ async (request: FastifyRequest, reply: FastifyReply) => {
132
+ const multipartRequest = request as MultipartRequest;
133
+ const parts = multipartRequest.files();
134
+ const uploadedFiles: MultipartFile[] = [];
135
+
136
+ for await (const part of parts) {
137
+ uploadedFiles.push({
138
+ filename: part.filename,
139
+ mimetype: part.mimetype,
140
+ encoding: part.encoding,
141
+ file: part.file,
142
+ toBuffer: () => part.toBuffer(),
143
+ });
144
+ }
145
+
146
+ if (uploadedFiles.length === 0) {
147
+ return reply.status(400).send({ success: false, message: 'No files provided' });
148
+ }
149
+
150
+ const results = await uploadService.uploadMultiple(uploadedFiles);
151
+ return reply.status(201).send({ success: true, data: results });
152
+ }
153
+ );
154
+
155
+ // Upload image with transformation
156
+ app.post(
157
+ '/upload/image',
158
+ {
159
+ preHandler: [authenticate],
160
+ schema: {
161
+ tags: [uploadTag],
162
+ summary: 'Upload and transform an image',
163
+ description: 'Upload an image with optional resize, format conversion, and effects',
164
+ security: [{ bearerAuth: [] }],
165
+ consumes: ['multipart/form-data'],
166
+ body: {
167
+ type: 'object',
168
+ properties: {
169
+ file: { type: 'string', format: 'binary' },
170
+ width: { type: 'integer', minimum: 1, maximum: 4096 },
171
+ height: { type: 'integer', minimum: 1, maximum: 4096 },
172
+ fit: { type: 'string', enum: ['cover', 'contain', 'fill', 'inside', 'outside'] },
173
+ format: { type: 'string', enum: ['jpeg', 'png', 'webp', 'avif'] },
174
+ quality: { type: 'integer', minimum: 1, maximum: 100 },
175
+ grayscale: { type: 'boolean' },
176
+ },
177
+ },
178
+ response: {
179
+ 201: fileResponse,
180
+ 400: commonResponses.error,
181
+ 401: commonResponses.unauthorized,
182
+ },
183
+ },
184
+ },
185
+ async (request: FastifyRequest, reply: FastifyReply) => {
186
+ const multipartRequest = request as MultipartRequest;
187
+ const data = await multipartRequest.file();
188
+ if (!data) {
189
+ return reply.status(400).send({ success: false, message: 'No file provided' });
190
+ }
191
+
192
+ if (!data.mimetype.startsWith('image/')) {
193
+ return reply.status(400).send({ success: false, message: 'File must be an image' });
194
+ }
195
+
196
+ const fields = data.fields as Record<string, { value?: string }>;
197
+ const transform: ImageTransformOptions = {};
198
+
199
+ if (fields.width?.value) transform.width = parseInt(fields.width.value);
200
+ if (fields.height?.value) transform.height = parseInt(fields.height.value);
201
+ if (fields.fit?.value) transform.fit = fields.fit.value as ImageTransformOptions['fit'];
202
+ if (fields.format?.value)
203
+ transform.format = fields.format.value as ImageTransformOptions['format'];
204
+ if (fields.quality?.value) transform.quality = parseInt(fields.quality.value);
205
+ if (fields.grayscale?.value) transform.grayscale = fields.grayscale.value === 'true';
206
+
207
+ const file: MultipartFile = {
208
+ filename: data.filename,
209
+ mimetype: data.mimetype,
210
+ encoding: data.encoding,
211
+ file: data.file,
212
+ toBuffer: () => data.toBuffer(),
213
+ };
214
+
215
+ const uploaded = await uploadService.upload(file, { transform });
216
+ return reply.status(201).send({ success: true, data: uploaded });
217
+ }
218
+ );
219
+
220
+ // Get file info
221
+ app.get<{ Params: { id: string } }>(
222
+ '/files/:id',
223
+ {
224
+ preHandler: [authenticate],
225
+ schema: {
226
+ tags: [uploadTag],
227
+ summary: 'Get file information',
228
+ security: [{ bearerAuth: [] }],
229
+ params: idParam,
230
+ response: {
231
+ 200: fileResponse,
232
+ 401: commonResponses.unauthorized,
233
+ 404: commonResponses.notFound,
234
+ },
235
+ },
236
+ },
237
+ async (request, reply) => {
238
+ const file = await uploadService.getFile(request.params.id);
239
+ if (!file) {
240
+ return reply.status(404).send({ success: false, message: 'File not found' });
241
+ }
242
+ return reply.send({ success: true, data: file });
243
+ }
244
+ );
245
+
246
+ // Get signed URL for private files
247
+ app.get<{ Params: { id: string }; Querystring: { expiresIn?: number } }>(
248
+ '/files/:id/signed-url',
249
+ {
250
+ preHandler: [authenticate],
251
+ schema: {
252
+ tags: [uploadTag],
253
+ summary: 'Get a signed URL for a private file',
254
+ security: [{ bearerAuth: [] }],
255
+ params: idParam,
256
+ querystring: {
257
+ type: 'object',
258
+ properties: {
259
+ expiresIn: { type: 'integer', minimum: 60, maximum: 604800, default: 3600 },
260
+ },
261
+ },
262
+ response: {
263
+ 200: {
264
+ type: 'object',
265
+ properties: {
266
+ success: { type: 'boolean' },
267
+ data: {
268
+ type: 'object',
269
+ properties: {
270
+ url: { type: 'string' },
271
+ expiresAt: { type: 'string', format: 'date-time' },
272
+ },
273
+ },
274
+ },
275
+ },
276
+ 401: commonResponses.unauthorized,
277
+ 404: commonResponses.notFound,
278
+ },
279
+ },
280
+ },
281
+ async (request, reply) => {
282
+ const expiresIn = request.query.expiresIn || 3600;
283
+ const url = await uploadService.getSignedUrl(request.params.id, expiresIn);
284
+ const expiresAt = new Date(Date.now() + expiresIn * 1000);
285
+ return reply.send({ success: true, data: { url, expiresAt } });
286
+ }
287
+ );
288
+
289
+ // Delete file
290
+ app.delete<{ Params: { id: string } }>(
291
+ '/files/:id',
292
+ {
293
+ preHandler: [authenticate],
294
+ schema: {
295
+ tags: [uploadTag],
296
+ summary: 'Delete a file',
297
+ security: [{ bearerAuth: [] }],
298
+ params: idParam,
299
+ response: {
300
+ 204: { description: 'File deleted' },
301
+ 401: commonResponses.unauthorized,
302
+ 404: commonResponses.notFound,
303
+ },
304
+ },
305
+ },
306
+ async (request, reply) => {
307
+ await uploadService.deleteFile(request.params.id);
308
+ return reply.status(204).send();
309
+ }
310
+ );
311
+ }