feat(sync): harden the Hiboutik sync, share rate limits across instances, update dependencies (#4)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-08 13:45:08 +00:00
parent 8c8a2ccf15
commit cc3dcd3f0a
62 changed files with 1546 additions and 639 deletions

View File

@@ -9,7 +9,7 @@ export class BrandConverter extends Converter<BrandCreateInput, BrandHiboutikDto
return {
hiboutikId: dto.brand_id,
name: dto.brand_name,
enabled: dto.brand_enabled == 1,
enabled: dto.brand_enabled === 1,
};
}

View File

@@ -10,9 +10,9 @@ export class CategoryConverter extends Converter<CategoryCreateInput, CategoryHi
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,
enabled: hiboutikDto.category_enabled === 1,
position: hiboutikDto.category_position,
webEnabled: hiboutikDto.category_enabled_www === 1,
};
}

View File

@@ -1,17 +1,16 @@
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;
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,
});
describe('CustomerConverter', () => {
const converter = new CustomerConverter();
@@ -22,7 +21,8 @@ describe('CustomerConverter', () => {
email: 'jane@example.com',
firstName: 'Jane',
hiboutikId: 42,
hiboutikUpdatedAt: new Date('2026-06-28T17:02:55'),
// French summer time (UTC+2)
hiboutikUpdatedAt: new Date('2026-06-28T15:02:55Z'),
lastName: 'Doe',
phone: '0600000000',
});
@@ -32,13 +32,13 @@ describe('CustomerConverter', () => {
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 email when Hiboutik has none (null)', () => {
expect(converter.fromHiboutik(hiboutikCustomer({ email: '' })).email).toBeNull();
expect(converter.fromHiboutik(hiboutikCustomer({ email: ' ' })).email).toBeNull();
});
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();
expect(converter.fromHiboutik(hiboutikCustomer({ phone: ' ' })).phone).toBeNull();
});
});

View File

@@ -9,14 +9,14 @@ export class CustomerConverter extends Converter<CustomerCreateInput, FullCustom
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,
// null (not undefined) so that an email removed in Hiboutik is also removed in database (data minimization)
email: dto.email.trim() || null,
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,
phone: dto.phone.trim() || null,
};
}
toHiboutik(): FullCustomerHiboutikDto {

View File

@@ -1,23 +1,22 @@
import { CustomerAddressConverter } from './customerAddress.converter';
import { CustomerAddressHiboutikDto } from '../hiboutikDTO/customer.hiboutik.dto';
const hiboutikAddress = (overrides: Partial<CustomerAddressHiboutikDto> = {}): CustomerAddressHiboutikDto =>
({
address_id: 7,
customers_id: 42,
first_name: 'Jane',
last_name: 'Doe',
company: 'ACME',
phone: '0600000000',
address: ' 1 rue de la Paix ',
zip_code: '75002',
city: 'Paris',
state: '',
country: 'FRA',
other: 'Bât. B',
default: 1,
...overrides,
}) as CustomerAddressHiboutikDto;
const hiboutikAddress = (overrides: Partial<CustomerAddressHiboutikDto> = {}): CustomerAddressHiboutikDto => ({
address_id: 7,
customers_id: 42,
first_name: 'Jane',
last_name: 'Doe',
company: 'ACME',
phone: '0600000000',
address: ' 1 rue de la Paix ',
zip_code: '75002',
city: 'Paris',
state: '',
country: 'FRA',
other: 'Bât. B',
default: 1,
...overrides,
});
describe('CustomerAddressConverter', () => {
const converter = new CustomerAddressConverter();
@@ -40,17 +39,8 @@ describe('CustomerAddressConverter', () => {
});
});
it('accepts ids and flags sent as strings', () => {
const address = converter.fromHiboutik(hiboutikAddress({ address_id: '8', customers_id: '43', default: '0' } as never));
expect(address.hiboutikId).toBe(8);
expect(address.isDefault).toBe(false);
expect(address.customer).toEqual({ connect: { hiboutikId: 43 } });
});
it('erases empty optional fields (null) and defaults empty required ones to ""', () => {
const address = converter.fromHiboutik(
hiboutikAddress({ company: ' ', phone: null as unknown as string, other: '', first_name: null as unknown as string }),
);
const address = converter.fromHiboutik(hiboutikAddress({ company: ' ', phone: '', other: '', first_name: '' }));
expect(address.company).toBeNull();
expect(address.phone).toBeNull();
expect(address.other).toBeNull();

View File

@@ -31,8 +31,8 @@ export class CustomerAddressConverter extends Converter<
companyNumber: companyDetail(details.company_number),
country: required(dto.country),
firstName: required(dto.first_name),
hiboutikId: Number(dto.address_id),
isDefault: Number(dto.default) === 1,
hiboutikId: dto.address_id,
isDefault: dto.default === 1,
lastName: required(dto.last_name),
legalStatus: companyDetail(details.legal_status),
other: optional(dto.other),
@@ -40,7 +40,7 @@ export class CustomerAddressConverter extends Converter<
state: optional(dto.state),
taxNumber: companyDetail(details.tax_number),
zipCode: required(dto.zip_code),
customer: { connect: { hiboutikId: Number(dto.customers_id) } },
customer: { connect: { hiboutikId: dto.customers_id } },
};
}
toHiboutik(): CustomerAddressHiboutikDto {

View File

@@ -2,29 +2,26 @@ import { OrderConverter } from './order.converter';
import { SaleHiboutikDto, SaleLineHiboutikDto } from '../hiboutikDTO/sale.hiboutik.dto';
const hiboutikLine = (overrides: Partial<SaleLineHiboutikDto> = {}): SaleLineHiboutikDto => ({
line_item_id: 3,
product_id: 746,
product_size: 1,
quantity: 2,
product_model: 'KATSEYE - WILD',
product_barcode: '2430010007467',
size_name: 'Daniela',
item_unit_gross_wo_discount: '19.00',
item_unit_gross: '17.10',
...overrides,
});
const hiboutikSale = (overrides: Partial<SaleHiboutikDto> = {}): SaleHiboutikDto =>
({
sale_id: 5,
created_at: '2026-10-18 11:00:21',
completed_at: '2026-10-18 11:01:04',
customer_id: 1,
total: '34.20',
quotation: 0,
line_items: [hiboutikLine()],
...overrides,
}) as SaleHiboutikDto;
const hiboutikSale = (overrides: Partial<SaleHiboutikDto> = {}): SaleHiboutikDto => ({
sale_id: 5,
created_at: '2026-10-18 11:00:21',
completed_at: '2026-10-18 11:01:04',
customer_id: 1,
total: '34.20',
quotation: 0,
line_items: [hiboutikLine()],
...overrides,
});
describe('OrderConverter', () => {
const converter = new OrderConverter();
@@ -32,7 +29,8 @@ describe('OrderConverter', () => {
it('maps a completed Hiboutik sale with its lines', () => {
expect(converter.fromHiboutik(hiboutikSale())).toEqual({
hiboutikSaleId: 5,
orderedAt: new Date('2026-10-18T11:01:04'),
// French summer time (UTC+2)
orderedAt: new Date('2026-10-18T09:01:04Z'),
source: 'STORE',
status: 'COMPLETED',
total: '34.20',
@@ -54,7 +52,7 @@ describe('OrderConverter', () => {
it('maps a sale not completed yet as PENDING, ordered at its creation date', () => {
const order = converter.fromHiboutik(hiboutikSale({ completed_at: '0000-00-00 00:00:00' }));
expect(order.status).toBe('PENDING');
expect(order.orderedAt).toEqual(new Date('2026-10-18T11:00:21'));
expect(order.orderedAt).toEqual(new Date('2026-10-18T09:00:21Z'));
});
it('throws when the sale has no valid date', () => {
@@ -75,6 +73,6 @@ describe('OrderConverter', () => {
});
it('accepts a sale without lines', () => {
expect(converter.fromHiboutik(hiboutikSale({ line_items: undefined as never })).lines).toEqual([]);
expect(converter.fromHiboutik(hiboutikSale({ line_items: [] })).lines).toEqual([]);
});
});

View File

@@ -17,29 +17,28 @@ export class OrderConverter extends Converter<OrderWithLinesInput, SaleHiboutikD
const orderedAt = completedAt ?? parseHiboutikDate(dto.created_at);
if (!orderedAt) throw new Error(`Hiboutik sale ${dto.sale_id} has no valid date`);
const customerId = Number(dto.customer_id);
return {
hiboutikSaleId: Number(dto.sale_id),
hiboutikSaleId: dto.sale_id,
orderedAt,
source: OrderSource.STORE,
status: completedAt ? OrderStatus.COMPLETED : OrderStatus.PENDING,
total: dto.total,
// 0 for an anonymous sale
customer: customerId > 0 ? { connect: { hiboutikId: customerId } } : undefined,
lines: (dto.line_items ?? []).map((line) => this.lineFromHiboutik(line)),
customer: dto.customer_id > 0 ? { connect: { hiboutikId: dto.customer_id } } : undefined,
lines: dto.line_items.map((line) => this.lineFromHiboutik(line)),
};
}
private lineFromHiboutik(line: SaleLineHiboutikDto): OrderWithLinesInput['lines'][number] {
return {
barcode: line.product_barcode?.trim() || null,
hiboutikProductId: Number(line.product_id),
hiboutikSizeId: Number(line.product_size),
barcode: line.product_barcode.trim() || null,
hiboutikProductId: line.product_id,
hiboutikSizeId: line.product_size,
productName: line.product_model,
quantity: Number(line.quantity),
quantity: line.quantity,
unitPrice: line.item_unit_gross,
// same rule as ProductVersion: the product name for a product without sizes
versionName: line.size_name?.trim() || line.product_model,
versionName: line.size_name.trim() || line.product_model,
};
}

View File

@@ -9,13 +9,13 @@ const hiboutikProduct = (overrides: Partial<ProductHiboutikDto> = {}): ProductHi
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: [],
product_size_details: [],
...overrides,
});
@@ -31,7 +31,7 @@ describe('ProductConverter', () => {
description: '',
discountPrice: null,
hiboutikId: 1,
hiboutikUpdatedAt: new Date('2026-06-28T17:02:55'),
hiboutikUpdatedAt: new Date('2026-06-28T15:02:55Z'),
name: 'BOUTEILLE MÉTAL ISOTHERME 260ML BT21',
price: '13.99',
enabled: false,

View File

@@ -10,7 +10,7 @@ export class ProductConverter extends Converter<ProductCreateInput, ProductHibou
/** `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,
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,
@@ -20,7 +20,7 @@ export class ProductConverter extends Converter<ProductCreateInput, ProductHibou
hiboutikUpdatedAt: parseHiboutikDate(dto.updated_at),
name: dto.product_model,
price: dto.product_price,
enabled: dto.product_display_www == 1,
enabled: dto.product_display_www === 1,
supplier: dto.product_supplier ? { connect: { hiboutikId: dto.product_supplier } } : undefined,
};
}

View File

@@ -9,7 +9,7 @@ export class SupplierConverter extends Converter<SupplierCreateInput, SupplierHi
return {
hiboutikId: dto.supplier_id,
name: dto.supplier_name,
enabled: dto.supplier_enabled == 1,
enabled: dto.supplier_enabled === 1,
};
}