--- description: Storage integration patterns for Evolution API globs: - "src/api/integrations/storage/**/*.ts" alwaysApply: false --- # Evolution API Storage Integration Rules ## Storage Service Pattern ### Base Storage Service Structure ```typescript import { InstanceDto } from '@api/dto/instance.dto'; import { PrismaRepository } from '@api/repository/repository.service'; import { Logger } from '@config/logger.config'; import { BadRequestException } from '@exceptions'; export class StorageService { constructor(private readonly prismaRepository: PrismaRepository) {} private readonly logger = new Logger('StorageService'); public async getMedia(instance: InstanceDto, query?: MediaDto) { try { const where: any = { instanceId: instance.instanceId, ...query, }; const media = await this.prismaRepository.media.findMany({ where, select: { id: true, fileName: true, type: true, mimetype: true, createdAt: true, Message: true, }, }); if (!media || media.length === 0) { throw 'Media not found'; } return media; } catch (error) { throw new BadRequestException(error); } } public async getMediaUrl(instance: InstanceDto, data: MediaDto) { const media = (await this.getMedia(instance, { id: data.id }))[0]; const mediaUrl = await this.generateUrl(media.fileName, data.expiry); return { mediaUrl, ...media, }; } protected abstract generateUrl(fileName: string, expiry?: number): Promise; } ``` ## S3/MinIO Integration Pattern ### MinIO Client Setup ```typescript import { ConfigService, S3 } from '@config/env.config'; import { Logger } from '@config/logger.config'; import { BadRequestException } from '@exceptions'; import * as MinIo from 'minio'; import { join } from 'path'; import { Readable, Transform } from 'stream'; const logger = new Logger('S3 Service'); const BUCKET = new ConfigService().get('S3'); interface Metadata extends MinIo.ItemBucketMetadata { instanceId: string; messageId?: string; } const minioClient = (() => { if (BUCKET?.ENABLE) { return new MinIo.Client({ endPoint: BUCKET.ENDPOINT, port: BUCKET.PORT, useSSL: BUCKET.USE_SSL, accessKey: BUCKET.ACCESS_KEY, secretKey: BUCKET.SECRET_KEY, region: BUCKET.REGION, }); } })(); const bucketName = process.env.S3_BUCKET; ``` ### Bucket Management Functions ```typescript const bucketExists = async (): Promise => { if (minioClient) { try { const list = await minioClient.listBuckets(); return !!list.find((bucket) => bucket.name === bucketName); } catch (error) { logger.error('Error checking bucket existence:', error); return false; } } return false; }; const setBucketPolicy = async (): Promise => { if (minioClient && bucketName) { try { const policy = { Version: '2012-10-17', Statement: [ { Effect: 'Allow', Principal: { AWS: ['*'] }, Action: ['s3:GetObject'], Resource: [`arn:aws:s3:::${bucketName}/*`], }, ], }; await minioClient.setBucketPolicy(bucketName, JSON.stringify(policy)); logger.log('Bucket policy set successfully'); } catch (error) { logger.error('Error setting bucket policy:', error); } } }; const createBucket = async (): Promise => { if (minioClient && bucketName) { try { const exists = await bucketExists(); if (!exists) { await minioClient.makeBucket(bucketName, BUCKET.REGION || 'us-east-1'); await setBucketPolicy(); logger.log(`Bucket ${bucketName} created successfully`); } } catch (error) { logger.error('Error creating bucket:', error); } } }; ``` ### File Upload Functions ```typescript export const uploadFile = async ( fileName: string, buffer: Buffer, mimetype: string, metadata?: Metadata, ): Promise => { if (!minioClient || !bucketName) { throw new BadRequestException('S3 storage not configured'); } try { await createBucket(); const uploadMetadata = { 'Content-Type': mimetype, ...metadata, }; await minioClient.putObject(bucketName, fileName, buffer, buffer.length, uploadMetadata); logger.log(`File ${fileName} uploaded successfully`); return fileName; } catch (error) { logger.error(`Error uploading file ${fileName}:`, error); throw new BadRequestException(`Failed to upload file: ${error.message}`); } }; export const uploadStream = async ( fileName: string, stream: Readable, size: number, mimetype: string, metadata?: Metadata, ): Promise => { if (!minioClient || !bucketName) { throw new BadRequestException('S3 storage not configured'); } try { await createBucket(); const uploadMetadata = { 'Content-Type': mimetype, ...metadata, }; await minioClient.putObject(bucketName, fileName, stream, size, uploadMetadata); logger.log(`Stream ${fileName} uploaded successfully`); return fileName; } catch (error) { logger.error(`Error uploading stream ${fileName}:`, error); throw new BadRequestException(`Failed to upload stream: ${error.message}`); } }; ``` ### File Download Functions ```typescript export const getObject = async (fileName: string): Promise => { if (!minioClient || !bucketName) { throw new BadRequestException('S3 storage not configured'); } try { const stream = await minioClient.getObject(bucketName, fileName); const chunks: Buffer[] = []; return new Promise((resolve, reject) => { stream.on('data', (chunk) => chunks.push(chunk)); stream.on('end', () => resolve(Buffer.concat(chunks))); stream.on('error', reject); }); } catch (error) { logger.error(`Error getting object ${fileName}:`, error); throw new BadRequestException(`Failed to get object: ${error.message}`); } }; export const getObjectUrl = async (fileName: string, expiry: number = 3600): Promise => { if (!minioClient || !bucketName) { throw new BadRequestException('S3 storage not configured'); } try { const url = await minioClient.presignedGetObject(bucketName, fileName, expiry); logger.log(`Generated URL for ${fileName} with expiry ${expiry}s`); return url; } catch (error) { logger.error(`Error generating URL for ${fileName}:`, error); throw new BadRequestException(`Failed to generate URL: ${error.message}`); } }; export const getObjectStream = async (fileName: string): Promise => { if (!minioClient || !bucketName) { throw new BadRequestException('S3 storage not configured'); } try { const stream = await minioClient.getObject(bucketName, fileName); return stream; } catch (error) { logger.error(`Error getting object stream ${fileName}:`, error); throw new BadRequestException(`Failed to get object stream: ${error.message}`); } }; ``` ### File Management Functions ```typescript export const deleteObject = async (fileName: string): Promise => { if (!minioClient || !bucketName) { throw new BadRequestException('S3 storage not configured'); } try { await minioClient.removeObject(bucketName, fileName); logger.log(`File ${fileName} deleted successfully`); } catch (error) { logger.error(`Error deleting file ${fileName}:`, error); throw new BadRequestException(`Failed to delete file: ${error.message}`); } }; export const listObjects = async (prefix?: string): Promise => { if (!minioClient || !bucketName) { throw new BadRequestException('S3 storage not configured'); } try { const objects: MinIo.BucketItem[] = []; const stream = minioClient.listObjects(bucketName, prefix, true); return new Promise((resolve, reject) => { stream.on('data', (obj) => objects.push(obj)); stream.on('end', () => resolve(objects)); stream.on('error', reject); }); } catch (error) { logger.error('Error listing objects:', error); throw new BadRequestException(`Failed to list objects: ${error.message}`); } }; export const objectExists = async (fileName: string): Promise => { if (!minioClient || !bucketName) { return false; } try { await minioClient.statObject(bucketName, fileName); return true; } catch (error) { return false; } }; ``` ## Storage Controller Pattern ### S3 Controller Implementation ```typescript import { InstanceDto } from '@api/dto/instance.dto'; import { MediaDto } from '@api/integrations/storage/s3/dto/media.dto'; import { S3Service } from '@api/integrations/storage/s3/services/s3.service'; export class S3Controller { constructor(private readonly s3Service: S3Service) {} public async getMedia(instance: InstanceDto, data: MediaDto) { return this.s3Service.getMedia(instance, data); } public async getMediaUrl(instance: InstanceDto, data: MediaDto) { return this.s3Service.getMediaUrl(instance, data); } public async uploadMedia(instance: InstanceDto, data: UploadMediaDto) { return this.s3Service.uploadMedia(instance, data); } public async deleteMedia(instance: InstanceDto, data: MediaDto) { return this.s3Service.deleteMedia(instance, data); } } ``` ## Storage Router Pattern ### Storage Router Structure ```typescript import { S3Router } from '@api/integrations/storage/s3/routes/s3.router'; import { Router } from 'express'; export class StorageRouter { public readonly router: Router; constructor(...guards: any[]) { this.router = Router(); this.router.use('/s3', new S3Router(...guards).router); // Add other storage providers here // this.router.use('/gcs', new GCSRouter(...guards).router); // this.router.use('/azure', new AzureRouter(...guards).router); } } ``` ### S3 Specific Router ```typescript import { RouterBroker } from '@api/abstract/abstract.router'; import { MediaDto } from '@api/integrations/storage/s3/dto/media.dto'; import { s3Schema, s3UrlSchema } from '@api/integrations/storage/s3/validate/s3.schema'; import { HttpStatus } from '@api/routes/index.router'; import { s3Controller } from '@api/server.module'; import { RequestHandler, Router } from 'express'; export class S3Router extends RouterBroker { constructor(...guards: RequestHandler[]) { super(); this.router .post(this.routerPath('getMedia'), ...guards, async (req, res) => { const response = await this.dataValidate({ request: req, schema: s3Schema, ClassRef: MediaDto, execute: (instance, data) => s3Controller.getMedia(instance, data), }); res.status(HttpStatus.OK).json(response); }) .post(this.routerPath('getMediaUrl'), ...guards, async (req, res) => { const response = await this.dataValidate({ request: req, schema: s3UrlSchema, ClassRef: MediaDto, execute: (instance, data) => s3Controller.getMediaUrl(instance, data), }); res.status(HttpStatus.OK).json(response); }) .post(this.routerPath('uploadMedia'), ...guards, async (req, res) => { const response = await this.dataValidate({ request: req, schema: uploadSchema, ClassRef: UploadMediaDto, execute: (instance, data) => s3Controller.uploadMedia(instance, data), }); res.status(HttpStatus.CREATED).json(response); }) .delete(this.routerPath('deleteMedia'), ...guards, async (req, res) => { const response = await this.dataValidate({ request: req, schema: s3Schema, ClassRef: MediaDto, execute: (instance, data) => s3Controller.deleteMedia(instance, data), }); res.status(HttpStatus.OK).json(response); }); } public readonly router: Router = Router(); } ``` ## Storage DTO Pattern ### Media DTO ```typescript export class MediaDto { id?: string; fileName?: string; type?: string; mimetype?: string; expiry?: number; } export class UploadMediaDto { fileName: string; mimetype: string; buffer?: Buffer; base64?: string; url?: string; metadata?: { instanceId: string; messageId?: string; contactId?: string; [key: string]: any; }; } ``` ## Storage Validation Schema ### S3 Validation Schemas ```typescript import Joi from 'joi'; export const s3Schema = Joi.object({ id: Joi.string().optional(), fileName: Joi.string().optional(), type: Joi.string().optional().valid('image', 'video', 'audio', 'document'), mimetype: Joi.string().optional(), expiry: Joi.number().optional().min(60).max(604800).default(3600), // 1 min to 7 days }).min(1).required(); export const s3UrlSchema = Joi.object({ id: Joi.string().required(), expiry: Joi.number().optional().min(60).max(604800).default(3600), }).required(); export const uploadSchema = Joi.object({ fileName: Joi.string().required().max(255), mimetype: Joi.string().required(), buffer: Joi.binary().optional(), base64: Joi.string().base64().optional(), url: Joi.string().uri().optional(), metadata: Joi.object({ instanceId: Joi.string().required(), messageId: Joi.string().optional(), contactId: Joi.string().optional(), }).optional(), }).xor('buffer', 'base64', 'url').required(); // Exactly one of these must be present ``` ## Error Handling in Storage ### Storage-Specific Error Handling ```typescript // CORRECT - Storage-specific error handling public async uploadFile(fileName: string, buffer: Buffer): Promise { try { const result = await this.storageClient.upload(fileName, buffer); return result; } catch (error) { this.logger.error(`Storage upload failed: ${error.message}`); if (error.code === 'NoSuchBucket') { throw new BadRequestException('Storage bucket not found'); } if (error.code === 'AccessDenied') { throw new UnauthorizedException('Storage access denied'); } if (error.code === 'EntityTooLarge') { throw new BadRequestException('File too large'); } throw new InternalServerErrorException('Storage operation failed'); } } ``` ## Storage Configuration Pattern ### Environment Configuration ```typescript export interface S3Config { ENABLE: boolean; ENDPOINT: string; PORT: number; USE_SSL: boolean; ACCESS_KEY: string; SECRET_KEY: string; REGION: string; BUCKET: string; } // Usage in service const s3Config = this.configService.get('S3'); if (!s3Config.ENABLE) { throw new BadRequestException('S3 storage is disabled'); } ``` ## Storage Testing Pattern ### Storage Service Testing ```typescript describe('S3Service', () => { let service: S3Service; let prismaRepository: jest.Mocked; beforeEach(() => { service = new S3Service(prismaRepository); }); describe('getMedia', () => { it('should return media list', async () => { const instance = { instanceId: 'test-instance' }; const mockMedia = [ { id: '1', fileName: 'test.jpg', type: 'image', mimetype: 'image/jpeg' }, ]; prismaRepository.media.findMany.mockResolvedValue(mockMedia); const result = await service.getMedia(instance); expect(result).toEqual(mockMedia); expect(prismaRepository.media.findMany).toHaveBeenCalledWith({ where: { instanceId: 'test-instance' }, select: expect.objectContaining({ id: true, fileName: true, type: true, mimetype: true, }), }); }); it('should throw error when no media found', async () => { const instance = { instanceId: 'test-instance' }; prismaRepository.media.findMany.mockResolvedValue([]); await expect(service.getMedia(instance)).rejects.toThrow(BadRequestException); }); }); }); ``` ## Storage Performance Considerations ### Efficient File Handling ```typescript // CORRECT - Stream-based upload for large files public async uploadLargeFile(fileName: string, stream: Readable, size: number): Promise { const uploadStream = new Transform({ transform(chunk, encoding, callback) { // Optional: Add compression, encryption, etc. callback(null, chunk); }, }); return new Promise((resolve, reject) => { stream .pipe(uploadStream) .on('error', reject) .on('finish', () => resolve(fileName)); }); } // INCORRECT - Loading entire file into memory public async uploadLargeFile(fileName: string, filePath: string): Promise { const buffer = fs.readFileSync(filePath); // ❌ Memory intensive for large files return await this.uploadFile(fileName, buffer); } ```