feat(sync): synchronize customer adresses and orders from Hiboutik sales #1

Merged
quentin_duwa merged 3 commits from feat/customer-addresses-orders into main 2026-11-07 09:56:53 +00:00
7 changed files with 229 additions and 7 deletions
Showing only changes of commit 9e6e5162ad - Show all commits

View File

@@ -146,16 +146,23 @@ model Customer {
@@map("customers") @@map("customers")
} }
/// Address of a customer, mirrored from Hiboutik (sync not implemented yet). /// Address of a customer, mirrored from Hiboutik. Its recipient (name, company, phone) can differ from the customer.
model CustomerAddress { model CustomerAddress {
id String @id @default(uuid()) id String @id @default(uuid())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
address String address String
city String city String
company String?
country String country String
firstName String
hiboutikId Int @unique hiboutikId Int @unique
isDefault Boolean @default(false) isDefault Boolean @default(false)
lastName String
/// Free complement (building, floor, door code...)
other String?
phone String?
state String?
zipCode String zipCode String
customer Customer @relation(fields: [customerId], references: [id], onDelete: Cascade) customer Customer @relation(fields: [customerId], references: [id], onDelete: Cascade)

View File

@@ -22,6 +22,8 @@ import { ProductVersionDatabaseRepository } from './database/productVersion.data
import { CustomerHiboutikRepository } from './hiboutik/customer.hiboutik.repository'; import { CustomerHiboutikRepository } from './hiboutik/customer.hiboutik.repository';
import { CustomerDatabaseRepository } from './database/customer.database.repository'; import { CustomerDatabaseRepository } from './database/customer.database.repository';
import { CustomerConverter } from './models/converter/customer.converter'; import { CustomerConverter } from './models/converter/customer.converter';
import { CustomerAddressConverter } from './models/converter/customerAddress.converter';
import { CustomerAddressDatabaseRepository } from './database/customerAddress.database.repository';
import { BrandHiboutikService } from './services/hiboutik/brand.hiboutik.service'; import { BrandHiboutikService } from './services/hiboutik/brand.hiboutik.service';
import { CategoryHiboutikService } from './services/hiboutik/category.hiboutik.service'; import { CategoryHiboutikService } from './services/hiboutik/category.hiboutik.service';
import { CustomerHiboutikService } from './services/hiboutik/customer.hiboutik.service'; import { CustomerHiboutikService } from './services/hiboutik/customer.hiboutik.service';
@@ -46,6 +48,8 @@ import { ProductHiboutikService } from './services/hiboutik/product.hiboutik.ser
CategoryDatabaseRepository, CategoryDatabaseRepository,
CategoryHiboutikRepository, CategoryHiboutikRepository,
CategoryHiboutikService, CategoryHiboutikService,
CustomerAddressConverter,
CustomerAddressDatabaseRepository,
CustomerHiboutikRepository, CustomerHiboutikRepository,
CustomerDatabaseRepository, CustomerDatabaseRepository,
CustomerConverter, CustomerConverter,

View File

@@ -0,0 +1,27 @@
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../../prisma/prisma.service';
import { CustomerAddress } from '../../prisma/generated/client';
import { CustomerAddressCreateInput } from '../../prisma/generated/models/CustomerAddress';
/** Database access to customer addresses. Rows are matched with Hiboutik by `hiboutikId`. */
@Injectable()
export class CustomerAddressDatabaseRepository {
constructor(private readonly prisma: PrismaService) {}
/** Creates or updates the address with the same `hiboutikId`. */
async upsert(address: CustomerAddressCreateInput): Promise<CustomerAddress> {
return this.prisma.customerAddress.upsert({
where: { hiboutikId: address.hiboutikId },
create: address,
update: address,
});
}
/** Deletes the addresses of a customer that no longer exist in Hiboutik (all of them if `keptHiboutikIds` is empty). */
async deleteMissing(customerId: string, keptHiboutikIds: number[]): Promise<number> {
const result = await this.prisma.customerAddress.deleteMany({
where: { customerId, hiboutikId: { notIn: keptHiboutikIds } },
});
return result.count;
}
}

View File

@@ -0,0 +1,59 @@
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;
describe('CustomerAddressConverter', () => {
const converter = new CustomerAddressConverter();
it('maps a Hiboutik address', () => {
expect(converter.fromHiboutik(hiboutikAddress())).toEqual({
address: '1 rue de la Paix',
city: 'Paris',
company: 'ACME',
country: 'FRA',
firstName: 'Jane',
hiboutikId: 7,
isDefault: true,
lastName: 'Doe',
other: 'Bât. B',
phone: '0600000000',
state: null,
zipCode: '75002',
customer: { connect: { hiboutikId: 42 } },
});
});
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 }),
);
expect(address.company).toBeNull();
expect(address.phone).toBeNull();
expect(address.other).toBeNull();
expect(address.firstName).toBe('');
});
});

View File

@@ -0,0 +1,32 @@
import { Injectable } from '@nestjs/common';
import { Converter } from './converter';
import { CustomerAddressCreateInput } from '../../../prisma/generated/models/CustomerAddress';
import { CustomerAddressHiboutikDto } from '../hiboutikDTO/customer.hiboutik.dto';
/** Trimmed value, `null` when empty so that a value removed in Hiboutik is also removed in database. */
const optional = (value: string | null | undefined): string | null => value?.trim() || null;
const required = (value: string | null | undefined): string => value?.trim() ?? '';
@Injectable()
export class CustomerAddressConverter extends Converter<CustomerAddressCreateInput, CustomerAddressHiboutikDto> {
fromHiboutik(dto: CustomerAddressHiboutikDto): CustomerAddressCreateInput {
return {
address: required(dto.address),
city: required(dto.city),
company: optional(dto.company),
country: required(dto.country),
firstName: required(dto.first_name),
hiboutikId: Number(dto.address_id),
isDefault: Number(dto.default) === 1,
lastName: required(dto.last_name),
other: optional(dto.other),
phone: optional(dto.phone),
state: optional(dto.state),
zipCode: required(dto.zip_code),
customer: { connect: { hiboutikId: Number(dto.customers_id) } },
};
}
toHiboutik(): CustomerAddressHiboutikDto {
throw new Error('Method not implemented.');
}
}

View File

@@ -5,6 +5,8 @@ describe('CustomerHiboutikService', () => {
const customerHiboutikRepository = { getCustomers: jest.fn(), getCustomerById: jest.fn() }; const customerHiboutikRepository = { getCustomers: jest.fn(), getCustomerById: jest.fn() };
const customerDatabaseRepository = { upsert: jest.fn(), deleteMissing: jest.fn() }; const customerDatabaseRepository = { upsert: jest.fn(), deleteMissing: jest.fn() };
const customerConverter = { fromHiboutik: jest.fn() }; const customerConverter = { fromHiboutik: jest.fn() };
const customerAddressDatabaseRepository = { upsert: jest.fn(), deleteMissing: jest.fn() };
const customerAddressConverter = { fromHiboutik: jest.fn((dto: unknown) => dto) };
let service: CustomerHiboutikService; let service: CustomerHiboutikService;
beforeAll(() => { beforeAll(() => {
@@ -16,12 +18,16 @@ describe('CustomerHiboutikService', () => {
beforeEach(() => { beforeEach(() => {
jest.clearAllMocks(); jest.clearAllMocks();
customerHiboutikRepository.getCustomers.mockResolvedValue([]); customerHiboutikRepository.getCustomers.mockResolvedValue([]);
customerHiboutikRepository.getCustomerById.mockResolvedValue({}); customerHiboutikRepository.getCustomerById.mockResolvedValue({ customers_id: 1, addresses: [] });
customerDatabaseRepository.upsert.mockResolvedValue({ id: 'customer-uuid' });
customerDatabaseRepository.deleteMissing.mockResolvedValue(0); customerDatabaseRepository.deleteMissing.mockResolvedValue(0);
customerAddressDatabaseRepository.deleteMissing.mockResolvedValue(0);
service = new CustomerHiboutikService( service = new CustomerHiboutikService(
customerHiboutikRepository as never, customerHiboutikRepository as never,
customerDatabaseRepository as never, customerDatabaseRepository as never,
customerConverter as never, customerConverter as never,
customerAddressDatabaseRepository as never,
customerAddressConverter as never,
); );
}); });
@@ -48,4 +54,57 @@ describe('CustomerHiboutikService', () => {
expect(customerDatabaseRepository.upsert).toHaveBeenCalledTimes(1); expect(customerDatabaseRepository.upsert).toHaveBeenCalledTimes(1);
}); });
describe('addresses', () => {
beforeEach(() => {
customerHiboutikRepository.getCustomers.mockResolvedValue([{ customers_id: 1 }]);
});
it('deletes the missing addresses of the customer, then upserts the others linked to it', async () => {
customerHiboutikRepository.getCustomerById.mockResolvedValue({
customers_id: 1,
addresses: [
{ address_id: 10, customers_id: 1 },
{ address_id: '11', customers_id: 999 },
],
});
await service.syncCustomers();
expect(customerAddressDatabaseRepository.deleteMissing).toHaveBeenCalledWith('customer-uuid', [10, 11]);
expect(customerAddressDatabaseRepository.upsert).toHaveBeenCalledTimes(2);
expect(customerAddressConverter.fromHiboutik).toHaveBeenLastCalledWith({ address_id: '11', customers_id: 1 });
expect(customerAddressDatabaseRepository.deleteMissing.mock.invocationCallOrder[0]).toBeLessThan(
customerAddressDatabaseRepository.upsert.mock.invocationCallOrder[0],
);
});
it('deletes all the addresses of a customer that has none left', async () => {
await service.syncCustomers();
expect(customerAddressDatabaseRepository.deleteMissing).toHaveBeenCalledWith('customer-uuid', []);
expect(customerAddressDatabaseRepository.upsert).not.toHaveBeenCalled();
});
it('keeps the stored addresses when the payload has no addresses array', async () => {
customerHiboutikRepository.getCustomerById.mockResolvedValue({ customers_id: 1 });
await service.syncCustomers();
expect(customerAddressDatabaseRepository.deleteMissing).not.toHaveBeenCalled();
expect(customerAddressDatabaseRepository.upsert).not.toHaveBeenCalled();
});
it('keeps syncing the other addresses when one fails', async () => {
customerHiboutikRepository.getCustomerById.mockResolvedValue({
customers_id: 1,
addresses: [{ address_id: 10 }, { address_id: 11 }],
});
customerAddressDatabaseRepository.upsert.mockRejectedValueOnce(new Error('boom'));
await service.syncCustomers();
expect(customerAddressDatabaseRepository.upsert).toHaveBeenCalledTimes(2);
});
});
}); });

View File

@@ -2,6 +2,10 @@ import { Injectable, Logger } from '@nestjs/common';
import { CustomerHiboutikRepository } from '../../hiboutik/customer.hiboutik.repository'; import { CustomerHiboutikRepository } from '../../hiboutik/customer.hiboutik.repository';
import { CustomerDatabaseRepository } from '../../database/customer.database.repository'; import { CustomerDatabaseRepository } from '../../database/customer.database.repository';
import { CustomerConverter } from '../../models/converter/customer.converter'; import { CustomerConverter } from '../../models/converter/customer.converter';
import { CustomerAddressDatabaseRepository } from '../../database/customerAddress.database.repository';
import { CustomerAddressConverter } from '../../models/converter/customerAddress.converter';
import { FullCustomerHiboutikDto } from '../../models/hiboutikDTO/customer.hiboutik.dto';
import { forEachSafely } from '../../utils/sync';
@Injectable() @Injectable()
export class CustomerHiboutikService { export class CustomerHiboutikService {
@@ -11,11 +15,13 @@ export class CustomerHiboutikService {
private readonly customerHiboutikRepository: CustomerHiboutikRepository, private readonly customerHiboutikRepository: CustomerHiboutikRepository,
private readonly customerDatabaseRepository: CustomerDatabaseRepository, private readonly customerDatabaseRepository: CustomerDatabaseRepository,
private readonly customerConverter: CustomerConverter, private readonly customerConverter: CustomerConverter,
private readonly customerAddressDatabaseRepository: CustomerAddressDatabaseRepository,
private readonly customerAddressConverter: CustomerAddressConverter,
) {} ) {}
/** /**
* Mirrors Hiboutik customers: deletes the customers missing from Hiboutik, then fetches and upserts each of them * Mirrors Hiboutik customers: deletes the customers missing from Hiboutik, then fetches and upserts each of them
* (one API call per customer, as the list endpoint lacks addresses and sales). * with its addresses (one API call per customer, as the list endpoint lacks addresses and sales).
*/ */
async syncCustomers() { async syncCustomers() {
const hiboutikCustomers = await this.customerHiboutikRepository.getCustomers(); const hiboutikCustomers = await this.customerHiboutikRepository.getCustomers();
@@ -29,12 +35,12 @@ export class CustomerHiboutikService {
} }
let nbFailed = 0; let nbFailed = 0;
let nbFailedAddresses = 0;
for (const hiboutikCustomer of hiboutikCustomers) { for (const hiboutikCustomer of hiboutikCustomers) {
try { try {
const hiboutikFullCustomer = await this.customerHiboutikRepository.getCustomerById(hiboutikCustomer.customers_id); const hiboutikFullCustomer = await this.customerHiboutikRepository.getCustomerById(hiboutikCustomer.customers_id);
await this.customerDatabaseRepository.upsert(this.customerConverter.fromHiboutik(hiboutikFullCustomer)); const customer = await this.customerDatabaseRepository.upsert(this.customerConverter.fromHiboutik(hiboutikFullCustomer));
// retreive addresses DTOs nbFailedAddresses += await this.syncAddresses(customer.id, hiboutikFullCustomer);
// save addresses
// retreive orders DTOs // retreive orders DTOs
// save orders // save orders
} catch (error) { } catch (error) {
@@ -43,7 +49,35 @@ export class CustomerHiboutikService {
} }
} }
this.logger.log( this.logger.log(
`Customers sync ended: ${hiboutikCustomers.length - nbFailed}/${hiboutikCustomers.length} synced, ${nbFailed} failed, ${nbDeleted} deleted.`, `Customers sync ended: ${hiboutikCustomers.length - nbFailed}/${hiboutikCustomers.length} synced, ${nbFailed} failed, ${nbDeleted} deleted, ${nbFailedAddresses} addresses failed.`,
);
}
/**
* Mirrors the addresses embedded in a full Hiboutik customer: deletes the ones missing from Hiboutik, then upserts
* each of them (a failing address is logged and skipped).
* @returns the number of failed addresses.
*/
private async syncAddresses(customerId: string, hiboutikCustomer: FullCustomerHiboutikDto): Promise<number> {
// A missing array means an unexpected payload, not a customer without addresses: keep the stored ones
if (!Array.isArray(hiboutikCustomer.addresses)) {
this.logger.warn(`Hiboutik customer ${hiboutikCustomer.customers_id} has no addresses array: addresses sync skipped.`);
return 0;
}
await this.customerAddressDatabaseRepository.deleteMissing(
customerId,
hiboutikCustomer.addresses.map((dto) => Number(dto.address_id)),
);
return forEachSafely(
hiboutikCustomer.addresses,
// Linked to the parent customer, whatever the embedded customers_id says
(dto) =>
this.customerAddressDatabaseRepository.upsert(
this.customerAddressConverter.fromHiboutik({ ...dto, customers_id: hiboutikCustomer.customers_id }),
),
this.logger,
(dto) => `Hiboutik address ${dto.address_id} of customer ${hiboutikCustomer.customers_id}`,
); );
} }
} }