Add food products and their ingredients, with their carbon footprint calculator.

This commit is contained in:
Matthieu Hochlander 2025-01-29 01:24:52 +01:00
parent b29356bb8b
commit a038de4294
Signed by: Madeorsk
GPG key ID: 677E51CA765BB79F
16 changed files with 606 additions and 2 deletions

View file

@ -0,0 +1,19 @@
import { MigrationInterface, QueryRunner } from "typeorm";
export class FoodProduct1738106535803 implements MigrationInterface {
name = 'FoodProduct1738106535803'
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`CREATE TABLE "food_products" ("id" SERIAL NOT NULL, "name" character varying NOT NULL, "footprint" double precision, CONSTRAINT "PK_3aca8796e89325904061ed18b12" PRIMARY KEY ("id"))`);
await queryRunner.query(`CREATE TABLE "food_products_ingredients" ("id" SERIAL NOT NULL, "name" character varying NOT NULL, "quantity" double precision NOT NULL, "unit" character varying NOT NULL, "foodProductId" integer NOT NULL, "carbonEmissionFactorId" integer, CONSTRAINT "PK_fbf9b2563664e5f4dbb4f2dbac6" PRIMARY KEY ("id"))`);
await queryRunner.query(`ALTER TABLE "food_products_ingredients" ADD CONSTRAINT "FK_34cb9f791c7b07183b47521431e" FOREIGN KEY ("foodProductId") REFERENCES "food_products"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`);
await queryRunner.query(`ALTER TABLE "food_products_ingredients" ADD CONSTRAINT "FK_311390501389e8467a7cd001ff3" FOREIGN KEY ("carbonEmissionFactorId") REFERENCES "carbon_emission_factors"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "food_products_ingredients" DROP CONSTRAINT "FK_311390501389e8467a7cd001ff3"`);
await queryRunner.query(`ALTER TABLE "food_products_ingredients" DROP CONSTRAINT "FK_34cb9f791c7b07183b47521431e"`);
await queryRunner.query(`DROP TABLE "food_products_ingredients"`);
await queryRunner.query(`DROP TABLE "food_products"`);
}
}

View file

@ -6,7 +6,7 @@
"private": true,
"license": "UNLICENSED",
"scripts": {
"init-project": "yarn build && yarn start-docker && yarn migration:run && yarn seed",
"init-project": "yarn build && yarn start-docker && sleep 10 && yarn migration:run && yarn seed",
"start": "nest start",
"start:dev": "nest start --watch",
"start:debug": "nest start --debug --watch",
@ -22,7 +22,7 @@
"test:e2e": "NODE_ENV=test jest --config ./test-e2e/jest-e2e.json",
"typeorm": "ts-node ./node_modules/typeorm/cli -d ./config/dataSource.ts",
"migration:run": "yarn build && yarn typeorm migration:run",
"migration:generate": "yarn build && yarn typeorm -- migration:generate ./src/migrations/$migrationName",
"migration:generate": "yarn build && yarn typeorm -- migration:generate ./migrations/$migrationName",
"seed": "ts-node ./src/seed-dev-data.ts"
},
"dependencies": {

View file

@ -3,6 +3,7 @@ import { ConfigModule, ConfigService } from "@nestjs/config";
import { TypeOrmModule } from "@nestjs/typeorm";
import { typeorm } from "../config/dataSource";
import { CarbonEmissionFactorsModule } from "./carbonEmissionFactor/carbonEmissionFactors.module";
import { FoodProductFootprintModule } from "./foodProductFootprint/foodProductFootprint.module";
@Module({
imports: [
@ -16,6 +17,7 @@ import { CarbonEmissionFactorsModule } from "./carbonEmissionFactor/carbonEmissi
configService.getOrThrow("typeorm"),
}),
CarbonEmissionFactorsModule,
FoodProductFootprintModule,
],
})
export class AppModule {}

View file

@ -0,0 +1,7 @@
import {CreateFoodProductIngredientDto} from "./create-foodProductIngredient.dto";
export interface CreateFoodProductDto
{
name: string;
ingredients: CreateFoodProductIngredientDto[];
}

View file

@ -0,0 +1,7 @@
export interface CreateFoodProductIngredientDto
{
name: string;
quantity: number;
unit: string;
}

View file

@ -0,0 +1,55 @@
import {FoodProduct} from "./foodProduct.entity";
import {getTestEmissionFactor} from "../seed-dev-data";
import {FoodProductIngredient} from "./foodProductIngredient.entity";
let hamCheesePizza: FoodProduct;
beforeEach(async () => {
hamCheesePizza = new FoodProduct({
name: "hamCheesePizza",
ingredients: [
{ name: "ham", quantity: 0.1, unit: "kg" },
{ name: "cheese", quantity: 0.15, unit: "kg" },
{ name: "tomato", quantity: 0.4, unit: "kg" },
{ name: "flour", quantity: 0.7, unit: "kg" },
{ name: "oliveOil", quantity: 0.3, unit: "kg" },
],
});
});
describe("FoodProduct", () => {
describe("constructor", () => {
it("should create a food product with its ingredients", () => {
expect(hamCheesePizza.name).toBe("hamCheesePizza");
expect(hamCheesePizza.ingredients).toHaveLength(5);
expect(hamCheesePizza.ingredients?.[1]?.name).toBe("cheese");
expect(hamCheesePizza.ingredients?.[2]?.quantity).toBe(0.4);
expect(hamCheesePizza.ingredients?.[3]?.unit).toBe("kg");
});
});
describe("compute footprint", () => {
it("shouldn't be able to compute the carbon footprint as carbon emission factors are unknown", () => {
expect(hamCheesePizza.computeFootprint()).toBeNull();
expect(hamCheesePizza.footprint).toBeNull();
});
it("should compute the carbon footprint for one ingredient but not the others", () => {
// Set one ingredient test emission factor.
(hamCheesePizza.ingredients as FoodProductIngredient[])[0].carbonEmissionFactor = getTestEmissionFactor("ham");
// Compute its footprint.
expect((hamCheesePizza.ingredients as FoodProductIngredient[])[0].computeFootprint()).toBeCloseTo(0.011, 3);
expect(hamCheesePizza.computeFootprint()).toBeNull();
});
it("should compute the product carbon footprint", () => {
// Set ingredients test emission factors.
for (const ingredient of (hamCheesePizza.ingredients as FoodProductIngredient[]))
ingredient.carbonEmissionFactor = getTestEmissionFactor(ingredient.name ?? "");
// Compute product footprint.
expect(hamCheesePizza.computeFootprint()).toBeCloseTo(0.224, 3);
expect(hamCheesePizza.footprint).toBeCloseTo(0.224, 3);
});
});
});

View file

@ -0,0 +1,68 @@
import {BaseEntity, Column, Entity, OneToMany, PrimaryGeneratedColumn} from "typeorm";
import {FoodProductIngredient} from "./foodProductIngredient.entity";
/**
* Food product entity.
*/
@Entity("food_products")
export class FoodProduct extends BaseEntity {
@PrimaryGeneratedColumn()
id: number;
@Column({
nullable: false,
})
name?: string;
/**
* List of ingredients of the food product.
*/
@OneToMany(() => FoodProductIngredient, (ingredient) => ingredient.foodProduct, {
cascade: true,
})
ingredients?: FoodProductIngredient[];
/**
* Computed carbon footprint of the product.
* Set to null if the carbon footprint couldn't be computed (e.g. because an ingredient is unknown).
*/
@Column({
type: "float",
nullable: true,
})
footprint: number|null = null;
constructor(props?: {
name: string,
ingredients: {
name: string,
quantity: number,
unit: string,
}[],
})
{
super();
this.name = props?.name;
this.ingredients = props?.ingredients?.map((ingredientProps) => new FoodProductIngredient(ingredientProps));
}
/**
* Try to compute (and set) the carbon footprint of the food product from its ingredients.
* If the carbon footprint cannot be computed, it's set to NULL.
* @return The computed carbon footprint.
*/
computeFootprint(): number|null
{
let totalFootprint: number = 0;
if (this.ingredients) for (const ingredient of this.ingredients) {
// Compute each ingredient footprint and add it to the total footprint.
const ingredientFootprint = ingredient.computeFootprint();
if (ingredientFootprint === null) return this.footprint = null;
totalFootprint += ingredientFootprint;
}
return this.footprint = totalFootprint;
}
}

View file

@ -0,0 +1,79 @@
import {dataSource, GreenlyDataSource} from "../../config/dataSource";
import {FoodProductService} from "./foodProduct.service";
import {CarbonEmissionFactor} from "../carbonEmissionFactor/carbonEmissionFactor.entity";
import {seedTestCarbonEmissionFactors} from "../seed-dev-data";
import {FoodProductIngredient} from "./foodProductIngredient.entity";
import {FoodProduct} from "./foodProduct.entity";
let foodProductService: FoodProductService;
beforeAll(async () => {
await dataSource.initialize();
foodProductService = new FoodProductService(
dataSource.getRepository(FoodProduct),
dataSource.getRepository(CarbonEmissionFactor),
);
});
describe("FoodProduct.service", () => {
it("should initialize a food product from DTO", async () => {
const hamCheesePizza = await foodProductService.initialize({
name: "hamCheesePizza",
ingredients: [
{ name: "ham", quantity: 0.1, unit: "kg" },
{ name: "cheese", quantity: 0.15, unit: "kg" },
{ name: "tomato", quantity: 0.4, unit: "kg" },
{ name: "flour", quantity: 0.7, unit: "kg" },
{ name: "oliveOil", quantity: 0.3, unit: "kg" },
],
});
expect(hamCheesePizza.ingredients).toHaveLength(5);
expect(hamCheesePizza.ingredients?.[0].carbonEmissionFactor?.name).toBe("ham");
expect(hamCheesePizza.ingredients?.[0].carbonEmissionFactor?.unit).toBe("kg");
for (const ingredient of (hamCheesePizza.ingredients as FoodProductIngredient[]))
expect(ingredient.carbonEmissionFactor?.id).not.toBeUndefined();
});
it("should create a food product from DTO", async () => {
const noFoodProducts = await foodProductService.findAll();
expect(noFoodProducts).toHaveLength(0);
const hamCheesePizza = await foodProductService.create({
name: "hamCheesePizza",
ingredients: [
{ name: "ham", quantity: 0.1, unit: "kg" },
{ name: "cheese", quantity: 0.15, unit: "kg" },
{ name: "tomato", quantity: 0.4, unit: "kg" },
{ name: "flour", quantity: 0.7, unit: "kg" },
{ name: "oliveOil", quantity: 0.3, unit: "kg" },
],
});
const retrievedHamCheesePizza = await dataSource
.getRepository(FoodProduct)
.findOne({
where: { name: "hamCheesePizza" },
relations: { ingredients: { carbonEmissionFactor: true } }
});
expect(retrievedHamCheesePizza?.name).toBe("hamCheesePizza");
expect(retrievedHamCheesePizza?.ingredients).toHaveLength(5);
expect(retrievedHamCheesePizza?.ingredients?.[0].carbonEmissionFactor?.name).toBe("ham");
expect(retrievedHamCheesePizza?.ingredients?.[0].carbonEmissionFactor?.unit).toBe("kg");
for (const ingredient of (retrievedHamCheesePizza?.ingredients as FoodProductIngredient[]))
expect(ingredient.carbonEmissionFactor?.id).not.toBeUndefined();
const allFoodProducts = await foodProductService.findAll();
expect(allFoodProducts).toHaveLength(1);
});
});
beforeEach(async () => {
await GreenlyDataSource.cleanDatabase();
await seedTestCarbonEmissionFactors();
});
afterAll(async () => {
await dataSource.destroy();
});

View file

@ -0,0 +1,56 @@
import {InjectRepository} from "@nestjs/typeorm";
import {Repository} from "typeorm";
import {FoodProduct} from "./foodProduct.entity";
import {CreateFoodProductDto} from "./dto/create-foodProduct.dto";
import {CarbonEmissionFactor} from "../carbonEmissionFactor/carbonEmissionFactor.entity";
export class FoodProductService
{
constructor(
@InjectRepository(FoodProduct) private foodProductRepository: Repository<FoodProduct>,
@InjectRepository(CarbonEmissionFactor) private carbonEmissionFactorRepository: Repository<CarbonEmissionFactor>,
) {}
/**
* Find all created food products, with their foodprint.
*/
findAll(): Promise<FoodProduct[]>
{
return this.foodProductRepository.find();
}
/**
* Initialize a new food product object from a provided food product creation object.
* @param rawFoodProduct Food product creation object.
*/
async initialize(rawFoodProduct: CreateFoodProductDto): Promise<FoodProduct>
{
// Try to find all matching ingredients emission factors.
const ingredientsEmissionFactors = await this.carbonEmissionFactorRepository.find({
where: rawFoodProduct.ingredients.map((ingredient) => (
{ name: ingredient.name, unit: ingredient.unit }
)),
});
// Convert a simple map to retrieve ingredients emission factors from their "name + unit" key.
const ingredientsEmissionFactorsMap = Object.fromEntries(
ingredientsEmissionFactors.map((ingredient) => [ingredient.name + ";" + ingredient.unit, ingredient])
);
// Create the food product object with its ingredients.
const foodProduct = new FoodProduct(rawFoodProduct);
foodProduct.ingredients?.forEach((ingredient) => {
ingredient.carbonEmissionFactor = ingredientsEmissionFactorsMap[ingredient.name + ";" + ingredient.unit] ?? null;
});
return foodProduct;
}
/**
* Create a new food product object from a provided food product creation object.
* @param rawFoodProduct Food product creation object.
*/
async create(rawFoodProduct: CreateFoodProductDto): Promise<FoodProduct>
{ // Initialize and save the food product.
return (await this.initialize(rawFoodProduct)).save();
}
}

View file

@ -0,0 +1,17 @@
import {FoodProductIngredient} from "./foodProductIngredient.entity";
import {getTestEmissionFactor} from "../seed-dev-data";
let testIngredient: FoodProductIngredient;
beforeEach(async () => {
testIngredient = new FoodProductIngredient();
testIngredient.carbonEmissionFactor = getTestEmissionFactor("ham");
});
describe("FoodProductIngredient", () => {
it("should compute a 0 footprint with undefined quantity", () => {
expect(testIngredient.quantity).toBeUndefined();
expect(testIngredient.carbonEmissionFactor).not.toBeNull();
expect(testIngredient.computeFootprint()).toBe(0);
});
});

View file

@ -0,0 +1,70 @@
import {BaseEntity, Column, Entity, ManyToOne, PrimaryGeneratedColumn} from "typeorm";
import {FoodProduct} from "./foodProduct.entity";
import {CarbonEmissionFactor} from "../carbonEmissionFactor/carbonEmissionFactor.entity";
/**
* Food product entity.
*/
@Entity("food_products_ingredients")
export class FoodProductIngredient extends BaseEntity {
@PrimaryGeneratedColumn()
id: number;
/**
* The food product of the ingredient.
*/
@ManyToOne(() => FoodProduct, (product) => product.ingredients, {
nullable: false,
})
foodProduct: FoodProduct;
/**
* The carbon emission factor of the ingredient.
*/
@ManyToOne(() => CarbonEmissionFactor, {
nullable: true,
})
carbonEmissionFactor: CarbonEmissionFactor|null = null;
@Column({
nullable: false,
})
name?: string;
@Column({
type: "float",
nullable: false,
})
quantity?: number;
@Column({
nullable: false,
})
unit?: string;
constructor(props?: {
name: string,
quantity: number,
unit: string,
})
{
super();
this.name = props?.name;
this.quantity = props?.quantity;
this.unit = props?.unit;
}
/**
* Try to compute the carbon footprint of the food product ingredient.
* If the carbon footprint cannot be computed, it's set to NULL.
* @return The computed carbon footprint.
*/
computeFootprint(): number|null
{
if (this.carbonEmissionFactor)
return (this.quantity ?? 0) * this.carbonEmissionFactor.emissionCO2eInKgPerUnit;
else
return null;
}
}

View file

@ -0,0 +1,34 @@
import {Body, Controller, Get, Logger, Post} from "@nestjs/common";
import {FoodProductFootprintService} from "./foodProductFootprint.service";
import {FoodProductService} from "../foodProduct/foodProduct.service";
import {FoodProduct} from "../foodProduct/foodProduct.entity";
import {CreateFoodProductDto} from "../foodProduct/dto/create-foodProduct.dto";
@Controller("food-product-footprint")
export class FoodProductFootprintController {
constructor(
private readonly foodProductService: FoodProductService,
private readonly foodProductFootprintService: FoodProductFootprintService,
) {}
@Get()
getFoodProductFootprint(): Promise<FoodProduct[]> {
Logger.log(
`[food-product-footprint] [GET] FoodProduct: getting all FoodProducts`
);
return this.foodProductService.findAll();
}
@Post()
async createFoodProductFoodprint(
@Body() foodProductDto: CreateFoodProductDto
): Promise<FoodProduct> {
``;
Logger.log(
`[food-product-footprint] [POST] FoodProduct: ${foodProductDto} created`
);
return this.foodProductFootprintService.computeAndSave(
await this.foodProductService.initialize(foodProductDto)
);
}
}

View file

@ -0,0 +1,15 @@
import {Module} from "@nestjs/common";
import {TypeOrmModule} from "@nestjs/typeorm";
import {FoodProduct} from "../foodProduct/foodProduct.entity";
import {FoodProductIngredient} from "../foodProduct/foodProductIngredient.entity";
import {FoodProductFootprintService} from "./foodProductFootprint.service";
import {FoodProductService} from "../foodProduct/foodProduct.service";
import {CarbonEmissionFactor} from "../carbonEmissionFactor/carbonEmissionFactor.entity";
import {FoodProductFootprintController} from "./foodProductFootprint.controller";
@Module({
imports: [TypeOrmModule.forFeature([FoodProduct, FoodProductIngredient, CarbonEmissionFactor])],
providers: [FoodProductService, FoodProductFootprintService],
controllers: [FoodProductFootprintController],
})
export class FoodProductFootprintModule {}

View file

@ -0,0 +1,77 @@
import {GreenlyDataSource, dataSource} from "../../config/dataSource";
import {seedTestCarbonEmissionFactors} from "../seed-dev-data";
import {FoodProductService} from "../foodProduct/foodProduct.service";
import {CarbonEmissionFactor} from "../carbonEmissionFactor/carbonEmissionFactor.entity";
import {FoodProductFootprintService} from "./foodProductFootprint.service";
import {FoodProduct} from "../foodProduct/foodProduct.entity";
let foodProductService: FoodProductService;
let foodProductFootprintService: FoodProductFootprintService;
beforeAll(async () => {
await dataSource.initialize();
foodProductService = new FoodProductService(
dataSource.getRepository(FoodProduct),
dataSource.getRepository(CarbonEmissionFactor),
);
foodProductFootprintService = new FoodProductFootprintService();
});
beforeEach(async () => {
await GreenlyDataSource.cleanDatabase();
await seedTestCarbonEmissionFactors();
});
describe("FoodProductFootprint.service", () => {
it("should compute and save carbon footprint", async () => {
let hamCheesePizza = await foodProductService.initialize({
name: "hamCheesePizza",
ingredients: [
{ name: "ham", quantity: 0.1, unit: "kg" },
{ name: "cheese", quantity: 0.15, unit: "kg" },
{ name: "tomato", quantity: 0.4, unit: "kg" },
{ name: "flour", quantity: 0.7, unit: "kg" },
{ name: "oliveOil", quantity: 0.3, unit: "kg" },
],
});
await foodProductFootprintService.computeAndSave(hamCheesePizza);
expect(hamCheesePizza.footprint).toBeCloseTo(0.224, 3);
const retrievedHamCheesePizza = await dataSource
.getRepository(FoodProduct)
.findOne({ where: { name: "hamCheesePizza" } });
expect(retrievedHamCheesePizza?.name).toBe("hamCheesePizza");
expect(retrievedHamCheesePizza?.footprint).toBeCloseTo(0.224, 3);
});
it("should fail to compute and save NULL carbon footprint", async () => {
let hamCheesePizza = await foodProductService.initialize({
name: "hamCheesePizza",
ingredients: [
{ name: "ham", quantity: 0.1, unit: "kg" },
{ name: "cheese", quantity: 0.15, unit: "kg" },
{ name: "tomato", quantity: 0.4, unit: "kg" },
{ name: "flour", quantity: 0.7, unit: "kg" },
{ name: "anyOil", quantity: 0.3, unit: "kg" },
],
});
await foodProductFootprintService.computeAndSave(hamCheesePizza);
expect(hamCheesePizza.footprint).toBeNull();
const retrievedHamCheesePizza = await dataSource
.getRepository(FoodProduct)
.findOne({ where: { name: "hamCheesePizza" } });
expect(retrievedHamCheesePizza?.name).toBe("hamCheesePizza");
expect(retrievedHamCheesePizza?.footprint).toBeNull();
});
});
afterAll(async () => {
await dataSource.destroy();
});

View file

@ -0,0 +1,18 @@
import {FoodProduct} from "../foodProduct/foodProduct.entity";
import {Injectable} from "@nestjs/common";
@Injectable()
export class FoodProductFootprintService
{
constructor() {}
/**
* Compute the carbon footprint of a food product from its ingredients and save it.
* @param foodProduct The food product for which to compute the carbon footprint.
*/
async computeAndSave(foodProduct: FoodProduct): Promise<FoodProduct>
{
foodProduct.computeFootprint();
return foodProduct.save();
}
}

View file

@ -0,0 +1,80 @@
import {INestApplication} from "@nestjs/common";
import {Test, TestingModule} from "@nestjs/testing";
import * as request from "supertest";
import {dataSource, GreenlyDataSource} from "../config/dataSource";
import {AppModule} from "../src/app.module";
import {CarbonEmissionFactor} from "../src/carbonEmissionFactor/carbonEmissionFactor.entity";
import {seedTestCarbonEmissionFactors} from "../src/seed-dev-data";
import {FoodProductService} from "../src/foodProduct/foodProduct.service";
import {FoodProduct} from "../src/foodProduct/foodProduct.entity";
import {FoodProductFootprintService} from "../src/foodProductFootprint/foodProductFootprint.service";
beforeAll(async () => {
await dataSource.initialize();
await GreenlyDataSource.cleanDatabase();
});
afterAll(async () => {
await dataSource.destroy();
});
describe("FoodProductFootprintController", () => {
let app: INestApplication;
let defaultProducts: FoodProduct[];
beforeEach(async () => {
const moduleFixture: TestingModule = await Test.createTestingModule({
imports: [AppModule],
}).compile();
app = moduleFixture.createNestApplication();
await app.init();
await seedTestCarbonEmissionFactors();
const foodProductService = new FoodProductService(dataSource.getRepository(FoodProduct), dataSource.getRepository(CarbonEmissionFactor));
const foodProductFootprintService = new FoodProductFootprintService();
await foodProductFootprintService.computeAndSave(await foodProductService.initialize({
name: "ketchup",
ingredients: [
{ name: "tomato", quantity: 0.9, unit: "kg" },
{ name: "vinegar", quantity: 0.1, unit: "kg" },
],
}));
defaultProducts = await dataSource
.getRepository(FoodProduct)
.find();
});
it("GET /food-product-footprint", async () => {
return request(app.getHttpServer())
.get("/food-product-footprint")
.expect(200)
.expect(({ body }) => {
expect(body).toEqual(defaultProducts);
});
});
it("POST /food-product-footprint", async () => {
const hamCheesePizza = {
name: "hamCheesePizza",
ingredients: [
{ name: "ham", quantity: 0.1, unit: "kg" },
{ name: "cheese", quantity: 0.15, unit: "kg" },
{ name: "tomato", quantity: 0.4, unit: "kg" },
{ name: "flour", quantity: 0.7, unit: "kg" },
{ name: "oliveOil", quantity: 0.3, unit: "kg" },
],
};
return request(app.getHttpServer())
.post("/food-product-footprint")
.send(hamCheesePizza)
.expect(201)
.expect(({ body }) => {
expect(body).toMatchObject(hamCheesePizza);
expect(body.footprint).not.toBeNull();
expect(body.footprint).toBeCloseTo(0.224, 3);
});
});
});