feat(sync): add converters from Hiboutik DTOs to Prisma inputs

This commit is contained in:
MastersGame
2026-11-05 23:00:48 +01:00
parent 9cfcd12351
commit aa4262c45a
12 changed files with 294 additions and 0 deletions

View File

@@ -0,0 +1,19 @@
import { Injectable } from '@nestjs/common';
import { Converter } from './converter';
import { BrandHiboutikDto } from '../hiboutikDTO/brand.hiboutik.dto';
import { BrandCreateInput } from '../../../prisma/generated/models/Brand';
@Injectable()
export class BrandConverter extends Converter<BrandCreateInput, BrandHiboutikDto> {
fromHiboutik(dto: BrandHiboutikDto): BrandCreateInput {
return {
hiboutikId: dto.brand_id,
name: dto.brand_name,
enabled: dto.brand_enabled == 1,
};
}
toHiboutik(): BrandHiboutikDto {
throw new Error('Method not implemented.');
}
}

View File

@@ -0,0 +1,22 @@
import { Injectable } from '@nestjs/common';
import { CategoryHiboutikDto } from '../hiboutikDTO/category.hiboutik.dto';
import { Converter } from './converter';
import { CategoryCreateInput } from '../../../prisma/generated/models/Category';
@Injectable()
export class CategoryConverter extends Converter<CategoryCreateInput, CategoryHiboutikDto> {
/** The parent is not set here: it is linked afterwards, once every category exists (see CategoryHiboutikService). */
fromHiboutik(hiboutikDto: CategoryHiboutikDto): CategoryCreateInput {
return {
hiboutikId: hiboutikDto.category_id,
name: hiboutikDto.category_name,
enabled: hiboutikDto.category_enabled == 1,
position: Number(hiboutikDto.category_position) || 0,
webEnabled: hiboutikDto.category_enabled_www == 1,
};
}
toHiboutik(): CategoryHiboutikDto {
throw new Error('Method not implemented.');
}
}

View File

@@ -0,0 +1,29 @@
/**
* Maps a Hiboutik DTO to a Prisma input, and back. One implementation per synced entity.
* @typeParam Model Prisma `*CreateInput` of the entity.
* @typeParam Dto Hiboutik payload of the entity.
*/
export abstract class Converter<Model, Dto> {
/**
* Builds the Prisma input from a Hiboutik DTO. It is used for both create and update (upsert):
* a field set to `undefined` keeps its stored value on update, `null` erases it.
*/
abstract fromHiboutik(dto: Dto): Model;
/** Not implemented yet: nothing is written back to Hiboutik. */
abstract toHiboutik(model: Model): Dto;
/** Returns [] for a missing or non-array input. */
toHiboutikList(models: Model[]): Dto[] {
if (!models || !Array.isArray(models)) return [];
return models.map((model) => this.toHiboutik(model));
}
/** Returns [] for a missing or non-array input. */
fromHiboutikList(hiboutikDtos: Dto[]): Model[] {
if (!hiboutikDtos || !Array.isArray(hiboutikDtos)) return [];
return hiboutikDtos.map((hiboutikDto) => this.fromHiboutik(hiboutikDto));
}
}

View File

@@ -0,0 +1,44 @@
import { CustomerConverter } from './customer.converter';
import { FullCustomerHiboutikDto } from '../hiboutikDTO/customer.hiboutik.dto';
const hiboutikCustomer = (overrides: Partial<FullCustomerHiboutikDto> = {}): FullCustomerHiboutikDto =>
({
customers_id: 42,
first_name: 'Jane',
last_name: 'Doe',
email: 'jane@example.com',
phone: '0600000000',
date_of_birth: '1990-05-12',
updated_at: '2026-06-28 17:02:55',
...overrides,
}) as FullCustomerHiboutikDto;
describe('CustomerConverter', () => {
const converter = new CustomerConverter();
it('maps a Hiboutik customer', () => {
expect(converter.fromHiboutik(hiboutikCustomer())).toEqual({
dateOfBirth: new Date('1990-05-12'),
email: 'jane@example.com',
firstName: 'Jane',
hiboutikId: 42,
hiboutikUpdatedAt: new Date('2026-06-28T17:02:55'),
lastName: 'Doe',
phone: '0600000000',
});
});
it('ignores an invalid date of birth', () => {
expect(converter.fromHiboutik(hiboutikCustomer({ date_of_birth: '0000-00-00' })).dateOfBirth).toBeUndefined();
});
it('keeps the stored email when Hiboutik has none (undefined)', () => {
expect(converter.fromHiboutik(hiboutikCustomer({ email: '' })).email).toBeUndefined();
expect(converter.fromHiboutik(hiboutikCustomer({ email: null as unknown as string })).email).toBeUndefined();
});
it('erases the phone when Hiboutik has none (null)', () => {
expect(converter.fromHiboutik(hiboutikCustomer({ phone: '' })).phone).toBeNull();
expect(converter.fromHiboutik(hiboutikCustomer({ phone: null as unknown as string })).phone).toBeNull();
});
});

View File

@@ -0,0 +1,25 @@
import { Injectable } from '@nestjs/common';
import { Converter } from './converter';
import { CustomerCreateInput } from '../../../prisma/generated/models/Customer';
import { FullCustomerHiboutikDto } from '../hiboutikDTO/customer.hiboutik.dto';
import { parseHiboutikDate } from '../../utils/date';
@Injectable()
export class CustomerConverter extends Converter<CustomerCreateInput, FullCustomerHiboutikDto> {
fromHiboutik(dto: FullCustomerHiboutikDto): CustomerCreateInput {
return {
dateOfBirth: parseHiboutikDate(dto.date_of_birth),
// undefined (not null) on purpose: an email removed in Hiboutik must not erase the one stored in database
email: dto.email?.trim() || undefined,
firstName: dto.first_name,
hiboutikId: dto.customers_id,
hiboutikUpdatedAt: parseHiboutikDate(dto.updated_at),
lastName: dto.last_name,
// null (not undefined) so that a phone removed in Hiboutik is also removed in database
phone: dto.phone?.trim() || null,
};
}
toHiboutik(): FullCustomerHiboutikDto {
throw new Error('Method not implemented.');
}
}

View File

@@ -0,0 +1,59 @@
import { ProductConverter } from './product.converter';
import { ProductHiboutikDto } from '../hiboutikDTO/product.hiboutik.dto';
const hiboutikProduct = (overrides: Partial<ProductHiboutikDto> = {}): ProductHiboutikDto => ({
product_id: 1,
product_model: 'BOUTEILLE MÉTAL ISOTHERME 260ML BT21',
product_barcode: '8435497280048',
product_brand: 1,
product_supplier: 2,
product_price: '13.99',
product_discount_price: '0.00',
product_supply_price: '7.49',
product_category: 19,
product_display_www: 0,
product_arch: 0,
products_desc: '',
updated_at: '2026-06-28 17:02:55',
product_specific_rules: [],
...overrides,
});
describe('ProductConverter', () => {
const converter = new ProductConverter();
it('maps a Hiboutik product', () => {
expect(converter.fromHiboutik(hiboutikProduct())).toEqual({
archived: false,
barcode: '8435497280048',
brand: { connect: { hiboutikId: 1 } },
category: { connect: { hiboutikId: 19 } },
description: '',
discountPrice: null,
hiboutikId: 1,
hiboutikUpdatedAt: new Date('2026-06-28T17:02:55'),
name: 'BOUTEILLE MÉTAL ISOTHERME 260ML BT21',
price: '13.99',
enabled: false,
supplier: { connect: { hiboutikId: 2 } },
});
});
it('maps archived, web display and discount price', () => {
const product = converter.fromHiboutik(hiboutikProduct({ product_arch: 1, product_display_www: 1, product_discount_price: '9.99' }));
expect(product.archived).toBe(true);
expect(product.enabled).toBe(true);
expect(product.discountPrice).toBe('9.99');
});
it('stores an empty barcode as null', () => {
expect(converter.fromHiboutik(hiboutikProduct({ product_barcode: '' })).barcode).toBeNull();
});
it('does not link missing relations', () => {
const product = converter.fromHiboutik(hiboutikProduct({ product_brand: 0, product_category: 0, product_supplier: 0 }));
expect(product.brand).toBeUndefined();
expect(product.category).toBeUndefined();
expect(product.supplier).toBeUndefined();
});
});

View File

@@ -0,0 +1,31 @@
import { Injectable } from '@nestjs/common';
import { Converter } from './converter';
import { ProductHiboutikDto } from '../hiboutikDTO/product.hiboutik.dto';
import { ProductCreateInput } from '../../../prisma/generated/models/Product';
import { parseHiboutikDate } from '../../utils/date';
import { parseOptionalHiboutikPrice } from '../../utils/price';
@Injectable()
export class ProductConverter extends Converter<ProductCreateInput, ProductHiboutikDto> {
/** `globalStock` and versions are not set here: they depend on the stocks endpoint (see ProductHiboutikService). */
fromHiboutik(dto: ProductHiboutikDto): ProductCreateInput {
return {
archived: dto.product_arch == 1,
barcode: dto.product_barcode || null,
brand: dto.product_brand ? { connect: { hiboutikId: dto.product_brand } } : undefined,
category: dto.product_category ? { connect: { hiboutikId: dto.product_category } } : undefined,
description: dto.products_desc,
discountPrice: parseOptionalHiboutikPrice(dto.product_discount_price),
hiboutikId: dto.product_id,
hiboutikUpdatedAt: parseHiboutikDate(dto.updated_at),
name: dto.product_model,
price: dto.product_price,
enabled: dto.product_display_www == 1,
supplier: dto.product_supplier ? { connect: { hiboutikId: dto.product_supplier } } : undefined,
};
}
toHiboutik(): ProductHiboutikDto {
throw new Error('Method not implemented.');
}
}

View File

@@ -0,0 +1,19 @@
import { Injectable } from '@nestjs/common';
import { SupplierHiboutikDto } from '../hiboutikDTO/supplier.hiboutik.dto';
import { Converter } from './converter';
import { SupplierCreateInput } from '../../../prisma/generated/models/Supplier';
@Injectable()
export class SupplierConverter extends Converter<SupplierCreateInput, SupplierHiboutikDto> {
fromHiboutik(dto: SupplierHiboutikDto): SupplierCreateInput {
return {
hiboutikId: dto.supplier_id,
name: dto.supplier_name,
enabled: dto.supplier_enabled == 1,
};
}
toHiboutik(): SupplierHiboutikDto {
throw new Error('Method not implemented.');
}
}

15
src/utils/date.spec.ts Normal file
View File

@@ -0,0 +1,15 @@
import { parseHiboutikDate } from './date';
describe('parseHiboutikDate', () => {
it('parses a Hiboutik datetime', () => {
expect(parseHiboutikDate('2026-06-28 17:02:55')).toEqual(new Date('2026-06-28T17:02:55'));
});
it('parses a date without time', () => {
expect(parseHiboutikDate('1990-05-12')).toEqual(new Date('1990-05-12'));
});
it.each(['', '0000-00-00', '0000-00-00 00:00:00', 'not a date', null, undefined])('returns undefined for %p', (value) => {
expect(parseHiboutikDate(value)).toBeUndefined();
});
});

10
src/utils/date.ts Normal file
View File

@@ -0,0 +1,10 @@
/**
* Parses a Hiboutik date ("YYYY-MM-DD HH:mm:ss" or "YYYY-MM-DD").
* Returns undefined for empty or invalid dates (e.g. "0000-00-00").
*/
export function parseHiboutikDate(value: string | null | undefined): Date | undefined {
if (!value) return undefined;
const date = new Date(value.trim().replace(' ', 'T'));
return isNaN(date.getTime()) ? undefined : date;
}

11
src/utils/price.spec.ts Normal file
View File

@@ -0,0 +1,11 @@
import { parseOptionalHiboutikPrice } from './price';
describe('parseOptionalHiboutikPrice', () => {
it('keeps a positive price', () => {
expect(parseOptionalHiboutikPrice('12.50')).toBe('12.50');
});
it.each(['0.00', '0', '', 'abc', '-1.00', null, undefined])('returns null for %p', (value) => {
expect(parseOptionalHiboutikPrice(value)).toBeNull();
});
});

10
src/utils/price.ts Normal file
View File

@@ -0,0 +1,10 @@
/**
* Parses an optional Hiboutik price (discount price, size price override).
* Hiboutik sends "0.00" when the value is not set: returns null for empty, zero or invalid values.
*/
export function parseOptionalHiboutikPrice(value: string | null | undefined): string | null {
if (!value) return null;
const price = Number(value);
return Number.isFinite(price) && price > 0 ? value : null;
}