Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4,405 changes: 3,104 additions & 1,301 deletions package-lock.json

Large diffs are not rendered by default.

6 changes: 5 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,10 +21,14 @@
"test:e2e": "jest --config ./test/jest-e2e.json"
},
"dependencies": {
"@aws-sdk/client-comprehend": "^3.966.0",
"@aws-sdk/client-rekognition": "^3.966.0",
"@aws-sdk/client-s3": "^3.966.0",
"@aws-sdk/s3-request-presigner": "^3.966.0",
"@nestjs/common": "^10.2.0",
"@nestjs/core": "^10.2.0",
"@nestjs/jwt": "^10.0.0",
"@nestjs/mapped-types": "^1.0.0",
"@nestjs/mapped-types": "^2.1.0",
"@nestjs/passport": "^10.0.0",
"@nestjs/platform-express": "^10.2.0",
"@nestjs/platform-socket.io": "^10.2.0",
Expand Down
3 changes: 3 additions & 0 deletions src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { ServeStaticModule } from '@nestjs/serve-static';
import { join } from 'path';
import { AuthModule } from './auth/auth.module';
import { MessagesWsModule } from './messages-ws/messages-ws.module';
import { DmsModule } from './dms/dms.module';



Expand Down Expand Up @@ -42,6 +43,8 @@ import { MessagesWsModule } from './messages-ws/messages-ws.module';
AuthModule,

MessagesWsModule,

DmsModule,
],
})
export class AppModule {}
121 changes: 121 additions & 0 deletions src/dms/ai.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
import { ClassifyDocumentCommand, ComprehendClient, DetectEntitiesCommand, DetectKeyPhrasesCommand } from "@aws-sdk/client-comprehend";
import { DetectLabelsCommand, DetectTextCommand, RekognitionClient } from "@aws-sdk/client-rekognition";
import { GetObjectCommand, S3Client } from "@aws-sdk/client-s3";
import { Injectable } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";


@Injectable()
export class AiService {
private rekognitionClient: RekognitionClient;
private s3Client: S3Client;
private comprehendClient: ComprehendClient;

constructor(
private readonly configService: ConfigService,
) {
this.rekognitionClient = new RekognitionClient({
region: this.configService.get('AWS_REGION'),
});

this.s3Client = new S3Client({
region: this.configService.get('S3_REGION'),
credentials: {
accessKeyId: this.configService.get('S3_ACCESS_KEY'),
secretAccessKey: this.configService.get('S3_SECRET_ACCESS_KEY'),
},
});

this.comprehendClient = new ComprehendClient({
region: this.configService.get('AWS_REGION'),
});
}

async getImageBytes(s3Key: string) {
const obj = await this.s3Client.send(new GetObjectCommand({
Bucket: this.configService.get('S3_BUCKET_NAME')!,
Key: s3Key,
}));

return obj.Body!.transformToByteArray();
}

async analyzeWithRekognition(imageBytes: Uint8Array) {
const [labelsResp, textResp] = await Promise.all([this.rekognitionClient.send(new DetectLabelsCommand({ Image: { Bytes: imageBytes }, MaxLabels: 15, MinConfidence: 70, })), this.rekognitionClient.send(new DetectTextCommand({ Image: { Bytes: imageBytes }, })),]);
const labels = (labelsResp.Labels ?? []).map(l => ({ name: l.Name!, confidence: l.Confidence ?? 0 }));
const texts = (textResp.TextDetections ?? []).filter(t => t.Type === 'LINE').map(t => t.DetectedText!);

return { labels, texts };
}

async analyzeTextWithComprehend(text: string) { const [keyResp, entResp] = await Promise.all([ this.comprehendClient.send(new DetectKeyPhrasesCommand({ Text: text, LanguageCode: 'es' })), this.comprehendClient.send(new DetectEntitiesCommand({ Text: text, LanguageCode: 'es' })), ]);
const keyPhrases = (keyResp.KeyPhrases ?? []).map(k => k.Text!);
const entities = (entResp.Entities ?? []).map(e => e.Text!);
return { keyPhrases, entities };
}

async classifyWithComprehend(text: string) {
const resp = await this.comprehendClient.send(new ClassifyDocumentCommand({
Text: text,
EndpointArn: process.env.COMPREHEND_ENDPOINT_ARN!
}));
return (resp.Classes ?? []).sort((a, b) => (b.Score ?? 0) - (a.Score ?? 0));
}

async analyzeText(text: string) {
const [entitiesResp, keyResp] = await Promise.all([ this.comprehendClient.send(new DetectEntitiesCommand({ Text: text, LanguageCode: 'es', })), this.comprehendClient.send(new DetectKeyPhrasesCommand({ Text: text, LanguageCode: 'es', })), ]);
const entities = entitiesResp.Entities?.map(e => e.Text) ?? [];
const keyPhrases = keyResp.KeyPhrases?.map(k => k.Text) ?? [];
return { entities, keyPhrases };
}

async suggestFromImage(s3Key: string) {
const bytes = await this.getImageBytes(s3Key);
const { labels, texts } = await this.analyzeWithRekognition(bytes);
const combinedText = [ ...labels.map(l => l.name), ...texts, ].join(' ').toLowerCase();

const { keyPhrases, entities } = await this.analyzeTextWithComprehend(combinedText);

const classes = await this.classifyWithComprehend(combinedText);
const category = classes[0]?.Name ?? 'Sin categoría';
const features = Array.from(new Set([ ...keyPhrases, ...entities, ...labels.map(l => l.name), ])).slice(0, 10);
const nameCandidates = this.buildNameCandidates(entities, features);
const name = nameCandidates[0] ?? 'Producto sugerido';

return { name, category, features, labels, texts, confidence: classes[0]?.Score ?? 0 };
}

private buildNameCandidates(entities: string[], features: string[]) {
const tokens = Array.from(new Set([...entities, ...features])) .filter(t => /[a-zA-Záéíóúñ0-9]/.test(t)).map(t => this.capitalize(t)) .slice(0, 4);
const patterns = [ `${tokens[0] ?? 'Producto'} ${tokens[1] ?? ''}`.trim(), `${tokens[0] ?? 'Producto'} ${tokens[1] ?? ''} ${tokens[2] ?? ''}`.trim(), `${tokens[0] ?? 'Producto'} ${tokens[2] ?? ''}`.trim(), ];
return patterns;
}

private capitalize(s: string) {
return s.charAt(0).toUpperCase() + s.slice(1);
}

async analyzeImage(imageBytes: Uint8Array) {
const [labelsResp, textResp] = await Promise.all([
this.rekognitionClient.send(new DetectLabelsCommand({ Image: { Bytes: imageBytes }, MaxLabels: 10, MinConfidence: 70, })),
this.rekognitionClient.send(new DetectTextCommand({ Image: { Bytes: imageBytes }, })),
]);

const labels = labelsResp.Labels?.map(l => l.Name) ?? [];
const texts = textResp.TextDetections?.map(t => t.DetectedText!) ?? [];

return { labels, texts };
}

async suggestProductInfo(s3Key: string) {
const bytes = await this.getImageBytes(s3Key);
const { labels, texts } = await this.analyzeImage(bytes);
const combinedText = [...labels, ...texts].join(" ");
const { entities, keyPhrases } = await this.analyzeText(combinedText); // Construir sugerencias simples
const name = entities[0] ?? labels[0] ?? "Producto deportivo";
const category = labels.includes("Shoe") || labels.includes("Sneaker") ? "Calzado" : labels.includes("Shirt") || labels.includes("T-shirt") ? "Playeras" : "Accesorios";
const features = Array.from(new Set([...labels, ...texts, ...keyPhrases]));

return { name, category, features };
}
}
20 changes: 20 additions & 0 deletions src/dms/dms.controller.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { Test, TestingModule } from '@nestjs/testing';
import { DmsController } from './dms.controller';
import { DmsService } from './dms.service';

describe('DmsController', () => {
let controller: DmsController;

beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
controllers: [DmsController],
providers: [DmsService],
}).compile();

controller = module.get<DmsController>(DmsController);
});

it('should be defined', () => {
expect(controller).toBeDefined();
});
});
57 changes: 57 additions & 0 deletions src/dms/dms.controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { AiService } from './ai.service';
import { Controller, Get, Post, Body, Patch, Param, Delete, UseInterceptors, UploadedFile, ParseFilePipe, FileTypeValidator, MaxFileSizeValidator } from '@nestjs/common';
import { DmsService } from './dms.service';
import { FileInterceptor } from '@nestjs/platform-express';

const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB

@Controller('dms')
export class DmsController {
constructor(
private readonly dmsService: DmsService,
private readonly aiService: AiService,
) {}

@Post('/file')
@UseInterceptors(FileInterceptor('file'))
async upload(
@UploadedFile(
new ParseFilePipe({
validators: [
new FileTypeValidator({ fileType: 'image/jpeg'}),
new MaxFileSizeValidator({
maxSize: MAX_FILE_SIZE,
message: 'File is too large. Max file size is 10MB',
}),
],
fileIsRequired: true,
}),
)
file: Express.Multer.File,
@Body('isPublic') isPublic: string,
) {
const isPublicBool = isPublic === 'true' ? true : false;
const {key} = await this.dmsService.uploadSingleFile({ file, isPublic: isPublicBool });
const suggestions = await this.aiService.suggestProductInfo(key)

return {
key,
suggestions,
}
}

@Get(':key')
async getFileUrl(@Param('key') key: string) {
return this.dmsService.getFileUrl(key);
}

@Get('/signed-url/:key')
async getPresignedUrl(@Param('key') key: string) {
return this.dmsService.getPresingnedUrl(key);
}

@Delete(':key')
async deleteFile(@Param('key') key: string) {
return this.dmsService.deleteFile(key);
}
}
12 changes: 12 additions & 0 deletions src/dms/dms.module.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { DmsService } from './dms.service';
import { DmsController } from './dms.controller';
import { ConfigService } from '@nestjs/config';
import { AiService } from './ai.service';

@Module({
controllers: [DmsController],
providers: [DmsService, ConfigService, AiService],
exports: [DmsService],
})
export class DmsModule {}
18 changes: 18 additions & 0 deletions src/dms/dms.service.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { Test, TestingModule } from '@nestjs/testing';
import { DmsService } from './dms.service';

describe('DmsService', () => {
let service: DmsService;

beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [DmsService],
}).compile();

service = module.get<DmsService>(DmsService);
});

it('should be defined', () => {
expect(service).toBeDefined();
});
});
93 changes: 93 additions & 0 deletions src/dms/dms.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import { Injectable, InternalServerErrorException } from '@nestjs/common';
import { DeleteObjectCommand, GetObjectCommand, PutObjectCommand, S3Client } from '@aws-sdk/client-s3';
import { ConfigService } from '@nestjs/config';
import { v4 as uuidv4 } from 'uuid';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';

interface SingleFileRequest {
file: Express.Multer.File;
isPublic: boolean;
}

@Injectable()
export class DmsService {
private s3Client: S3Client;
private bucketName = this.configService.get('S3_BUCKET_NAME');

constructor(
private readonly configService: ConfigService,
) {
const S3_REGION = this.configService.get('S3_REGION');

this.s3Client = new S3Client({
region: this.configService.get('S3_REGION'),
credentials: {
accessKeyId: this.configService.get('S3_ACCESS_KEY'),
secretAccessKey: this.configService.get('S3_SECRET_ACCESS_KEY'),
},
});
}

async getFileUrl(key: string, isPublic = true) {
return { url: `https://${this.bucketName}.s3.amazonaws.com/${key}` };
}

async uploadSingleFile({ file, isPublic = true }: SingleFileRequest) {
try {
const key = `${uuidv4()}`;
const command = new PutObjectCommand({
Bucket: this.bucketName,
Key: key,
Body: file.buffer,
ContentType: file.mimetype,
Metadata: {
originalName: file.originalname,
},
});

const uploadResult = await this.s3Client.send(command);

return {
url: isPublic ? (await this.getFileUrl(key)).url
: (await this.getFileUrl(key, false)).url,
key,
isPublic,
};
} catch (error) {
throw new InternalServerErrorException(error);
}
}

async getPresingnedUrl(key: string) {
try {
const command = new GetObjectCommand({
Bucket: this.bucketName,
Key: key,
});

const url = await getSignedUrl(this.s3Client, command, {
expiresIn: 60 * 60 * 24,
});

return { url };
} catch (error) {
throw new InternalServerErrorException(error);
}
}

async deleteFile(key: string) {
try {
const command = new DeleteObjectCommand({
Bucket: this.bucketName,
Key: key,
});

await this.s3Client.send(command);

return { message: 'File deleted successfully'};
} catch (error) {
throw new InternalServerErrorException(error);
}
}
}

10 changes: 5 additions & 5 deletions src/files/files.controller.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import { Controller, Get, Post, Body, Patch, Param, Delete, UploadedFile, UseInterceptors, BadRequestException, Res } from '@nestjs/common';
import { FilesService } from './files.service';
import { BadRequestException, Controller, Get, Param, Post, Res, UploadedFile, UseInterceptors } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { FileInterceptor } from '@nestjs/platform-express';
import { fileFilter, fileNamer } from './helpers';
import { diskStorage } from 'multer';
import { Response } from 'express';
import { ConfigService } from '@nestjs/config';
import { diskStorage } from 'multer';
import { FilesService } from './files.service';
import { fileFilter, fileNamer } from './helpers';

@Controller('files')
export class FilesController {
Expand Down
4 changes: 2 additions & 2 deletions src/files/files.module.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
import { Module } from '@nestjs/common';
import { FilesService } from './files.service';
import { FilesController } from './files.controller';
import { ConfigModule } from '@nestjs/config';
import { ConfigModule, ConfigService } from '@nestjs/config';

@Module({
controllers: [FilesController],
providers: [FilesService],
providers: [FilesService, ConfigService],
imports: [ ConfigModule]
})
export class FilesModule {}
Loading