diff --git a/prisma/schema.prisma b/prisma/schema.prisma index a682abd..1decaeb 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -146,16 +146,23 @@ model Customer { @@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 { id String @id @default(uuid()) updatedAt DateTime @updatedAt address String city String + company String? country String + firstName String hiboutikId Int @unique isDefault Boolean @default(false) + lastName String + /// Free complement (building, floor, door code...) + other String? + phone String? + state String? zipCode String customer Customer @relation(fields: [customerId], references: [id], onDelete: Cascade) diff --git a/src/app.module.ts b/src/app.module.ts index bb44aee..84bca7f 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -22,6 +22,8 @@ import { ProductVersionDatabaseRepository } from './database/productVersion.data import { CustomerHiboutikRepository } from './hiboutik/customer.hiboutik.repository'; import { CustomerDatabaseRepository } from './database/customer.database.repository'; 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 { CategoryHiboutikService } from './services/hiboutik/category.hiboutik.service'; import { CustomerHiboutikService } from './services/hiboutik/customer.hiboutik.service'; @@ -46,6 +48,8 @@ import { ProductHiboutikService } from './services/hiboutik/product.hiboutik.ser CategoryDatabaseRepository, CategoryHiboutikRepository, CategoryHiboutikService, + CustomerAddressConverter, + CustomerAddressDatabaseRepository, CustomerHiboutikRepository, CustomerDatabaseRepository, CustomerConverter, diff --git a/src/database/customerAddress.database.repository.ts b/src/database/customerAddress.database.repository.ts new file mode 100644 index 0000000..c5308fc --- /dev/null +++ b/src/database/customerAddress.database.repository.ts @@ -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 { + 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 { + const result = await this.prisma.customerAddress.deleteMany({ + where: { customerId, hiboutikId: { notIn: keptHiboutikIds } }, + }); + return result.count; + } +} diff --git a/src/models/converter/customerAddress.converter.spec.ts b/src/models/converter/customerAddress.converter.spec.ts new file mode 100644 index 0000000..75aa57b --- /dev/null +++ b/src/models/converter/customerAddress.converter.spec.ts @@ -0,0 +1,59 @@ +import { CustomerAddressConverter } from './customerAddress.converter'; +import { CustomerAddressHiboutikDto } from '../hiboutikDTO/customer.hiboutik.dto'; + +const hiboutikAddress = (overrides: Partial = {}): 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(''); + }); +}); diff --git a/src/models/converter/customerAddress.converter.ts b/src/models/converter/customerAddress.converter.ts new file mode 100644 index 0000000..dcd52c9 --- /dev/null +++ b/src/models/converter/customerAddress.converter.ts @@ -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 { + 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.'); + } +} diff --git a/src/services/hiboutik/customer.hiboutik.service.spec.ts b/src/services/hiboutik/customer.hiboutik.service.spec.ts index b15648a..c1508b9 100644 --- a/src/services/hiboutik/customer.hiboutik.service.spec.ts +++ b/src/services/hiboutik/customer.hiboutik.service.spec.ts @@ -5,6 +5,8 @@ describe('CustomerHiboutikService', () => { const customerHiboutikRepository = { getCustomers: jest.fn(), getCustomerById: jest.fn() }; const customerDatabaseRepository = { upsert: jest.fn(), deleteMissing: 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; beforeAll(() => { @@ -16,12 +18,16 @@ describe('CustomerHiboutikService', () => { beforeEach(() => { jest.clearAllMocks(); customerHiboutikRepository.getCustomers.mockResolvedValue([]); - customerHiboutikRepository.getCustomerById.mockResolvedValue({}); + customerHiboutikRepository.getCustomerById.mockResolvedValue({ customers_id: 1, addresses: [] }); + customerDatabaseRepository.upsert.mockResolvedValue({ id: 'customer-uuid' }); customerDatabaseRepository.deleteMissing.mockResolvedValue(0); + customerAddressDatabaseRepository.deleteMissing.mockResolvedValue(0); service = new CustomerHiboutikService( customerHiboutikRepository as never, customerDatabaseRepository as never, customerConverter as never, + customerAddressDatabaseRepository as never, + customerAddressConverter as never, ); }); @@ -48,4 +54,57 @@ describe('CustomerHiboutikService', () => { 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); + }); + }); }); diff --git a/src/services/hiboutik/customer.hiboutik.service.ts b/src/services/hiboutik/customer.hiboutik.service.ts index 90c19a9..909183e 100644 --- a/src/services/hiboutik/customer.hiboutik.service.ts +++ b/src/services/hiboutik/customer.hiboutik.service.ts @@ -2,6 +2,10 @@ import { Injectable, Logger } from '@nestjs/common'; import { CustomerHiboutikRepository } from '../../hiboutik/customer.hiboutik.repository'; import { CustomerDatabaseRepository } from '../../database/customer.database.repository'; 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() export class CustomerHiboutikService { @@ -11,11 +15,13 @@ export class CustomerHiboutikService { private readonly customerHiboutikRepository: CustomerHiboutikRepository, private readonly customerDatabaseRepository: CustomerDatabaseRepository, 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 - * (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() { const hiboutikCustomers = await this.customerHiboutikRepository.getCustomers(); @@ -29,12 +35,12 @@ export class CustomerHiboutikService { } let nbFailed = 0; + let nbFailedAddresses = 0; for (const hiboutikCustomer of hiboutikCustomers) { try { const hiboutikFullCustomer = await this.customerHiboutikRepository.getCustomerById(hiboutikCustomer.customers_id); - await this.customerDatabaseRepository.upsert(this.customerConverter.fromHiboutik(hiboutikFullCustomer)); - // retreive addresses DTOs - // save addresses + const customer = await this.customerDatabaseRepository.upsert(this.customerConverter.fromHiboutik(hiboutikFullCustomer)); + nbFailedAddresses += await this.syncAddresses(customer.id, hiboutikFullCustomer); // retreive orders DTOs // save orders } catch (error) { @@ -43,7 +49,35 @@ export class CustomerHiboutikService { } } 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 { + // 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}`, ); } }