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:
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user