diff --git a/src/models/converter/brand.converter.ts b/src/models/converter/brand.converter.ts new file mode 100644 index 0000000..d77270d --- /dev/null +++ b/src/models/converter/brand.converter.ts @@ -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 { + 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.'); + } +} diff --git a/src/models/converter/category.converter.ts b/src/models/converter/category.converter.ts new file mode 100644 index 0000000..5661b03 --- /dev/null +++ b/src/models/converter/category.converter.ts @@ -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 { + /** 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.'); + } +} diff --git a/src/models/converter/converter.ts b/src/models/converter/converter.ts new file mode 100644 index 0000000..b9b6a6d --- /dev/null +++ b/src/models/converter/converter.ts @@ -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 { + /** + * 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)); + } +} diff --git a/src/models/converter/customer.converter.spec.ts b/src/models/converter/customer.converter.spec.ts new file mode 100644 index 0000000..5329eab --- /dev/null +++ b/src/models/converter/customer.converter.spec.ts @@ -0,0 +1,44 @@ +import { CustomerConverter } from './customer.converter'; +import { FullCustomerHiboutikDto } from '../hiboutikDTO/customer.hiboutik.dto'; + +const hiboutikCustomer = (overrides: Partial = {}): 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(); + }); +}); diff --git a/src/models/converter/customer.converter.ts b/src/models/converter/customer.converter.ts new file mode 100644 index 0000000..1c6fb4a --- /dev/null +++ b/src/models/converter/customer.converter.ts @@ -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 { + 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.'); + } +} diff --git a/src/models/converter/product.converter.spec.ts b/src/models/converter/product.converter.spec.ts new file mode 100644 index 0000000..24ebd64 --- /dev/null +++ b/src/models/converter/product.converter.spec.ts @@ -0,0 +1,59 @@ +import { ProductConverter } from './product.converter'; +import { ProductHiboutikDto } from '../hiboutikDTO/product.hiboutik.dto'; + +const hiboutikProduct = (overrides: Partial = {}): 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(); + }); +}); diff --git a/src/models/converter/product.converter.ts b/src/models/converter/product.converter.ts new file mode 100644 index 0000000..650d649 --- /dev/null +++ b/src/models/converter/product.converter.ts @@ -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 { + /** `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.'); + } +} diff --git a/src/models/converter/supplier.converter.ts b/src/models/converter/supplier.converter.ts new file mode 100644 index 0000000..0aec1e2 --- /dev/null +++ b/src/models/converter/supplier.converter.ts @@ -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 { + 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.'); + } +} diff --git a/src/utils/date.spec.ts b/src/utils/date.spec.ts new file mode 100644 index 0000000..58f4198 --- /dev/null +++ b/src/utils/date.spec.ts @@ -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(); + }); +}); diff --git a/src/utils/date.ts b/src/utils/date.ts new file mode 100644 index 0000000..34bf91a --- /dev/null +++ b/src/utils/date.ts @@ -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; +} diff --git a/src/utils/price.spec.ts b/src/utils/price.spec.ts new file mode 100644 index 0000000..385b58d --- /dev/null +++ b/src/utils/price.spec.ts @@ -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(); + }); +}); diff --git a/src/utils/price.ts b/src/utils/price.ts new file mode 100644 index 0000000..2e7060d --- /dev/null +++ b/src/utils/price.ts @@ -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; +}