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.
- package/.claude/settings.local.json +29 -0
- package/.github/CODEOWNERS +18 -0
- package/.github/PULL_REQUEST_TEMPLATE.md +46 -0
- package/.github/dependabot.yml +59 -0
- package/.github/workflows/ci.yml +188 -0
- package/.github/workflows/release.yml +195 -0
- package/AUDIT.md +602 -0
- package/README.md +1070 -1
- package/dist/cli/index.cjs +2026 -2168
- package/dist/cli/index.cjs.map +1 -1
- package/dist/cli/index.js +2026 -2168
- package/dist/cli/index.js.map +1 -1
- package/dist/index.cjs +595 -616
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +114 -52
- package/dist/index.d.ts +114 -52
- package/dist/index.js +595 -616
- package/dist/index.js.map +1 -1
- package/docs/CLI-001_MULTI_DB_PLAN.md +546 -0
- package/docs/DATABASE_MULTI_ORM.md +399 -0
- package/docs/PHASE1_BREAKDOWN.md +346 -0
- package/docs/PROGRESS.md +550 -0
- package/docs/modules/ANALYTICS.md +226 -0
- package/docs/modules/API-VERSIONING.md +252 -0
- package/docs/modules/AUDIT.md +192 -0
- package/docs/modules/AUTH.md +431 -0
- package/docs/modules/CACHE.md +346 -0
- package/docs/modules/EMAIL.md +254 -0
- package/docs/modules/FEATURE-FLAG.md +291 -0
- package/docs/modules/I18N.md +294 -0
- package/docs/modules/MEDIA-PROCESSING.md +281 -0
- package/docs/modules/MFA.md +266 -0
- package/docs/modules/NOTIFICATION.md +311 -0
- package/docs/modules/OAUTH.md +237 -0
- package/docs/modules/PAYMENT.md +804 -0
- package/docs/modules/QUEUE.md +540 -0
- package/docs/modules/RATE-LIMIT.md +339 -0
- package/docs/modules/SEARCH.md +288 -0
- package/docs/modules/SECURITY.md +327 -0
- package/docs/modules/SESSION.md +382 -0
- package/docs/modules/SWAGGER.md +305 -0
- package/docs/modules/UPLOAD.md +296 -0
- package/docs/modules/USER.md +505 -0
- package/docs/modules/VALIDATION.md +294 -0
- package/docs/modules/WEBHOOK.md +270 -0
- package/docs/modules/WEBSOCKET.md +691 -0
- package/package.json +53 -38
- package/prisma/schema.prisma +395 -1
- package/src/cli/commands/add-module.ts +520 -87
- package/src/cli/commands/db.ts +3 -4
- package/src/cli/commands/docs.ts +256 -6
- package/src/cli/commands/generate.ts +12 -19
- package/src/cli/commands/init.ts +384 -214
- package/src/cli/index.ts +0 -4
- package/src/cli/templates/repository.ts +6 -1
- package/src/cli/templates/routes.ts +6 -21
- package/src/cli/utils/docs-generator.ts +6 -7
- package/src/cli/utils/env-manager.ts +717 -0
- package/src/cli/utils/field-parser.ts +16 -7
- package/src/cli/utils/interactive-prompt.ts +223 -0
- package/src/cli/utils/template-manager.ts +346 -0
- package/src/config/database.config.ts +183 -0
- package/src/config/env.ts +0 -10
- package/src/config/index.ts +0 -14
- package/src/core/server.ts +1 -1
- package/src/database/adapters/mongoose.adapter.ts +132 -0
- package/src/database/adapters/prisma.adapter.ts +118 -0
- package/src/database/connection.ts +190 -0
- package/src/database/interfaces/database.interface.ts +85 -0
- package/src/database/interfaces/index.ts +7 -0
- package/src/database/interfaces/repository.interface.ts +129 -0
- package/src/database/models/mongoose/index.ts +7 -0
- package/src/database/models/mongoose/payment.schema.ts +347 -0
- package/src/database/models/mongoose/user.schema.ts +154 -0
- package/src/database/prisma.ts +1 -4
- package/src/database/redis.ts +101 -0
- package/src/database/repositories/mongoose/index.ts +7 -0
- package/src/database/repositories/mongoose/payment.repository.ts +380 -0
- package/src/database/repositories/mongoose/user.repository.ts +255 -0
- package/src/database/seed.ts +6 -1
- package/src/index.ts +9 -20
- package/src/middleware/security.ts +2 -6
- package/src/modules/analytics/analytics.routes.ts +80 -0
- package/src/modules/analytics/analytics.service.ts +364 -0
- package/src/modules/analytics/index.ts +18 -0
- package/src/modules/analytics/types.ts +180 -0
- package/src/modules/api-versioning/index.ts +15 -0
- package/src/modules/api-versioning/types.ts +86 -0
- package/src/modules/api-versioning/versioning.middleware.ts +120 -0
- package/src/modules/api-versioning/versioning.routes.ts +54 -0
- package/src/modules/api-versioning/versioning.service.ts +189 -0
- package/src/modules/audit/audit.repository.ts +206 -0
- package/src/modules/audit/audit.service.ts +27 -59
- package/src/modules/auth/auth.controller.ts +2 -2
- package/src/modules/auth/auth.middleware.ts +3 -9
- package/src/modules/auth/auth.routes.ts +10 -107
- package/src/modules/auth/auth.service.ts +126 -23
- package/src/modules/auth/index.ts +3 -4
- package/src/modules/cache/cache.service.ts +367 -0
- package/src/modules/cache/index.ts +10 -0
- package/src/modules/cache/types.ts +44 -0
- package/src/modules/email/email.service.ts +3 -10
- package/src/modules/email/templates.ts +2 -8
- package/src/modules/feature-flag/feature-flag.repository.ts +303 -0
- package/src/modules/feature-flag/feature-flag.routes.ts +247 -0
- package/src/modules/feature-flag/feature-flag.service.ts +566 -0
- package/src/modules/feature-flag/index.ts +20 -0
- package/src/modules/feature-flag/types.ts +192 -0
- package/src/modules/i18n/i18n.middleware.ts +186 -0
- package/src/modules/i18n/i18n.routes.ts +191 -0
- package/src/modules/i18n/i18n.service.ts +456 -0
- package/src/modules/i18n/index.ts +18 -0
- package/src/modules/i18n/types.ts +118 -0
- package/src/modules/media-processing/index.ts +17 -0
- package/src/modules/media-processing/media-processing.routes.ts +111 -0
- package/src/modules/media-processing/media-processing.service.ts +245 -0
- package/src/modules/media-processing/types.ts +156 -0
- package/src/modules/mfa/index.ts +20 -0
- package/src/modules/mfa/mfa.repository.ts +206 -0
- package/src/modules/mfa/mfa.routes.ts +595 -0
- package/src/modules/mfa/mfa.service.ts +572 -0
- package/src/modules/mfa/totp.ts +150 -0
- package/src/modules/mfa/types.ts +57 -0
- package/src/modules/notification/index.ts +20 -0
- package/src/modules/notification/notification.repository.ts +356 -0
- package/src/modules/notification/notification.service.ts +483 -0
- package/src/modules/notification/types.ts +119 -0
- package/src/modules/oauth/index.ts +20 -0
- package/src/modules/oauth/oauth.repository.ts +219 -0
- package/src/modules/oauth/oauth.routes.ts +446 -0
- package/src/modules/oauth/oauth.service.ts +293 -0
- package/src/modules/oauth/providers/apple.provider.ts +250 -0
- package/src/modules/oauth/providers/facebook.provider.ts +181 -0
- package/src/modules/oauth/providers/github.provider.ts +248 -0
- package/src/modules/oauth/providers/google.provider.ts +189 -0
- package/src/modules/oauth/providers/twitter.provider.ts +214 -0
- package/src/modules/oauth/types.ts +94 -0
- package/src/modules/payment/index.ts +19 -0
- package/src/modules/payment/payment.repository.ts +733 -0
- package/src/modules/payment/payment.routes.ts +390 -0
- package/src/modules/payment/payment.service.ts +354 -0
- package/src/modules/payment/providers/mobile-money.provider.ts +274 -0
- package/src/modules/payment/providers/paypal.provider.ts +190 -0
- package/src/modules/payment/providers/stripe.provider.ts +215 -0
- package/src/modules/payment/types.ts +140 -0
- package/src/modules/queue/cron.ts +438 -0
- package/src/modules/queue/index.ts +87 -0
- package/src/modules/queue/queue.routes.ts +600 -0
- package/src/modules/queue/queue.service.ts +842 -0
- package/src/modules/queue/types.ts +222 -0
- package/src/modules/queue/workers.ts +366 -0
- package/src/modules/rate-limit/index.ts +59 -0
- package/src/modules/rate-limit/rate-limit.middleware.ts +134 -0
- package/src/modules/rate-limit/rate-limit.routes.ts +269 -0
- package/src/modules/rate-limit/rate-limit.service.ts +348 -0
- package/src/modules/rate-limit/stores/memory.store.ts +165 -0
- package/src/modules/rate-limit/stores/redis.store.ts +322 -0
- package/src/modules/rate-limit/types.ts +153 -0
- package/src/modules/search/adapters/elasticsearch.adapter.ts +326 -0
- package/src/modules/search/adapters/meilisearch.adapter.ts +261 -0
- package/src/modules/search/adapters/memory.adapter.ts +278 -0
- package/src/modules/search/index.ts +21 -0
- package/src/modules/search/search.service.ts +234 -0
- package/src/modules/search/types.ts +214 -0
- package/src/modules/security/index.ts +40 -0
- package/src/modules/security/sanitize.ts +223 -0
- package/src/modules/security/security-audit.service.ts +388 -0
- package/src/modules/security/security.middleware.ts +398 -0
- package/src/modules/session/index.ts +3 -0
- package/src/modules/session/session.repository.ts +159 -0
- package/src/modules/session/session.service.ts +340 -0
- package/src/modules/session/types.ts +38 -0
- package/src/modules/swagger/index.ts +7 -1
- package/src/modules/swagger/schema-builder.ts +16 -4
- package/src/modules/swagger/swagger.service.ts +9 -10
- package/src/modules/swagger/types.ts +0 -2
- package/src/modules/upload/index.ts +14 -0
- package/src/modules/upload/types.ts +83 -0
- package/src/modules/upload/upload.repository.ts +199 -0
- package/src/modules/upload/upload.routes.ts +311 -0
- package/src/modules/upload/upload.service.ts +448 -0
- package/src/modules/user/index.ts +3 -3
- package/src/modules/user/user.controller.ts +15 -9
- package/src/modules/user/user.repository.ts +237 -113
- package/src/modules/user/user.routes.ts +39 -164
- package/src/modules/user/user.service.ts +4 -3
- package/src/modules/validation/validator.ts +12 -17
- package/src/modules/webhook/index.ts +91 -0
- package/src/modules/webhook/retry.ts +196 -0
- package/src/modules/webhook/signature.ts +135 -0
- package/src/modules/webhook/types.ts +181 -0
- package/src/modules/webhook/webhook.repository.ts +358 -0
- package/src/modules/webhook/webhook.routes.ts +442 -0
- package/src/modules/webhook/webhook.service.ts +457 -0
- package/src/modules/websocket/features.ts +504 -0
- package/src/modules/websocket/index.ts +106 -0
- package/src/modules/websocket/middlewares.ts +298 -0
- package/src/modules/websocket/types.ts +181 -0
- package/src/modules/websocket/websocket.service.ts +692 -0
- package/src/utils/errors.ts +7 -0
- package/src/utils/pagination.ts +4 -1
- package/tests/helpers/db-check.ts +79 -0
- package/tests/integration/auth-redis.test.ts +94 -0
- package/tests/integration/cache-redis.test.ts +387 -0
- package/tests/integration/mongoose-repositories.test.ts +410 -0
- package/tests/integration/payment-prisma.test.ts +637 -0
- package/tests/integration/queue-bullmq.test.ts +417 -0
- package/tests/integration/user-prisma.test.ts +441 -0
- package/tests/integration/websocket-socketio.test.ts +552 -0
- package/tests/setup.ts +11 -9
- package/vitest.config.ts +3 -8
- package/npm-cache/_cacache/content-v2/sha512/1c/d0/03440d500a0487621aad1d6402978340698976602046db8e24fa03c01ee6c022c69b0582f969042d9442ee876ac35c038e960dd427d1e622fa24b8eb7dba +0 -0
- package/npm-cache/_cacache/content-v2/sha512/42/55/28b493ca491833e5aab0e9c3108d29ab3f36c248ca88f45d4630674fce9130959e56ae308797ac2b6328fa7f09a610b9550ed09cb971d039876d293fc69d +0 -0
- package/npm-cache/_cacache/content-v2/sha512/e0/12/f360dc9315ee5f17844a0c8c233ee6bf7c30837c4a02ea0d56c61c7f7ab21c0e958e50ed2c57c59f983c762b93056778c9009b2398ffc26def0183999b13 +0 -0
- package/npm-cache/_cacache/content-v2/sha512/ed/b0/fae1161902898f4c913c67d7f6cdf6be0665aec3b389b9c4f4f0a101ca1da59badf1b59c4e0030f5223023b8d63cfe501c46a32c20c895d4fb3f11ca2232 +0 -0
- 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
|
+
}
|