@xenterprises/fastify-xstorage 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +28 -0
- package/EXAMPLES.md +797 -0
- package/QUICK_START.md +345 -0
- package/README.md +492 -0
- package/TESTING.md +567 -0
- package/package.json +51 -0
- package/server/app.js +136 -0
- package/src/index.js +9 -0
- package/src/services/storage.js +352 -0
- package/src/utils/helpers.js +238 -0
- package/src/xStorage.js +86 -0
- package/test/storage.test.js +257 -0
package/EXAMPLES.md
ADDED
|
@@ -0,0 +1,797 @@
|
|
|
1
|
+
# Examples
|
|
2
|
+
|
|
3
|
+
Real-world examples showing how to use xStorage in production applications.
|
|
4
|
+
|
|
5
|
+
## Table of Contents
|
|
6
|
+
|
|
7
|
+
- [User Profile Pictures](#user-profile-pictures)
|
|
8
|
+
- [Product Images with Variants](#product-images-with-variants)
|
|
9
|
+
- [Document Management](#document-management)
|
|
10
|
+
- [Photo Gallery](#photo-gallery)
|
|
11
|
+
- [PDF Invoice System](#pdf-invoice-system)
|
|
12
|
+
- [File Attachment System](#file-attachment-system)
|
|
13
|
+
- [Image Optimization Pipeline](#image-optimization-pipeline)
|
|
14
|
+
- [Multi-tenant File Storage](#multi-tenant-file-storage)
|
|
15
|
+
|
|
16
|
+
## User Profile Pictures
|
|
17
|
+
|
|
18
|
+
Upload user avatars with automatic thumbnail generation.
|
|
19
|
+
|
|
20
|
+
```javascript
|
|
21
|
+
import Fastify from "fastify";
|
|
22
|
+
import multipart from "@fastify/multipart";
|
|
23
|
+
import xStorage from "@xenterprises/fastify-xstorage";
|
|
24
|
+
|
|
25
|
+
const fastify = Fastify({ logger: true });
|
|
26
|
+
|
|
27
|
+
await fastify.register(multipart);
|
|
28
|
+
await fastify.register(xStorage, {
|
|
29
|
+
endpoint: process.env.STORAGE_ENDPOINT,
|
|
30
|
+
region: process.env.STORAGE_REGION,
|
|
31
|
+
accessKeyId: process.env.STORAGE_ACCESS_KEY_ID,
|
|
32
|
+
secretAccessKey: process.env.STORAGE_SECRET_ACCESS_KEY,
|
|
33
|
+
bucket: process.env.STORAGE_BUCKET,
|
|
34
|
+
publicUrl: process.env.STORAGE_PUBLIC_URL,
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
// Upload avatar
|
|
38
|
+
fastify.post("/users/:userId/avatar", async (request, reply) => {
|
|
39
|
+
const { userId } = request.params;
|
|
40
|
+
const data = await request.file();
|
|
41
|
+
|
|
42
|
+
// Validate file type
|
|
43
|
+
if (!helpers.isImage(data.filename)) {
|
|
44
|
+
return reply.code(400).send({ error: "Only images allowed" });
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const buffer = await data.toBuffer();
|
|
48
|
+
|
|
49
|
+
// Delete old avatar if exists
|
|
50
|
+
const user = await fastify.prisma.user.findUnique({
|
|
51
|
+
where: { id: userId },
|
|
52
|
+
select: { avatarKey: true, avatarThumbKey: true },
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
if (user.avatarKey) {
|
|
56
|
+
await fastify.storage.delete(user.avatarKey);
|
|
57
|
+
await fastify.storage.delete(user.avatarThumbKey);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Upload new avatar with thumbnail
|
|
61
|
+
const result = await fastify.images.uploadWithThumbnails(buffer, data.filename, {
|
|
62
|
+
imageOptions: {
|
|
63
|
+
folder: `users/${userId}/avatar`,
|
|
64
|
+
quality: 85,
|
|
65
|
+
format: "webp",
|
|
66
|
+
resize: { width: 500, height: 500, fit: "cover" },
|
|
67
|
+
},
|
|
68
|
+
thumbnailOptions: {
|
|
69
|
+
folder: `users/${userId}/avatar/thumbs`,
|
|
70
|
+
sizes: [
|
|
71
|
+
{ name: "small", width: 50, height: 50 },
|
|
72
|
+
{ name: "medium", width: 150, height: 150 },
|
|
73
|
+
],
|
|
74
|
+
format: "webp",
|
|
75
|
+
quality: 80,
|
|
76
|
+
fit: "cover",
|
|
77
|
+
},
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
// Update database
|
|
81
|
+
await fastify.prisma.user.update({
|
|
82
|
+
where: { id: userId },
|
|
83
|
+
data: {
|
|
84
|
+
avatarUrl: result.original.url,
|
|
85
|
+
avatarKey: result.original.key,
|
|
86
|
+
avatarThumbUrl: result.thumbnails.find((t) => t.size === "medium").url,
|
|
87
|
+
avatarThumbKey: result.thumbnails.find((t) => t.size === "medium").key,
|
|
88
|
+
avatarSmallUrl: result.thumbnails.find((t) => t.size === "small").url,
|
|
89
|
+
},
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
return {
|
|
93
|
+
success: true,
|
|
94
|
+
avatar: {
|
|
95
|
+
url: result.original.url,
|
|
96
|
+
thumbnail: result.thumbnails.find((t) => t.size === "medium").url,
|
|
97
|
+
},
|
|
98
|
+
};
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
// Delete avatar
|
|
102
|
+
fastify.delete("/users/:userId/avatar", async (request, reply) => {
|
|
103
|
+
const { userId } = request.params;
|
|
104
|
+
|
|
105
|
+
const user = await fastify.prisma.user.findUnique({
|
|
106
|
+
where: { id: userId },
|
|
107
|
+
select: { avatarKey: true, avatarThumbKey: true },
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
if (user.avatarKey) {
|
|
111
|
+
await fastify.storage.delete(user.avatarKey);
|
|
112
|
+
await fastify.storage.delete(user.avatarThumbKey);
|
|
113
|
+
|
|
114
|
+
await fastify.prisma.user.update({
|
|
115
|
+
where: { id: userId },
|
|
116
|
+
data: {
|
|
117
|
+
avatarUrl: null,
|
|
118
|
+
avatarKey: null,
|
|
119
|
+
avatarThumbUrl: null,
|
|
120
|
+
avatarThumbKey: null,
|
|
121
|
+
},
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return { success: true };
|
|
126
|
+
});
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
## Product Images with Variants
|
|
130
|
+
|
|
131
|
+
E-commerce product images with multiple sizes.
|
|
132
|
+
|
|
133
|
+
```javascript
|
|
134
|
+
fastify.post("/products/:productId/images", async (request, reply) => {
|
|
135
|
+
const { productId } = request.params;
|
|
136
|
+
const parts = request.parts();
|
|
137
|
+
const uploadedImages = [];
|
|
138
|
+
|
|
139
|
+
for await (const part of parts) {
|
|
140
|
+
if (part.type === "file") {
|
|
141
|
+
const buffer = await part.toBuffer();
|
|
142
|
+
|
|
143
|
+
// Generate multiple variants
|
|
144
|
+
const result = await fastify.images.uploadWithThumbnails(buffer, part.filename, {
|
|
145
|
+
imageOptions: {
|
|
146
|
+
folder: `products/${productId}`,
|
|
147
|
+
quality: 90,
|
|
148
|
+
format: "webp",
|
|
149
|
+
resize: { width: 2000, height: 2000, fit: "inside" },
|
|
150
|
+
},
|
|
151
|
+
thumbnailOptions: {
|
|
152
|
+
folder: `products/${productId}/variants`,
|
|
153
|
+
sizes: [
|
|
154
|
+
{ name: "thumbnail", width: 100, height: 100 }, // Grid view
|
|
155
|
+
{ name: "card", width: 300, height: 300 }, // Card view
|
|
156
|
+
{ name: "detail", width: 800, height: 800 }, // Product detail
|
|
157
|
+
{ name: "zoom", width: 1500, height: 1500 }, // Zoom view
|
|
158
|
+
],
|
|
159
|
+
format: "webp",
|
|
160
|
+
quality: 85,
|
|
161
|
+
fit: "inside",
|
|
162
|
+
},
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
uploadedImages.push(result);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// Save to database
|
|
170
|
+
const createdImages = await fastify.prisma.productImage.createMany({
|
|
171
|
+
data: uploadedImages.map((img, index) => ({
|
|
172
|
+
productId,
|
|
173
|
+
position: index,
|
|
174
|
+
originalUrl: img.original.url,
|
|
175
|
+
originalKey: img.original.key,
|
|
176
|
+
thumbnailUrl: img.thumbnails.find((t) => t.size === "thumbnail").url,
|
|
177
|
+
cardUrl: img.thumbnails.find((t) => t.size === "card").url,
|
|
178
|
+
detailUrl: img.thumbnails.find((t) => t.size === "detail").url,
|
|
179
|
+
zoomUrl: img.thumbnails.find((t) => t.size === "zoom").url,
|
|
180
|
+
width: img.original.width,
|
|
181
|
+
height: img.original.height,
|
|
182
|
+
})),
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
return {
|
|
186
|
+
success: true,
|
|
187
|
+
images: uploadedImages,
|
|
188
|
+
count: uploadedImages.length,
|
|
189
|
+
};
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
// Reorder product images
|
|
193
|
+
fastify.patch("/products/:productId/images/reorder", async (request, reply) => {
|
|
194
|
+
const { productId } = request.params;
|
|
195
|
+
const { imageIds } = request.body; // Array of image IDs in new order
|
|
196
|
+
|
|
197
|
+
await Promise.all(
|
|
198
|
+
imageIds.map((imageId, index) =>
|
|
199
|
+
fastify.prisma.productImage.update({
|
|
200
|
+
where: { id: imageId, productId },
|
|
201
|
+
data: { position: index },
|
|
202
|
+
})
|
|
203
|
+
)
|
|
204
|
+
);
|
|
205
|
+
|
|
206
|
+
return { success: true };
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
// Delete product image
|
|
210
|
+
fastify.delete("/products/:productId/images/:imageId", async (request, reply) => {
|
|
211
|
+
const { productId, imageId } = request.params;
|
|
212
|
+
|
|
213
|
+
const image = await fastify.prisma.productImage.findUnique({
|
|
214
|
+
where: { id: imageId, productId },
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
if (!image) {
|
|
218
|
+
return reply.code(404).send({ error: "Image not found" });
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// Delete all variants from storage
|
|
222
|
+
await fastify.storage.delete(image.originalKey);
|
|
223
|
+
await fastify.storage.delete(image.thumbnailUrl.replace(process.env.STORAGE_PUBLIC_URL + "/", ""));
|
|
224
|
+
await fastify.storage.delete(image.cardUrl.replace(process.env.STORAGE_PUBLIC_URL + "/", ""));
|
|
225
|
+
await fastify.storage.delete(image.detailUrl.replace(process.env.STORAGE_PUBLIC_URL + "/", ""));
|
|
226
|
+
await fastify.storage.delete(image.zoomUrl.replace(process.env.STORAGE_PUBLIC_URL + "/", ""));
|
|
227
|
+
|
|
228
|
+
// Delete from database
|
|
229
|
+
await fastify.prisma.productImage.delete({
|
|
230
|
+
where: { id: imageId },
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
return { success: true };
|
|
234
|
+
});
|
|
235
|
+
```
|
|
236
|
+
|
|
237
|
+
## Document Management
|
|
238
|
+
|
|
239
|
+
Upload and manage documents with metadata.
|
|
240
|
+
|
|
241
|
+
```javascript
|
|
242
|
+
import { helpers } from "@xenterprises/fastify-xstorage";
|
|
243
|
+
|
|
244
|
+
fastify.post("/documents", async (request, reply) => {
|
|
245
|
+
const data = await request.file();
|
|
246
|
+
const { category, tags, description } = request.body;
|
|
247
|
+
|
|
248
|
+
// Validate file type
|
|
249
|
+
const allowedTypes = ["pdf", "doc", "docx", "xls", "xlsx", "txt"];
|
|
250
|
+
if (!helpers.isValidFileType(data.filename, allowedTypes)) {
|
|
251
|
+
return reply.code(400).send({
|
|
252
|
+
error: `Only ${allowedTypes.join(", ")} files allowed`,
|
|
253
|
+
});
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
const buffer = await data.toBuffer();
|
|
257
|
+
const isPdf = helpers.isPdf(data.filename);
|
|
258
|
+
|
|
259
|
+
// Upload document
|
|
260
|
+
let result;
|
|
261
|
+
if (isPdf) {
|
|
262
|
+
result = await fastify.pdfs.upload(buffer, data.filename, {
|
|
263
|
+
folder: `documents/${category}`,
|
|
264
|
+
});
|
|
265
|
+
} else {
|
|
266
|
+
result = await fastify.storage.upload(buffer, data.filename, {
|
|
267
|
+
folder: `documents/${category}`,
|
|
268
|
+
metadata: {
|
|
269
|
+
category,
|
|
270
|
+
uploadedBy: request.user.id,
|
|
271
|
+
uploadedAt: new Date().toISOString(),
|
|
272
|
+
},
|
|
273
|
+
});
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// Save to database
|
|
277
|
+
const document = await fastify.prisma.document.create({
|
|
278
|
+
data: {
|
|
279
|
+
filename: data.filename,
|
|
280
|
+
originalFilename: data.filename,
|
|
281
|
+
url: result.url,
|
|
282
|
+
key: result.key,
|
|
283
|
+
size: result.size,
|
|
284
|
+
contentType: result.contentType,
|
|
285
|
+
category,
|
|
286
|
+
tags: tags ? tags.split(",") : [],
|
|
287
|
+
description,
|
|
288
|
+
pageCount: result.pageCount || null,
|
|
289
|
+
uploadedById: request.user.id,
|
|
290
|
+
},
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
return {
|
|
294
|
+
success: true,
|
|
295
|
+
document,
|
|
296
|
+
};
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
// Search documents
|
|
300
|
+
fastify.get("/documents/search", async (request, reply) => {
|
|
301
|
+
const { q, category, tags } = request.query;
|
|
302
|
+
|
|
303
|
+
const documents = await fastify.prisma.document.findMany({
|
|
304
|
+
where: {
|
|
305
|
+
AND: [
|
|
306
|
+
q ? { originalFilename: { contains: q, mode: "insensitive" } } : {},
|
|
307
|
+
category ? { category } : {},
|
|
308
|
+
tags ? { tags: { hasSome: tags.split(",") } } : {},
|
|
309
|
+
],
|
|
310
|
+
},
|
|
311
|
+
include: {
|
|
312
|
+
uploadedBy: {
|
|
313
|
+
select: { id: true, name: true, email: true },
|
|
314
|
+
},
|
|
315
|
+
},
|
|
316
|
+
orderBy: { createdAt: "desc" },
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
return {
|
|
320
|
+
success: true,
|
|
321
|
+
documents,
|
|
322
|
+
count: documents.length,
|
|
323
|
+
};
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
// Download document
|
|
327
|
+
fastify.get("/documents/:id/download", async (request, reply) => {
|
|
328
|
+
const { id } = request.params;
|
|
329
|
+
|
|
330
|
+
const document = await fastify.prisma.document.findUnique({
|
|
331
|
+
where: { id },
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
if (!document) {
|
|
335
|
+
return reply.code(404).send({ error: "Document not found" });
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
// Generate signed URL for secure download
|
|
339
|
+
const signedUrl = await fastify.storage.getSignedUrl(document.key, 300); // 5 minutes
|
|
340
|
+
|
|
341
|
+
return reply.redirect(signedUrl);
|
|
342
|
+
});
|
|
343
|
+
```
|
|
344
|
+
|
|
345
|
+
## Photo Gallery
|
|
346
|
+
|
|
347
|
+
Build a photo gallery with albums.
|
|
348
|
+
|
|
349
|
+
```javascript
|
|
350
|
+
fastify.post("/albums/:albumId/photos", async (request, reply) => {
|
|
351
|
+
const { albumId } = request.params;
|
|
352
|
+
const parts = request.parts();
|
|
353
|
+
const uploadedPhotos = [];
|
|
354
|
+
|
|
355
|
+
for await (const part of parts) {
|
|
356
|
+
if (part.type === "file") {
|
|
357
|
+
const buffer = await part.toBuffer();
|
|
358
|
+
|
|
359
|
+
// Get image metadata
|
|
360
|
+
const metadata = await fastify.images.getMetadata(buffer);
|
|
361
|
+
|
|
362
|
+
// Upload with thumbnails
|
|
363
|
+
const result = await fastify.images.uploadWithThumbnails(buffer, part.filename, {
|
|
364
|
+
imageOptions: {
|
|
365
|
+
folder: `albums/${albumId}/photos`,
|
|
366
|
+
quality: 90,
|
|
367
|
+
format: "webp",
|
|
368
|
+
},
|
|
369
|
+
thumbnailOptions: {
|
|
370
|
+
folder: `albums/${albumId}/thumbnails`,
|
|
371
|
+
sizes: [
|
|
372
|
+
{ name: "thumb", width: 200, height: 200 },
|
|
373
|
+
{ name: "medium", width: 600, height: 600 },
|
|
374
|
+
{ name: "large", width: 1200, height: 1200 },
|
|
375
|
+
],
|
|
376
|
+
format: "webp",
|
|
377
|
+
quality: 80,
|
|
378
|
+
fit: "cover",
|
|
379
|
+
},
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
uploadedPhotos.push({
|
|
383
|
+
result,
|
|
384
|
+
metadata,
|
|
385
|
+
});
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
// Save to database
|
|
390
|
+
const photos = await fastify.prisma.photo.createMany({
|
|
391
|
+
data: uploadedPhotos.map((photo) => ({
|
|
392
|
+
albumId,
|
|
393
|
+
originalUrl: photo.result.original.url,
|
|
394
|
+
originalKey: photo.result.original.key,
|
|
395
|
+
thumbUrl: photo.result.thumbnails.find((t) => t.size === "thumb").url,
|
|
396
|
+
mediumUrl: photo.result.thumbnails.find((t) => t.size === "medium").url,
|
|
397
|
+
largeUrl: photo.result.thumbnails.find((t) => t.size === "large").url,
|
|
398
|
+
width: photo.result.original.width,
|
|
399
|
+
height: photo.result.original.height,
|
|
400
|
+
format: photo.metadata.format,
|
|
401
|
+
})),
|
|
402
|
+
});
|
|
403
|
+
|
|
404
|
+
return {
|
|
405
|
+
success: true,
|
|
406
|
+
photos: uploadedPhotos,
|
|
407
|
+
count: uploadedPhotos.length,
|
|
408
|
+
};
|
|
409
|
+
});
|
|
410
|
+
|
|
411
|
+
// Get album with photos
|
|
412
|
+
fastify.get("/albums/:albumId", async (request, reply) => {
|
|
413
|
+
const { albumId } = request.params;
|
|
414
|
+
|
|
415
|
+
const album = await fastify.prisma.album.findUnique({
|
|
416
|
+
where: { id: albumId },
|
|
417
|
+
include: {
|
|
418
|
+
photos: {
|
|
419
|
+
orderBy: { createdAt: "desc" },
|
|
420
|
+
},
|
|
421
|
+
},
|
|
422
|
+
});
|
|
423
|
+
|
|
424
|
+
if (!album) {
|
|
425
|
+
return reply.code(404).send({ error: "Album not found" });
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
return {
|
|
429
|
+
success: true,
|
|
430
|
+
album,
|
|
431
|
+
};
|
|
432
|
+
});
|
|
433
|
+
|
|
434
|
+
// Delete album (with all photos)
|
|
435
|
+
fastify.delete("/albums/:albumId", async (request, reply) => {
|
|
436
|
+
const { albumId } = request.params;
|
|
437
|
+
|
|
438
|
+
const photos = await fastify.prisma.photo.findMany({
|
|
439
|
+
where: { albumId },
|
|
440
|
+
});
|
|
441
|
+
|
|
442
|
+
// Delete all photos from storage
|
|
443
|
+
const keys = photos.flatMap((photo) => [
|
|
444
|
+
photo.originalKey,
|
|
445
|
+
helpers.getKeyFromUrl(photo.thumbUrl, process.env.STORAGE_PUBLIC_URL),
|
|
446
|
+
helpers.getKeyFromUrl(photo.mediumUrl, process.env.STORAGE_PUBLIC_URL),
|
|
447
|
+
helpers.getKeyFromUrl(photo.largeUrl, process.env.STORAGE_PUBLIC_URL),
|
|
448
|
+
]);
|
|
449
|
+
|
|
450
|
+
await fastify.storage.deleteMultiple(keys);
|
|
451
|
+
|
|
452
|
+
// Delete from database
|
|
453
|
+
await fastify.prisma.album.delete({
|
|
454
|
+
where: { id: albumId },
|
|
455
|
+
});
|
|
456
|
+
|
|
457
|
+
return { success: true };
|
|
458
|
+
});
|
|
459
|
+
```
|
|
460
|
+
|
|
461
|
+
## PDF Invoice System
|
|
462
|
+
|
|
463
|
+
Generate and store PDF invoices.
|
|
464
|
+
|
|
465
|
+
```javascript
|
|
466
|
+
import { PDFDocument, StandardFonts, rgb } from "pdf-lib";
|
|
467
|
+
|
|
468
|
+
fastify.post("/invoices/:invoiceId/generate", async (request, reply) => {
|
|
469
|
+
const { invoiceId } = request.params;
|
|
470
|
+
|
|
471
|
+
// Get invoice data
|
|
472
|
+
const invoice = await fastify.prisma.invoice.findUnique({
|
|
473
|
+
where: { id: invoiceId },
|
|
474
|
+
include: {
|
|
475
|
+
customer: true,
|
|
476
|
+
items: true,
|
|
477
|
+
},
|
|
478
|
+
});
|
|
479
|
+
|
|
480
|
+
// Create PDF (simplified example)
|
|
481
|
+
const pdfDoc = await PDFDocument.create();
|
|
482
|
+
const page = pdfDoc.addPage([600, 800]);
|
|
483
|
+
const font = await pdfDoc.embedFont(StandardFonts.Helvetica);
|
|
484
|
+
|
|
485
|
+
page.drawText(`Invoice #${invoice.number}`, {
|
|
486
|
+
x: 50,
|
|
487
|
+
y: 750,
|
|
488
|
+
size: 24,
|
|
489
|
+
font,
|
|
490
|
+
});
|
|
491
|
+
|
|
492
|
+
page.drawText(`Customer: ${invoice.customer.name}`, {
|
|
493
|
+
x: 50,
|
|
494
|
+
y: 700,
|
|
495
|
+
size: 12,
|
|
496
|
+
font,
|
|
497
|
+
});
|
|
498
|
+
|
|
499
|
+
// ... add more content
|
|
500
|
+
|
|
501
|
+
const pdfBytes = await pdfDoc.save();
|
|
502
|
+
const pdfBuffer = Buffer.from(pdfBytes);
|
|
503
|
+
|
|
504
|
+
// Upload to storage
|
|
505
|
+
const result = await fastify.pdfs.upload(pdfBuffer, `invoice-${invoice.number}.pdf`, {
|
|
506
|
+
folder: `invoices/${new Date().getFullYear()}`,
|
|
507
|
+
});
|
|
508
|
+
|
|
509
|
+
// Update database
|
|
510
|
+
await fastify.prisma.invoice.update({
|
|
511
|
+
where: { id: invoiceId },
|
|
512
|
+
data: {
|
|
513
|
+
pdfUrl: result.url,
|
|
514
|
+
pdfKey: result.key,
|
|
515
|
+
},
|
|
516
|
+
});
|
|
517
|
+
|
|
518
|
+
return {
|
|
519
|
+
success: true,
|
|
520
|
+
invoice: {
|
|
521
|
+
id: invoice.id,
|
|
522
|
+
pdfUrl: result.url,
|
|
523
|
+
},
|
|
524
|
+
};
|
|
525
|
+
});
|
|
526
|
+
|
|
527
|
+
// Merge multiple invoices
|
|
528
|
+
fastify.post("/invoices/merge", async (request, reply) => {
|
|
529
|
+
const { invoiceIds } = request.body;
|
|
530
|
+
|
|
531
|
+
// Download all invoice PDFs
|
|
532
|
+
const invoices = await fastify.prisma.invoice.findMany({
|
|
533
|
+
where: { id: { in: invoiceIds } },
|
|
534
|
+
});
|
|
535
|
+
|
|
536
|
+
const pdfBuffers = await Promise.all(
|
|
537
|
+
invoices.map((invoice) => fastify.storage.download(invoice.pdfKey))
|
|
538
|
+
);
|
|
539
|
+
|
|
540
|
+
// Merge PDFs
|
|
541
|
+
const merged = await fastify.pdfs.merge(pdfBuffers, "merged-invoices.pdf", {
|
|
542
|
+
folder: "invoices/merged",
|
|
543
|
+
});
|
|
544
|
+
|
|
545
|
+
return {
|
|
546
|
+
success: true,
|
|
547
|
+
pdf: merged,
|
|
548
|
+
};
|
|
549
|
+
});
|
|
550
|
+
```
|
|
551
|
+
|
|
552
|
+
## File Attachment System
|
|
553
|
+
|
|
554
|
+
Generic file attachment system for any model.
|
|
555
|
+
|
|
556
|
+
```javascript
|
|
557
|
+
fastify.post("/attachments", async (request, reply) => {
|
|
558
|
+
const data = await request.file();
|
|
559
|
+
const { attachableType, attachableId, name, description } = request.body;
|
|
560
|
+
|
|
561
|
+
const buffer = await data.toBuffer();
|
|
562
|
+
|
|
563
|
+
// Determine folder based on type
|
|
564
|
+
const folder = `attachments/${attachableType.toLowerCase()}s/${attachableId}`;
|
|
565
|
+
|
|
566
|
+
// Upload file
|
|
567
|
+
const result = await fastify.storage.upload(buffer, data.filename, {
|
|
568
|
+
folder,
|
|
569
|
+
metadata: {
|
|
570
|
+
attachableType,
|
|
571
|
+
attachableId,
|
|
572
|
+
uploadedBy: request.user.id,
|
|
573
|
+
},
|
|
574
|
+
});
|
|
575
|
+
|
|
576
|
+
// Save to database
|
|
577
|
+
const attachment = await fastify.prisma.attachment.create({
|
|
578
|
+
data: {
|
|
579
|
+
name: name || data.filename,
|
|
580
|
+
description,
|
|
581
|
+
filename: data.filename,
|
|
582
|
+
url: result.url,
|
|
583
|
+
key: result.key,
|
|
584
|
+
size: result.size,
|
|
585
|
+
contentType: result.contentType,
|
|
586
|
+
attachableType,
|
|
587
|
+
attachableId,
|
|
588
|
+
uploadedById: request.user.id,
|
|
589
|
+
},
|
|
590
|
+
});
|
|
591
|
+
|
|
592
|
+
return {
|
|
593
|
+
success: true,
|
|
594
|
+
attachment,
|
|
595
|
+
};
|
|
596
|
+
});
|
|
597
|
+
|
|
598
|
+
// Get attachments for a resource
|
|
599
|
+
fastify.get("/attachments/:attachableType/:attachableId", async (request, reply) => {
|
|
600
|
+
const { attachableType, attachableId } = request.params;
|
|
601
|
+
|
|
602
|
+
const attachments = await fastify.prisma.attachment.findMany({
|
|
603
|
+
where: {
|
|
604
|
+
attachableType,
|
|
605
|
+
attachableId,
|
|
606
|
+
},
|
|
607
|
+
include: {
|
|
608
|
+
uploadedBy: {
|
|
609
|
+
select: { id: true, name: true },
|
|
610
|
+
},
|
|
611
|
+
},
|
|
612
|
+
orderBy: { createdAt: "desc" },
|
|
613
|
+
});
|
|
614
|
+
|
|
615
|
+
return {
|
|
616
|
+
success: true,
|
|
617
|
+
attachments,
|
|
618
|
+
};
|
|
619
|
+
});
|
|
620
|
+
|
|
621
|
+
// Example usage:
|
|
622
|
+
// POST /attachments
|
|
623
|
+
// {
|
|
624
|
+
// attachableType: "Post",
|
|
625
|
+
// attachableId: "post-123",
|
|
626
|
+
// file: [file]
|
|
627
|
+
// }
|
|
628
|
+
//
|
|
629
|
+
// GET /attachments/Post/post-123
|
|
630
|
+
```
|
|
631
|
+
|
|
632
|
+
## Image Optimization Pipeline
|
|
633
|
+
|
|
634
|
+
Automatically optimize and resize images on upload.
|
|
635
|
+
|
|
636
|
+
```javascript
|
|
637
|
+
fastify.post("/uploads/optimize", async (request, reply) => {
|
|
638
|
+
const data = await request.file();
|
|
639
|
+
|
|
640
|
+
if (!helpers.isImage(data.filename)) {
|
|
641
|
+
return reply.code(400).send({ error: "Only images allowed" });
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
const buffer = await data.toBuffer();
|
|
645
|
+
|
|
646
|
+
// Get original metadata
|
|
647
|
+
const metadata = await fastify.images.getMetadata(buffer);
|
|
648
|
+
|
|
649
|
+
// Generate responsive sizes
|
|
650
|
+
const responsiveSizes = helpers.generateResponsiveSizes(metadata.width, metadata.height);
|
|
651
|
+
|
|
652
|
+
// Upload original (optimized)
|
|
653
|
+
const original = await fastify.images.upload(buffer, data.filename, {
|
|
654
|
+
folder: "images/originals",
|
|
655
|
+
quality: 90,
|
|
656
|
+
format: "webp",
|
|
657
|
+
});
|
|
658
|
+
|
|
659
|
+
// Generate responsive variants
|
|
660
|
+
const variants = await Promise.all(
|
|
661
|
+
responsiveSizes.map(async (size) => {
|
|
662
|
+
const resized = await fastify.images.upload(buffer, data.filename, {
|
|
663
|
+
folder: `images/responsive/${size.name}`,
|
|
664
|
+
quality: 85,
|
|
665
|
+
format: "webp",
|
|
666
|
+
resize: {
|
|
667
|
+
width: size.width,
|
|
668
|
+
height: size.height,
|
|
669
|
+
fit: "inside",
|
|
670
|
+
},
|
|
671
|
+
});
|
|
672
|
+
|
|
673
|
+
return {
|
|
674
|
+
size: size.name,
|
|
675
|
+
width: size.width,
|
|
676
|
+
height: size.height,
|
|
677
|
+
url: resized.url,
|
|
678
|
+
key: resized.key,
|
|
679
|
+
};
|
|
680
|
+
})
|
|
681
|
+
);
|
|
682
|
+
|
|
683
|
+
return {
|
|
684
|
+
success: true,
|
|
685
|
+
image: {
|
|
686
|
+
original,
|
|
687
|
+
variants,
|
|
688
|
+
metadata,
|
|
689
|
+
},
|
|
690
|
+
};
|
|
691
|
+
});
|
|
692
|
+
```
|
|
693
|
+
|
|
694
|
+
## Multi-tenant File Storage
|
|
695
|
+
|
|
696
|
+
Isolate files by tenant/organization.
|
|
697
|
+
|
|
698
|
+
```javascript
|
|
699
|
+
// Middleware to get tenant
|
|
700
|
+
fastify.decorateRequest("tenant", null);
|
|
701
|
+
|
|
702
|
+
fastify.addHook("preHandler", async (request, reply) => {
|
|
703
|
+
const tenantId = request.headers["x-tenant-id"];
|
|
704
|
+
|
|
705
|
+
if (!tenantId) {
|
|
706
|
+
return reply.code(400).send({ error: "Tenant ID required" });
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
const tenant = await fastify.prisma.tenant.findUnique({
|
|
710
|
+
where: { id: tenantId },
|
|
711
|
+
});
|
|
712
|
+
|
|
713
|
+
if (!tenant) {
|
|
714
|
+
return reply.code(404).send({ error: "Tenant not found" });
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
request.tenant = tenant;
|
|
718
|
+
});
|
|
719
|
+
|
|
720
|
+
// Upload file for tenant
|
|
721
|
+
fastify.post("/files", async (request, reply) => {
|
|
722
|
+
const data = await request.file();
|
|
723
|
+
const buffer = await data.toBuffer();
|
|
724
|
+
|
|
725
|
+
// Upload to tenant-specific folder
|
|
726
|
+
const result = await fastify.storage.upload(buffer, data.filename, {
|
|
727
|
+
folder: `tenants/${request.tenant.id}/files`,
|
|
728
|
+
metadata: {
|
|
729
|
+
tenantId: request.tenant.id,
|
|
730
|
+
uploadedBy: request.user.id,
|
|
731
|
+
},
|
|
732
|
+
});
|
|
733
|
+
|
|
734
|
+
// Save to database
|
|
735
|
+
const file = await fastify.prisma.file.create({
|
|
736
|
+
data: {
|
|
737
|
+
tenantId: request.tenant.id,
|
|
738
|
+
filename: data.filename,
|
|
739
|
+
url: result.url,
|
|
740
|
+
key: result.key,
|
|
741
|
+
size: result.size,
|
|
742
|
+
uploadedById: request.user.id,
|
|
743
|
+
},
|
|
744
|
+
});
|
|
745
|
+
|
|
746
|
+
return {
|
|
747
|
+
success: true,
|
|
748
|
+
file,
|
|
749
|
+
};
|
|
750
|
+
});
|
|
751
|
+
|
|
752
|
+
// List tenant files
|
|
753
|
+
fastify.get("/files", async (request, reply) => {
|
|
754
|
+
const files = await fastify.prisma.file.findMany({
|
|
755
|
+
where: { tenantId: request.tenant.id },
|
|
756
|
+
orderBy: { createdAt: "desc" },
|
|
757
|
+
});
|
|
758
|
+
|
|
759
|
+
return {
|
|
760
|
+
success: true,
|
|
761
|
+
files,
|
|
762
|
+
};
|
|
763
|
+
});
|
|
764
|
+
|
|
765
|
+
// Delete tenant file (with authorization check)
|
|
766
|
+
fastify.delete("/files/:id", async (request, reply) => {
|
|
767
|
+
const { id } = request.params;
|
|
768
|
+
|
|
769
|
+
const file = await fastify.prisma.file.findUnique({
|
|
770
|
+
where: { id },
|
|
771
|
+
});
|
|
772
|
+
|
|
773
|
+
if (!file) {
|
|
774
|
+
return reply.code(404).send({ error: "File not found" });
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
// Ensure file belongs to tenant
|
|
778
|
+
if (file.tenantId !== request.tenant.id) {
|
|
779
|
+
return reply.code(403).send({ error: "Access denied" });
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
// Delete from storage
|
|
783
|
+
await fastify.storage.delete(file.key);
|
|
784
|
+
|
|
785
|
+
// Delete from database
|
|
786
|
+
await fastify.prisma.file.delete({
|
|
787
|
+
where: { id },
|
|
788
|
+
});
|
|
789
|
+
|
|
790
|
+
return { success: true };
|
|
791
|
+
});
|
|
792
|
+
```
|
|
793
|
+
|
|
794
|
+
## Next Steps
|
|
795
|
+
|
|
796
|
+
- See [README.md](./README.md) for complete API reference
|
|
797
|
+
- See [TESTING.md](./TESTING.md) for testing guide
|