feat(sync): synchronize customer addresses
Mirror the addresses embedded in GET /customer/🆔 per customer, delete the ones missing from Hiboutik, then upsert each of them (failures logged and skipped). CustomerAddress gains its own recipient (firstName, lastName, company, phone) plus state and other. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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)
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
27
src/database/customerAddress.database.repository.ts
Normal file
27
src/database/customerAddress.database.repository.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
59
src/models/converter/customerAddress.converter.spec.ts
Normal file
59
src/models/converter/customerAddress.converter.spec.ts
Normal 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('');
|
||||||
|
});
|
||||||
|
});
|
||||||
32
src/models/converter/customerAddress.converter.ts
Normal file
32
src/models/converter/customerAddress.converter.ts
Normal 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.');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user