Skip to content
887 changes: 869 additions & 18 deletions package-lock.json

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
"@nestjs/platform-express": "^11.0.1",
"@nestjs/swagger": "^11.0.6",
"@nestjs/typeorm": "^11.0.0",
"@tensorflow/tfjs-node": "^4.22.0",
"@types/bcrypt": "^5.0.2",
"bcryptjs": "^3.0.2",
"class-transformer": "^0.5.1",
Expand Down
2 changes: 2 additions & 0 deletions src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import { DiscountModule } from './discount/discount.module';
import { PaymentModule } from './payments/payment.module';
import { OrderModule } from './order/order.module';
import { NotificationModule } from './notification/notification.module';
import { RecommendationModule } from './recommendation/recommendation.module';

@Module({
imports: [
Expand Down Expand Up @@ -59,6 +60,7 @@ import { NotificationModule } from './notification/notification.module';
PaymentModule,
OrderModule,
NotificationModule,
RecommendationModule,
],
controllers: [AppController],
providers: [AppService],
Expand Down
40 changes: 38 additions & 2 deletions src/products/products.controller.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,11 @@
import { Controller, Get, Query, UseInterceptors } from '@nestjs/common';
import {
Controller,
Get,
Query,
Req,
UseGuards,
UseInterceptors,
} from '@nestjs/common';
import { ProductsService } from './products.service';
import {
ApiExtraModels,
Expand All @@ -11,11 +18,16 @@ import { ProductPresentationDTO, ProductQueryDTO } from './dto/product.dto';
import { PaginationDTO } from 'src/utils/dto/pagination.dto';
import { PaginationInterceptor } from 'src/utils/pagination.interceptor';
import { plainToInstance } from 'class-transformer';
import { AuthGuard, CustomRequest } from 'src/auth/auth.guard';
import { RecommendationService } from 'src/recommendation/recommendation.service';

@Controller('product')
@ApiExtraModels(PaginationDTO, ProductPresentationDTO)
export class ProductsController {
constructor(private productsServices: ProductsService) {}
constructor(
private productsServices: ProductsService,
private recommendationService: RecommendationService,
) {}
@Get()
@UseInterceptors(PaginationInterceptor)
@ApiOperation({
Expand Down Expand Up @@ -133,4 +145,28 @@ export class ProductsController {
total,
};
}

@UseGuards(AuthGuard)
@Get('recommendations')
async getProductRecommendations(@Req() req: CustomRequest) {
const userId = req.user.id;
const recommendations = await this.recommendationService.recommend(userId);
const products = await this.productsServices.getProducts(
1,
10,
undefined,
[],
[],
[],
[],
recommendations,
);
return {
data: plainToInstance(ProductPresentationDTO, products.products, {
excludeExtraneousValues: true,
enableImplicitConversion: true,
}),
total: products.total,
};
}
}
2 changes: 2 additions & 0 deletions src/products/products.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import { ProductImageService } from './services/product-image.service';
import { ProductCategoryController } from './controllers/product-category.controller';
import { ProductCategoryService } from './services/product-category.service';
import { CategoryService } from 'src/category/category.service';
import { RecommendationService } from 'src/recommendation/recommendation.service';

@Module({
imports: [
Expand Down Expand Up @@ -59,6 +60,7 @@ import { CategoryService } from 'src/category/category.service';
ProductPresentationService,
ProductImageService,
ProductCategoryService,
RecommendationService,
],
})
export class ProductsModule {}
26 changes: 26 additions & 0 deletions src/recommendation/data.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import * as tf from '@tensorflow/tfjs';

export const PRODUCT_MAPPING = {
0: 'Advil Pain Reliever',
1: 'Amlodipina',
2: 'Amoxicilina',
3: 'Aspirina',
4: 'Atenolol',
5: 'citrato de potasio',
6: 'Dayzol',
7: 'Izaban',
8: 'Meloxicam',
9: 'Miovit',
10: 'Vitamina C',
11: 'Zithromax',
};

export const RECOMMENDATION_DATA = [
tf.tensor([12.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 2.0, 4.0]),
tf.tensor([20.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 3.0, 11.0]),
tf.tensor([42.0, 4.0, 1.0, 11.0, 0.0, 4.0, 15.0, 4.0, 0.0, 2.0, 32.0, 29.0]),
tf.tensor([15.0, 0.0, 0.0, 11.0, 2.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 5.0]),
tf.tensor([29.0, 0.0, 0.0, 2.0, 0.0, 0.0, 0.0, 0.0, 0.0, 4.0, 9.0, 28.0]),
tf.tensor([2.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 3.0, 0.0]),
tf.tensor([2.0, 0.0, 0.0, 0.0, 0.0, 1.0, 2.0, 3.0, 2.0, 3.0, 0.0, 1.0]),
];
52 changes: 52 additions & 0 deletions src/recommendation/model.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import * as tf from '@tensorflow/tfjs';
import { PRODUCT_MAPPING } from './data';

export class NearestNeighbors {
neighbors: number;
data: tf.Tensor[];
constructor(neighbors = 5) {
this.neighbors = neighbors;
this.data = [];
}

fit(data: tf.Tensor[]) {
this.data = data;
}

cosineSimilarity(a: tf.Tensor, b: tf.Tensor) {
const dotProduct = tf.dot(a, b).dataSync()[0];
const magnitudeA = tf.norm(a).dataSync()[0];
const magnitudeB = tf.norm(b).dataSync()[0];
return dotProduct / (magnitudeA * magnitudeB);
}

euclideanDistance(a: tf.Tensor, b: tf.Tensor) {
return tf.sqrt(tf.squaredDifference(a, b)).dataSync()[0];
}

kneighbors(point: tf.Tensor) {
const distances = this.data.map((datapoint, index) => ({
index,
distance: this.cosineSimilarity(point, datapoint),
}));

distances.sort((a, b) => a.distance - b.distance);

const neighborsIndices = distances
.slice(0, this.neighbors)
.map((item) => item.index);
const neighbors = neighborsIndices.map((index) => this.data[index]);

const result = {
neighbors,
distances: distances
.slice(0, this.neighbors)
.map((item) => item.distance),
};
const recommendations = result.neighbors.map((neighbor) => {
const productIndex = neighbor.argMax(0).dataSync()[0];
return PRODUCT_MAPPING[productIndex as keyof typeof PRODUCT_MAPPING];
});
return recommendations;
}
}
12 changes: 12 additions & 0 deletions src/recommendation/recommendation.module.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { RecommendationService } from './recommendation.service';
import { TypeOrmModule } from '@nestjs/typeorm';
import { ProductPresentation } from 'src/products/entities/product-presentation.entity';
import { Product } from 'src/products/entities/product.entity';

@Module({
imports: [TypeOrmModule.forFeature([ProductPresentation, Product])],
controllers: [],
providers: [RecommendationService],
})
export class RecommendationModule {}
74 changes: 74 additions & 0 deletions src/recommendation/recommendation.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { ProductPresentation } from 'src/products/entities/product-presentation.entity';
import { Repository } from 'typeorm';
import * as tf from '@tensorflow/tfjs';
import { NearestNeighbors } from './model';
import { PRODUCT_MAPPING, RECOMMENDATION_DATA } from './data';
import { Product } from 'src/products/entities/product.entity';

export type ProductData = {
name: string;
totalBought: number;
};

@Injectable()
export class RecommendationService {
constructor(
@InjectRepository(ProductPresentation)
private readonly productPresentationRepository: Repository<ProductPresentation>,
@InjectRepository(Product)
private readonly productRepository: Repository<Product>,
) {}

async getProductsId(productNames: string[]): Promise<string[]> {
const products: { id: string }[] = await this.productRepository
.createQueryBuilder('product')
.select('id')
.where('product.name IN (:...productNames)', { productNames })
.getRawMany();
return products.map((product) => product.id);
}

async getProductsByUserId(userId: string): Promise<ProductData[]> {
const products: { name: string; totalBought: number }[] =
await this.productPresentationRepository
.createQueryBuilder('productPresentation')
.select('product.name', 'name')
.addSelect('COALESCE(SUM(orderDetail.quantity), 0)', 'totalBought')
.leftJoin('productPresentation.product', 'product')
.leftJoin('productPresentation.orders', 'orderDetail')
.leftJoin('orderDetail.order', 'order')
.andWhere('order.user_id = :userId OR order.user_id IS NULL', {
userId,
})
.andWhere('order.status = :status', { status: 'completed' })
.groupBy('product.name')
.orderBy('product.name', 'ASC')
.getRawMany();
const completeProductData: ProductData[] = Object.values(
PRODUCT_MAPPING,
).map((productName) => {
const existingProduct: ProductData | undefined = products.find(
(p) => p.name === productName,
);
return existingProduct ?? { name: productName, totalBought: 0 };
});
return completeProductData;
}

async recommend(userId: string): Promise<string[]> {
const productData = await this.getProductsByUserId(userId);
const recommender = new NearestNeighbors(3);
recommender.fit(RECOMMENDATION_DATA);
const productsTensor = tf.tensor(
productData.map((product) =>
Number(product.totalBought) ? product.totalBought : 0,
),
[12],
'float32',
);
const recommendations = recommender.kneighbors(productsTensor);
return this.getProductsId(recommendations);
}
}
Loading