From 35a7c27cb750df2de4fd950baaaea8826c91e518 Mon Sep 17 00:00:00 2001 From: MastersGame Date: Sun, 8 Nov 2026 12:20:58 +0100 Subject: [PATCH] feat(sync): validate Hiboutik payloads, read French dates and guard deletions - Hiboutik payloads are zod schemas, validated by HiboutikClient (getOne added for single resources: null on 404 or empty answer). - Hiboutik dates are read as Europe/Paris whatever the server time zone. - Paginated lists driving deletions are walked twice when they span several pages (brands, categories, products); categories are sorted by id. - A customer missing from the list is deleted only once Hiboutik confirms it; completed orders that lost their customer are linked back. - Each product and its versions are written in one transaction. - An email removed in Hiboutik is erased in database. - A 429 is retried with backoff, then stops the synchronization. Co-Authored-By: Claude Opus 5.5 --- src/database/customer.database.repository.ts | 13 +- src/database/order.database.repository.ts | 13 +- src/database/product.database.repository.ts | 11 +- .../productVersion.database.repository.ts | 20 +- src/hiboutik/brand.hiboutik.repository.ts | 5 +- src/hiboutik/category.hiboutik.repository.ts | 11 +- src/hiboutik/customer.hiboutik.repository.ts | 31 +++- src/hiboutik/hiboutik.client.spec.ts | 172 ++++++++++++++++-- src/hiboutik/hiboutik.client.ts | 139 ++++++++++++-- src/hiboutik/product.hiboutik.repository.ts | 9 +- src/hiboutik/sale.hiboutik.repository.ts | 7 +- src/hiboutik/stock.hiboutik.repository.ts | 10 +- src/hiboutik/supplier.hiboutik.repository.ts | 4 +- src/models/converter/brand.converter.ts | 2 +- src/models/converter/category.converter.ts | 6 +- .../converter/customer.converter.spec.ts | 32 ++-- src/models/converter/customer.converter.ts | 6 +- .../customerAddress.converter.spec.ts | 44 ++--- .../converter/customerAddress.converter.ts | 6 +- src/models/converter/order.converter.spec.ts | 30 ++- src/models/converter/order.converter.ts | 17 +- .../converter/product.converter.spec.ts | 4 +- src/models/converter/product.converter.ts | 4 +- src/models/converter/supplier.converter.ts | 2 +- src/models/hiboutikDTO/brand.hiboutik.dto.ts | 17 +- .../hiboutikDTO/category.hiboutik.dto.ts | 28 +-- .../hiboutikDTO/customer.hiboutik.dto.ts | 140 ++++++-------- .../hiboutikDTO/hiboutik.schema.spec.ts | 87 +++++++++ src/models/hiboutikDTO/hiboutik.schema.ts | 58 ++++++ .../hiboutikDTO/product.hiboutik.dto.ts | 89 ++++----- src/models/hiboutikDTO/sale.hiboutik.dto.ts | 71 ++++---- src/models/hiboutikDTO/stock.hiboutik.dto.ts | 19 +- .../hiboutikDTO/supplier.hiboutik.dto.ts | 17 +- .../hiboutik/brand.hiboutik.service.ts | 2 +- .../category.hiboutik.service.spec.ts | 19 +- .../hiboutik/category.hiboutik.service.ts | 4 +- .../customer.hiboutik.service.spec.ts | 90 ++++++--- .../hiboutik/customer.hiboutik.service.ts | 62 +++++-- .../hiboutik/product.hiboutik.service.spec.ts | 84 ++++++--- .../hiboutik/product.hiboutik.service.ts | 59 ++++-- .../hiboutik/supplier.hiboutik.service.ts | 2 +- src/utils/date.spec.ts | 30 ++- src/utils/date.ts | 53 +++++- src/utils/sync.ts | 3 + 44 files changed, 1065 insertions(+), 467 deletions(-) create mode 100644 src/models/hiboutikDTO/hiboutik.schema.spec.ts create mode 100644 src/models/hiboutikDTO/hiboutik.schema.ts diff --git a/src/database/customer.database.repository.ts b/src/database/customer.database.repository.ts index 8529b78..f96f512 100644 --- a/src/database/customer.database.repository.ts +++ b/src/database/customer.database.repository.ts @@ -23,13 +23,20 @@ export class CustomerDatabaseRepository { }); } + async getAllHiboutikIds(): Promise { + const customers = await this.prisma.customer.findMany({ select: { hiboutikId: true } }); + return customers.map((customer) => customer.hiboutikId); + } + /** - * Deletes the customers that no longer exist in Hiboutik. + * Deletes customers that no longer exist in Hiboutik. * Their addresses are deleted (onDelete: Cascade), their orders are kept with no customer (onDelete: SetNull). */ - async deleteMissing(keptHiboutikIds: number[]): Promise { + async deleteByHiboutikIds(hiboutikIds: number[]): Promise { + if (hiboutikIds.length === 0) return 0; + const result = await this.prisma.customer.deleteMany({ - where: { hiboutikId: { notIn: keptHiboutikIds } }, + where: { hiboutikId: { in: hiboutikIds } }, }); return result.count; } diff --git a/src/database/order.database.repository.ts b/src/database/order.database.repository.ts index 3222cc0..d215617 100644 --- a/src/database/order.database.repository.ts +++ b/src/database/order.database.repository.ts @@ -9,12 +9,19 @@ import { OrderStatus } from '../models/orderStatus'; export class OrderDatabaseRepository { constructor(private readonly prisma: PrismaService) {} - /** Among `hiboutikSaleIds`, the ones already stored as completed orders. */ - async getCompletedSaleIds(hiboutikSaleIds: number[]): Promise> { + /** + * Among `hiboutikSaleIds`, the ones already stored as completed orders of this customer. A completed order that lost + * its customer (customer deleted, then found again in Hiboutik) is not returned, so that the sync links it back. + */ + async getCompletedSaleIds(customerHiboutikId: number, hiboutikSaleIds: number[]): Promise> { if (hiboutikSaleIds.length === 0) return new Set(); const orders = await this.prisma.order.findMany({ - where: { hiboutikSaleId: { in: hiboutikSaleIds }, status: OrderStatus.COMPLETED }, + where: { + hiboutikSaleId: { in: hiboutikSaleIds }, + status: OrderStatus.COMPLETED, + customer: { is: { hiboutikId: customerHiboutikId } }, + }, select: { hiboutikSaleId: true }, }); return new Set(orders.map((order) => order.hiboutikSaleId).filter((id): id is number => id !== null)); diff --git a/src/database/product.database.repository.ts b/src/database/product.database.repository.ts index f0f6783..bd89dcf 100644 --- a/src/database/product.database.repository.ts +++ b/src/database/product.database.repository.ts @@ -1,6 +1,6 @@ import { Injectable } from '@nestjs/common'; import { PrismaService } from '../../prisma/prisma.service'; -import { Product } from '../../prisma/generated/client'; +import { Prisma, Product } from '../../prisma/generated/client'; import { ProductCreateInput } from '../../prisma/generated/models/Product'; /** Database access to products. Rows are matched with Hiboutik by `hiboutikId`. */ @@ -14,9 +14,12 @@ export class ProductDatabaseRepository { }); } - /** Creates or updates the product with the same `hiboutikId`. On update, the relations missing from the input are unlinked. */ - async upsert(product: ProductCreateInput): Promise { - return this.prisma.product.upsert({ + /** + * Creates or updates the product with the same `hiboutikId`. On update, the relations missing from the input are unlinked. + * @param db transaction client, to run it inside a transaction. + */ + async upsert(product: ProductCreateInput, db: Prisma.TransactionClient = this.prisma): Promise { + return db.product.upsert({ where: { hiboutikId: product.hiboutikId }, create: product, update: { diff --git a/src/database/productVersion.database.repository.ts b/src/database/productVersion.database.repository.ts index c9efa52..3e7df96 100644 --- a/src/database/productVersion.database.repository.ts +++ b/src/database/productVersion.database.repository.ts @@ -1,6 +1,6 @@ import { Injectable } from '@nestjs/common'; import { PrismaService } from '../../prisma/prisma.service'; -import { ProductVersion } from '../../prisma/generated/client'; +import { Prisma, ProductVersion } from '../../prisma/generated/client'; import { ProductVersionCreateInput } from '../../prisma/generated/models/ProductVersion'; /** Database access to product versions (one per Hiboutik size), identified by their product and `hiboutikSizeId`. */ @@ -14,9 +14,16 @@ export class ProductVersionDatabaseRepository { }); } - /** Creates or updates the version of `productId` with the same `hiboutikSizeId`. */ - async upsert(productId: string, productVersion: ProductVersionCreateInput): Promise { - return this.prisma.productVersion.upsert({ + /** + * Creates or updates the version of `productId` with the same `hiboutikSizeId`. + * @param db transaction client, to run it inside a transaction. + */ + async upsert( + productId: string, + productVersion: ProductVersionCreateInput, + db: Prisma.TransactionClient = this.prisma, + ): Promise { + return db.productVersion.upsert({ where: { productId_hiboutikSizeId: { productId: productId, hiboutikSizeId: productVersion.hiboutikSizeId } }, create: productVersion, update: productVersion, @@ -26,9 +33,10 @@ export class ProductVersionDatabaseRepository { /** * Deletes the versions of a product that no longer exist in Hiboutik. * Order lines keep their own snapshot of the product, their link is set to null. + * @param db transaction client, to run it inside a transaction. */ - async deleteMissing(productId: string, keptHiboutikSizeIds: number[]): Promise { - const result = await this.prisma.productVersion.deleteMany({ + async deleteMissing(productId: string, keptHiboutikSizeIds: number[], db: Prisma.TransactionClient = this.prisma): Promise { + const result = await db.productVersion.deleteMany({ where: { productId, hiboutikSizeId: { notIn: keptHiboutikSizeIds } }, }); return result.count; diff --git a/src/hiboutik/brand.hiboutik.repository.ts b/src/hiboutik/brand.hiboutik.repository.ts index dca990f..083ba3e 100644 --- a/src/hiboutik/brand.hiboutik.repository.ts +++ b/src/hiboutik/brand.hiboutik.repository.ts @@ -1,14 +1,15 @@ import { Injectable } from '@nestjs/common'; import { HiboutikClient } from './hiboutik.client'; -import { BrandHiboutikDto } from '../models/hiboutikDTO/brand.hiboutik.dto'; +import { BrandHiboutikDto, brandHiboutikSchema } from '../models/hiboutikDTO/brand.hiboutik.dto'; /** Reads brands from the Hiboutik API. */ @Injectable() export class BrandHiboutikRepository { constructor(private readonly hiboutikClient: HiboutikClient) {} + /** Walked twice when it spans several pages: it drives the deletion of brands (see `getAllPagesForSync`). */ async getBrands(): Promise { - return this.hiboutikClient.getAllPages('/brands', { + return this.hiboutikClient.getAllPagesForSync('/brands', brandHiboutikSchema, (brand) => brand.brand_id, { order_by: 'brand_id', sort: 'ASC', }); diff --git a/src/hiboutik/category.hiboutik.repository.ts b/src/hiboutik/category.hiboutik.repository.ts index 43ef168..0acdefb 100644 --- a/src/hiboutik/category.hiboutik.repository.ts +++ b/src/hiboutik/category.hiboutik.repository.ts @@ -1,5 +1,5 @@ import { Injectable } from '@nestjs/common'; -import { CategoryHiboutikDto } from '../models/hiboutikDTO/category.hiboutik.dto'; +import { CategoryHiboutikDto, categoryHiboutikSchema } from '../models/hiboutikDTO/category.hiboutik.dto'; import { HiboutikClient } from './hiboutik.client'; /** Reads categories from the Hiboutik API. */ @@ -7,10 +7,13 @@ import { HiboutikClient } from './hiboutik.client'; export class CategoryHiboutikRepository { constructor(private readonly hiboutikClient: HiboutikClient) {} - /** Sorted by parent id, which does not guarantee that a parent comes before its children. */ + /** + * Sorted by id (unique, so that the pages are stable), a parent may come after its children. Walked twice when it + * spans several pages: it drives the deletion of categories (see `getAllPagesForSync`). + */ async getCategories(): Promise { - return this.hiboutikClient.getAllPages('/categories', { - order_by: 'category_id_parent', + return this.hiboutikClient.getAllPagesForSync('/categories', categoryHiboutikSchema, (category) => category.category_id, { + order_by: 'category_id', sort: 'ASC', }); } diff --git a/src/hiboutik/customer.hiboutik.repository.ts b/src/hiboutik/customer.hiboutik.repository.ts index 50179dd..2c7f983 100644 --- a/src/hiboutik/customer.hiboutik.repository.ts +++ b/src/hiboutik/customer.hiboutik.repository.ts @@ -2,8 +2,11 @@ import { Injectable } from '@nestjs/common'; import { HiboutikClient } from './hiboutik.client'; import { CustomerAddressDetailsHiboutikDto, + customerAddressDetailsHiboutikSchema, CustomerHiboutikDto, + customerHiboutikSchema, FullCustomerHiboutikDto, + fullCustomerHiboutikSchema, } from '../models/hiboutikDTO/customer.hiboutik.dto'; /** Reads customers from the Hiboutik API. */ @@ -11,29 +14,39 @@ import { export class CustomerHiboutikRepository { constructor(private readonly hiboutikClient: HiboutikClient) {} - /** Customer summaries only: use {@link getCustomerById} for addresses and sales. */ + /** + * Customer summaries only: use {@link getCustomerById} for addresses and sales. Sorted by id so that the pages are + * stable, but a customer created or deleted while they are walked can still shift the following ones. + */ async getCustomers(): Promise { - return this.hiboutikClient.getAllPages('/customers'); + return this.hiboutikClient.getAllPages('/customers', customerHiboutikSchema, { + order_by: 'customers_id', + sort: 'ASC', + }); } /** - * Full customer, with its addresses and sales (Hiboutik answers with a one-element array). - * @throws if the customer does not exist. + * Full customer, with its addresses and sales. + * @returns `null` when Hiboutik says the customer does not exist (404 or empty answer). + * @throws on any other failure: an unknown state is never taken for a deleted customer. */ + async findCustomerById(id: number): Promise { + return this.hiboutikClient.getOne(`/customer/${id}`, fullCustomerHiboutikSchema); + } + + /** Same as {@link findCustomerById}, but a missing customer throws. */ async getCustomerById(id: number): Promise { - const data = await this.hiboutikClient.get('/customer/' + String(id)); - const customer = data[0]; + const customer = await this.findCustomerById(id); if (!customer) throw new Error(`Hiboutik customer ${id} not found`); return customer; } /** - * Address with its company details (Hiboutik answers with a one-element array). + * Address with its company details. * @throws if the address does not exist. */ async getAddressById(id: number): Promise { - const data = await this.hiboutikClient.get('/customers_addresses/' + String(id)); - const address = data[0]; + const address = await this.hiboutikClient.getOne(`/customers_addresses/${id}`, customerAddressDetailsHiboutikSchema); if (!address) throw new Error(`Hiboutik address ${id} not found`); return address; } diff --git a/src/hiboutik/hiboutik.client.spec.ts b/src/hiboutik/hiboutik.client.spec.ts index 2b204ce..28afdb0 100644 --- a/src/hiboutik/hiboutik.client.spec.ts +++ b/src/hiboutik/hiboutik.client.spec.ts @@ -1,12 +1,26 @@ +import { AxiosError, AxiosHeaders } from 'axios'; import { of, throwError } from 'rxjs'; -import { HiboutikClient } from './hiboutik.client'; +import { z } from 'zod'; +import { HiboutikClient, HiboutikRateLimitError } from './hiboutik.client'; describe('HiboutikClient', () => { const httpService = { get: jest.fn() }; const configService = { get: jest.fn((key: string) => (key === 'HIBOUTIK_BASE_URL' ? 'https://shop.hiboutik.test/api' : 'x')) }; + const schema = z.object({ id: z.coerce.number() }); let client: HiboutikClient; const answer = (data: unknown) => of({ data }); + const httpError = (status: number) => + throwError( + () => + new AxiosError(`Request failed with status code ${status}`, undefined, undefined, undefined, { + status, + statusText: '', + headers: {}, + config: { headers: new AxiosHeaders() }, + data: null, + }), + ); const page = (size: number) => Array.from({ length: size }, (_, index) => ({ id: index })); beforeEach(() => { @@ -18,30 +32,42 @@ describe('HiboutikClient', () => { it('returns the list of a 2xx answer, empty or not', async () => { httpService.get.mockReturnValueOnce(answer([{ id: 1 }])).mockReturnValueOnce(answer([])); - await expect(client.getList('/x')).resolves.toEqual([{ id: 1 }]); - await expect(client.getList('/x')).resolves.toEqual([]); + await expect(client.getList('/x', schema)).resolves.toEqual([{ id: 1 }]); + await expect(client.getList('/x', schema)).resolves.toEqual([]); }); it('treats a 2xx answer without body as an empty list', async () => { httpService.get.mockReturnValueOnce(answer('')).mockReturnValueOnce(answer(null)); - await expect(client.getList('/x')).resolves.toEqual([]); - await expect(client.getList('/x')).resolves.toEqual([]); + await expect(client.getList('/x', schema)).resolves.toEqual([]); + await expect(client.getList('/x', schema)).resolves.toEqual([]); }); it('rejects a 2xx answer that is not a list, without leaking its content', async () => { httpService.get.mockReturnValueOnce(answer({ error: 'secret details' })); - const result = client.getList('/x'); + const result = client.getList('/x', schema); await expect(result).rejects.toThrow('a list was expected'); await expect(result).rejects.not.toThrow('secret details'); }); - it('lets HTTP errors through', async () => { - httpService.get.mockReturnValueOnce(throwError(() => new Error('Request failed with status code 500'))); + it('validates and normalizes each item', async () => { + httpService.get.mockReturnValueOnce(answer([{ id: '1', extra: true }])); - await expect(client.getList('/x')).rejects.toThrow('500'); + await expect(client.getList('/x', schema)).resolves.toEqual([{ id: 1 }]); + }); + + it('rejects the whole list when an item is invalid, so that nothing is deleted', async () => { + httpService.get.mockReturnValueOnce(answer([{ id: 1 }, { id: 'abc' }])); + + await expect(client.getList('/x', schema)).rejects.toThrow('Unexpected answer from Hiboutik /x: invalid 1.id'); + }); + + it('lets HTTP errors through', async () => { + httpService.get.mockReturnValueOnce(httpError(500)); + + await expect(client.getList('/x', schema)).rejects.toThrow('500'); }); }); @@ -49,18 +75,140 @@ describe('HiboutikClient', () => { it('walks the pages until a short one', async () => { httpService.get.mockReturnValueOnce(answer(page(250))).mockReturnValueOnce(answer(page(10))); - const results = await client.getAllPages('/x'); + const results = await client.getAllPages('/x', schema); expect(results).toHaveLength(260); expect(httpService.get).toHaveBeenCalledTimes(2); }); it('fails as a whole when a later page fails, instead of returning a truncated list', async () => { + httpService.get.mockReturnValueOnce(answer(page(250))).mockReturnValueOnce(httpError(502)); + + await expect(client.getAllPages('/x', schema)).rejects.toThrow('502'); + }); + }); + + describe('getAllPagesForSync', () => { + const idOf = (item: { id: number }) => item.id; + const ids = (from: number, to: number) => Array.from({ length: to - from + 1 }, (_, index) => ({ id: from + index })); + + it('walks a list that fits in one page only once', async () => { + httpService.get.mockReturnValueOnce(answer(page(10))); + + await expect(client.getAllPagesForSync('/x', schema, idOf)).resolves.toHaveLength(10); + expect(httpService.get).toHaveBeenCalledTimes(1); + }); + + it('walks a multi-page list twice and keeps an item skipped by a page shift in one walk', async () => { + // First walk: item 10 is deleted right after page 1 is read, so item 250 moves to page 1 and page 2 starts at 251 + httpService.get.mockReturnValueOnce(answer(ids(0, 249))).mockReturnValueOnce(answer(ids(251, 260))); + // Second walk: the list without item 10 + const withoutTen = ids(0, 250).filter(({ id }) => id !== 10); + httpService.get.mockReturnValueOnce(answer(withoutTen)).mockReturnValueOnce(answer(ids(251, 260))); + + const results = await client.getAllPagesForSync('/x', schema, idOf); + + expect(httpService.get).toHaveBeenCalledTimes(4); + // Item 250 is kept (it would otherwise be deleted); item 10 is only deleted at the next sync + expect(results.map(idOf).sort((a, b) => a - b)).toEqual(ids(0, 260).map(idOf)); + }); + + it('fails as a whole when the second walk fails', async () => { httpService.get .mockReturnValueOnce(answer(page(250))) - .mockReturnValueOnce(throwError(() => new Error('Request failed with status code 502'))); + .mockReturnValueOnce(answer(page(10))) + .mockReturnValueOnce(httpError(502)); - await expect(client.getAllPages('/x')).rejects.toThrow('502'); + await expect(client.getAllPagesForSync('/x', schema, idOf)).rejects.toThrow('502'); + }); + }); + + describe('rate limit (429)', () => { + let sleep: jest.SpyInstance; + const tooManyRequests = (retryAfter?: string) => + throwError( + () => + new AxiosError('Request failed with status code 429', undefined, undefined, undefined, { + status: 429, + statusText: '', + headers: retryAfter === undefined ? {} : { 'retry-after': retryAfter }, + config: { headers: new AxiosHeaders() }, + data: null, + }), + ); + + beforeEach(() => { + sleep = jest.spyOn(client as unknown as { sleep: (ms: number) => Promise }, 'sleep').mockResolvedValue(undefined); + }); + + it('retries with exponential backoff, then succeeds', async () => { + httpService.get + .mockReturnValueOnce(tooManyRequests()) + .mockReturnValueOnce(tooManyRequests()) + .mockReturnValueOnce(answer([{ id: 1 }])); + + await expect(client.getList('/x', schema)).resolves.toEqual([{ id: 1 }]); + expect(sleep.mock.calls).toEqual([[2_000], [4_000]]); + }); + + it('waits as asked by Retry-After, capped at 60 s', async () => { + httpService.get + .mockReturnValueOnce(tooManyRequests('5')) + .mockReturnValueOnce(tooManyRequests('3600')) + .mockReturnValueOnce(answer([])); + + await client.getList('/x', schema); + + expect(sleep.mock.calls).toEqual([[5_000], [60_000]]); + }); + + it('gives up with a HiboutikRateLimitError after 3 retries', async () => { + httpService.get.mockReturnValue(tooManyRequests()); + + await expect(client.getList('/x', schema)).rejects.toThrow(HiboutikRateLimitError); + expect(httpService.get).toHaveBeenCalledTimes(4); + }); + + it('does not retry other errors', async () => { + httpService.get.mockReturnValueOnce(httpError(500)); + + await expect(client.getList('/x', schema)).rejects.toThrow('500'); + expect(sleep).not.toHaveBeenCalled(); + }); + + it('does not take a rate limited single resource for a missing one', async () => { + httpService.get.mockReturnValue(tooManyRequests()); + + await expect(client.getOne('/x/7', schema)).rejects.toThrow(HiboutikRateLimitError); + }); + }); + + describe('getOne', () => { + it('returns the single element of the answer', async () => { + httpService.get.mockReturnValueOnce(answer([{ id: '7' }])); + + await expect(client.getOne('/x/7', schema)).resolves.toEqual({ id: 7 }); + }); + + it('returns null when Hiboutik says the resource does not exist (404 or empty answer)', async () => { + httpService.get.mockReturnValueOnce(httpError(404)).mockReturnValueOnce(answer([])).mockReturnValueOnce(answer('')); + + await expect(client.getOne('/x/7', schema)).resolves.toBeNull(); + await expect(client.getOne('/x/7', schema)).resolves.toBeNull(); + await expect(client.getOne('/x/7', schema)).resolves.toBeNull(); + }); + + it('throws on any other failure: an unknown state is never taken for a missing resource', async () => { + httpService.get + .mockReturnValueOnce(httpError(500)) + .mockReturnValueOnce(httpError(401)) + .mockReturnValueOnce(throwError(() => new Error('timeout of 30000ms exceeded'))) + .mockReturnValueOnce(answer({ error: 'x' })); + + await expect(client.getOne('/x/7', schema)).rejects.toThrow('500'); + await expect(client.getOne('/x/7', schema)).rejects.toThrow('401'); + await expect(client.getOne('/x/7', schema)).rejects.toThrow('timeout'); + await expect(client.getOne('/x/7', schema)).rejects.toThrow('a list was expected'); }); }); }); diff --git a/src/hiboutik/hiboutik.client.ts b/src/hiboutik/hiboutik.client.ts index f0bb9d5..368acb6 100644 --- a/src/hiboutik/hiboutik.client.ts +++ b/src/hiboutik/hiboutik.client.ts @@ -1,12 +1,49 @@ import { Injectable } from '@nestjs/common'; import { HttpService } from '@nestjs/axios'; import { ConfigService } from '@nestjs/config'; +import { isAxiosError } from 'axios'; import { firstValueFrom } from 'rxjs'; +import { z } from 'zod'; +import { parseHiboutik } from '../models/hiboutikDTO/hiboutik.schema'; /** Results per page of every paginated Hiboutik endpoint (see "Results per page" in the API docs). */ const HIBOUTIK_PAGE_SIZE = 250; -/** Low-level HTTP client for the Hiboutik REST API: base URL and Basic auth credentials come from the environment. */ +/** Retries of a request answered 429 (too many requests), with exponential backoff: 2 s, 4 s, 8 s. */ +const RATE_LIMIT_RETRIES = 3; +const RATE_LIMIT_BASE_DELAY_MS = 2_000; +/** Cap of the wait asked by a `Retry-After` header: beyond, waiting holds the sync for nothing (monthly quota). */ +const RATE_LIMIT_MAX_DELAY_MS = 60_000; + +/** + * Hiboutik still answers 429 after the retries: the rate limit (30 concurrent calls) or the monthly quota (10,000 + * calls per shop) is reached. The synchronization must stop: every next call would fail the same way. + */ +export class HiboutikRateLimitError extends Error { + constructor(path: string) { + super(`Hiboutik rate limit reached on ${path} (still 429 after ${RATE_LIMIT_RETRIES} retries): monthly quota exhausted?`); + this.name = 'HiboutikRateLimitError'; + } +} + +/** Rethrows a {@link HiboutikRateLimitError}: for the per-item `catch` blocks of the sync, which must not swallow it. */ +export function rethrowIfRateLimited(error: unknown): void { + if (error instanceof HiboutikRateLimitError) throw error; +} + +/** Wait asked by a `Retry-After` header (seconds or HTTP date), if any. */ +function retryAfterMs(header: unknown): number | undefined { + if (typeof header !== 'string' || header.trim() === '') return undefined; + const seconds = Number(header); + if (Number.isFinite(seconds)) return Math.max(0, seconds * 1000); + const date = Date.parse(header); + return Number.isNaN(date) ? undefined : Math.max(0, date - Date.now()); +} + +/** + * Low-level HTTP client for the Hiboutik REST API: base URL and Basic auth credentials come from the environment. + * Every answer is validated against a zod schema (see `hiboutik.schema.ts`): a malformed one throws, like an HTTP error. + */ @Injectable() export class HiboutikClient { private readonly baseUrl: string; @@ -22,45 +59,109 @@ export class HiboutikClient { this.baseUrl = this.configService.get('HIBOUTIK_BASE_URL') ?? ''; } - /** GET on a Hiboutik path (relative to `HIBOUTIK_BASE_URL`), returns the response body. */ - async get(path: string, params?: Record): Promise { - const response = await firstValueFrom( - this.httpService.get(`${this.baseUrl}${path}`, { - headers: { Authorization: this.authHeader }, - params, - }), - ); - return response.data; + /** + * GET on a Hiboutik path (relative to `HIBOUTIK_BASE_URL`), returns the raw response body. A 429 is retried with + * backoff (or after the `Retry-After` delay), then turns into a {@link HiboutikRateLimitError}. + */ + private async get(path: string, params?: Record): Promise { + for (let attempt = 0; ; attempt++) { + try { + const response = await firstValueFrom( + this.httpService.get(`${this.baseUrl}${path}`, { + headers: { Authorization: this.authHeader }, + params, + }), + ); + return response.data; + } catch (error) { + if (!isAxiosError(error) || error.response?.status !== 429) throw error; + if (attempt >= RATE_LIMIT_RETRIES) throw new HiboutikRateLimitError(path); + + const delay = retryAfterMs(error.response.headers['retry-after']) ?? RATE_LIMIT_BASE_DELAY_MS * 2 ** attempt; + await this.sleep(Math.min(delay, RATE_LIMIT_MAX_DELAY_MS)); + } + } + } + + /** Separate method so that tests can skip the waits. */ + protected sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); } /** * GET on a list endpoint. HTTP errors (4xx/5xx) throw, a 2xx answer without body is an empty list, and any other - * non-list body throws: an empty list triggers deletions in the sync, so it must never be inferred from a malformed answer. + * non-list body or invalid item throws: an empty list triggers deletions in the sync, so it must never be inferred + * from a malformed answer. */ - async getList(path: string, params?: Record): Promise { - const data = await this.get(path, params); + async getList(path: string, itemSchema: T, params?: Record): Promise[]> { + const data = await this.get(path, params); if (data === '' || data == null) return []; if (!Array.isArray(data)) throw new Error(`Unexpected answer from Hiboutik ${path}: a list was expected`); - return data; + return parseHiboutik(z.array(itemSchema), data, path); } /** * Fetches every page of a paginated endpoint (`p` param, starting at 1) and concatenates the results. * Only for paginated endpoints: on a non-paginated one, a full first page would loop forever on the same results. */ - async getAllPages(path: string, baseParams: Record = {}): Promise { - const results: T[] = []; + async getAllPages(path: string, itemSchema: T, baseParams: Record = {}): Promise[]> { + return (await this.walkPages(path, itemSchema, baseParams)).items; + } + + /** + * Same as {@link getAllPages}, for a list whose content drives deletions. An item created or deleted in Hiboutik while + * the pages are walked shifts the following pages, so an item can be skipped. When the list spans several pages, it + * is walked a second time and both walks are merged by id (the second one wins): an item is missing from the result + * only if both walks missed it. A list that fits in one page costs no extra call. + */ + async getAllPagesForSync( + path: string, + itemSchema: T, + idOf: (item: z.output) => number, + baseParams: Record = {}, + ): Promise[]> { + const firstWalk = await this.walkPages(path, itemSchema, baseParams); + if (firstWalk.nbPages <= 1) return firstWalk.items; + + const secondWalk = await this.walkPages(path, itemSchema, baseParams); + const byId = new Map>(); + for (const item of [...firstWalk.items, ...secondWalk.items]) byId.set(idOf(item), item); + return [...byId.values()]; + } + + /** Walks the pages of a paginated endpoint. `nbPages` counts the pages holding results. */ + private async walkPages( + path: string, + itemSchema: T, + baseParams: Record, + ): Promise<{ items: z.output[]; nbPages: number }> { + const items: z.output[] = []; let page = 1; while (true) { - const pageData = await this.getList(path, { ...baseParams, p: page }); + const pageData = await this.getList(path, itemSchema, { ...baseParams, p: page }); if (pageData.length === 0) break; - results.push(...pageData); + items.push(...pageData); // A page smaller than the page size is the last one: no need to request an empty page if (pageData.length < HIBOUTIK_PAGE_SIZE) break; page++; } - return results; + return { items, nbPages: Math.ceil(items.length / HIBOUTIK_PAGE_SIZE) }; + } + + /** + * GET on a single-resource endpoint (Hiboutik answers with a one-element array). + * @returns `null` when Hiboutik says the resource does not exist: a 404, or a 2xx answer that is empty. + * @throws on any other HTTP error or malformed answer: an unknown state is never taken for a missing resource. + */ + async getOne(path: string, schema: T): Promise | null> { + try { + const [item] = await this.getList(path, schema); + return item ?? null; + } catch (error) { + if (isAxiosError(error) && error.response?.status === 404) return null; + throw error; + } } } diff --git a/src/hiboutik/product.hiboutik.repository.ts b/src/hiboutik/product.hiboutik.repository.ts index 25db422..5afd546 100644 --- a/src/hiboutik/product.hiboutik.repository.ts +++ b/src/hiboutik/product.hiboutik.repository.ts @@ -1,15 +1,18 @@ import { Injectable } from '@nestjs/common'; import { HiboutikClient } from './hiboutik.client'; -import { ProductHiboutikDto } from '../models/hiboutikDTO/product.hiboutik.dto'; +import { ProductHiboutikDto, productHiboutikSchema } from '../models/hiboutikDTO/product.hiboutik.dto'; /** Reads products from the Hiboutik API. */ @Injectable() export class ProductHiboutikRepository { constructor(private readonly hiboutikClient: HiboutikClient) {} - /** All products, archived ones included, with their sizes and per-size rules. */ + /** + * All products, archived ones included, with their sizes and per-size rules. Walked twice when it spans several + * pages: it drives the deletion of products (see `getAllPagesForSync`). + */ async getProducts(): Promise { - return this.hiboutikClient.getAllPages('/products', { + return this.hiboutikClient.getAllPagesForSync('/products', productHiboutikSchema, (product) => product.product_id, { order_by: 'product_id', sort: 'ASC', }); diff --git a/src/hiboutik/sale.hiboutik.repository.ts b/src/hiboutik/sale.hiboutik.repository.ts index 993a8c3..68d2df5 100644 --- a/src/hiboutik/sale.hiboutik.repository.ts +++ b/src/hiboutik/sale.hiboutik.repository.ts @@ -1,6 +1,6 @@ import { Injectable } from '@nestjs/common'; import { HiboutikClient } from './hiboutik.client'; -import { SaleHiboutikDto } from '../models/hiboutikDTO/sale.hiboutik.dto'; +import { SaleHiboutikDto, saleHiboutikSchema } from '../models/hiboutikDTO/sale.hiboutik.dto'; /** Reads sales from the Hiboutik API. */ @Injectable() @@ -8,12 +8,11 @@ export class SaleHiboutikRepository { constructor(private readonly hiboutikClient: HiboutikClient) {} /** - * Full sale, with its lines (Hiboutik answers with a one-element array). + * Full sale, with its lines. * @throws if the sale does not exist. */ async getSaleById(id: number): Promise { - const data = await this.hiboutikClient.get('/sales/' + String(id)); - const sale = data[0]; + const sale = await this.hiboutikClient.getOne(`/sales/${id}`, saleHiboutikSchema); if (!sale) throw new Error(`Hiboutik sale ${id} not found`); return sale; } diff --git a/src/hiboutik/stock.hiboutik.repository.ts b/src/hiboutik/stock.hiboutik.repository.ts index a77a6fe..24ef013 100644 --- a/src/hiboutik/stock.hiboutik.repository.ts +++ b/src/hiboutik/stock.hiboutik.repository.ts @@ -1,6 +1,6 @@ import { Injectable } from '@nestjs/common'; import { HiboutikClient } from './hiboutik.client'; -import { StockHiboutikDto } from '../models/hiboutikDTO/stock.hiboutik.dto'; +import { StockHiboutikDto, stockHiboutikSchema } from '../models/hiboutikDTO/stock.hiboutik.dto'; /** Reads available stocks from the Hiboutik API. */ @Injectable() @@ -9,12 +9,6 @@ export class StockHiboutikRepository { /** Stock of every product and size, all warehouses combined. Not paginated. */ async getStocks(): Promise { - const stocks = await this.hiboutikClient.getList('/stock_available/all_wh', {}); - // Hiboutik may return ids as strings: normalize them so strict comparisons work downstream - return stocks.map((stock) => ({ - ...stock, - product_id: Number(stock.product_id), - product_size: Number(stock.product_size), - })); + return this.hiboutikClient.getList('/stock_available/all_wh', stockHiboutikSchema); } } diff --git a/src/hiboutik/supplier.hiboutik.repository.ts b/src/hiboutik/supplier.hiboutik.repository.ts index 666c799..11cb261 100644 --- a/src/hiboutik/supplier.hiboutik.repository.ts +++ b/src/hiboutik/supplier.hiboutik.repository.ts @@ -1,6 +1,6 @@ import { Injectable } from '@nestjs/common'; import { HiboutikClient } from './hiboutik.client'; -import { SupplierHiboutikDto } from '../models/hiboutikDTO/supplier.hiboutik.dto'; +import { SupplierHiboutikDto, supplierHiboutikSchema } from '../models/hiboutikDTO/supplier.hiboutik.dto'; /** Reads suppliers from the Hiboutik API. */ @Injectable() @@ -9,7 +9,7 @@ export class SupplierHiboutikRepository { /** `/suppliers` is not paginated: everything comes in one call. */ async getSuppliers(): Promise { - return this.hiboutikClient.getList('/suppliers', { + return this.hiboutikClient.getList('/suppliers', supplierHiboutikSchema, { order_by: 'supplier_id', sort: 'ASC', }); diff --git a/src/models/converter/brand.converter.ts b/src/models/converter/brand.converter.ts index d77270d..b97cd00 100644 --- a/src/models/converter/brand.converter.ts +++ b/src/models/converter/brand.converter.ts @@ -9,7 +9,7 @@ export class BrandConverter extends Converter = {}): 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 => ({ + 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(); }); }); diff --git a/src/models/converter/customer.converter.ts b/src/models/converter/customer.converter.ts index 1c6fb4a..5a696ad 100644 --- a/src/models/converter/customer.converter.ts +++ b/src/models/converter/customer.converter.ts @@ -9,14 +9,14 @@ export class CustomerConverter extends Converter = {}): 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 => ({ + 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(); diff --git a/src/models/converter/customerAddress.converter.ts b/src/models/converter/customerAddress.converter.ts index ab50c37..bb1201c 100644 --- a/src/models/converter/customerAddress.converter.ts +++ b/src/models/converter/customerAddress.converter.ts @@ -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 { diff --git a/src/models/converter/order.converter.spec.ts b/src/models/converter/order.converter.spec.ts index c38bfcf..f191d1e 100644 --- a/src/models/converter/order.converter.spec.ts +++ b/src/models/converter/order.converter.spec.ts @@ -2,29 +2,26 @@ import { OrderConverter } from './order.converter'; import { SaleHiboutikDto, SaleLineHiboutikDto } from '../hiboutikDTO/sale.hiboutik.dto'; const hiboutikLine = (overrides: Partial = {}): 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 => - ({ - 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 => ({ + 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([]); }); }); diff --git a/src/models/converter/order.converter.ts b/src/models/converter/order.converter.ts index 9d8e3af..095f19b 100644 --- a/src/models/converter/order.converter.ts +++ b/src/models/converter/order.converter.ts @@ -17,29 +17,28 @@ export class OrderConverter extends Converter 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, }; } diff --git a/src/models/converter/product.converter.spec.ts b/src/models/converter/product.converter.spec.ts index 24ebd64..3966f06 100644 --- a/src/models/converter/product.converter.spec.ts +++ b/src/models/converter/product.converter.spec.ts @@ -9,13 +9,13 @@ const hiboutikProduct = (overrides: Partial = {}): 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, diff --git a/src/models/converter/product.converter.ts b/src/models/converter/product.converter.ts index 650d649..751b257 100644 --- a/src/models/converter/product.converter.ts +++ b/src/models/converter/product.converter.ts @@ -10,7 +10,7 @@ export class ProductConverter extends Converter; diff --git a/src/models/hiboutikDTO/category.hiboutik.dto.ts b/src/models/hiboutikDTO/category.hiboutik.dto.ts index 30732a1..79c9b58 100644 --- a/src/models/hiboutikDTO/category.hiboutik.dto.ts +++ b/src/models/hiboutikDTO/category.hiboutik.dto.ts @@ -1,18 +1,18 @@ -/** Category as returned by Hiboutik `GET /categories`. */ -export interface CategoryHiboutikDto { - category_id: number; - category_name: string; +import { z } from 'zod'; +import { hiboutikId, hiboutikInt, hiboutikRef, hiboutikText } from './hiboutik.schema'; + +/** Category as returned by Hiboutik `GET /categories` (only the fields used by the sync). */ +export const categoryHiboutikSchema = z.object({ + category_id: hiboutikId, + category_name: hiboutikText, /** Id of the parent category, 0 for a root category. */ - category_id_parent: number; + category_id_parent: hiboutikRef, /** Enabled in the POS: 1 = yes, 0 = no. */ - category_enabled: number; + category_enabled: hiboutikInt, /** Displayed on the website: 1 = yes, 0 = no. */ - category_enabled_www: number; + category_enabled_www: hiboutikInt, /** Display order among its siblings. */ - category_position: number; - category_bck_color: string; - category_color: string; - category_desc: string; - accounting_account: string; - category_ref_ext: string; -} + category_position: hiboutikInt, +}); + +export type CategoryHiboutikDto = z.output; diff --git a/src/models/hiboutikDTO/customer.hiboutik.dto.ts b/src/models/hiboutikDTO/customer.hiboutik.dto.ts index ccb42b6..bee0355 100644 --- a/src/models/hiboutikDTO/customer.hiboutik.dto.ts +++ b/src/models/hiboutikDTO/customer.hiboutik.dto.ts @@ -1,91 +1,65 @@ -/** Customer summary as returned by Hiboutik `GET /customers` (no addresses nor sales). */ -export interface CustomerHiboutikDto { - customers_id: number; - last_name: string; - first_name: string; - company: string; - email: string; - phone: string; - vat: string; - country: string; - /** "YYYY-MM-DD", "0000-00-00" when unknown. */ - date_of_birth: string; - customers_code: string; - loyalty_points: number; - intial_loyalty_points: number; - validity: string; - prepaid_purchases: string; - store_credit: string; - turnover: string; - orders: number; - customer_ref_ext: string; - last_order_date: string; - /** Local time, "YYYY-MM-DD HH:mm:ss". */ - updated_at: string; -} +import { z } from 'zod'; +import { hiboutikId, hiboutikInt, hiboutikOptionalList, hiboutikRef, hiboutikText } from './hiboutik.schema'; -/** Full customer as returned by Hiboutik `GET /customer/:customers_id`, with its addresses and sales. */ -export interface FullCustomerHiboutikDto extends CustomerHiboutikDto { - gender: number; - comments: string; - customers_id_referrer: number; - customers_misc: string; - accounting_account: string; - addresses: CustomerAddressHiboutikDto[]; - sales: CustomerSaleHiboutikDto[]; - tags: unknown[]; - customers_referee: unknown[]; -} +/** Customer summary as returned by Hiboutik `GET /customers`: only its id is used (no addresses nor sales). */ +export const customerHiboutikSchema = z.object({ + customers_id: hiboutikId, +}); -/** Address of a customer, embedded in {@link FullCustomerHiboutikDto}. Name and phone can differ from the customer's. */ -export interface CustomerAddressHiboutikDto { - address_id: number; - gender: string; - first_name: string; - last_name: string; - creation_date: string; - email: string; - phone: string; - company: string; - address: string; - zip_code: string; - city: string; - state: string; - country: string; - customers_id: number; - display: number; - editable: number; +/** Address of a customer, embedded in the full customer. Name and phone can differ from the customer's. */ +export const customerAddressHiboutikSchema = z.object({ + address_id: hiboutikId, + first_name: hiboutikText, + last_name: hiboutikText, + phone: hiboutikText, + company: hiboutikText, + address: hiboutikText, + zip_code: hiboutikText, + city: hiboutikText, + state: hiboutikText, + country: hiboutikText, + customers_id: hiboutikRef, /** 1 for the customer's default address. */ - default: number; - other: string; -} + default: hiboutikInt, + other: hiboutikText, +}); /** * Address as returned by Hiboutik `GET /customers_addresses/:address_id`: the embedded address plus the company - * details and its last update. One API call per address, so only fetched for addresses with a company. + * details. One API call per address, so only fetched for addresses with a company. */ -export interface CustomerAddressDetailsHiboutikDto extends CustomerAddressHiboutikDto { - tax_number: string; - company_number: string; - legal_status: string; - /** Local time, "YYYY-MM-DD HH:mm:ss". */ - updated_at: string; -} +export const customerAddressDetailsHiboutikSchema = customerAddressHiboutikSchema.extend({ + tax_number: hiboutikText, + company_number: hiboutikText, + legal_status: hiboutikText, + /** French local time, "YYYY-MM-DD HH:mm:ss". */ + updated_at: hiboutikText, +}); -/** Sale header embedded in {@link FullCustomerHiboutikDto}: no sale lines, they come from `GET /sales/:sale_id`. */ -export interface CustomerSaleHiboutikDto { - sale_id: number; - created_at: string; - completed_at: string; - date_z: number; - store_id: number; - vendor_id: number; - unique_sale_id: string; - customer_id: number; - currency: string; - /** Decimal as a string. */ - total: string; - points: number; - billing_address: number; - shipping_address: number; -} +/** Sale header embedded in the full customer: no sale lines, they come from `GET /sales/:sale_id`. */ +export const customerSaleHiboutikSchema = z.object({ + sale_id: hiboutikId, +}); + +/** Full customer as returned by Hiboutik `GET /customer/:customers_id`, with its addresses and sales. */ +export const fullCustomerHiboutikSchema = z.object({ + customers_id: hiboutikId, + last_name: hiboutikText, + first_name: hiboutikText, + email: hiboutikText, + phone: hiboutikText, + /** "YYYY-MM-DD", "0000-00-00" when unknown. */ + date_of_birth: hiboutikText, + /** French local time, "YYYY-MM-DD HH:mm:ss". */ + updated_at: hiboutikText, + /** `undefined` when not an array: the stored addresses are then kept. */ + addresses: hiboutikOptionalList(customerAddressHiboutikSchema), + /** `undefined` when not an array: the orders sync is then skipped. */ + sales: hiboutikOptionalList(customerSaleHiboutikSchema), +}); + +export type CustomerHiboutikDto = z.output; +export type CustomerAddressHiboutikDto = z.output; +export type CustomerAddressDetailsHiboutikDto = z.output; +export type CustomerSaleHiboutikDto = z.output; +export type FullCustomerHiboutikDto = z.output; diff --git a/src/models/hiboutikDTO/hiboutik.schema.spec.ts b/src/models/hiboutikDTO/hiboutik.schema.spec.ts new file mode 100644 index 0000000..3f53220 --- /dev/null +++ b/src/models/hiboutikDTO/hiboutik.schema.spec.ts @@ -0,0 +1,87 @@ +import { parseHiboutik } from './hiboutik.schema'; +import { customerAddressHiboutikSchema, fullCustomerHiboutikSchema } from './customer.hiboutik.dto'; +import { productHiboutikSchema } from './product.hiboutik.dto'; +import { saleHiboutikSchema } from './sale.hiboutik.dto'; +import { stockHiboutikSchema } from './stock.hiboutik.dto'; + +const parse = [0]>(schema: T, data: unknown) => parseHiboutik(schema, data, '/x'); + +describe('Hiboutik schemas', () => { + const product = { + product_id: 1, + product_model: 'BT21 bottle', + product_barcode: '8435497280048', + product_brand: 1, + product_supplier: 2, + product_price: '13.99', + product_discount_price: '0.00', + product_category: 19, + product_display_www: 1, + product_arch: 0, + products_desc: '', + updated_at: '2026-06-28 17:02:55', + product_specific_rules: [], + }; + + it('accepts ids, references and flags sent as strings', () => { + const parsed = parse(customerAddressHiboutikSchema, { address_id: ' 8 ', customers_id: '43', default: '0' }); + expect(parsed.address_id).toBe(8); + expect(parsed.customers_id).toBe(43); + expect(parsed.default).toBe(0); + }); + + it('turns empty references into 0 and empty text into ""', () => { + const parsed = parse(productHiboutikSchema, { ...product, product_brand: '', product_category: null, products_desc: null }); + expect(parsed.product_brand).toBe(0); + expect(parsed.product_category).toBe(0); + expect(parsed.products_desc).toBe(''); + }); + + it('keeps numeric text (zip code) and prices as strings', () => { + expect(parse(customerAddressHiboutikSchema, { address_id: 1, zip_code: 75002 }).zip_code).toBe('75002'); + expect(parse(productHiboutikSchema, { ...product, product_price: 13.5 }).product_price).toBe('13.5'); + }); + + it('defaults missing lists to [] and strips unknown fields', () => { + const parsed = parse(productHiboutikSchema, { ...product, product_specific_rules: '', product_supply_price: '7.49' }); + expect(parsed.product_specific_rules).toEqual([]); + expect(parsed.product_size_details).toEqual([]); + expect(parsed).not.toHaveProperty('product_supply_price'); + expect(parse(saleHiboutikSchema, { sale_id: 5, total: '0.00' }).line_items).toEqual([]); + }); + + it('leaves addresses and sales undefined when they are not arrays, so that stored data is kept', () => { + const parsed = parse(fullCustomerHiboutikSchema, { customers_id: 1, addresses: '', sales: { error: 'x' } }); + expect(parsed.addresses).toBeUndefined(); + expect(parsed.sales).toBeUndefined(); + }); + + it('parses stocks sent as decimal strings', () => { + expect(parse(stockHiboutikSchema, { product_id: '1', product_size: '0', stock_available: '3.000' })).toEqual({ + product_id: 1, + product_size: 0, + stock_available: 3, + }); + }); + + it.each([ + ['a missing id', { ...product, product_id: undefined }], + ['a zero id', { ...product, product_id: 0 }], + ['a non-numeric id', { ...product, product_id: 'abc' }], + ['a negative reference', { ...product, product_brand: -1 }], + ['an invalid price', { ...product, product_price: 'free' }], + ['a list that is not an array', { ...product, product_size_details: { size_id: 1 } }], + ])('rejects %s', (_, data) => { + expect(() => parse(productHiboutikSchema, data)).toThrow('Unexpected answer from Hiboutik /x'); + }); + + it('names the faulty fields, never their values', () => { + expect.assertions(2); + try { + parse(fullCustomerHiboutikSchema, { customers_id: 'jane@example.com', addresses: [{ address_id: 'Jane Doe' }] }); + } catch (error) { + expect((error as Error).message).toMatch(/customers_id.*addresses\.0\.address_id/); + expect((error as Error).message).not.toMatch(/jane|Doe/i); + } + }); +}); diff --git a/src/models/hiboutikDTO/hiboutik.schema.ts b/src/models/hiboutikDTO/hiboutik.schema.ts new file mode 100644 index 0000000..2d0af2a --- /dev/null +++ b/src/models/hiboutikDTO/hiboutik.schema.ts @@ -0,0 +1,58 @@ +import { z } from 'zod'; + +/** + * Field types shared by the Hiboutik payload schemas. Hiboutik sends numbers either as JSON numbers or as numeric + * strings, and empty values as "", null or a missing key: the schemas normalize all that, so that the rest of the + * code gets real numbers and strings. Strict on what the sync relies on (identifiers, relations, prices), + * lenient (empty string) on free text. + */ + +const trimmed = (value: unknown) => (typeof value === 'string' ? value.trim() : value); +const emptyAsZero = (value: unknown) => { + const v = trimmed(value); + return v == null || v === '' ? 0 : v; +}; + +/** Identifier of a Hiboutik entity (join key): a positive integer. */ +export const hiboutikId = z.preprocess(trimmed, z.coerce.number().int().positive()); + +/** Reference to another entity: its id, or 0 (also sent as "" or null) when none. */ +export const hiboutikRef = z.preprocess(emptyAsZero, z.coerce.number().int().nonnegative()); + +/** Integer, e.g. a 1/0 flag, a position or a quantity (0 when empty). */ +export const hiboutikInt = z.preprocess(emptyAsZero, z.coerce.number().int()); + +/** Number, e.g. a stock that may be sent as "3.000" (0 when empty). */ +export const hiboutikNumber = z.preprocess(emptyAsZero, z.coerce.number()); + +/** Free text, "" when empty. */ +export const hiboutikText = z.preprocess((value) => (value == null ? '' : typeof value === 'number' ? String(value) : value), z.string()); + +/** Required decimal kept as a string for Prisma `Decimal`, e.g. "13.99". */ +export const hiboutikDecimal = z.preprocess( + (value) => (typeof value === 'number' ? String(value) : trimmed(value)), + z.string().regex(/^-?\d+(\.\d+)?$/, 'decimal expected'), +); + +/** List that is empty when missing ("", null or no key). */ +export const hiboutikList = (item: T) => + z.preprocess((value) => (value == null || value === '' ? [] : value), z.array(item)); + +/** List that is `undefined` when it is not an array: the caller then keeps the stored data instead of emptying it. */ +export const hiboutikOptionalList = (item: T) => + z.preprocess((value) => (Array.isArray(value) ? value : undefined), z.array(item).optional()); + +/** + * Validates a Hiboutik payload. The error names the faulty fields (path and issue code) but never their values, + * which may be personal data. + */ +export function parseHiboutik(schema: T, data: unknown, path: string): z.output { + const result = schema.safeParse(data); + if (result.success) return result.data; + + const issues = result.error.issues + .slice(0, 5) + .map((issue) => `${issue.path.join('.') || '(root)'} (${issue.code})`) + .join(', '); + throw new Error(`Unexpected answer from Hiboutik ${path}: invalid ${issues}`); +} diff --git a/src/models/hiboutikDTO/product.hiboutik.dto.ts b/src/models/hiboutikDTO/product.hiboutik.dto.ts index 6080276..783c808 100644 --- a/src/models/hiboutikDTO/product.hiboutik.dto.ts +++ b/src/models/hiboutikDTO/product.hiboutik.dto.ts @@ -1,46 +1,51 @@ -/** Product as returned by Hiboutik `GET /products` (archived products included). */ -export interface ProductHiboutikDto { - product_id: number; - /** Product name. */ - product_model: string; - /** Empty string when the product has no barcode. */ - product_barcode: string; - /** Brand id, 0 when none. */ - product_brand: number; - /** Supplier id, 0 when none. */ - product_supplier: number; - /** Decimal as a string, e.g. "13.99". */ - product_price: string; - /** "0.00" when the product has no discount. */ - product_discount_price: string; - product_supply_price: string; - /** Category id, 0 when none. */ - product_category: number; - /** Displayed on the website: 1 = yes, 0 = no. */ - product_display_www: number; - /** Archived: 1 = yes, 0 = no. */ - product_arch: number; - products_desc: string; - /** Local time, "YYYY-MM-DD HH:mm:ss". */ - updated_at: string; - /** Per-size overrides of the product's price, discount and barcode. */ - product_specific_rules: ProductSpecificRulesHiboutikDto[]; - /** Sizes of the product; missing or empty for a product without sizes. */ - product_size_details?: ProductSizeDetailsHiboutikDto[]; -} +import { z } from 'zod'; +import { hiboutikDecimal, hiboutikId, hiboutikInt, hiboutikList, hiboutikRef, hiboutikText } from './hiboutik.schema'; /** Override of a product's values for one size. Prices are "0.00" (or missing) when not overridden. */ -export interface ProductSpecificRulesHiboutikDto { - size_id: number; - barcode: string; - product_price?: string; - product_discount_price?: string; -} +export const productSpecificRulesHiboutikSchema = z.object({ + size_id: hiboutikRef, + product_price: hiboutikText, + product_discount_price: hiboutikText, +}); /** One size of a product (e.g. "S", "M"). */ -export interface ProductSizeDetailsHiboutikDto { - size_id: number; - size_name: string; - /** Empty string when the size has no barcode. */ - barcode: string; -} +export const productSizeDetailsHiboutikSchema = z.object({ + size_id: hiboutikId, + size_name: hiboutikText, + /** Empty when the size has no barcode. */ + barcode: hiboutikText, +}); + +/** Product as returned by Hiboutik `GET /products`, archived products included (only the fields used by the sync). */ +export const productHiboutikSchema = z.object({ + product_id: hiboutikId, + /** Product name. */ + product_model: hiboutikText, + /** Empty when the product has no barcode. */ + product_barcode: hiboutikText, + /** Brand id, 0 when none. */ + product_brand: hiboutikRef, + /** Supplier id, 0 when none. */ + product_supplier: hiboutikRef, + /** e.g. "13.99". */ + product_price: hiboutikDecimal, + /** "0.00" when the product has no discount. */ + product_discount_price: hiboutikText, + /** Category id, 0 when none. */ + product_category: hiboutikRef, + /** Displayed on the website: 1 = yes, 0 = no. */ + product_display_www: hiboutikInt, + /** Archived: 1 = yes, 0 = no. */ + product_arch: hiboutikInt, + products_desc: hiboutikText, + /** French local time, "YYYY-MM-DD HH:mm:ss". */ + updated_at: hiboutikText, + /** Per-size overrides of the product's price, discount and barcode. */ + product_specific_rules: hiboutikList(productSpecificRulesHiboutikSchema), + /** Sizes of the product; empty for a product without sizes. */ + product_size_details: hiboutikList(productSizeDetailsHiboutikSchema), +}); + +export type ProductSpecificRulesHiboutikDto = z.output; +export type ProductSizeDetailsHiboutikDto = z.output; +export type ProductHiboutikDto = z.output; diff --git a/src/models/hiboutikDTO/sale.hiboutik.dto.ts b/src/models/hiboutikDTO/sale.hiboutik.dto.ts index 4d3486c..ffd97eb 100644 --- a/src/models/hiboutikDTO/sale.hiboutik.dto.ts +++ b/src/models/hiboutikDTO/sale.hiboutik.dto.ts @@ -1,40 +1,39 @@ -/** - * Sale as returned by Hiboutik `GET /sales/:sale_id`, with its lines. - * Only the fields used by the sync are typed: the payload also carries many computed copies (dates split in parts, - * totals by tax, customer and store details...). - */ -export interface SaleHiboutikDto { - sale_id: number; - /** Local time, "YYYY-MM-DD HH:mm:ss". */ - created_at: string; - /** Local time, "0000-00-00 00:00:00" while the sale is not completed. */ - completed_at: string; - store_id: number; - customer_id: number; - currency: string; - /** Decimal as a string, taxes included. */ - total: string; - /** Payment type code, e.g. "CB", "ESP", "WEB". */ - payment: string; - /** 1 for a quotation. */ - quotation: number; - line_items: SaleLineHiboutikDto[]; -} +import { z } from 'zod'; +import { hiboutikDecimal, hiboutikId, hiboutikInt, hiboutikList, hiboutikRef, hiboutikText } from './hiboutik.schema'; -/** Line of a {@link SaleHiboutikDto}: a snapshot of the product at sale time. */ -export interface SaleLineHiboutikDto { - line_item_id: number; - product_id: number; +/** Line of a sale: a snapshot of the product at sale time. */ +export const saleLineHiboutikSchema = z.object({ + product_id: hiboutikRef, /** Hiboutik size id, 0 for a product without sizes. */ - product_size: number; + product_size: hiboutikRef, /** Negative for a returned item. */ - quantity: number; - product_model: string; - product_barcode: string; + quantity: hiboutikInt, + product_model: hiboutikText, + product_barcode: hiboutikText, /** Size name, empty for a product without sizes. */ - size_name: string; - /** Unit price before discount, taxes included (decimal as a string). */ - item_unit_gross_wo_discount: string; - /** Unit price actually paid, discount deducted, taxes included (decimal as a string). */ - item_unit_gross: string; -} + size_name: hiboutikText, + /** Unit price actually paid, discount deducted, taxes included. */ + item_unit_gross: hiboutikDecimal, +}); + +/** + * Sale as returned by Hiboutik `GET /sales/:sale_id`, with its lines. Only the fields used by the sync: the payload + * also carries many computed copies (dates split in parts, totals by tax, customer and store details...). + */ +export const saleHiboutikSchema = z.object({ + sale_id: hiboutikId, + /** French local time, "YYYY-MM-DD HH:mm:ss". */ + created_at: hiboutikText, + /** French local time, "0000-00-00 00:00:00" while the sale is not completed. */ + completed_at: hiboutikText, + /** 0 for an anonymous sale. */ + customer_id: hiboutikRef, + /** Taxes included. */ + total: hiboutikDecimal, + /** 1 for a quotation. */ + quotation: hiboutikInt, + line_items: hiboutikList(saleLineHiboutikSchema), +}); + +export type SaleLineHiboutikDto = z.output; +export type SaleHiboutikDto = z.output; diff --git a/src/models/hiboutikDTO/stock.hiboutik.dto.ts b/src/models/hiboutikDTO/stock.hiboutik.dto.ts index 45d7bea..325dc9c 100644 --- a/src/models/hiboutikDTO/stock.hiboutik.dto.ts +++ b/src/models/hiboutikDTO/stock.hiboutik.dto.ts @@ -1,10 +1,13 @@ +import { z } from 'zod'; +import { hiboutikId, hiboutikNumber, hiboutikRef } from './hiboutik.schema'; + /** Available stock of one product size, as returned by Hiboutik `GET /stock_available/all_wh` (all warehouses). */ -export interface StockHiboutikDto { - product_id: number; +export const stockHiboutikSchema = z.object({ + product_id: hiboutikId, /** Size id, 0 for a product without sizes. */ - product_size: number; - /** Decimal as a string, e.g. "3". Stays at 0 while stock management is disabled in Hiboutik. */ - stock_available: string; - product_barcode: string; - product_supplier_reference: string; -} + product_size: hiboutikRef, + /** Sent as a decimal string, e.g. "3". Stays at 0 while stock management is disabled in Hiboutik. */ + stock_available: hiboutikNumber, +}); + +export type StockHiboutikDto = z.output; diff --git a/src/models/hiboutikDTO/supplier.hiboutik.dto.ts b/src/models/hiboutikDTO/supplier.hiboutik.dto.ts index 10dac3d..30b3feb 100644 --- a/src/models/hiboutikDTO/supplier.hiboutik.dto.ts +++ b/src/models/hiboutikDTO/supplier.hiboutik.dto.ts @@ -1,7 +1,12 @@ -/** Supplier as returned by Hiboutik `GET /suppliers`. */ -export interface SupplierHiboutikDto { - supplier_id: number; - supplier_name: string; +import { z } from 'zod'; +import { hiboutikId, hiboutikInt, hiboutikText } from './hiboutik.schema'; + +/** Supplier as returned by Hiboutik `GET /suppliers` (only the fields used by the sync). */ +export const supplierHiboutikSchema = z.object({ + supplier_id: hiboutikId, + supplier_name: hiboutikText, /** 1 = enabled, 0 = disabled. */ - supplier_enabled: number; -} + supplier_enabled: hiboutikInt, +}); + +export type SupplierHiboutikDto = z.output; diff --git a/src/services/hiboutik/brand.hiboutik.service.ts b/src/services/hiboutik/brand.hiboutik.service.ts index b3a1409..7bc896b 100644 --- a/src/services/hiboutik/brand.hiboutik.service.ts +++ b/src/services/hiboutik/brand.hiboutik.service.ts @@ -26,7 +26,7 @@ export class BrandHiboutikService { ); // Hiboutik is trusted: a successful (2xx) answer, even an empty list, is the truth. An HTTP error throws before this point. - const nbDeleted = await this.brandDatabaseRepository.deleteMissing(hiboutikBrands.map((dto) => Number(dto.brand_id))); + const nbDeleted = await this.brandDatabaseRepository.deleteMissing(hiboutikBrands.map((dto) => dto.brand_id)); this.logger.log( `Brands sync ended: ${hiboutikBrands.length - nbFailed}/${hiboutikBrands.length} synced, ${nbFailed} failed, ${nbDeleted} deleted.`, ); diff --git a/src/services/hiboutik/category.hiboutik.service.spec.ts b/src/services/hiboutik/category.hiboutik.service.spec.ts index 871adbf..e60b69e 100644 --- a/src/services/hiboutik/category.hiboutik.service.spec.ts +++ b/src/services/hiboutik/category.hiboutik.service.spec.ts @@ -3,16 +3,15 @@ import { CategoryHiboutikService } from './category.hiboutik.service'; import { CategoryConverter } from '../../models/converter/category.converter'; import { CategoryHiboutikDto } from '../../models/hiboutikDTO/category.hiboutik.dto'; -const hiboutikCategory = (id: number, parentId: number, overrides: Partial = {}): CategoryHiboutikDto => - ({ - category_id: id, - category_name: `Category ${id}`, - category_id_parent: parentId, - category_enabled: 1, - category_enabled_www: 0, - category_position: 3, - ...overrides, - }) as CategoryHiboutikDto; +const hiboutikCategory = (id: number, parentId: number, overrides: Partial = {}): CategoryHiboutikDto => ({ + category_id: id, + category_name: `Category ${id}`, + category_id_parent: parentId, + category_enabled: 1, + category_enabled_www: 0, + category_position: 3, + ...overrides, +}); describe('CategoryHiboutikService', () => { const categoryHiboutikRepository = { getCategories: jest.fn() }; diff --git a/src/services/hiboutik/category.hiboutik.service.ts b/src/services/hiboutik/category.hiboutik.service.ts index c50ec64..a9c9585 100644 --- a/src/services/hiboutik/category.hiboutik.service.ts +++ b/src/services/hiboutik/category.hiboutik.service.ts @@ -32,13 +32,13 @@ export class CategoryHiboutikService { // Pass 2: parent links, once every category exists (a child can come before its parent in Hiboutik's list) const nbParentsFailed = await forEachSafely( hiboutikCategories, - (dto) => this.categoryDatabaseRepository.setParent(Number(dto.category_id), Number(dto.category_id_parent) || null), + (dto) => this.categoryDatabaseRepository.setParent(dto.category_id, dto.category_id_parent || null), this.logger, (dto) => `parent of Hiboutik category ${dto.category_id}`, ); // Hiboutik is trusted: a successful (2xx) answer, even an empty list, is the truth. An HTTP error throws before this point. - const nbDeleted = await this.categoryDatabaseRepository.deleteMissing(hiboutikCategories.map((dto) => Number(dto.category_id))); + const nbDeleted = await this.categoryDatabaseRepository.deleteMissing(hiboutikCategories.map((dto) => dto.category_id)); this.logger.log( `Categories sync ended: ${hiboutikCategories.length - nbFailed}/${hiboutikCategories.length} synced, ${nbFailed} failed, ` + `${nbParentsFailed} parent links failed, ${nbDeleted} deleted.`, diff --git a/src/services/hiboutik/customer.hiboutik.service.spec.ts b/src/services/hiboutik/customer.hiboutik.service.spec.ts index e59443c..628a693 100644 --- a/src/services/hiboutik/customer.hiboutik.service.spec.ts +++ b/src/services/hiboutik/customer.hiboutik.service.spec.ts @@ -2,8 +2,13 @@ import { Logger } from '@nestjs/common'; import { CustomerHiboutikService } from './customer.hiboutik.service'; describe('CustomerHiboutikService', () => { - const customerHiboutikRepository = { getCustomers: jest.fn(), getCustomerById: jest.fn(), getAddressById: jest.fn() }; - const customerDatabaseRepository = { upsert: jest.fn(), deleteMissing: jest.fn() }; + const customerHiboutikRepository = { + getCustomers: jest.fn(), + getCustomerById: jest.fn(), + findCustomerById: jest.fn(), + getAddressById: jest.fn(), + }; + const customerDatabaseRepository = { upsert: jest.fn(), getAllHiboutikIds: jest.fn(), deleteByHiboutikIds: jest.fn() }; const customerConverter = { fromHiboutik: jest.fn() }; const customerAddressDatabaseRepository = { upsert: jest.fn(), deleteMissing: jest.fn() }; const customerAddressConverter = { fromHiboutik: jest.fn((dto: unknown) => dto) }; @@ -23,7 +28,9 @@ describe('CustomerHiboutikService', () => { customerHiboutikRepository.getCustomers.mockResolvedValue([]); customerHiboutikRepository.getCustomerById.mockResolvedValue({ customers_id: 1, addresses: [] }); customerDatabaseRepository.upsert.mockResolvedValue({ id: 'customer-uuid' }); - customerDatabaseRepository.deleteMissing.mockResolvedValue(0); + customerDatabaseRepository.getAllHiboutikIds.mockResolvedValue([]); + customerDatabaseRepository.deleteByHiboutikIds.mockImplementation((ids: number[]) => Promise.resolve(ids.length)); + customerHiboutikRepository.findCustomerById.mockResolvedValue(null); customerAddressDatabaseRepository.deleteMissing.mockResolvedValue(0); orderDatabaseRepository.getCompletedSaleIds.mockResolvedValue(new Set()); saleHiboutikRepository.getSaleById.mockImplementation((id: number) => Promise.resolve({ sale_id: id, quotation: 0 })); @@ -39,27 +46,61 @@ describe('CustomerHiboutikService', () => { ); }); - it('deletes customers missing from Hiboutik', async () => { - customerHiboutikRepository.getCustomers.mockResolvedValue([{ customers_id: 1 }, { customers_id: '2' }]); + describe('deletions', () => { + it('deletes the stored customers missing from the list once Hiboutik confirms they no longer exist', async () => { + customerHiboutikRepository.getCustomers.mockResolvedValue([{ customers_id: 1 }, { customers_id: 2 }]); + customerDatabaseRepository.getAllHiboutikIds.mockResolvedValue([1, 2, 3, 4]); - await service.syncCustomers(); + await service.syncCustomers(); - expect(customerDatabaseRepository.deleteMissing).toHaveBeenCalledWith([1, 2]); - expect(customerDatabaseRepository.upsert).toHaveBeenCalledTimes(2); - }); + expect(customerHiboutikRepository.findCustomerById.mock.calls).toEqual([[3], [4]]); + expect(customerDatabaseRepository.deleteByHiboutikIds).toHaveBeenCalledWith([3, 4]); + expect(customerDatabaseRepository.upsert).toHaveBeenCalledTimes(2); + }); - it('deletes every customer when Hiboutik answers with an empty list', async () => { - await service.syncCustomers(); + it('keeps and syncs a customer missing from the list (page shift) that still exists in Hiboutik', async () => { + customerHiboutikRepository.getCustomers.mockResolvedValue([{ customers_id: 1 }]); + customerDatabaseRepository.getAllHiboutikIds.mockResolvedValue([1, 2]); + customerHiboutikRepository.findCustomerById.mockResolvedValue({ customers_id: 2 }); - expect(customerDatabaseRepository.deleteMissing).toHaveBeenCalledWith([]); - }); + await service.syncCustomers(); - it('deletes nothing when the Hiboutik call fails', async () => { - customerHiboutikRepository.getCustomers.mockRejectedValue(new Error('Request failed with status code 500')); + expect(customerDatabaseRepository.deleteByHiboutikIds).toHaveBeenCalledWith([]); + expect(customerHiboutikRepository.getCustomerById.mock.calls).toEqual([[1], [2]]); + }); - await expect(service.syncCustomers()).rejects.toThrow('500'); + it('keeps a customer whose check fails', async () => { + customerDatabaseRepository.getAllHiboutikIds.mockResolvedValue([3, 4]); + customerHiboutikRepository.findCustomerById.mockRejectedValueOnce(new Error('Request failed with status code 500')); - expect(customerDatabaseRepository.deleteMissing).not.toHaveBeenCalled(); + await service.syncCustomers(); + + expect(customerDatabaseRepository.deleteByHiboutikIds).toHaveBeenCalledWith([4]); + }); + + it('deletes every confirmed customer when Hiboutik answers with an empty list', async () => { + customerDatabaseRepository.getAllHiboutikIds.mockResolvedValue([1, 2]); + + await service.syncCustomers(); + + expect(customerDatabaseRepository.deleteByHiboutikIds).toHaveBeenCalledWith([1, 2]); + }); + + it('deletes nothing when the Hiboutik list call fails', async () => { + customerHiboutikRepository.getCustomers.mockRejectedValue(new Error('Request failed with status code 500')); + + await expect(service.syncCustomers()).rejects.toThrow('500'); + + expect(customerDatabaseRepository.deleteByHiboutikIds).not.toHaveBeenCalled(); + }); + + it('syncs once a customer listed twice', async () => { + customerHiboutikRepository.getCustomers.mockResolvedValue([{ customers_id: 1 }, { customers_id: 1 }]); + + await service.syncCustomers(); + + expect(customerHiboutikRepository.getCustomerById).toHaveBeenCalledTimes(1); + }); }); it('keeps syncing the other customers when one fails', async () => { @@ -80,8 +121,8 @@ describe('CustomerHiboutikService', () => { customerHiboutikRepository.getCustomerById.mockResolvedValue({ customers_id: 1, addresses: [ - { address_id: 10, customers_id: 1 }, - { address_id: '11', customers_id: 999 }, + { address_id: 10, customers_id: 1, company: '' }, + { address_id: 11, customers_id: 999, company: '' }, ], }); @@ -89,7 +130,7 @@ describe('CustomerHiboutikService', () => { expect(customerAddressDatabaseRepository.deleteMissing).toHaveBeenCalledWith('customer-uuid', [10, 11]); expect(customerAddressDatabaseRepository.upsert).toHaveBeenCalledTimes(2); - expect(customerAddressConverter.fromHiboutik).toHaveBeenLastCalledWith({ address_id: '11', customers_id: 1 }); + expect(customerAddressConverter.fromHiboutik).toHaveBeenLastCalledWith({ address_id: 11, customers_id: 1, company: '' }); expect(customerAddressDatabaseRepository.deleteMissing.mock.invocationCallOrder[0]).toBeLessThan( customerAddressDatabaseRepository.upsert.mock.invocationCallOrder[0], ); @@ -155,7 +196,10 @@ describe('CustomerHiboutikService', () => { it('keeps syncing the other addresses when one fails', async () => { customerHiboutikRepository.getCustomerById.mockResolvedValue({ customers_id: 1, - addresses: [{ address_id: 10 }, { address_id: 11 }], + addresses: [ + { address_id: 10, company: '' }, + { address_id: 11, company: '' }, + ], }); customerAddressDatabaseRepository.upsert.mockRejectedValueOnce(new Error('boom')); @@ -171,7 +215,7 @@ describe('CustomerHiboutikService', () => { customerHiboutikRepository.getCustomerById.mockResolvedValue({ customers_id: 1, addresses: [], - sales: [{ sale_id: 5 }, { sale_id: '6' }, { sale_id: 7 }], + sales: [{ sale_id: 5 }, { sale_id: 6 }, { sale_id: 7 }], }); }); @@ -180,7 +224,7 @@ describe('CustomerHiboutikService', () => { await service.syncCustomers(); - expect(orderDatabaseRepository.getCompletedSaleIds).toHaveBeenCalledWith([5, 6, 7]); + expect(orderDatabaseRepository.getCompletedSaleIds).toHaveBeenCalledWith(1, [5, 6, 7]); expect(saleHiboutikRepository.getSaleById.mock.calls.map((call: unknown[]) => call[0])).toEqual([5, 7]); expect(orderDatabaseRepository.upsert).toHaveBeenCalledTimes(2); expect(orderConverter.fromHiboutik).toHaveBeenCalledWith({ sale_id: 5, quotation: 0, customer_id: 1 }); diff --git a/src/services/hiboutik/customer.hiboutik.service.ts b/src/services/hiboutik/customer.hiboutik.service.ts index a8d959d..e56f919 100644 --- a/src/services/hiboutik/customer.hiboutik.service.ts +++ b/src/services/hiboutik/customer.hiboutik.service.ts @@ -13,6 +13,7 @@ import { forEachSafely } from '../../utils/sync'; import { SaleHiboutikRepository } from '../../hiboutik/sale.hiboutik.repository'; import { OrderDatabaseRepository } from '../../database/order.database.repository'; import { OrderConverter } from '../../models/converter/order.converter'; +import { rethrowIfRateLimited } from '../../hiboutik/hiboutik.client'; @Injectable() export class CustomerHiboutikService { @@ -30,35 +31,66 @@ export class CustomerHiboutikService { ) {} /** - * Mirrors Hiboutik customers: deletes the customers missing from Hiboutik, then fetches and upserts each of them - * with its addresses and orders (one API call per customer, as the list endpoint lacks addresses and sales, plus one - * per sale not yet stored as completed). + * Mirrors Hiboutik customers: deletes the customers Hiboutik confirms as deleted, then fetches and upserts each of + * them with its addresses and orders (one API call per customer, as the list endpoint lacks addresses and sales, plus + * one per sale not yet stored as completed). */ async syncCustomers() { const hiboutikCustomers = await this.customerHiboutikRepository.getCustomers(); + // A page shift can list a customer twice: sync it once + const listedIds = [...new Set(hiboutikCustomers.map((dto) => dto.customers_id))]; - // Hiboutik is trusted: a successful (2xx) answer, even an empty list, is the truth. An HTTP error throws before this point. - const nbDeleted = await this.customerDatabaseRepository.deleteMissing(hiboutikCustomers.map((dto) => Number(dto.customers_id))); + const { nbDeleted, unlistedIds } = await this.deleteMissingCustomers(listedIds); + const customerIds = [...listedIds, ...unlistedIds]; let nbFailed = 0; let nbFailedAddresses = 0; let nbFailedOrders = 0; - for (const hiboutikCustomer of hiboutikCustomers) { + for (const customerId of customerIds) { try { - const hiboutikFullCustomer = await this.customerHiboutikRepository.getCustomerById(hiboutikCustomer.customers_id); + const hiboutikFullCustomer = await this.customerHiboutikRepository.getCustomerById(customerId); const customer = await this.customerDatabaseRepository.upsert(this.customerConverter.fromHiboutik(hiboutikFullCustomer)); nbFailedAddresses += await this.syncAddresses(customer.id, hiboutikFullCustomer); nbFailedOrders += await this.syncOrders(hiboutikFullCustomer); } catch (error) { + rethrowIfRateLimited(error); nbFailed++; - this.logger.error(`Failed to sync Hiboutik customer ${hiboutikCustomer.customers_id}`, (error as Error)?.stack); + this.logger.error(`Failed to sync Hiboutik customer ${customerId}`, (error as Error)?.stack); } } this.logger.log( - `Customers sync ended: ${hiboutikCustomers.length - nbFailed}/${hiboutikCustomers.length} synced, ${nbFailed} failed, ${nbDeleted} deleted, ${nbFailedAddresses} addresses failed, ${nbFailedOrders} orders failed.`, + `Customers sync ended: ${customerIds.length - nbFailed}/${customerIds.length} synced, ${nbFailed} failed, ${nbDeleted} deleted, ` + + `${unlistedIds.length} missing from the list but still in Hiboutik, ${nbFailedAddresses} addresses failed, ${nbFailedOrders} orders failed.`, ); } + /** + * Deletes the stored customers missing from the Hiboutik list, once Hiboutik confirms one by one that they no longer + * exist. The list is paginated: a customer created or deleted in Hiboutik while it is walked shifts the following + * pages, so a customer can be missing from it without being deleted. Hiboutik is trusted: a 404 or an empty 2xx + * answer is a confirmation. A customer whose check fails is kept. + * @returns the number of deleted customers, and the ids of the customers that still exist although not listed. + */ + private async deleteMissingCustomers(listedIds: number[]): Promise<{ nbDeleted: number; unlistedIds: number[] }> { + const listed = new Set(listedIds); + const missingIds = (await this.customerDatabaseRepository.getAllHiboutikIds()).filter((id) => !listed.has(id)); + + const deletedIds: number[] = []; + const unlistedIds: number[] = []; + for (const id of missingIds) { + try { + const customer = await this.customerHiboutikRepository.findCustomerById(id); + (customer ? unlistedIds : deletedIds).push(id); + } catch (error) { + rethrowIfRateLimited(error); + this.logger.warn(`Failed to check whether Hiboutik customer ${id} still exists, it is kept: ${(error as Error)?.message}`); + } + } + + const nbDeleted = await this.customerDatabaseRepository.deleteByHiboutikIds(deletedIds); + return { nbDeleted, unlistedIds }; + } + /** * 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). @@ -73,13 +105,13 @@ export class CustomerHiboutikService { await this.customerAddressDatabaseRepository.deleteMissing( customerId, - hiboutikCustomer.addresses.map((dto) => Number(dto.address_id)), + hiboutikCustomer.addresses.map((dto) => dto.address_id), ); return forEachSafely( hiboutikCustomer.addresses, // Linked to the parent customer, whatever the embedded customers_id says async (dto) => { - const address = dto.company?.trim() ? await this.withCompanyDetails(dto) : dto; + const address = dto.company.trim() ? await this.withCompanyDetails(dto) : dto; await this.customerAddressDatabaseRepository.upsert( this.customerAddressConverter.fromHiboutik({ ...address, customers_id: hiboutikCustomer.customers_id }), ); @@ -106,6 +138,7 @@ export class CustomerHiboutikService { updated_at: details.updated_at, }; } catch (error) { + rethrowIfRateLimited(error); this.logger.warn(`Failed to fetch company details of Hiboutik address ${dto.address_id}: ${(error as Error)?.message}`); return dto; } @@ -124,15 +157,16 @@ export class CustomerHiboutikService { // A completed sale no longer changes in Hiboutik: no need to fetch it again const completedSaleIds = await this.orderDatabaseRepository.getCompletedSaleIds( - hiboutikCustomer.sales.map((dto) => Number(dto.sale_id)), + hiboutikCustomer.customers_id, + hiboutikCustomer.sales.map((dto) => dto.sale_id), ); - const salesToSync = hiboutikCustomer.sales.filter((dto) => !completedSaleIds.has(Number(dto.sale_id))); + const salesToSync = hiboutikCustomer.sales.filter((dto) => !completedSaleIds.has(dto.sale_id)); return forEachSafely( salesToSync, async (dto) => { const sale = await this.saleHiboutikRepository.getSaleById(dto.sale_id); - if (Number(sale.quotation) === 1) return; + if (sale.quotation === 1) return; // Linked to the parent customer, whatever the sale says await this.orderDatabaseRepository.upsert( this.orderConverter.fromHiboutik({ ...sale, customer_id: hiboutikCustomer.customers_id }), diff --git a/src/services/hiboutik/product.hiboutik.service.spec.ts b/src/services/hiboutik/product.hiboutik.service.spec.ts index 5a0068d..a4c2229 100644 --- a/src/services/hiboutik/product.hiboutik.service.spec.ts +++ b/src/services/hiboutik/product.hiboutik.service.spec.ts @@ -12,22 +12,20 @@ const hiboutikProduct = (id: number, overrides: Partial = {} product_supplier: 0, product_price: '10.00', product_discount_price: '0.00', - product_supply_price: '5.00', product_category: 0, product_display_www: 1, product_arch: 0, products_desc: '', updated_at: '2026-06-28 17:02:55', product_specific_rules: [], + product_size_details: [], ...overrides, }); -const stock = (productId: number, size: number, available: string): StockHiboutikDto => ({ +const stock = (productId: number, size: number, available: number): StockHiboutikDto => ({ product_id: productId, product_size: size, stock_available: available, - product_barcode: '', - product_supplier_reference: '', }); describe('ProductHiboutikService', () => { @@ -37,6 +35,9 @@ describe('ProductHiboutikService', () => { const stockHiboutikRepository = { getStocks: jest.fn() }; const productDatabaseRepository = { getAll: jest.fn(), upsert: jest.fn(), deleteAll: jest.fn() }; const productVersionDatabaseRepository = { upsert: jest.fn(), deleteMissing: jest.fn() }; + // The transaction client handed to the repositories inside $transaction + const tx = { transaction: true }; + const prisma = { $transaction: jest.fn() }; beforeAll(() => { jest.spyOn(Logger.prototype, 'log').mockImplementation(() => undefined); @@ -47,6 +48,7 @@ describe('ProductHiboutikService', () => { beforeEach(() => { jest.clearAllMocks(); calls = []; + prisma.$transaction.mockImplementation((run: (client: unknown) => Promise) => run(tx)); stockHiboutikRepository.getStocks.mockResolvedValue([]); productDatabaseRepository.getAll.mockResolvedValue([]); productDatabaseRepository.deleteAll.mockImplementation((ids: string[]) => { @@ -73,6 +75,7 @@ describe('ProductHiboutikService', () => { productVersionDatabaseRepository as never, new ProductConverter(), stockHiboutikRepository as never, + prisma as never, ); }); @@ -109,16 +112,17 @@ describe('ProductHiboutikService', () => { it('creates a single version for a product without sizes', async () => { productHiboutikRepository.getProducts.mockResolvedValue([hiboutikProduct(1)]); - stockHiboutikRepository.getStocks.mockResolvedValue([stock(1, 0, '4'), stock(2, 0, '99')]); + stockHiboutikRepository.getStocks.mockResolvedValue([stock(1, 0, 4), stock(2, 0, 99)]); await service.syncProducts(); - expect(productDatabaseRepository.upsert).toHaveBeenCalledWith(expect.objectContaining({ hiboutikId: 1, globalStock: 4 })); - expect(productVersionDatabaseRepository.deleteMissing).toHaveBeenCalledWith('uuid-1', [0]); + expect(productDatabaseRepository.upsert).toHaveBeenCalledWith(expect.objectContaining({ hiboutikId: 1, globalStock: 4 }), tx); + expect(productVersionDatabaseRepository.deleteMissing).toHaveBeenCalledWith('uuid-1', [0], tx); expect(productVersionDatabaseRepository.upsert).toHaveBeenCalledTimes(1); expect(productVersionDatabaseRepository.upsert).toHaveBeenCalledWith( 'uuid-1', expect.objectContaining({ hiboutikSizeId: 0, barcode: 'barcode-1', stock: 4, versionName: 'Product 1', discountPrice: null }), + tx, ); }); @@ -129,34 +133,44 @@ describe('ProductHiboutikService', () => { { size_id: 10, size_name: 'S', barcode: 'b-s' }, { size_id: 11, size_name: 'M', barcode: '' }, ], - product_specific_rules: [{ size_id: 10, barcode: 'b-s', product_price: '12.00', product_discount_price: '0.00' }], + product_specific_rules: [{ size_id: 10, product_price: '12.00', product_discount_price: '0.00' }], }), ]); - stockHiboutikRepository.getStocks.mockResolvedValue([stock(1, 10, '3'), stock(1, 11, '2')]); + stockHiboutikRepository.getStocks.mockResolvedValue([stock(1, 10, 3), stock(1, 11, 2)]); await service.syncProducts(); - expect(productDatabaseRepository.upsert).toHaveBeenCalledWith(expect.objectContaining({ globalStock: 5 })); - expect(productVersionDatabaseRepository.deleteMissing).toHaveBeenCalledWith('uuid-1', [10, 11]); + expect(productDatabaseRepository.upsert).toHaveBeenCalledWith(expect.objectContaining({ globalStock: 5 }), tx); + expect(productVersionDatabaseRepository.deleteMissing).toHaveBeenCalledWith('uuid-1', [10, 11], tx); expect(calls).toEqual(['deleteProducts', 'upsertProduct', 'deleteVersions', 'upsertVersion', 'upsertVersion']); - expect(productVersionDatabaseRepository.upsert).toHaveBeenNthCalledWith(1, 'uuid-1', { - barcode: 'b-s', - discountPrice: null, - hiboutikSizeId: 10, - price: '12.00', - product: { connect: { id: 'uuid-1' } }, - stock: 3, - versionName: 'S', - }); - expect(productVersionDatabaseRepository.upsert).toHaveBeenNthCalledWith(2, 'uuid-1', { - barcode: null, - discountPrice: null, - hiboutikSizeId: 11, - price: null, - product: { connect: { id: 'uuid-1' } }, - stock: 2, - versionName: 'M', - }); + expect(productVersionDatabaseRepository.upsert).toHaveBeenNthCalledWith( + 1, + 'uuid-1', + { + barcode: 'b-s', + discountPrice: null, + hiboutikSizeId: 10, + price: '12.00', + product: { connect: { id: 'uuid-1' } }, + stock: 3, + versionName: 'S', + }, + tx, + ); + expect(productVersionDatabaseRepository.upsert).toHaveBeenNthCalledWith( + 2, + 'uuid-1', + { + barcode: null, + discountPrice: null, + hiboutikSizeId: 11, + price: null, + product: { connect: { id: 'uuid-1' } }, + stock: 2, + versionName: 'M', + }, + tx, + ); }); it('keeps syncing the other products when one fails', async () => { @@ -168,4 +182,16 @@ describe('ProductHiboutikService', () => { expect(productDatabaseRepository.upsert).toHaveBeenCalledTimes(2); expect(productVersionDatabaseRepository.upsert).toHaveBeenCalledTimes(1); }); + + it('writes a product and its versions in one transaction per product, and counts a failed one', async () => { + productHiboutikRepository.getProducts.mockResolvedValue([hiboutikProduct(1), hiboutikProduct(2)]); + productVersionDatabaseRepository.upsert.mockRejectedValueOnce(new Error('boom')); + + await expect(service.syncProducts()).resolves.toBeUndefined(); + + expect(prisma.$transaction).toHaveBeenCalledTimes(2); + // the failure inside the transaction is what makes Prisma roll the product back + await expect(prisma.$transaction.mock.results[0].value).rejects.toThrow('boom'); + await expect(prisma.$transaction.mock.results[1].value).resolves.toBeUndefined(); + }); }); diff --git a/src/services/hiboutik/product.hiboutik.service.ts b/src/services/hiboutik/product.hiboutik.service.ts index e0172f7..a9d2ad0 100644 --- a/src/services/hiboutik/product.hiboutik.service.ts +++ b/src/services/hiboutik/product.hiboutik.service.ts @@ -9,6 +9,8 @@ import { StockHiboutikDto } from '../../models/hiboutikDTO/stock.hiboutik.dto'; import { Product } from '../../../prisma/generated/client'; import { ProductVersionCreateInput } from '../../../prisma/generated/models/ProductVersion'; import { parseOptionalHiboutikPrice } from '../../utils/price'; +import { PrismaService } from '../../../prisma/prisma.service'; +import { rethrowIfRateLimited } from '../../hiboutik/hiboutik.client'; /** Version id used for products that have no sizes in Hiboutik. */ const NO_SIZE_ID = 0; @@ -23,6 +25,7 @@ export class ProductHiboutikService { private readonly productVersionDatabaseRepository: ProductVersionDatabaseRepository, private readonly productConverter: ProductConverter, private readonly stockHiboutikRepository: StockHiboutikRepository, + private readonly prisma: PrismaService, ) {} /** Mirrors Hiboutik products, their versions (sizes) and stocks. Products missing from Hiboutik are deleted. */ @@ -33,16 +36,24 @@ export class ProductHiboutikService { this.productDatabaseRepository.getAll(), ]); - // Deletion happens before upserts so that a product recreated in Hiboutik (new id, same barcode) - // does not collide with the old one on the unique barcode. + // Deletion happens before the upserts, so that order lines of a deleted product are unlinked before anything else changes const nbDeleted = await this.deleteMissingProducts(productsHiboutik, existingProducts); + // Grouped once by product: one pass over the stocks instead of one per product + const stocksByProduct = new Map(); + for (const stock of hiboutikStocks) { + const productStocks = stocksByProduct.get(stock.product_id); + if (productStocks) productStocks.push(stock); + else stocksByProduct.set(stock.product_id, [stock]); + } + let nbFailed = 0; for (const productHiboutik of productsHiboutik) { - const hiboutikProductStocks = hiboutikStocks.filter((stock) => stock.product_id === Number(productHiboutik.product_id)); + const hiboutikProductStocks = stocksByProduct.get(productHiboutik.product_id) ?? []; try { await this.updateProduct(productHiboutik, hiboutikProductStocks); } catch (error) { + rethrowIfRateLimited(error); nbFailed++; this.logger.error(`Failed to sync Hiboutik product ${productHiboutik.product_id}`, (error as Error)?.stack); } @@ -56,27 +67,35 @@ export class ProductHiboutikService { /** @returns the number of deleted products. */ private async deleteMissingProducts(productsHiboutik: ProductHiboutikDto[], existingProducts: Product[]): Promise { // Hiboutik is trusted: a successful (2xx) answer, even an empty list, is the truth. An HTTP error throws before this point. - const hiboutikIds = new Set(productsHiboutik.map((product) => Number(product.product_id))); + const hiboutikIds = new Set(productsHiboutik.map((product) => product.product_id)); const missingProductIds = existingProducts.filter((product) => !hiboutikIds.has(product.hiboutikId)).map((product) => product.id); return this.productDatabaseRepository.deleteAll(missingProductIds); } - /** Upserts a product with its global stock, then replaces its versions. */ + /** + * Upserts a product with its global stock, then replaces its versions, in a single transaction: on failure, nothing + * is changed (no global stock out of step with the versions). + */ private async updateProduct(productHiboutik: ProductHiboutikDto, productStocks: StockHiboutikDto[]): Promise { const newProduct = this.productConverter.fromHiboutik(productHiboutik); - newProduct.globalStock = productStocks.map((stock) => Number(stock.stock_available)).reduce((sum, stock) => sum + stock, 0); + newProduct.globalStock = productStocks.reduce((sum, stock) => sum + stock.stock_available, 0); - const savedProduct: Product = await this.productDatabaseRepository.upsert(newProduct); - const versions = this.buildVersions(productHiboutik, productStocks, savedProduct); + await this.prisma.$transaction( + async (tx) => { + const savedProduct: Product = await this.productDatabaseRepository.upsert(newProduct, tx); + const versions = this.buildVersions(productHiboutik, productStocks, savedProduct); - // Same reason as for products: delete removed versions first to free their barcodes - await this.productVersionDatabaseRepository.deleteMissing( - savedProduct.id, - versions.map((version) => version.hiboutikSizeId), + await this.productVersionDatabaseRepository.deleteMissing( + savedProduct.id, + versions.map((version) => version.hiboutikSizeId), + tx, + ); + for (const version of versions) { + await this.productVersionDatabaseRepository.upsert(savedProduct.id, version, tx); + } + }, + { timeout: 15_000 }, ); - for (const version of versions) { - await this.productVersionDatabaseRepository.upsert(savedProduct.id, version); - } } /** @@ -88,8 +107,8 @@ export class ProductHiboutikService { productStocks: StockHiboutikDto[], savedProduct: Product, ): ProductVersionCreateInput[] { - const specificRules = productHiboutik.product_specific_rules ?? []; - const sizesDetails = productHiboutik.product_size_details ?? []; + const specificRules = productHiboutik.product_specific_rules; + const sizesDetails = productHiboutik.product_size_details; if (sizesDetails.length === 0) { return [ @@ -106,8 +125,8 @@ export class ProductHiboutikService { } return sizesDetails.map((sizeDetails) => { - const sizeId = Number(sizeDetails.size_id); - const specificRule = specificRules.find((rule) => Number(rule.size_id) === sizeId); + const sizeId = sizeDetails.size_id; + const specificRule = specificRules.find((rule) => rule.size_id === sizeId); const stock = productStocks.find((stock) => stock.product_size === sizeId); // Values are always set explicitly (null / 0 rather than undefined), otherwise Prisma would keep // the previous value on update when a rule or a stock is removed in Hiboutik. @@ -117,7 +136,7 @@ export class ProductHiboutikService { hiboutikSizeId: sizeId, price: parseOptionalHiboutikPrice(specificRule?.product_price), product: { connect: { id: savedProduct.id } }, - stock: stock ? Number(stock.stock_available) : 0, + stock: stock?.stock_available ?? 0, versionName: sizeDetails.size_name, }; }); diff --git a/src/services/hiboutik/supplier.hiboutik.service.ts b/src/services/hiboutik/supplier.hiboutik.service.ts index 9353aae..0bf6f01 100644 --- a/src/services/hiboutik/supplier.hiboutik.service.ts +++ b/src/services/hiboutik/supplier.hiboutik.service.ts @@ -26,7 +26,7 @@ export class SupplierHiboutikService { ); // Hiboutik is trusted: a successful (2xx) answer, even an empty list, is the truth. An HTTP error throws before this point. - const nbDeleted = await this.supplierDatabaseRepository.deleteMissing(hiboutikSuppliers.map((dto) => Number(dto.supplier_id))); + const nbDeleted = await this.supplierDatabaseRepository.deleteMissing(hiboutikSuppliers.map((dto) => dto.supplier_id)); this.logger.log( `Suppliers sync ended: ${hiboutikSuppliers.length - nbFailed}/${hiboutikSuppliers.length} synced, ${nbFailed} failed, ${nbDeleted} deleted.`, ); diff --git a/src/utils/date.spec.ts b/src/utils/date.spec.ts index 58f4198..d724d93 100644 --- a/src/utils/date.spec.ts +++ b/src/utils/date.spec.ts @@ -1,15 +1,35 @@ import { parseHiboutikDate } from './date'; describe('parseHiboutikDate', () => { - it('parses a Hiboutik datetime', () => { - expect(parseHiboutikDate('2026-06-28 17:02:55')).toEqual(new Date('2026-06-28T17:02:55')); + it('parses a Hiboutik datetime as French summer time (UTC+2)', () => { + expect(parseHiboutikDate('2026-06-28 17:02:55')).toEqual(new Date('2026-06-28T15:02:55Z')); }); - it('parses a date without time', () => { - expect(parseHiboutikDate('1990-05-12')).toEqual(new Date('1990-05-12')); + it('parses a Hiboutik datetime as French winter time (UTC+1)', () => { + expect(parseHiboutikDate('2026-01-15 10:00:00')).toEqual(new Date('2026-01-15T09:00:00Z')); }); - it.each(['', '0000-00-00', '0000-00-00 00:00:00', 'not a date', null, undefined])('returns undefined for %p', (value) => { + it('switches offset at the DST changes', () => { + expect(parseHiboutikDate('2026-03-29 01:59:59')).toEqual(new Date('2026-03-29T00:59:59Z')); + expect(parseHiboutikDate('2026-03-29 03:00:00')).toEqual(new Date('2026-03-29T01:00:00Z')); + expect(parseHiboutikDate('2026-10-25 03:00:00')).toEqual(new Date('2026-10-25T02:00:00Z')); + }); + + it('parses a date without time as midnight UTC (calendar date)', () => { + expect(parseHiboutikDate('1990-05-12')).toEqual(new Date('1990-05-12T00:00:00Z')); + }); + + it.each([ + '', + '0000-00-00', + '0000-00-00 00:00:00', + '2026-02-30', + '2026-13-01 10:00:00', + '2026-06-28 25:00:00', + 'not a date', + null, + undefined, + ])('returns undefined for %p', (value) => { expect(parseHiboutikDate(value)).toBeUndefined(); }); }); diff --git a/src/utils/date.ts b/src/utils/date.ts index 34bf91a..eeb64ae 100644 --- a/src/utils/date.ts +++ b/src/utils/date.ts @@ -1,10 +1,53 @@ +/** Time zone of the dates sent by Hiboutik: the shop's local (French) time, without any offset. */ +export const HIBOUTIK_TIME_ZONE = 'Europe/Paris'; + +const HIBOUTIK_DATE = /^(\d{4})-(\d{2})-(\d{2})(?:[ T](\d{2}):(\d{2})(?::(\d{2}))?)?$/; + +const timeZoneFormatter = new Intl.DateTimeFormat('en-US', { + timeZone: HIBOUTIK_TIME_ZONE, + hourCycle: 'h23', + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', +}); + +/** Offset (ms) of {@link HIBOUTIK_TIME_ZONE} from UTC at the given instant: +1 h in winter, +2 h in summer. */ +function timeZoneOffset(instant: number): number { + const parts = Object.fromEntries(timeZoneFormatter.formatToParts(new Date(instant)).map((part) => [part.type, Number(part.value)])); + const wallClockAsUtc = Date.UTC(parts.year, parts.month - 1, parts.day, parts.hour, parts.minute, parts.second); + return wallClockAsUtc - instant; +} + /** - * Parses a Hiboutik date ("YYYY-MM-DD HH:mm:ss" or "YYYY-MM-DD"). - * Returns undefined for empty or invalid dates (e.g. "0000-00-00"). + * Parses a Hiboutik date. Returns undefined for empty or invalid dates (e.g. "0000-00-00", "2026-02-30"). + * - "YYYY-MM-DD HH:mm:ss": French local time ({@link HIBOUTIK_TIME_ZONE}), whatever the time zone of the server. + * A time that is ambiguous or skipped at a DST change (02:00-03:00) resolves to a nearby valid instant. + * - "YYYY-MM-DD" (calendar date, e.g. a date of birth): midnight UTC, so that the stored date is the same day. */ export function parseHiboutikDate(value: string | null | undefined): Date | undefined { - if (!value) return undefined; + const match = HIBOUTIK_DATE.exec(value?.trim() ?? ''); + if (!match) return undefined; - const date = new Date(value.trim().replace(' ', 'T')); - return isNaN(date.getTime()) ? undefined : date; + const [year, month, day, hour, minute, second] = match.slice(1).map((part) => Number(part ?? 0)); + const wallClockAsUtc = Date.UTC(year, month - 1, day, hour, minute, second); + + // Rejects out-of-range values (month 0 or 13, February 30, 25:00...), which Date.UTC would silently roll over + const check = new Date(wallClockAsUtc); + const isValid = + check.getUTCFullYear() === year && + check.getUTCMonth() === month - 1 && + check.getUTCDate() === day && + check.getUTCHours() === hour && + check.getUTCMinutes() === minute && + check.getUTCSeconds() === second; + if (!isValid) return undefined; + + if (match[4] === undefined) return check; + + // The offset depends on the instant itself (DST): guess it from the wall clock, then correct it once + const firstGuess = wallClockAsUtc - timeZoneOffset(wallClockAsUtc); + return new Date(wallClockAsUtc - timeZoneOffset(firstGuess)); } diff --git a/src/utils/sync.ts b/src/utils/sync.ts index d35a6d1..454bc70 100644 --- a/src/utils/sync.ts +++ b/src/utils/sync.ts @@ -1,7 +1,9 @@ import { Logger } from '@nestjs/common'; +import { rethrowIfRateLimited } from '../hiboutik/hiboutik.client'; /** * Runs `action` on each item, one at a time: a failing item is logged and skipped, the others are still processed. + * A Hiboutik rate limit error stops the loop (rethrown): the next items would fail the same way. * @param items * @param action * @param logger @@ -19,6 +21,7 @@ export async function forEachSafely( try { await action(item); } catch (error) { + rethrowIfRateLimited(error); nbFailed++; logger.error(`Failed to sync ${describe(item)}`, (error as Error)?.stack); }