file service module added

This commit is contained in:
2025-11-26 17:07:07 +03:00
parent 04b7c41abf
commit bf9988d9bc
13 changed files with 277 additions and 1 deletions
+26
View File
@@ -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",
+2
View File
@@ -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"
},
@@ -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");
+14
View File
@@ -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])
}
+2
View File
@@ -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 {}
+31
View File
@@ -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);
}
}
+27
View File
@@ -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 {}
+53
View File
@@ -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' },
});
}
}
@@ -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<string> {
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<void> {
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);
}
}
@@ -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<string> {
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<void> {
const fullPath = path.join(process.cwd(), filePath);
await fs.unlink(fullPath).catch(() => {});
}
}
@@ -0,0 +1,4 @@
export interface IStorageProvider {
upload(file: any, folder: string): Promise<string>;
delete(filePath: string): Promise<void>;
}
+38
View File
@@ -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!,
};
+5 -1
View File
@@ -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);