From bf9988d9bcca4908483a6c7484ae382399a86f63 Mon Sep 17 00:00:00 2001 From: danijemmo Date: Wed, 26 Nov 2025 17:07:07 +0300 Subject: [PATCH] file service module added --- package-lock.json | 26 +++++++++ package.json | 2 + .../migration.sql | 17 ++++++ prisma/schema.prisma | 14 +++++ src/app.module.ts | 2 + src/file/file.controller.ts | 31 +++++++++++ src/file/file.module.ts | 27 ++++++++++ src/file/file.service.ts | 53 +++++++++++++++++++ .../cloudinary-storage.provider.ts | 35 ++++++++++++ .../local-storage.provider.ts | 23 ++++++++ .../storage-provider.interface.ts | 4 ++ src/shared/config/config.ts | 38 +++++++++++++ src/user/user.controller.ts | 6 ++- 13 files changed, 277 insertions(+), 1 deletion(-) create mode 100644 prisma/migrations/20251126124342_file_metadata/migration.sql create mode 100644 src/file/file.controller.ts create mode 100644 src/file/file.module.ts create mode 100644 src/file/file.service.ts create mode 100644 src/file/storage-providers/cloudinary-storage.provider.ts create mode 100644 src/file/storage-providers/local-storage.provider.ts create mode 100644 src/file/storage-providers/storage-provider.interface.ts create mode 100644 src/shared/config/config.ts diff --git a/package-lock.json b/package-lock.json index 3737787..0567db9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,7 +17,9 @@ "better-auth": "^1.3.8", "class-transformer": "^0.5.1", "class-validator": "^0.14.2", + "cloudinary": "^2.8.0", "jose": "3.16.0", + "multer": "^2.0.2", "reflect-metadata": "^0.2.2", "rxjs": "^7.8.1" }, @@ -5165,6 +5167,19 @@ "node": ">=0.8" } }, + "node_modules/cloudinary": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/cloudinary/-/cloudinary-2.8.0.tgz", + "integrity": "sha512-s7frvR0HnQXeJsQSIsbLa/I09IMb1lOnVLEDH5b5E53WTiCYgrNNOBGV/i/nLHwrcEOUkqjfSwP1+enXWNYmdw==", + "license": "MIT", + "dependencies": { + "lodash": "^4.17.21", + "q": "^1.5.1" + }, + "engines": { + "node": ">=9" + } + }, "node_modules/co": { "version": "4.6.0", "resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz", @@ -9304,6 +9319,17 @@ "node": ">=16.0.0" } }, + "node_modules/q": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/q/-/q-1.5.1.tgz", + "integrity": "sha512-kV/CThkXo6xyFEZUugw/+pIOywXcDbFYgSct5cT3gqlbkBE1SJdwy6UQoZvodiWF/ckQLZyDE/Bu1M6gVu5lVw==", + "deprecated": "You or someone you depend on is using Q, the JavaScript Promise library that gave JavaScript developers strong feelings about promises. They can almost certainly migrate to the native JavaScript promise now. Thank you literally everyone for joining me in this bet against the odds. Be excellent to each other.\n\n(For a CapTP with native promises, see @endo/eventual-send and @endo/captp)", + "license": "MIT", + "engines": { + "node": ">=0.6.0", + "teleport": ">=0.2.0" + } + }, "node_modules/qs": { "version": "6.14.0", "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz", diff --git a/package.json b/package.json index 7977770..5abba09 100644 --- a/package.json +++ b/package.json @@ -28,7 +28,9 @@ "better-auth": "^1.3.8", "class-transformer": "^0.5.1", "class-validator": "^0.14.2", + "cloudinary": "^2.8.0", "jose": "3.16.0", + "multer": "^2.0.2", "reflect-metadata": "^0.2.2", "rxjs": "^7.8.1" }, diff --git a/prisma/migrations/20251126124342_file_metadata/migration.sql b/prisma/migrations/20251126124342_file_metadata/migration.sql new file mode 100644 index 0000000..0947612 --- /dev/null +++ b/prisma/migrations/20251126124342_file_metadata/migration.sql @@ -0,0 +1,17 @@ +-- CreateTable +CREATE TABLE "File" ( + "id" TEXT NOT NULL, + "originalName" TEXT NOT NULL, + "storagePath" TEXT NOT NULL, + "folder" TEXT NOT NULL, + "version" INTEGER NOT NULL DEFAULT 1, + "mimeType" TEXT NOT NULL, + "size" INTEGER NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "File_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "File_folder_originalName_idx" ON "File"("folder", "originalName"); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 094ad82..0047936 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -119,3 +119,17 @@ model MenuItem { createdAt DateTime @default(now()) } + +model File { + id String @id @default(uuid()) + originalName String + storagePath String + folder String + version Int @default(1) + mimeType String + size Int + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([folder, originalName]) +} diff --git a/src/app.module.ts b/src/app.module.ts index 9a42af0..6f473ba 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -6,6 +6,7 @@ import { PrismaModule } from './prisma/prisma.module'; import { CategoryModule } from './category/category.module'; import { RestaurantModule } from './restaurant/restaurant.module'; import { ItemModule } from './item/item.module'; +import { FileModule } from './file/file.module'; @Module({ imports: [ @@ -15,6 +16,7 @@ import { ItemModule } from './item/item.module'; CategoryModule, RestaurantModule, ItemModule, + FileModule, ], }) export class AppModule {} diff --git a/src/file/file.controller.ts b/src/file/file.controller.ts new file mode 100644 index 0000000..4d2ab3c --- /dev/null +++ b/src/file/file.controller.ts @@ -0,0 +1,31 @@ +// src/file/file.controller.ts +import { + Controller, + Post, + Body, + UploadedFile, + UseInterceptors, + UseGuards, + Delete, + Param, +} from '@nestjs/common'; +import { FileInterceptor } from '@nestjs/platform-express'; +import { FileService } from './file.service'; +import { AuthGuard } from '@thallesp/nestjs-better-auth'; + +@Controller('files') +@UseGuards(AuthGuard) +export class FileController { + constructor(private readonly fileService: FileService) {} + + @Post('upload') + @UseInterceptors(FileInterceptor('file')) + async upload(@UploadedFile() file: any, @Body('folder') folder: string) { + return this.fileService.uploadFile(file, folder ?? 'general'); + } + + @Delete(':id') + async deleteFile(@Param('id') id: string) { + return this.fileService.deleteFile(id); + } +} diff --git a/src/file/file.module.ts b/src/file/file.module.ts new file mode 100644 index 0000000..2ee616b --- /dev/null +++ b/src/file/file.module.ts @@ -0,0 +1,27 @@ +// src/file/file.module.ts +import { Module } from '@nestjs/common'; +import { FileService } from './file.service'; +import { FileController } from './file.controller'; +import { PrismaService } from 'src/prisma/prisma.service'; +import { LocalStorageProvider } from './storage-providers/local-storage.provider'; +import { CloudinaryStorageProvider } from './storage-providers/cloudinary-storage.provider'; + +@Module({ + controllers: [FileController], + providers: [ + FileService, + PrismaService, + { + provide: 'StorageProvider', + useFactory: () => { + switch (process.env.FILE_DRIVER) { + case 'cloudinary': + return new CloudinaryStorageProvider(); + default: + return new LocalStorageProvider(); + } + }, + }, + ], +}) +export class FileModule {} diff --git a/src/file/file.service.ts b/src/file/file.service.ts new file mode 100644 index 0000000..80153f8 --- /dev/null +++ b/src/file/file.service.ts @@ -0,0 +1,53 @@ +// src/file/file.service.ts +import { Injectable, Inject } from '@nestjs/common'; +import { PrismaService } from 'src/prisma/prisma.service'; +import type { IStorageProvider } from './storage-providers/storage-provider.interface'; + +@Injectable() +export class FileService { + constructor( + private prisma: PrismaService, + + @Inject('StorageProvider') + private readonly storageProvider: IStorageProvider, + ) {} + + async uploadFile(file: any, folder: string) { + const lastVersion = await this.prisma.file.findFirst({ + where: { folder, originalName: file.originalname }, + orderBy: { version: 'desc' }, + }); + + const nextVersion = lastVersion ? lastVersion.version + 1 : 1; + + const storagePath = await this.storageProvider.upload(file, folder); + + return this.prisma.file.create({ + data: { + originalName: file.originalname, + storagePath, + folder, + mimeType: file.mimetype, + size: file.size, + version: nextVersion, + }, + }); + } + + async deleteFile(id: string) { + const file = await this.prisma.file.findUnique({ where: { id } }); + + if (!file) return; + + await this.storageProvider.delete(file.storagePath); + + await this.prisma.file.delete({ where: { id } }); + } + + async getFileVersions(originalName: string, folder: string) { + return this.prisma.file.findMany({ + where: { folder, originalName }, + orderBy: { version: 'asc' }, + }); + } +} diff --git a/src/file/storage-providers/cloudinary-storage.provider.ts b/src/file/storage-providers/cloudinary-storage.provider.ts new file mode 100644 index 0000000..2ffb356 --- /dev/null +++ b/src/file/storage-providers/cloudinary-storage.provider.ts @@ -0,0 +1,35 @@ +// src/file/storage-providers/cloudinary-storage.provider.ts +import { IStorageProvider } from './storage-provider.interface'; +import { v2 as cloudinary } from 'cloudinary'; + +cloudinary.config({ + cloud_name: process.env.CLOUDINARY_CLOUD_NAME, + api_key: process.env.CLOUDINARY_API_KEY, + api_secret: process.env.CLOUDINARY_API_SECRET, +}); + +export class CloudinaryStorageProvider implements IStorageProvider { + async upload(file: any, folder: string): Promise { + return new Promise((resolve, reject) => { + cloudinary.uploader + .upload_stream({ folder }, (err, result) => { + if (err) return reject(err); + if (!result) return reject(new Error('Result is undefined')); + resolve(result.secure_url); + }) + .end(file.buffer); + }); + } + + async delete(fileUrl: string): Promise { + const segments = fileUrl.split('/'); + const publicIdWithExt = segments.pop(); + const folder = segments.pop(); + + if (!publicIdWithExt || !folder) return; + + const publicId = `${folder}/${publicIdWithExt.split('.')[0]}`; + + await cloudinary.uploader.destroy(publicId); + } +} diff --git a/src/file/storage-providers/local-storage.provider.ts b/src/file/storage-providers/local-storage.provider.ts new file mode 100644 index 0000000..504eb85 --- /dev/null +++ b/src/file/storage-providers/local-storage.provider.ts @@ -0,0 +1,23 @@ +import { IStorageProvider } from './storage-provider.interface'; +import { promises as fs } from 'fs'; +import * as path from 'path'; + +export class LocalStorageProvider implements IStorageProvider { + private baseFolder = path.join(process.cwd(), 'uploads'); + + async upload(file: any, folder: string): Promise { + const targetFolder = path.join(this.baseFolder, folder); + + await fs.mkdir(targetFolder, { recursive: true }); + + const filePath = path.join(targetFolder, file.originalname); + await fs.writeFile(filePath, file.buffer); + + return `/uploads/${folder}/${file.originalname}`; + } + + async delete(filePath: string): Promise { + const fullPath = path.join(process.cwd(), filePath); + await fs.unlink(fullPath).catch(() => {}); + } +} diff --git a/src/file/storage-providers/storage-provider.interface.ts b/src/file/storage-providers/storage-provider.interface.ts new file mode 100644 index 0000000..6fa6c2c --- /dev/null +++ b/src/file/storage-providers/storage-provider.interface.ts @@ -0,0 +1,4 @@ +export interface IStorageProvider { + upload(file: any, folder: string): Promise; + delete(filePath: string): Promise; +} diff --git a/src/shared/config/config.ts b/src/shared/config/config.ts new file mode 100644 index 0000000..fa1f609 --- /dev/null +++ b/src/shared/config/config.ts @@ -0,0 +1,38 @@ +import dotenv from 'dotenv'; +import path from 'path'; + +dotenv.config({ path: path.resolve(__dirname, '../../../.env') }); + +const requiredEnv = [ + 'BETTER_AUTH_SECRET', + 'BETTER_AUTH_URL', + 'TRUSTED_ORIGINS', + 'GOOGLE_CLIENT_ID', + 'GOOGLE_CLIENT_SECRET', + 'FILE_DRIVER', + 'DATABASE_URL', + 'CLOUDINARY_CLOUD_NAME', + 'CLOUDINARY_API_KEY', + 'CLOUDINARY_API_SECRET', +] as const; + +for (const varName of requiredEnv) { + if (!process.env[varName]) { + throw new Error(`Missing required environment variable: ${varName}`); + } +} + +export const config = { + betterAuthSecret: process.env.BETTER_AUTH_SECRET!, + betterAuthUrl: process.env.BETTER_AUTH_URL!, + trustedOrigins: process.env.TRUSTED_ORIGINS!.split(','), + googleClientId: process.env.GOOGLE_CLIENT_ID!, + googleClientSecret: process.env.GOOGLE_CLIENT_SECRET!, + fileDriver: process.env.FILE_DRIVER, + cloudinary: { + cloudName: process.env.CLOUDINARY_CLOUD_NAME!, + apiKey: process.env.CLOUDINARY_API_KEY!, + apiSecret: process.env.CLOUDINARY_API_SECRET!, + }, + databaseUrl: process.env.DATABASE_URL!, +}; diff --git a/src/user/user.controller.ts b/src/user/user.controller.ts index bfc6368..c21d6c6 100644 --- a/src/user/user.controller.ts +++ b/src/user/user.controller.ts @@ -13,7 +13,7 @@ import { UserService } from './user.service'; export class UserController { constructor(private readonly userService: UserService) {} - // @AllowAnonymous() + @Roles(['admin']) @Get() async getAllUsers() { return this.userService.getAllUsers(); @@ -25,16 +25,19 @@ export class UserController { return this.userService.getUserById(id); } + @Roles(['admin']) @Delete(':id') async deleteUser(@Param('id') id: string) { return this.userService.deleteUser(id); } + @Roles(['admin']) @Get(':id/sessions') async getSessionsByUser(@Param('id') id: string) { return this.userService.getSessionsByUserId(id); } + @Roles(['admin']) @Delete(':id/sessions/:sessionId') async removeSession( @Param('id') userId: string, @@ -43,6 +46,7 @@ export class UserController { return this.userService.removeSession(userId, sessionId); } + @Roles(['admin']) @Delete(':id/sessions') async removeAllSessions(@Param('id') id: string) { return this.userService.removeAllSessions(id);