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:
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user