@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/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