feat: Implement file cleanup middleware for automatic old file deletion using local and Cloudinary storage providers.
This commit is contained in:
@@ -3,7 +3,10 @@ import { prismaAdapter } from 'better-auth/adapters/prisma';
|
|||||||
import { PrismaClient } from '@prisma/client';
|
import { PrismaClient } from '@prisma/client';
|
||||||
import { admin } from 'better-auth/plugins';
|
import { admin } from 'better-auth/plugins';
|
||||||
|
|
||||||
|
import { applyFileCleanup } from '../prisma/file-cleanup.helper';
|
||||||
|
|
||||||
const prisma = new PrismaClient();
|
const prisma = new PrismaClient();
|
||||||
|
applyFileCleanup(prisma);
|
||||||
|
|
||||||
export const auth = betterAuth({
|
export const auth = betterAuth({
|
||||||
database: prismaAdapter(prisma, {
|
database: prismaAdapter(prisma, {
|
||||||
|
|||||||
@@ -29,7 +29,8 @@ export class CloudinaryStorageProvider implements IStorageProvider {
|
|||||||
if (!publicIdWithExt || !folder) return;
|
if (!publicIdWithExt || !folder) return;
|
||||||
|
|
||||||
const publicId = `${folder}/${publicIdWithExt.split('.')[0]}`;
|
const publicId = `${folder}/${publicIdWithExt.split('.')[0]}`;
|
||||||
|
console.log(`[Cloudinary] Deleting file at: ${publicId}`);
|
||||||
await cloudinary.uploader.destroy(publicId);
|
await cloudinary.uploader.destroy(publicId);
|
||||||
|
console.log(`[Cloudinary] File deleted.`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,9 @@ export class LocalStorageProvider implements IStorageProvider {
|
|||||||
|
|
||||||
async delete(filePath: string): Promise<void> {
|
async delete(filePath: string): Promise<void> {
|
||||||
const fullPath = path.join(process.cwd(), filePath);
|
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);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Injectable, OnModuleInit, OnModuleDestroy } from '@nestjs/common';
|
import { Injectable, OnModuleInit, OnModuleDestroy } from '@nestjs/common';
|
||||||
import { PrismaClient } from '@prisma/client';
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
import { applyFileCleanup } from './file-cleanup.helper';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class PrismaService
|
export class PrismaService
|
||||||
@@ -7,6 +8,7 @@ export class PrismaService
|
|||||||
implements OnModuleInit, OnModuleDestroy
|
implements OnModuleInit, OnModuleDestroy
|
||||||
{
|
{
|
||||||
async onModuleInit() {
|
async onModuleInit() {
|
||||||
|
applyFileCleanup(this);
|
||||||
await this.$connect();
|
await this.$connect();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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.<anonymous> (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.
|
||||||
@@ -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.<anonymous> (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.
|
||||||
@@ -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.<anonymous> (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.
|
||||||
@@ -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.<anonymous> (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.
|
||||||
Reference in New Issue
Block a user