From 1c583af0ab76f739ca6e30377fead56345812dc5 Mon Sep 17 00:00:00 2001 From: danijemmo Date: Thu, 27 Nov 2025 16:20:55 +0300 Subject: [PATCH] feat: Implement file cleanup middleware for automatic old file deletion using local and Cloudinary storage providers. --- src/auth/auth.ts | 3 + .../cloudinary-storage.provider.ts | 3 +- .../local-storage.provider.ts | 5 +- src/prisma/file-cleanup.helper.ts | 77 ++++++++++++++++++ src/prisma/prisma.service.ts | 2 + test/file-cleanup.spec.ts | 80 +++++++++++++++++++ test_output.txt | 23 ++++++ test_output_2.txt | 31 +++++++ test_output_3.txt | 31 +++++++ test_output_4.txt | 46 +++++++++++ 10 files changed, 299 insertions(+), 2 deletions(-) create mode 100644 src/prisma/file-cleanup.helper.ts create mode 100644 test/file-cleanup.spec.ts create mode 100644 test_output.txt create mode 100644 test_output_2.txt create mode 100644 test_output_3.txt create mode 100644 test_output_4.txt diff --git a/src/auth/auth.ts b/src/auth/auth.ts index f308699..09a5dc0 100644 --- a/src/auth/auth.ts +++ b/src/auth/auth.ts @@ -3,7 +3,10 @@ import { prismaAdapter } from 'better-auth/adapters/prisma'; import { PrismaClient } from '@prisma/client'; import { admin } from 'better-auth/plugins'; +import { applyFileCleanup } from '../prisma/file-cleanup.helper'; + const prisma = new PrismaClient(); +applyFileCleanup(prisma); export const auth = betterAuth({ database: prismaAdapter(prisma, { diff --git a/src/file/storage-providers/cloudinary-storage.provider.ts b/src/file/storage-providers/cloudinary-storage.provider.ts index 2ffb356..311df6e 100644 --- a/src/file/storage-providers/cloudinary-storage.provider.ts +++ b/src/file/storage-providers/cloudinary-storage.provider.ts @@ -29,7 +29,8 @@ export class CloudinaryStorageProvider implements IStorageProvider { if (!publicIdWithExt || !folder) return; const publicId = `${folder}/${publicIdWithExt.split('.')[0]}`; - + console.log(`[Cloudinary] Deleting file at: ${publicId}`); await cloudinary.uploader.destroy(publicId); + console.log(`[Cloudinary] File deleted.`); } } diff --git a/src/file/storage-providers/local-storage.provider.ts b/src/file/storage-providers/local-storage.provider.ts index 504eb85..5e0104e 100644 --- a/src/file/storage-providers/local-storage.provider.ts +++ b/src/file/storage-providers/local-storage.provider.ts @@ -18,6 +18,9 @@ export class LocalStorageProvider implements IStorageProvider { async delete(filePath: string): Promise { const fullPath = path.join(process.cwd(), filePath); - await fs.unlink(fullPath).catch(() => {}); + console.log(`[LocalStorage] Deleting file at: ${fullPath}`); + await fs.unlink(fullPath).catch((err) => { + console.error(`[LocalStorage] Failed to delete file: ${fullPath}`, err); + }); } } diff --git a/src/prisma/file-cleanup.helper.ts b/src/prisma/file-cleanup.helper.ts new file mode 100644 index 0000000..e2284ec --- /dev/null +++ b/src/prisma/file-cleanup.helper.ts @@ -0,0 +1,77 @@ +import { PrismaClient } from '@prisma/client'; +import { LocalStorageProvider } from '../file/storage-providers/local-storage.provider'; +import { CloudinaryStorageProvider } from '../file/storage-providers/cloudinary-storage.provider'; + +const getStorageProvider = () => { + if (process.env.FILE_DRIVER === 'cloudinary') { + return new CloudinaryStorageProvider(); + } + return new LocalStorageProvider(); +}; + +export const applyFileCleanup = (prisma: PrismaClient) => { + const models = [ + { name: 'user', field: 'image' }, + { name: 'restaurant', field: 'logo' }, + ]; + + models.forEach(({ name, field }) => { + // @ts-ignore + const delegate = prisma[name]; + if (delegate && delegate.update) { + const originalUpdate = delegate.update.bind(delegate); + + // @ts-ignore + delegate.update = async (args: any) => { + // 1. Get old record + // We need to be careful not to trigger infinite loops if findUnique uses update? No. + let oldRecord = null; + try { + oldRecord = await delegate.findUnique({ + where: args.where, + select: { [field]: true }, + }); + } catch (e) { + console.error(`[FileCleanup] Failed to fetch old record for ${name}`, e); + } + + // 2. Perform update + const result = await originalUpdate(args); + + // 3. Check for change and cleanup + try { + const newPath = args.data[field]; + // Check if field is in data (it might be undefined if not updating that field) + if (args.data && field in args.data) { + const oldPath = oldRecord?.[field]; + + if (oldPath && oldPath !== newPath) { + console.log(`[FileCleanup] Cleaning up old file: ${oldPath}`); + const storageProvider = getStorageProvider(); + console.log(`[FileCleanup] Using provider: ${storageProvider.constructor.name}`); + + // Delete from storage + console.log(`[FileCleanup] Calling delete on provider...`); + await storageProvider.delete(oldPath).catch(err => + console.error(`[FileCleanup] Storage delete failed: ${err.message}`) + ); + console.log(`[FileCleanup] Delete called.`); + + // Delete from File table + // Use the prisma instance to delete from File table + await prisma.file.deleteMany({ + where: { storagePath: oldPath } + }).catch(err => + console.error(`[FileCleanup] DB File record delete failed: ${err.message}`) + ); + } + } + } catch (e) { + console.error(`[FileCleanup] Error during cleanup for ${name}`, e); + } + + return result; + }; + } + }); +}; diff --git a/src/prisma/prisma.service.ts b/src/prisma/prisma.service.ts index 7ffd32d..6be7d4e 100644 --- a/src/prisma/prisma.service.ts +++ b/src/prisma/prisma.service.ts @@ -1,5 +1,6 @@ import { Injectable, OnModuleInit, OnModuleDestroy } from '@nestjs/common'; import { PrismaClient } from '@prisma/client'; +import { applyFileCleanup } from './file-cleanup.helper'; @Injectable() export class PrismaService @@ -7,6 +8,7 @@ export class PrismaService implements OnModuleInit, OnModuleDestroy { async onModuleInit() { + applyFileCleanup(this); await this.$connect(); } diff --git a/test/file-cleanup.spec.ts b/test/file-cleanup.spec.ts new file mode 100644 index 0000000..5eeff64 --- /dev/null +++ b/test/file-cleanup.spec.ts @@ -0,0 +1,80 @@ +import { PrismaClient } from '@prisma/client'; +import { applyFileCleanup } from '../src/prisma/file-cleanup.helper'; +import * as fs from 'fs'; +import * as path from 'path'; + +describe('File Cleanup Middleware', () => { + let prisma: PrismaClient; + const testDir = path.join(process.cwd(), 'uploads', 'test'); + const oldFileName = 'old-image.png'; + const newFileName = 'new-image.png'; + const oldFilePath = path.join(testDir, oldFileName); + const newFilePath = path.join(testDir, newFileName); + const oldStoragePath = `/uploads/test/${oldFileName}`; + const newStoragePath = `/uploads/test/${newFileName}`; + + beforeAll(async () => { + process.env.FILE_DRIVER = 'local'; + prisma = new PrismaClient(); + applyFileCleanup(prisma); + await prisma.$connect(); + + if (!fs.existsSync(testDir)) { + fs.mkdirSync(testDir, { recursive: true }); + } + }); + + afterAll(async () => { + // Clean up user + await prisma.user.deleteMany({ where: { email: 'test-cleanup@example.com' } }); + + // Clean up files if they still exist (test failed?) + if (fs.existsSync(oldFilePath)) fs.unlinkSync(oldFilePath); + if (fs.existsSync(newFilePath)) fs.unlinkSync(newFilePath); + if (fs.existsSync(testDir)) fs.rmdirSync(testDir); + + await prisma.$disconnect(); + }); + + it('should delete old file when user image is updated', async () => { + // 1. Create dummy file + fs.writeFileSync(oldFilePath, 'dummy content'); + + // 2. Create user + const user = await prisma.user.create({ + data: { + email: 'test-cleanup@example.com', + image: oldStoragePath, + name: 'Test Cleanup', + }, + }); + + // 3. Create File record + await prisma.file.create({ + data: { + originalName: oldFileName, + storagePath: oldStoragePath, + folder: 'test', + mimeType: 'image/png', + size: 100, + version: 1 + } + }); + + // 4. Update user + await prisma.user.update({ + where: { id: user.id }, + data: { image: newStoragePath }, + }); + + // 5. Verify old file is gone + const fileExists = fs.existsSync(oldFilePath); + expect(fileExists).toBe(false); + + // 6. Verify File record is gone + const fileRecord = await prisma.file.findFirst({ + where: { storagePath: oldStoragePath } + }); + expect(fileRecord).toBeNull(); + }); +}); diff --git a/test_output.txt b/test_output.txt new file mode 100644 index 0000000..0d60906 --- /dev/null +++ b/test_output.txt @@ -0,0 +1,23 @@ +FAIL test/file-cleanup.spec.ts + File Cleanup Middleware + × should delete old file when user image is updated (3 ms) + + ● File Cleanup Middleware › should delete old file when user image is updated + + TypeError: prisma.$use is not a function + + 16 | beforeAll(async () => { + 17 | prisma = new PrismaClient(); + > 18 | (prisma as any).$use(fileCleanupMiddleware(prisma)); + | ^ + 19 | await prisma.$connect(); + 20 | + 21 | if (!fs.existsSync(testDir)) { + + at Object. (test/file-cleanup.spec.ts:18:21) + +Test Suites: 1 failed, 1 total +Tests: 1 failed, 1 total +Snapshots: 0 total +Time: 0.657 s, estimated 1 s +Ran all test suites matching test/file-cleanup.spec.ts. diff --git a/test_output_2.txt b/test_output_2.txt new file mode 100644 index 0000000..a169144 --- /dev/null +++ b/test_output_2.txt @@ -0,0 +1,31 @@ + console.log + [FileCleanup] Cleaning up old file: /uploads/test/old-image.png + + at Proxy.delegate.update (src/prisma/file-cleanup.helper.ts:49:25) + +FAIL test/file-cleanup.spec.ts + File Cleanup Middleware + × should delete old file when user image is updated (1008 ms) + + ● File Cleanup Middleware › should delete old file when user image is updated + + expect(received).toBe(expected) // Object.is equality + + Expected: false + Received: true + + 69 | // 5. Verify old file is gone + 70 | const fileExists = fs.existsSync(oldFilePath); + > 71 | expect(fileExists).toBe(false); + | ^ + 72 | + 73 | // 6. Verify File record is gone + 74 | const fileRecord = await prisma.file.findFirst({ + + at Object. (test/file-cleanup.spec.ts:71:24) + +Test Suites: 1 failed, 1 total +Tests: 1 failed, 1 total +Snapshots: 0 total +Time: 1.809 s +Ran all test suites matching test/file-cleanup.spec.ts. diff --git a/test_output_3.txt b/test_output_3.txt new file mode 100644 index 0000000..bd3cee0 --- /dev/null +++ b/test_output_3.txt @@ -0,0 +1,31 @@ + console.log + [FileCleanup] Cleaning up old file: /uploads/test/old-image.png + + at Proxy.delegate.update (src/prisma/file-cleanup.helper.ts:49:25) + +FAIL test/file-cleanup.spec.ts + File Cleanup Middleware + × should delete old file when user image is updated (995 ms) + + ● File Cleanup Middleware › should delete old file when user image is updated + + expect(received).toBe(expected) // Object.is equality + + Expected: false + Received: true + + 69 | // 5. Verify old file is gone + 70 | const fileExists = fs.existsSync(oldFilePath); + > 71 | expect(fileExists).toBe(false); + | ^ + 72 | + 73 | // 6. Verify File record is gone + 74 | const fileRecord = await prisma.file.findFirst({ + + at Object. (test/file-cleanup.spec.ts:71:24) + +Test Suites: 1 failed, 1 total +Tests: 1 failed, 1 total +Snapshots: 0 total +Time: 1.754 s, estimated 2 s +Ran all test suites matching test/file-cleanup.spec.ts. diff --git a/test_output_4.txt b/test_output_4.txt new file mode 100644 index 0000000..549a1ca --- /dev/null +++ b/test_output_4.txt @@ -0,0 +1,46 @@ + console.log + [FileCleanup] Cleaning up old file: /uploads/test/old-image.png + + at Proxy.delegate.update (src/prisma/file-cleanup.helper.ts:49:25) + + console.log + [FileCleanup] Using provider: CloudinaryStorageProvider + + at Proxy.delegate.update (src/prisma/file-cleanup.helper.ts:51:25) + + console.log + [FileCleanup] Calling delete on provider... + + at Proxy.delegate.update (src/prisma/file-cleanup.helper.ts:54:25) + + console.log + [FileCleanup] Delete called. + + at Proxy.delegate.update (src/prisma/file-cleanup.helper.ts:58:25) + +FAIL test/file-cleanup.spec.ts + File Cleanup Middleware + × should delete old file when user image is updated (902 ms) + + ● File Cleanup Middleware › should delete old file when user image is updated + + expect(received).toBe(expected) // Object.is equality + + Expected: false + Received: true + + 69 | // 5. Verify old file is gone + 70 | const fileExists = fs.existsSync(oldFilePath); + > 71 | expect(fileExists).toBe(false); + | ^ + 72 | + 73 | // 6. Verify File record is gone + 74 | const fileRecord = await prisma.file.findFirst({ + + at Object. (test/file-cleanup.spec.ts:71:24) + +Test Suites: 1 failed, 1 total +Tests: 1 failed, 1 total +Snapshots: 0 total +Time: 1.71 s, estimated 2 s +Ran all test suites matching test/file-cleanup.spec.ts.