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 <noreply@anthropic.com>
This commit is contained in:
MastersGame
2026-11-08 12:20:58 +01:00
parent 4b0cda1740
commit 35a7c27cb7
44 changed files with 1065 additions and 467 deletions

View File

@@ -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();
});
});

View File

@@ -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));
}

View File

@@ -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<T>(
try {
await action(item);
} catch (error) {
rethrowIfRateLimited(error);
nbFailed++;
logger.error(`Failed to sync ${describe(item)}`, (error as Error)?.stack);
}