Compare commits
7 Commits
main
...
c5f65dcb62
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c5f65dcb62 | ||
|
|
27d0e0149d | ||
|
|
db699822f9 | ||
|
|
2424a086e0 | ||
|
|
8f377a1fe9 | ||
|
|
a0ddef240c | ||
|
|
24b699cfc4 |
@@ -1,17 +0,0 @@
|
|||||||
{
|
|
||||||
"permissions": {
|
|
||||||
"allow": [
|
|
||||||
"Bash(npm run lint)",
|
|
||||||
"Bash(npm run build)",
|
|
||||||
"Bash(npm test)",
|
|
||||||
"Bash(npm run test:e2e)",
|
|
||||||
"Bash(npx jest:*)",
|
|
||||||
"Bash(npx prisma generate --config prisma7.config.ts)"
|
|
||||||
],
|
|
||||||
"deny": [
|
|
||||||
"Read(./.env)",
|
|
||||||
"Read(./.env.*)",
|
|
||||||
"Bash(cat .env*)"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
1
.gitattributes
vendored
1
.gitattributes
vendored
@@ -1 +0,0 @@
|
|||||||
* text=auto eol=lf
|
|
||||||
17
.gitignore
vendored
17
.gitignore
vendored
@@ -55,20 +55,3 @@ report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
|
|||||||
|
|
||||||
# Prisma client (regenerated by `prisma generate`, run on npm install)
|
# Prisma client (regenerated by `prisma generate`, run on npm install)
|
||||||
/prisma/generated/
|
/prisma/generated/
|
||||||
|
|
||||||
# AI agent skills installed from skills-lock.json (keep skills-lock.json committed)
|
|
||||||
/.agents/
|
|
||||||
/.windsurf/
|
|
||||||
/.claude/skills/
|
|
||||||
# Claude Code personal settings (.claude/settings.json stays shareable)
|
|
||||||
/.claude/settings.local.json
|
|
||||||
|
|
||||||
# Postman: collections, specs and flows are versioned with the code.
|
|
||||||
# Ignored: skills and SDKs (generated by the CLI), globals and environments (may hold personal values or secrets),
|
|
||||||
# and .postman/ (binding to each developer's own workspace).
|
|
||||||
/postman/*
|
|
||||||
!/postman/collections/
|
|
||||||
!/postman/specs/
|
|
||||||
!/postman/flows/
|
|
||||||
/.postman/
|
|
||||||
/AGENTS.md
|
|
||||||
|
|||||||
97
CLAUDE.md
97
CLAUDE.md
@@ -1,97 +0,0 @@
|
|||||||
# CLAUDE.md
|
|
||||||
|
|
||||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
|
||||||
|
|
||||||
## What this is
|
|
||||||
|
|
||||||
Backend of a K-pop online shop. The real shop (catalog, stock, customers, sales) lives in **Hiboutik**, a POS/e-commerce platform; Hiboutik stays the source of truth. This backend sits in front of it as a buffer: it mirrors Hiboutik data into its own database so the storefront does not hammer the Hiboutik API with requests. It is also the place to build shop features Hiboutik does not offer, on top of the mirrored data.
|
|
||||||
|
|
||||||
Stack: NestJS 11, PostgreSQL via **Prisma 7**, Hiboutik REST API as the upstream.
|
|
||||||
|
|
||||||
## Commands
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm run start:dev # watch mode (port from PORT, default 3000)
|
|
||||||
npm run build # nest build -> dist/
|
|
||||||
npm run lint # eslint --fix on src/test
|
|
||||||
npm run format # prettier (singleQuote, trailingComma all, printWidth 140)
|
|
||||||
npm test # unit tests (*.spec.ts under src/)
|
|
||||||
npx jest src/app.controller.spec.ts # single test file
|
|
||||||
npx jest -t "test name" # single test by name
|
|
||||||
npm run test:e2e # e2e tests (test/jest-e2e.json)
|
|
||||||
```
|
|
||||||
|
|
||||||
Jest runs in CommonJS: its config (in `package.json` and `test/jest-e2e.json`) transpiles the ESM-only `@nestjs/config` (`transformIgnorePatterns`) and maps `./x.js` imports to `./x` (`moduleNameMapper`) for the generated Prisma client. Keep both configs in sync. Unit tests build services directly with mocked repositories; the e2e test boots `AppModule` with `PrismaService` and `HiboutikService` overridden, so it needs neither a database nor Hiboutik.
|
|
||||||
|
|
||||||
TypeScript runs in `strict` mode and ESLint treats floating promises as errors: a deliberate fire-and-forget call is written `void promise`.
|
|
||||||
|
|
||||||
Prisma: the config file is `prisma7.config.ts` (non-default name), so pass it explicitly:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npx prisma generate --config prisma7.config.ts
|
|
||||||
npx prisma migrate dev --name <name> --config prisma7.config.ts
|
|
||||||
```
|
|
||||||
|
|
||||||
The client is generated into `prisma/generated/` (generator `prisma-client`, not the legacy `prisma-client-js`) and must be regenerated after any `schema.prisma` change. Code imports types from there, e.g. `prisma/generated/client` and `prisma/generated/models/<Model>` (`ProductCreateInput`, etc.).
|
|
||||||
|
|
||||||
## Environment
|
|
||||||
|
|
||||||
Read from `.env` (`@nestjs/config`, global): `DATABASE_URL` (used by Prisma and `PrismaService`), `HIBOUTIK_BASE_URL`, `HIBOUTIK_USERNAME`, `HIBOUTIK_PASSWORD` (HTTP Basic auth). HTTP calls to Hiboutik (`HttpModule.register` in `AppModule`) time out after 30 s, never follow redirects (the Basic auth credentials must not leave) and are capped at 50 MB. `HiboutikClient` retries a 429 three times with backoff (2/4/8 s, or the `Retry-After` delay capped at 60 s), then throws `HiboutikRateLimitError`: per-item `catch` blocks must rethrow it (`rethrowIfRateLimited`) and `syncAll` stops on it. Hiboutik allows 10,000 calls per month and 30 concurrent calls per shop.
|
|
||||||
|
|
||||||
The environment is validated at startup (`src/config/env.validation.ts`, wired in `ConfigModule.forRoot`): a missing or malformed required variable stops the app, and `HIBOUTIK_BASE_URL` must be HTTPS (Basic auth). Optional variables: `NODE_ENV` (`development`/`production`/`test`; in `production`, `DATABASE_URL` must set `sslmode=require`, `verify-ca` or `verify-full`), `PORT`, `CORS_ORIGINS` (comma-separated; CORS stays disabled when unset), `TRUST_PROXY` (number of reverse proxies, so rate limiting sees the real client IP) and `SYNC_SHUTDOWN_TIMEOUT` (seconds a stopping instance waits for its running sync, default 25; keep it below the orchestrator's grace period). Any new variable goes in `EnvironmentVariables`; the e2e test gets dummy values from `test/setup-env.ts`.
|
|
||||||
|
|
||||||
Security defaults in `main.ts`/`AppModule`: Helmet, global `ValidationPipe`, `ThrottlerGuard` on every route (60 req/min/IP; `@Throttle` for stricter limits, e.g. `POST /sync` at 5/min; counters shared by every instance in the `throttler_records` table through `ThrottlerDatabaseStorage`, one atomic upsert per request on the database clock, failing open if the database is unreachable), `AllExceptionsFilter` (unexpected errors are logged with message + stack and answered with a generic 500; deliberate `HttpException`s and 4xx HTTP-layer errors pass through), HTTP header/request timeouts (15 s / 30 s), shutdown hooks. The app is meant to run as several instances. On shutdown, `HiboutikService.beforeApplicationShutdown` refuses new syncs (503) and waits for the running one up to `SYNC_SHUTDOWN_TIMEOUT`; Prisma disconnects last, in `onApplicationShutdown` (never move it back to `onModuleDestroy`, which runs first). Long work must run in the background (like `POST /sync`), never inside a request.
|
|
||||||
|
|
||||||
## Architecture
|
|
||||||
|
|
||||||
Everything is registered as providers in a single `AppModule` (`src/app.module.ts`); new repositories/converters/services must be added there. `PrismaModule` (in `prisma/`, outside `src/`) is global.
|
|
||||||
|
|
||||||
Sync pipeline, one entity per layer:
|
|
||||||
|
|
||||||
1. **`src/hiboutik/*.hiboutik.repository.ts`** – fetch data from Hiboutik through `HiboutikClient`: `getList`, `getAllPages` (walks the `p` page param until a page smaller than 250 results, Hiboutik's page size) and `getOne` (single resource: `null` on a 404 or an empty 2xx answer, throws on anything else). `/suppliers` and `/stock_available/all_wh` are not paginated.
|
|
||||||
2. **`src/models/hiboutikDTO/`** – zod schemas of the Hiboutik payloads (snake_case, only the fields used), the DTO types being inferred from them. `HiboutikClient` validates every answer: shared field types in `hiboutik.schema.ts` turn numeric strings into numbers and empty values into `0`/`""`/`[]`; an invalid id, relation or price rejects the whole answer (so nothing is deleted). Validation errors name fields, never values.
|
|
||||||
3. **`src/models/converter/`** – classes extending abstract `Converter<Model, Dto>`; `fromHiboutik` maps a DTO to a Prisma `*CreateInput`. Relations use `connect: { hiboutikId }`.
|
|
||||||
4. **`src/database/*.database.repository.ts`** – Prisma access, mainly `upsert` keyed on the unique `hiboutikId`.
|
|
||||||
5. **`src/services/hiboutik/*.hiboutik.service.ts`** – per-entity sync orchestration; `HiboutikService.syncAll` runs them in dependency order: brands → categories → suppliers → products → customers (products `connect` to the first three by `hiboutikId`, so order matters).
|
|
||||||
|
|
||||||
Domain notes:
|
|
||||||
- Synced models carry a unique `hiboutikId` (join key with Hiboutik), except `Order` (nullable `hiboutikSaleId`), `ProductVersion` (unique `[productId, hiboutikSizeId]`) and `OrderLine`; the internal `id` is a UUID. Empty Hiboutik barcodes are stored as `null`. `ProductVersion.barcode` is indexed but **not unique** (Hiboutik may reuse a barcode).
|
|
||||||
- Hiboutik is the source of truth: brands, categories, suppliers, products and customers missing from it are deleted. Hiboutik is trusted: a 2xx answer is the truth, even an empty list (everything is then deleted). Deletion only requires that the call did not fail: `HiboutikClient` throws on HTTP errors (4xx/5xx) and `getList`/`getAllPages` also throw on a 2xx body that is not a list (a 2xx without body is an empty list), so a failed step deletes nothing. Every call goes through `getList`/`getAllPages`/`getAllPagesForSync`/`getOne` with a schema (the bare `get` is private). A paginated list that drives deletions must guard against page shifts (an item created or deleted during the walk shifts the following pages, so an item can be skipped and wrongly deleted), and be sorted on a unique key. Brands, categories and products use `getAllPagesForSync`: a list spanning several pages is walked twice and both walks are merged by id. Customers do not, as each one is fetched anyway: as a customer created or deleted during the walk shifts the pages, a stored customer missing from the list is deleted only once `GET /customer/:id` confirms it (404 or empty answer); if it still exists it is synced, if the check fails it is kept. Products are deleted before the upserts; their versions go by cascade. Each product is written in one transaction (upsert, deletion of its missing versions, version upserts), with the repositories taking an optional `Prisma.TransactionClient`. Customer addresses go by cascade with their customer; other relations (product brand/category/supplier, sub-categories, order customer) are set to null and orders are kept.
|
|
||||||
- Brands, categories and suppliers are upserted through `forEachSafely` (`src/utils/sync.ts`): a failing row is logged and skipped. Each sync step and each product/customer is in a try/catch: failures are logged, the sync goes on, and a summary is logged at the end.
|
|
||||||
- Categories form a tree: parents are linked in a second pass (`setParent`). `enabled` mirrors `category_enabled` (POS), `webEnabled` mirrors `category_enabled_www`.
|
|
||||||
- Products: `ProductVersion` rows come from `product_size_details` (barcode, price/discount overrides from `product_specific_rules`, stock from the stocks endpoint); no sizes means one version with `hiboutikSizeId: 0`. `globalStock` is the sum of available stock. `archived` mirrors `product_arch`.
|
|
||||||
- `GET /customer/:id` embeds `addresses` and `sales` (typed in `customer.hiboutik.dto.ts`); `sales` only has headers, lines come from `GET /sales/:id` (`SaleHiboutikRepository`).
|
|
||||||
- Addresses (from the embedded array): missing ones are deleted, the rest upserted via `forEachSafely`; no array means stored addresses are left untouched. Each has its own recipient fields, empty optional values stored as `null`. Company details (`taxNumber`, `companyNumber`, `legalStatus`) come from `GET /customers_addresses/:id`, only for addresses with a `company` (`null` otherwise, untouched if the call fails).
|
|
||||||
- Customers: an email or phone removed in Hiboutik is erased in database (`null`, data minimization).
|
|
||||||
- Dates: Hiboutik sends French local time without offset; `parseHiboutikDate` reads datetimes as `Europe/Paris` whatever the server time zone, and date-only values (date of birth) as midnight UTC.
|
|
||||||
- Orders come from the customer's `sales`: sales already stored as `COMPLETED` and linked to this customer are skipped (an order that lost its customer is fetched again to be linked back), the others are fetched and upserted on `hiboutikSaleId` (`source: STORE` on creation only). `status` is `COMPLETED` or `PENDING`, `orderedAt` is the completion date else the creation date. Lines are replaced on each upsert and snapshot name, version, barcode, price and Hiboutik ids; the `ProductVersion` link is optional (`SetNull`). `unitPrice` is the paid price, taxes and discount included. Quotations are skipped, anonymous sales are never synced (only customers' sales are read), and orders are never deleted.
|
|
||||||
- Slugs (SEO): `Brand`, `Category` and `Product` have a unique `slug`, generated once by `SlugService.assignMissingSlugs` at the end of their sync step and never overwritten by the sync (an admin may replace it). Generation handles the oldest `hiboutikId` first: the slugified name, else (sub-categories only) `<parent name>-<name>`, else `name-<hiboutikId>` on a collision (logged as a warning), else `brand|category|product-<hiboutikId>` for a name without latin letters. `SlugService.changeSlug` keeps the former slug in `SlugRedirect` (for a 301); slugs of redirects stay reserved (an entity may only take back its own former slug). `GET /sitemap` lists the public slugs (brands `enabled`, categories `webEnabled`, products `enabled` and not `archived`) for the storefront's sitemap.xml; only products carry a date, `hiboutikUpdatedAt` (the row's `updatedAt` changes on every sync).
|
|
||||||
- Shared Prisma payload types (e.g. `ProductWithVersions`) live in `prisma/customObjects/`.
|
|
||||||
|
|
||||||
## Input validation
|
|
||||||
|
|
||||||
All data coming from API clients (`@Body()`, `@Query()`, `@Param()`) must be validated with NestJS `ValidationPipe` and `class-validator` DTO classes.
|
|
||||||
|
|
||||||
- Register `ValidationPipe` globally in `main.ts` with `whitelist: true`, `forbidNonWhitelisted: true` and `transform: true` (no `if` checks by hand in controllers/services).
|
|
||||||
- Every endpoint that reads a body, query string or route param takes a dedicated input DTO class decorated with `class-validator` rules (type, length, range, format). Cap pagination (`limit`/`take`) with `@Max`. For nested payloads use `@ValidateNested()` + `@Type()`.
|
|
||||||
- Never use Prisma models or Hiboutik DTOs as input types: the `src/models/hiboutikDTO/` types describe data coming from Hiboutik, not from API clients.
|
|
||||||
|
|
||||||
## Security rules
|
|
||||||
|
|
||||||
- **Logs**: never log a raw error object, a request/response or an axios config: they carry the Hiboutik `Authorization` header (credentials). Log `(error as Error).message` and `.stack` only, as the sync services do. No personal data (emails, names, addresses, phone numbers, tax numbers) in logs: ids only. `PrismaService` uses `errorFormat: 'minimal'` because the default error format prints the query arguments; keep it.
|
|
||||||
- **SQL**: use the Prisma query API. `$queryRaw`/`$executeRaw` are allowed only as tagged templates with parameters (`` $queryRaw`... ${value}` ``); never `$queryRawUnsafe`/`$executeRawUnsafe` nor string concatenation or interpolation into SQL text.
|
|
||||||
- **Sync lock**: only one synchronization runs at a time across all instances. `HiboutikService.startSync` takes a Postgres advisory lock (`SyncLockDatabaseRepository`, dedicated `pg` connection held for the whole sync, released by Postgres itself if the process dies). Start syncs only through `startSync`, never call `syncAll` directly from a controller or a scheduler.
|
|
||||||
|
|
||||||
## API
|
|
||||||
|
|
||||||
If the Postman plugin is installed, load its `postman:api-engineer` skill before any non-trivial API engineering task (designing, implementing, mocking, testing, monitoring, documenting, deploying). Pure questions and trivial one-line edits do not need it. The API collection lives in `postman/collections/` as v3 YAML and is versioned with the code: update it with the API, check with `postman collection lint` and `postman workspace diff --push-strategy default`, and only `postman workspace push` with the user's consent. Each developer binds the folder to their own Postman workspace (`.postman/resources.yaml`, git-ignored, created by `postman workspace connect-git <workspace-id>` or `postman init`). Also versioned: `postman/specs/`, `postman/flows/`; git-ignored: skills and SDKs (generated), globals and environments (personal values, secrets).
|
|
||||||
|
|
||||||
## Git workflow
|
|
||||||
|
|
||||||
The remote is a Gitea instance (`origin`). Use plain `git` or the Gitea CLI (`gt`) for pull requests and other Gitea operations.
|
|
||||||
|
|
||||||
- Never commit or push directly to the integration branch: always push changes on a `feature/<name>` branch.
|
|
||||||
- Merges go into `develop`. Until `develop` exists (early project phase), they go into `main`.
|
|
||||||
- Commit messages follow Conventional Commits (`feat(sync): ...`, `fix(db): ...`).
|
|
||||||
- Commit messages and PR/MR content must contain a single-line title only.
|
|
||||||
Do not include any body description or co-author metadata (e.g., "Co-authored-by: Claude").
|
|
||||||
@@ -31,14 +31,6 @@
|
|||||||
$ npm install
|
$ npm install
|
||||||
```
|
```
|
||||||
|
|
||||||
### AI agent skills (optional)
|
|
||||||
|
|
||||||
The Prisma skills used by Claude Code are pinned in `skills-lock.json`; the skill folders themselves are not committed. Restore them with:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
$ npx skills experimental_install
|
|
||||||
```
|
|
||||||
|
|
||||||
## Compile and run the project
|
## Compile and run the project
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ export default tseslint.config(
|
|||||||
{
|
{
|
||||||
rules: {
|
rules: {
|
||||||
'@typescript-eslint/no-explicit-any': 'off',
|
'@typescript-eslint/no-explicit-any': 'off',
|
||||||
'@typescript-eslint/no-floating-promises': 'error',
|
'@typescript-eslint/no-floating-promises': 'warn',
|
||||||
'@typescript-eslint/no-unsafe-argument': 'warn',
|
'@typescript-eslint/no-unsafe-argument': 'warn',
|
||||||
"prettier/prettier": "error",
|
"prettier/prettier": "error",
|
||||||
},
|
},
|
||||||
|
|||||||
685
package-lock.json
generated
685
package-lock.json
generated
@@ -11,28 +11,23 @@
|
|||||||
"license": "UNLICENSED",
|
"license": "UNLICENSED",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@nestjs/axios": "^4.0.1",
|
"@nestjs/axios": "^4.0.1",
|
||||||
"@nestjs/common": "^11.1.29",
|
"@nestjs/common": "^11.0.1",
|
||||||
"@nestjs/config": "^12.0.0",
|
"@nestjs/config": "^12.0.0",
|
||||||
"@nestjs/core": "^11.1.29",
|
"@nestjs/core": "^11.0.1",
|
||||||
"@nestjs/platform-express": "^11.1.29",
|
"@nestjs/platform-express": "^11.0.1",
|
||||||
"@nestjs/throttler": "^6.7.1",
|
|
||||||
"@prisma/adapter-pg": "^7.10.0",
|
"@prisma/adapter-pg": "^7.10.0",
|
||||||
"@prisma/client": "^7.10.0",
|
"@prisma/client": "^7.10.0",
|
||||||
"axios": "1.20.0",
|
"axios": "1.20.0",
|
||||||
"class-transformer": "^0.5.1",
|
|
||||||
"class-validator": "^0.15.1",
|
|
||||||
"helmet": "^8.3.0",
|
|
||||||
"pg": "^8.23.0",
|
"pg": "^8.23.0",
|
||||||
"reflect-metadata": "^0.2.2",
|
"reflect-metadata": "^0.2.2",
|
||||||
"rxjs": "^7.8.1",
|
"rxjs": "^7.8.1"
|
||||||
"zod": "4.6.7"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/eslintrc": "^3.2.0",
|
"@eslint/eslintrc": "^3.2.0",
|
||||||
"@eslint/js": "^9.18.0",
|
"@eslint/js": "^9.18.0",
|
||||||
"@nestjs/cli": "^12.0.11",
|
"@nestjs/cli": "^12.0.11",
|
||||||
"@nestjs/schematics": "^11.0.0",
|
"@nestjs/schematics": "^11.0.0",
|
||||||
"@nestjs/testing": "^11.1.29",
|
"@nestjs/testing": "^11.0.1",
|
||||||
"@types/express": "^5.0.0",
|
"@types/express": "^5.0.0",
|
||||||
"@types/jest": "^30.0.0",
|
"@types/jest": "^30.0.0",
|
||||||
"@types/node": "^24.0.0",
|
"@types/node": "^24.0.0",
|
||||||
@@ -55,10 +50,49 @@
|
|||||||
"typescript-eslint": "^8.20.0"
|
"typescript-eslint": "^8.20.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@angular-devkit/core": {
|
"node_modules/@angular-devkit/schematics": {
|
||||||
"version": "22.2.6",
|
"version": "22.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/@angular-devkit/core/-/core-22.2.6.tgz",
|
"resolved": "https://registry.npmjs.org/@angular-devkit/schematics/-/schematics-22.2.3.tgz",
|
||||||
"integrity": "sha512-OtFUe9fLXpHsqO6gCSbTWIaTO64MCrESRVigs9SAW4EzIagCAhgB0Y8GflTjmKGLvJjilBFJFd0JSIzNxLyifA==",
|
"integrity": "sha512-+XfBViIGqiFQK8KJXA6PgANX9tLuMO/x8GFjlzEmA4LIe9ikAtpGZw+Ezo547BIJQoJBIKCJYR0knE4BmbFeSQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@angular-devkit/core": "22.2.3",
|
||||||
|
"jsonc-parser": "3.3.1",
|
||||||
|
"magic-string": "1.4.1",
|
||||||
|
"ora": "9.4.1",
|
||||||
|
"rxjs": "7.8.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "^22.22.3 || ^24.15.0 || >=26.0.0",
|
||||||
|
"npm": "^6.11.0 || ^7.5.6 || >=8.0.0",
|
||||||
|
"yarn": ">= 1.13.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@angular-devkit/schematics-cli": {
|
||||||
|
"version": "22.2.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@angular-devkit/schematics-cli/-/schematics-cli-22.2.3.tgz",
|
||||||
|
"integrity": "sha512-GebRkurUplPBwJOz/LT40ZZ0Hxv0EzpllJD1Oj7Bjkk/9A8PKGPDSRgEGfWcVk2h35V4JNNC0g6pSMj9s0dWxg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@angular-devkit/core": "22.2.3",
|
||||||
|
"@angular-devkit/schematics": "22.2.3",
|
||||||
|
"@inquirer/prompts": "8.7.2"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"schematics": "bin/schematics.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "^22.22.3 || ^24.15.0 || >=26.0.0",
|
||||||
|
"npm": "^6.11.0 || ^7.5.6 || >=8.0.0",
|
||||||
|
"yarn": ">= 1.13.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@angular-devkit/schematics-cli/node_modules/@angular-devkit/core": {
|
||||||
|
"version": "22.2.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@angular-devkit/core/-/core-22.2.3.tgz",
|
||||||
|
"integrity": "sha512-bi1oBjJeg1XCgRrBYSuJikaJV4sO/mcnyFE/0a1H646yHhQpVYoU/M2Vh69Qv8h+YoMfC7RuSxT4LZHaubhhLw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -83,92 +117,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@angular-devkit/core/node_modules/ajv": {
|
|
||||||
"version": "8.20.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz",
|
|
||||||
"integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"fast-deep-equal": "^3.1.3",
|
|
||||||
"fast-uri": "^3.0.1",
|
|
||||||
"json-schema-traverse": "^1.0.0",
|
|
||||||
"require-from-string": "^2.0.2"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"type": "github",
|
|
||||||
"url": "https://github.com/sponsors/epoberezkin"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@angular-devkit/core/node_modules/json-schema-traverse": {
|
|
||||||
"version": "1.0.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
|
|
||||||
"integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/@angular-devkit/core/node_modules/picomatch": {
|
|
||||||
"version": "4.0.7",
|
|
||||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz",
|
|
||||||
"integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=12"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://github.com/sponsors/jonschlinkert"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@angular-devkit/core/node_modules/source-map": {
|
|
||||||
"version": "0.8.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.8.0.tgz",
|
|
||||||
"integrity": "sha512-d8EqvL+k/SOXCreS/SUzg2ciyHqBBLcN/yuRjFsbvVhHTE2pgei7oAhmPM7kWFbkX6OSMQfUq4KbkF3au9lhYQ==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "BSD-3-Clause",
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 12"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@angular-devkit/schematics": {
|
|
||||||
"version": "22.2.6",
|
|
||||||
"resolved": "https://registry.npmjs.org/@angular-devkit/schematics/-/schematics-22.2.6.tgz",
|
|
||||||
"integrity": "sha512-XACkbAmAhBGDfxr8Uny2k0ErDwLpauseccNWdIFs4fiisGTqiCByzkH60/4xTmbV3FTj8bQ6cXYIhhaqe0MK1g==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@angular-devkit/core": "22.2.6",
|
|
||||||
"jsonc-parser": "3.3.1",
|
|
||||||
"magic-string": "1.4.1",
|
|
||||||
"ora": "9.4.1",
|
|
||||||
"rxjs": "7.8.2"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": "^22.22.3 || ^24.15.0 || >=26.0.0",
|
|
||||||
"npm": "^6.11.0 || ^7.5.6 || >=8.0.0",
|
|
||||||
"yarn": ">= 1.13.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@angular-devkit/schematics-cli": {
|
|
||||||
"version": "22.2.6",
|
|
||||||
"resolved": "https://registry.npmjs.org/@angular-devkit/schematics-cli/-/schematics-cli-22.2.6.tgz",
|
|
||||||
"integrity": "sha512-+/zAu8jAdB5Hd6a4hYwurdwalC9wv2ElyDeDPyWZf4b8vZRdIaZwoYvrK91ArQGWn9Y3DrV3i0fpxHY38L91Pg==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@angular-devkit/core": "22.2.6",
|
|
||||||
"@angular-devkit/schematics": "22.2.6",
|
|
||||||
"@inquirer/prompts": "8.7.2"
|
|
||||||
},
|
|
||||||
"bin": {
|
|
||||||
"schematics": "bin/schematics.js"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": "^22.22.3 || ^24.15.0 || >=26.0.0",
|
|
||||||
"npm": "^6.11.0 || ^7.5.6 || >=8.0.0",
|
|
||||||
"yarn": ">= 1.13.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@angular-devkit/schematics-cli/node_modules/@inquirer/prompts": {
|
"node_modules/@angular-devkit/schematics-cli/node_modules/@inquirer/prompts": {
|
||||||
"version": "8.7.2",
|
"version": "8.7.2",
|
||||||
"resolved": "https://registry.npmjs.org/@inquirer/prompts/-/prompts-8.7.2.tgz",
|
"resolved": "https://registry.npmjs.org/@inquirer/prompts/-/prompts-8.7.2.tgz",
|
||||||
@@ -199,6 +147,98 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@angular-devkit/schematics-cli/node_modules/ajv": {
|
||||||
|
"version": "8.20.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz",
|
||||||
|
"integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"fast-deep-equal": "^3.1.3",
|
||||||
|
"fast-uri": "^3.0.1",
|
||||||
|
"json-schema-traverse": "^1.0.0",
|
||||||
|
"require-from-string": "^2.0.2"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/epoberezkin"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@angular-devkit/schematics-cli/node_modules/json-schema-traverse": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@angular-devkit/schematics-cli/node_modules/picomatch": {
|
||||||
|
"version": "4.0.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz",
|
||||||
|
"integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/jonschlinkert"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@angular-devkit/schematics-cli/node_modules/source-map": {
|
||||||
|
"version": "0.8.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.8.0.tgz",
|
||||||
|
"integrity": "sha512-d8EqvL+k/SOXCreS/SUzg2ciyHqBBLcN/yuRjFsbvVhHTE2pgei7oAhmPM7kWFbkX6OSMQfUq4KbkF3au9lhYQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "BSD-3-Clause",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@angular-devkit/schematics/node_modules/@angular-devkit/core": {
|
||||||
|
"version": "22.2.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@angular-devkit/core/-/core-22.2.3.tgz",
|
||||||
|
"integrity": "sha512-bi1oBjJeg1XCgRrBYSuJikaJV4sO/mcnyFE/0a1H646yHhQpVYoU/M2Vh69Qv8h+YoMfC7RuSxT4LZHaubhhLw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"ajv": "8.20.0",
|
||||||
|
"ajv-formats": "3.0.1",
|
||||||
|
"jsonc-parser": "3.3.1",
|
||||||
|
"picomatch": "4.0.7",
|
||||||
|
"rxjs": "7.8.2",
|
||||||
|
"source-map": "0.8.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "^22.22.3 || ^24.15.0 || >=26.0.0",
|
||||||
|
"npm": "^6.11.0 || ^7.5.6 || >=8.0.0",
|
||||||
|
"yarn": ">= 1.13.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"chokidar": "^5.0.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"chokidar": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@angular-devkit/schematics/node_modules/ajv": {
|
||||||
|
"version": "8.20.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz",
|
||||||
|
"integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"fast-deep-equal": "^3.1.3",
|
||||||
|
"fast-uri": "^3.0.1",
|
||||||
|
"json-schema-traverse": "^1.0.0",
|
||||||
|
"require-from-string": "^2.0.2"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/epoberezkin"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@angular-devkit/schematics/node_modules/ansi-regex": {
|
"node_modules/@angular-devkit/schematics/node_modules/ansi-regex": {
|
||||||
"version": "6.5.0",
|
"version": "6.5.0",
|
||||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.5.0.tgz",
|
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.5.0.tgz",
|
||||||
@@ -280,6 +320,13 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@angular-devkit/schematics/node_modules/json-schema-traverse": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@angular-devkit/schematics/node_modules/log-symbols": {
|
"node_modules/@angular-devkit/schematics/node_modules/log-symbols": {
|
||||||
"version": "7.0.1",
|
"version": "7.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-7.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-7.0.1.tgz",
|
||||||
@@ -346,6 +393,19 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@angular-devkit/schematics/node_modules/picomatch": {
|
||||||
|
"version": "4.0.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz",
|
||||||
|
"integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/jonschlinkert"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@angular-devkit/schematics/node_modules/restore-cursor": {
|
"node_modules/@angular-devkit/schematics/node_modules/restore-cursor": {
|
||||||
"version": "5.1.0",
|
"version": "5.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-5.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-5.1.0.tgz",
|
||||||
@@ -363,6 +423,16 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@angular-devkit/schematics/node_modules/source-map": {
|
||||||
|
"version": "0.8.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.8.0.tgz",
|
||||||
|
"integrity": "sha512-d8EqvL+k/SOXCreS/SUzg2ciyHqBBLcN/yuRjFsbvVhHTE2pgei7oAhmPM7kWFbkX6OSMQfUq4KbkF3au9lhYQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "BSD-3-Clause",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 12"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@angular-devkit/schematics/node_modules/string-width": {
|
"node_modules/@angular-devkit/schematics/node_modules/string-width": {
|
||||||
"version": "8.3.0",
|
"version": "8.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/string-width/-/string-width-8.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/string-width/-/string-width-8.3.0.tgz",
|
||||||
@@ -1438,22 +1508,22 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@inquirer/prompts": {
|
"node_modules/@inquirer/prompts": {
|
||||||
"version": "8.7.5",
|
"version": "8.7.3",
|
||||||
"resolved": "https://registry.npmjs.org/@inquirer/prompts/-/prompts-8.7.5.tgz",
|
"resolved": "https://registry.npmjs.org/@inquirer/prompts/-/prompts-8.7.3.tgz",
|
||||||
"integrity": "sha512-l/IWwC+QhOyXgxkZloOh+n5quGzc1m5ZawyzBmoizCZaxbJeLRdS3pStoqg/JVInJRXaG5aHMhjK6H6oYiBC9A==",
|
"integrity": "sha512-DHK20kO+H5k/XcJQpHQUpVQbMYHHhZchlGWhZVMUV4WIuRuBW5744hipOtriE1vMqPLgnIyXFMHWKkIbTepa4Q==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@inquirer/checkbox": "^5.2.8",
|
"@inquirer/checkbox": "^5.2.6",
|
||||||
"@inquirer/confirm": "^6.3.5",
|
"@inquirer/confirm": "^6.3.3",
|
||||||
"@inquirer/editor": "^5.3.6",
|
"@inquirer/editor": "^5.3.4",
|
||||||
"@inquirer/expand": "^5.1.8",
|
"@inquirer/expand": "^5.1.6",
|
||||||
"@inquirer/input": "^5.1.9",
|
"@inquirer/input": "^5.1.7",
|
||||||
"@inquirer/number": "^4.2.6",
|
"@inquirer/number": "^4.2.4",
|
||||||
"@inquirer/password": "^5.2.5",
|
"@inquirer/password": "^5.2.3",
|
||||||
"@inquirer/rawlist": "^5.3.8",
|
"@inquirer/rawlist": "^5.3.6",
|
||||||
"@inquirer/search": "^4.3.6",
|
"@inquirer/search": "^4.3.4",
|
||||||
"@inquirer/select": "^5.2.8"
|
"@inquirer/select": "^5.2.6"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=23.5.0 || ^22.13.0 || ^20.17.0"
|
"node": ">=23.5.0 || ^22.13.0 || ^20.17.0"
|
||||||
@@ -1657,13 +1727,6 @@
|
|||||||
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
|
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@isaacs/nonrecursive-glob-match": {
|
|
||||||
"version": "1.1.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/@isaacs/nonrecursive-glob-match/-/nonrecursive-glob-match-1.1.0.tgz",
|
|
||||||
"integrity": "sha512-GPm/Dbvk5RLmhRnxXIa7nTpK0xtl4E0wj0MslsuNiJ1FIROU+2+9lgUC/dgF7Ey4QCWhfEsgxilMRMCdHPfDfw==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "BlueOak-1.0.0"
|
|
||||||
},
|
|
||||||
"node_modules/@istanbuljs/load-nyc-config": {
|
"node_modules/@istanbuljs/load-nyc-config": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz",
|
||||||
@@ -2222,23 +2285,23 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@nestjs/cli": {
|
"node_modules/@nestjs/cli": {
|
||||||
"version": "12.0.12",
|
"version": "12.0.11",
|
||||||
"resolved": "https://registry.npmjs.org/@nestjs/cli/-/cli-12.0.12.tgz",
|
"resolved": "https://registry.npmjs.org/@nestjs/cli/-/cli-12.0.11.tgz",
|
||||||
"integrity": "sha512-6Ab0zxLqYzZD8aEYWvleCIkvxJhcUtjR1VfQ/Hqg1/xhinKmSaFXEbFxc3QgqsJdoM//hRoMl0iQ4sudam1pjg==",
|
"integrity": "sha512-ExNgbKfhwMNXCV6FbQQnQOcxuubkDPcpYXUh+jkFn5Xm+WkdP+wjYS0KqyZgFSfpY24mST6y50UOEHVsvQME1A==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@angular-devkit/core": "22.2.6",
|
"@angular-devkit/core": "22.2.3",
|
||||||
"@angular-devkit/schematics": "22.2.6",
|
"@angular-devkit/schematics": "22.2.3",
|
||||||
"@angular-devkit/schematics-cli": "22.2.6",
|
"@angular-devkit/schematics-cli": "22.2.3",
|
||||||
"@inquirer/prompts": "8.7.5",
|
"@inquirer/prompts": "8.7.3",
|
||||||
"@nestjs/schematics": "^12.0.13",
|
"@nestjs/schematics": "^12.0.0",
|
||||||
"ansis": "4.5.0",
|
"ansis": "4.4.0",
|
||||||
"chokidar": "5.0.0",
|
"chokidar": "5.0.0",
|
||||||
"cli-table3": "0.6.5",
|
"cli-table3": "0.6.5",
|
||||||
"commander": "15.0.0",
|
"commander": "15.0.0",
|
||||||
"jsonc-parser": "3.3.1",
|
"jsonc-parser": "3.3.1",
|
||||||
"minimatch": "11.0.5",
|
"minimatch": "10.2.6",
|
||||||
"node-emoji": "2.2.0",
|
"node-emoji": "2.2.0",
|
||||||
"ora": "9.4.1",
|
"ora": "9.4.1",
|
||||||
"tsconfig-paths": "4.2.0",
|
"tsconfig-paths": "4.2.0",
|
||||||
@@ -2287,15 +2350,43 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@nestjs/cli/node_modules/@nestjs/schematics": {
|
"node_modules/@nestjs/cli/node_modules/@angular-devkit/core": {
|
||||||
"version": "12.0.13",
|
"version": "22.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/@nestjs/schematics/-/schematics-12.0.13.tgz",
|
"resolved": "https://registry.npmjs.org/@angular-devkit/core/-/core-22.2.3.tgz",
|
||||||
"integrity": "sha512-Z9Xtp67fO5NuU7FgB/u44peK3XQnrL4qLxkf1dp6fqUk9l3ATBQneRNrWaC8e+HaimX8olxq2mH2ZXhhfalDhQ==",
|
"integrity": "sha512-bi1oBjJeg1XCgRrBYSuJikaJV4sO/mcnyFE/0a1H646yHhQpVYoU/M2Vh69Qv8h+YoMfC7RuSxT4LZHaubhhLw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@angular-devkit/core": "22.2.6",
|
"ajv": "8.20.0",
|
||||||
"@angular-devkit/schematics": "22.2.6",
|
"ajv-formats": "3.0.1",
|
||||||
|
"jsonc-parser": "3.3.1",
|
||||||
|
"picomatch": "4.0.7",
|
||||||
|
"rxjs": "7.8.2",
|
||||||
|
"source-map": "0.8.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "^22.22.3 || ^24.15.0 || >=26.0.0",
|
||||||
|
"npm": "^6.11.0 || ^7.5.6 || >=8.0.0",
|
||||||
|
"yarn": ">= 1.13.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"chokidar": "^5.0.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"chokidar": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@nestjs/cli/node_modules/@nestjs/schematics": {
|
||||||
|
"version": "12.0.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/@nestjs/schematics/-/schematics-12.0.12.tgz",
|
||||||
|
"integrity": "sha512-mZmpt1GuzRe4uwBKh/Men8SR6NeDNwCJ6cXHYiGOvFzl25X5u+7iWb3q/fS7xTbUxPUtu4kp1TgPcu+JJpqSPA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@angular-devkit/core": "22.2.4",
|
||||||
|
"@angular-devkit/schematics": "22.2.4",
|
||||||
"comment-json": "5.0.0",
|
"comment-json": "5.0.0",
|
||||||
"jsonc-parser": "3.3.1",
|
"jsonc-parser": "3.3.1",
|
||||||
"pluralize": "8.0.0"
|
"pluralize": "8.0.0"
|
||||||
@@ -2313,6 +2404,70 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@nestjs/cli/node_modules/@nestjs/schematics/node_modules/@angular-devkit/core": {
|
||||||
|
"version": "22.2.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@angular-devkit/core/-/core-22.2.4.tgz",
|
||||||
|
"integrity": "sha512-HDlsVK/qN7p5ttKgPvVLo87yrN0UcHR7OyQo1n/lvvMQcYrA0n01E+ix4t+q4C04ifpMbNQtrK0I1222OKi99w==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"ajv": "8.20.0",
|
||||||
|
"ajv-formats": "3.0.1",
|
||||||
|
"jsonc-parser": "3.3.1",
|
||||||
|
"picomatch": "4.0.7",
|
||||||
|
"rxjs": "7.8.2",
|
||||||
|
"source-map": "0.8.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "^22.22.3 || ^24.15.0 || >=26.0.0",
|
||||||
|
"npm": "^6.11.0 || ^7.5.6 || >=8.0.0",
|
||||||
|
"yarn": ">= 1.13.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"chokidar": "^5.0.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"chokidar": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@nestjs/cli/node_modules/@nestjs/schematics/node_modules/@angular-devkit/schematics": {
|
||||||
|
"version": "22.2.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@angular-devkit/schematics/-/schematics-22.2.4.tgz",
|
||||||
|
"integrity": "sha512-9pRNtcZN87yM7KQX+HTxGatrm86y30JxVwkF2WNG1pR0e9yeiYWc1KQ5hRS1Nq1LULoP95HApc+TBXqUXzF4Ag==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@angular-devkit/core": "22.2.4",
|
||||||
|
"jsonc-parser": "3.3.1",
|
||||||
|
"magic-string": "1.4.1",
|
||||||
|
"ora": "9.4.1",
|
||||||
|
"rxjs": "7.8.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "^22.22.3 || ^24.15.0 || >=26.0.0",
|
||||||
|
"npm": "^6.11.0 || ^7.5.6 || >=8.0.0",
|
||||||
|
"yarn": ">= 1.13.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@nestjs/cli/node_modules/ajv": {
|
||||||
|
"version": "8.20.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz",
|
||||||
|
"integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"fast-deep-equal": "^3.1.3",
|
||||||
|
"fast-uri": "^3.0.1",
|
||||||
|
"json-schema-traverse": "^1.0.0",
|
||||||
|
"require-from-string": "^2.0.2"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/epoberezkin"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@nestjs/cli/node_modules/ansi-regex": {
|
"node_modules/@nestjs/cli/node_modules/ansi-regex": {
|
||||||
"version": "6.5.0",
|
"version": "6.5.0",
|
||||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.5.0.tgz",
|
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.5.0.tgz",
|
||||||
@@ -2362,6 +2517,22 @@
|
|||||||
"url": "https://github.com/chalk/chalk?sponsor=1"
|
"url": "https://github.com/chalk/chalk?sponsor=1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@nestjs/cli/node_modules/chokidar": {
|
||||||
|
"version": "5.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz",
|
||||||
|
"integrity": "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"readdirp": "^5.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 20.19.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://paulmillr.com/funding/"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@nestjs/cli/node_modules/cli-cursor": {
|
"node_modules/@nestjs/cli/node_modules/cli-cursor": {
|
||||||
"version": "5.0.0",
|
"version": "5.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-5.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-5.0.0.tgz",
|
||||||
@@ -2417,6 +2588,13 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@nestjs/cli/node_modules/json-schema-traverse": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@nestjs/cli/node_modules/log-symbols": {
|
"node_modules/@nestjs/cli/node_modules/log-symbols": {
|
||||||
"version": "7.0.1",
|
"version": "7.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-7.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-7.0.1.tgz",
|
||||||
@@ -2434,18 +2612,27 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@nestjs/cli/node_modules/magic-string": {
|
||||||
|
"version": "1.4.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.4.1.tgz",
|
||||||
|
"integrity": "sha512-8lyCu36ErXR0J9uaGKlKQoiLZKmtI63YGLE8G2o9jyRPdr4X47LusSOwgOJOzcVtp81fTAAjxR7BwKz682Jhow==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@jridgewell/sourcemap-codec": "^1.6.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@nestjs/cli/node_modules/minimatch": {
|
"node_modules/@nestjs/cli/node_modules/minimatch": {
|
||||||
"version": "11.0.5",
|
"version": "10.2.6",
|
||||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-11.0.5.tgz",
|
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz",
|
||||||
"integrity": "sha512-snPlVppFcYWfLLFQGhDxUyBcHNtMlWmn3jtj7pEaO+DP8pqL0IvAzXhMYmn1z9ASNtgmwLBdFxSf4tMUB0OTKA==",
|
"integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "BlueOak-1.0.0",
|
"license": "BlueOak-1.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@isaacs/nonrecursive-glob-match": "^1.1.0",
|
"brace-expansion": "^5.0.8"
|
||||||
"brace-expansion": "^5.0.12"
|
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "20 || >=22"
|
"node": "18 || 20 || >=22"
|
||||||
},
|
},
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://github.com/sponsors/isaacs"
|
"url": "https://github.com/sponsors/isaacs"
|
||||||
@@ -2490,6 +2677,33 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@nestjs/cli/node_modules/picomatch": {
|
||||||
|
"version": "4.0.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz",
|
||||||
|
"integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/jonschlinkert"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@nestjs/cli/node_modules/readdirp": {
|
||||||
|
"version": "5.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.1.1.tgz",
|
||||||
|
"integrity": "sha512-Kko+Y5XQ6fM+Ce3dq3m9YGxnacYZYl9cA1wZjaF3Vbry2L3i1qVg8+CAgNPsXRArPMUMCaOR7oa9Nqntc43JKA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 20.19.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "individual",
|
||||||
|
"url": "https://paulmillr.com/funding/"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@nestjs/cli/node_modules/restore-cursor": {
|
"node_modules/@nestjs/cli/node_modules/restore-cursor": {
|
||||||
"version": "5.1.0",
|
"version": "5.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-5.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-5.1.0.tgz",
|
||||||
@@ -2507,6 +2721,16 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@nestjs/cli/node_modules/source-map": {
|
||||||
|
"version": "0.8.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.8.0.tgz",
|
||||||
|
"integrity": "sha512-d8EqvL+k/SOXCreS/SUzg2ciyHqBBLcN/yuRjFsbvVhHTE2pgei7oAhmPM7kWFbkX6OSMQfUq4KbkF3au9lhYQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "BSD-3-Clause",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 12"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@nestjs/cli/node_modules/string-width": {
|
"node_modules/@nestjs/cli/node_modules/string-width": {
|
||||||
"version": "8.3.0",
|
"version": "8.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/string-width/-/string-width-8.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/string-width/-/string-width-8.3.0.tgz",
|
||||||
@@ -2555,9 +2779,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@nestjs/common": {
|
"node_modules/@nestjs/common": {
|
||||||
"version": "11.1.29",
|
"version": "11.1.26",
|
||||||
"resolved": "https://registry.npmjs.org/@nestjs/common/-/common-11.1.29.tgz",
|
"resolved": "https://registry.npmjs.org/@nestjs/common/-/common-11.1.26.tgz",
|
||||||
"integrity": "sha512-zkeNRlfiQIH/044r5zphjNzKYxBRC4O00onTdCsH2qq0R30Ixo0gOS9so5TadEbJJCuGNm0Vx3PBE2FG2vkBdA==",
|
"integrity": "sha512-0VARQyzuGbprvjO+slWq9Jtj1P0jYCSKAUSv9LWFNWD39ZbDzXXM1pMs35kReVXwchra0urMfTQxw4uAOfdSzA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"file-type": "21.3.4",
|
"file-type": "21.3.4",
|
||||||
@@ -2602,9 +2826,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@nestjs/core": {
|
"node_modules/@nestjs/core": {
|
||||||
"version": "11.1.29",
|
"version": "11.1.26",
|
||||||
"resolved": "https://registry.npmjs.org/@nestjs/core/-/core-11.1.29.tgz",
|
"resolved": "https://registry.npmjs.org/@nestjs/core/-/core-11.1.26.tgz",
|
||||||
"integrity": "sha512-ANXnZxirMNAY+JuRHCIYIdTfbhX3rizeSbJloub/4EYwpIfCuUoaz9woOofWmywYYc3gsVkLxVZV03gqntI8vA==",
|
"integrity": "sha512-K45zUwYpowEsVqm8qNIzsMcl4LJev0MK9zVhDnmym7YRTJ2/caslqVeKYhPRd5+Fh81IkvWUVu6vEo46uZ5mgQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"fast-safe-stringify": "2.1.1",
|
"fast-safe-stringify": "2.1.1",
|
||||||
@@ -2641,14 +2865,14 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@nestjs/platform-express": {
|
"node_modules/@nestjs/platform-express": {
|
||||||
"version": "11.1.29",
|
"version": "11.2.8",
|
||||||
"resolved": "https://registry.npmjs.org/@nestjs/platform-express/-/platform-express-11.1.29.tgz",
|
"resolved": "https://registry.npmjs.org/@nestjs/platform-express/-/platform-express-11.2.8.tgz",
|
||||||
"integrity": "sha512-OngsAjdzM+SbbmP1XojKfBgl0Lj7u8b7IK5duwpFymzNP5EmjS8ujvqck9M8h0L14I91aRIFSjIpVfHj8WVtqw==",
|
"integrity": "sha512-aSAAz8E31qv+sDsL0s7Cw/HoXmeM0TVf35ny17BjLL10c2PgFDp2vunycL7muVyp3/IP5TStMb3Ia4B/RTNHZA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"cors": "2.8.6",
|
"cors": "2.8.6",
|
||||||
"express": "5.2.1",
|
"express": "5.2.1",
|
||||||
"multer": "2.2.0",
|
"multer": "2.4.0",
|
||||||
"path-to-regexp": "8.4.2",
|
"path-to-regexp": "8.4.2",
|
||||||
"tslib": "2.8.1"
|
"tslib": "2.8.1"
|
||||||
},
|
},
|
||||||
@@ -2766,9 +2990,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@nestjs/testing": {
|
"node_modules/@nestjs/testing": {
|
||||||
"version": "11.1.29",
|
"version": "11.1.26",
|
||||||
"resolved": "https://registry.npmjs.org/@nestjs/testing/-/testing-11.1.29.tgz",
|
"resolved": "https://registry.npmjs.org/@nestjs/testing/-/testing-11.1.26.tgz",
|
||||||
"integrity": "sha512-u+aEp90Ly3uQbLoicZs+ZOrJTyvaD0fbd6dpnSgFZz5Bf77gbKKfr+xFE2JxqZuIH15HlBrqDL7/OL2qL8oG+A==",
|
"integrity": "sha512-PNf/TSIoF525pb3O9bu06S5QFWl4gArngbjI6yRFKZrF7d85vGjleVkgVVbw879Y95EYDzIS3eel6YlaY4TGFw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -2793,20 +3017,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@nestjs/throttler": {
|
|
||||||
"version": "6.7.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/@nestjs/throttler/-/throttler-6.7.1.tgz",
|
|
||||||
"integrity": "sha512-gAyY1AkU+cSK5DCjFOB43xax9oakameH4xZa9kAgqBqSBQZgmv3DZylLV8KyZNzOGhceL3kDVtby+CF8VAdmEA==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"@nestjs/common": "^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0 || ^12.0.0",
|
|
||||||
"@nestjs/core": "^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0 || ^12.0.0",
|
|
||||||
"reflect-metadata": "^0.1.13 || ^0.2.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@noble/hashes": {
|
"node_modules/@noble/hashes": {
|
||||||
"version": "1.8.0",
|
"version": "1.8.0",
|
||||||
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz",
|
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz",
|
||||||
@@ -4030,12 +4240,6 @@
|
|||||||
"@types/superagent": "^8.1.0"
|
"@types/superagent": "^8.1.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@types/validator": {
|
|
||||||
"version": "13.15.10",
|
|
||||||
"resolved": "https://registry.npmjs.org/@types/validator/-/validator-13.15.10.tgz",
|
|
||||||
"integrity": "sha512-T8L6i7wCuyoK8A/ZeLYt1+q0ty3Zb9+qbSSvrIVitzT3YjZqkTZ40IbRsPanlB4h1QB3JVL1SYCdR6ngtFYcuA==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/@types/yargs": {
|
"node_modules/@types/yargs": {
|
||||||
"version": "17.0.35",
|
"version": "17.0.35",
|
||||||
"resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.35.tgz",
|
"resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.35.tgz",
|
||||||
@@ -5186,9 +5390,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/ansis": {
|
"node_modules/ansis": {
|
||||||
"version": "4.5.0",
|
"version": "4.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/ansis/-/ansis-4.5.0.tgz",
|
"resolved": "https://registry.npmjs.org/ansis/-/ansis-4.4.0.tgz",
|
||||||
"integrity": "sha512-KwIVkfFfJSSzBmZhDGuTmkhWqTdyB5oNVzzznBJbBZ5bZtHiqSrcugRF0RKJKT4Ss3d7ZrcjnlFVDlQNmtWfWw==",
|
"integrity": "sha512-9k3v7xcHwgdO/DruxGIg4HtjvlAZlcnsX/mzqUb1t3NkYnl9kK2UJ+Gq0io+vQf7iT//BD/HB/NBkUR1LWxoeA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -5642,6 +5846,36 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/c12/node_modules/chokidar": {
|
||||||
|
"version": "5.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz",
|
||||||
|
"integrity": "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==",
|
||||||
|
"devOptional": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"readdirp": "^5.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 20.19.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://paulmillr.com/funding/"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/c12/node_modules/readdirp": {
|
||||||
|
"version": "5.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.1.1.tgz",
|
||||||
|
"integrity": "sha512-Kko+Y5XQ6fM+Ce3dq3m9YGxnacYZYl9cA1wZjaF3Vbry2L3i1qVg8+CAgNPsXRArPMUMCaOR7oa9Nqntc43JKA==",
|
||||||
|
"devOptional": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 20.19.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "individual",
|
||||||
|
"url": "https://paulmillr.com/funding/"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/call-bind-apply-helpers": {
|
"node_modules/call-bind-apply-helpers": {
|
||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
|
||||||
@@ -5746,22 +5980,6 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/chokidar": {
|
|
||||||
"version": "5.0.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz",
|
|
||||||
"integrity": "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==",
|
|
||||||
"devOptional": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"readdirp": "^5.0.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 20.19.0"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://paulmillr.com/funding/"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/chrome-trace-event": {
|
"node_modules/chrome-trace-event": {
|
||||||
"version": "1.0.4",
|
"version": "1.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/chrome-trace-event/-/chrome-trace-event-1.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/chrome-trace-event/-/chrome-trace-event-1.0.4.tgz",
|
||||||
@@ -5796,23 +6014,6 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/class-transformer": {
|
|
||||||
"version": "0.5.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/class-transformer/-/class-transformer-0.5.1.tgz",
|
|
||||||
"integrity": "sha512-SQa1Ws6hUbfC98vKGxZH3KFY0Y1lm5Zm0SY8XX9zbK7FJCyVEac3ATW0RIpwzW+oOfmHE5PMPufDG9hCfoEOMw==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/class-validator": {
|
|
||||||
"version": "0.15.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/class-validator/-/class-validator-0.15.1.tgz",
|
|
||||||
"integrity": "sha512-LqoS80HBBSCVhz/3KloUly0ovokxpdOLR++Al3J3+dHXWt9sTKlKd4eYtoxhxyUjoe5+UcIM+5k9MIxyBWnRTw==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"@types/validator": "^13.15.3",
|
|
||||||
"libphonenumber-js": "^1.11.1",
|
|
||||||
"validator": "^13.15.22"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/classnames": {
|
"node_modules/classnames": {
|
||||||
"version": "2.5.1",
|
"version": "2.5.1",
|
||||||
"resolved": "https://registry.npmjs.org/classnames/-/classnames-2.5.1.tgz",
|
"resolved": "https://registry.npmjs.org/classnames/-/classnames-2.5.1.tgz",
|
||||||
@@ -7522,9 +7723,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/glob": {
|
"node_modules/glob": {
|
||||||
"version": "13.0.7",
|
"version": "13.0.6",
|
||||||
"resolved": "https://registry.npmjs.org/glob/-/glob-13.0.7.tgz",
|
"resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz",
|
||||||
"integrity": "sha512-wfsFgXQ8A3SEI9XGf7Nl3Y9l7ffSrGh7SUCT7vU/2CzYPep9kjxAn1S40F9OPKxAMGCVl7HtNlJhrCJUeUq83g==",
|
"integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "BlueOak-1.0.0",
|
"license": "BlueOak-1.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -7726,18 +7927,6 @@
|
|||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/helmet": {
|
|
||||||
"version": "8.3.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/helmet/-/helmet-8.3.0.tgz",
|
|
||||||
"integrity": "sha512-Qgpiaws3Sm30Av8Eah6sjMCZZwjlBu+E68rhpCWBshY1lb09HtLwj5GviX0OyQIn+ulUS0iX0AxN5n3tLZzz1w==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18.0.0"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://github.com/sponsors/EvanHahn"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/html-escaper": {
|
"node_modules/html-escaper": {
|
||||||
"version": "2.0.2",
|
"version": "2.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz",
|
||||||
@@ -8815,12 +9004,6 @@
|
|||||||
"node": ">= 0.8.0"
|
"node": ">= 0.8.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/libphonenumber-js": {
|
|
||||||
"version": "1.13.15",
|
|
||||||
"resolved": "https://registry.npmjs.org/libphonenumber-js/-/libphonenumber-js-1.13.15.tgz",
|
|
||||||
"integrity": "sha512-AHdhEQeDFEJ4GKDAQ9AwxChy+v2kUILUB3TGQRpkXMkWePjXTU/WQEzWVQoIx/Gz8fE73Wr5SEuZCy2vk8jrMQ==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/lines-and-columns": {
|
"node_modules/lines-and-columns": {
|
||||||
"version": "1.2.4",
|
"version": "1.2.4",
|
||||||
"resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz",
|
"resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz",
|
||||||
@@ -9161,9 +9344,9 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/multer": {
|
"node_modules/multer": {
|
||||||
"version": "2.5.0",
|
"version": "2.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/multer/-/multer-2.5.0.tgz",
|
"resolved": "https://registry.npmjs.org/multer/-/multer-2.4.0.tgz",
|
||||||
"integrity": "sha512-BsqOzYGVq49JcW2IzoSAxeKYRevb+dGGPPWleOR8Lrlk4igwxu1/9pwSXlByhCb7MVZDrNJFC0IBP5/O5nLYRw==",
|
"integrity": "sha512-7dqa0ZcFfzbefdTuIkzOSMvZWC0J7FLqBOjJUZvDCXShIURWKxAyTT1wHhnE5q19c7jOJf43IYYKjBmZVZmvhg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"append-field": "^1.0.0",
|
"append-field": "^1.0.0",
|
||||||
@@ -10200,20 +10383,6 @@
|
|||||||
"node": ">= 6"
|
"node": ">= 6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/readdirp": {
|
|
||||||
"version": "5.1.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.1.1.tgz",
|
|
||||||
"integrity": "sha512-Kko+Y5XQ6fM+Ce3dq3m9YGxnacYZYl9cA1wZjaF3Vbry2L3i1qVg8+CAgNPsXRArPMUMCaOR7oa9Nqntc43JKA==",
|
|
||||||
"devOptional": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 20.19.0"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"type": "individual",
|
|
||||||
"url": "https://paulmillr.com/funding/"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/reflect-metadata": {
|
"node_modules/reflect-metadata": {
|
||||||
"version": "0.2.2",
|
"version": "0.2.2",
|
||||||
"resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz",
|
||||||
@@ -11805,15 +11974,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/validator": {
|
|
||||||
"version": "13.15.35",
|
|
||||||
"resolved": "https://registry.npmjs.org/validator/-/validator-13.15.35.tgz",
|
|
||||||
"integrity": "sha512-TQ5pAGhd5whStmqWvYF4OjQROlmv9SMFVt37qoCBdqRffuuklWYQlCNnEs2ZaIBD1kZRNnikiZOS1eqgkar0iw==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 0.10"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/vary": {
|
"node_modules/vary": {
|
||||||
"version": "1.1.2",
|
"version": "1.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
|
||||||
@@ -12204,15 +12364,6 @@
|
|||||||
"grammex": "^3.1.11",
|
"grammex": "^3.1.11",
|
||||||
"graphmatch": "^1.1.0"
|
"graphmatch": "^1.1.0"
|
||||||
}
|
}
|
||||||
},
|
|
||||||
"node_modules/zod": {
|
|
||||||
"version": "4.6.7",
|
|
||||||
"resolved": "https://registry.npmjs.org/zod/-/zod-4.6.7.tgz",
|
|
||||||
"integrity": "sha512-g8W2D2kU6a8untc053fX1uVBKzM7yqAxW/3zwk83+6RyF2+SKOuV+c0wBDhUwzV1PaGapmZH+rmcUzPf+3CGwg==",
|
|
||||||
"license": "MIT",
|
|
||||||
"funding": {
|
|
||||||
"url": "https://github.com/sponsors/colinhacks"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
20
package.json
20
package.json
@@ -22,28 +22,23 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@nestjs/axios": "^4.0.1",
|
"@nestjs/axios": "^4.0.1",
|
||||||
"@nestjs/common": "^11.1.29",
|
"@nestjs/common": "^11.0.1",
|
||||||
"@nestjs/config": "^12.0.0",
|
"@nestjs/config": "^12.0.0",
|
||||||
"@nestjs/core": "^11.1.29",
|
"@nestjs/core": "^11.0.1",
|
||||||
"@nestjs/platform-express": "^11.1.29",
|
"@nestjs/platform-express": "^11.0.1",
|
||||||
"@nestjs/throttler": "^6.7.1",
|
|
||||||
"@prisma/adapter-pg": "^7.10.0",
|
"@prisma/adapter-pg": "^7.10.0",
|
||||||
"@prisma/client": "^7.10.0",
|
"@prisma/client": "^7.10.0",
|
||||||
"axios": "1.20.0",
|
"axios": "1.20.0",
|
||||||
"class-transformer": "^0.5.1",
|
|
||||||
"class-validator": "^0.15.1",
|
|
||||||
"helmet": "^8.3.0",
|
|
||||||
"pg": "^8.23.0",
|
"pg": "^8.23.0",
|
||||||
"reflect-metadata": "^0.2.2",
|
"reflect-metadata": "^0.2.2",
|
||||||
"rxjs": "^7.8.1",
|
"rxjs": "^7.8.1"
|
||||||
"zod": "4.6.7"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/eslintrc": "^3.2.0",
|
"@eslint/eslintrc": "^3.2.0",
|
||||||
"@eslint/js": "^9.18.0",
|
"@eslint/js": "^9.18.0",
|
||||||
"@nestjs/cli": "^12.0.11",
|
"@nestjs/cli": "^12.0.11",
|
||||||
"@nestjs/schematics": "^11.0.0",
|
"@nestjs/schematics": "^11.0.0",
|
||||||
"@nestjs/testing": "^11.1.29",
|
"@nestjs/testing": "^11.0.1",
|
||||||
"@types/express": "^5.0.0",
|
"@types/express": "^5.0.0",
|
||||||
"@types/jest": "^30.0.0",
|
"@types/jest": "^30.0.0",
|
||||||
"@types/node": "^24.0.0",
|
"@types/node": "^24.0.0",
|
||||||
@@ -95,15 +90,12 @@
|
|||||||
"/node_modules/(?!@nestjs/config/)"
|
"/node_modules/(?!@nestjs/config/)"
|
||||||
],
|
],
|
||||||
"moduleNameMapper": {
|
"moduleNameMapper": {
|
||||||
"^(\\.{1,2}/.*)\\.js$": "$1"
|
"^(.{1,2}/.*).js$": "$1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"allowScripts": {
|
"allowScripts": {
|
||||||
"@parcel/watcher@2.6.0": true,
|
"@parcel/watcher@2.6.0": true,
|
||||||
"@prisma/engines@7.10.1": true,
|
"@prisma/engines@7.10.1": true,
|
||||||
"prisma@7.10.1": true
|
"prisma@7.10.1": true
|
||||||
},
|
|
||||||
"overrides": {
|
|
||||||
"multer": "^2.5.0"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +0,0 @@
|
|||||||
$kind: collection
|
|
||||||
variables:
|
|
||||||
baseUrl: "http://localhost:3000"
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
$kind: http-example
|
|
||||||
request:
|
|
||||||
url: "{{baseUrl}}/sitemap"
|
|
||||||
method: GET
|
|
||||||
headers:
|
|
||||||
Accept: application/json
|
|
||||||
response:
|
|
||||||
statusCode: 200
|
|
||||||
statusText: OK
|
|
||||||
headers:
|
|
||||||
Content-Type: application/json; charset=utf-8
|
|
||||||
Cache-Control: public, max-age=600
|
|
||||||
body:
|
|
||||||
type: json
|
|
||||||
content: |
|
|
||||||
{
|
|
||||||
"brands": [{ "slug": "stray-kids" }],
|
|
||||||
"categories": [{ "slug": "albums" }],
|
|
||||||
"products": [{ "slug": "ate-digipack", "updatedAt": "2026-10-28T14:32:00.000Z" }]
|
|
||||||
}
|
|
||||||
order: 1000
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
$kind: http-example
|
|
||||||
request:
|
|
||||||
url: "{{baseUrl}}/sync"
|
|
||||||
method: POST
|
|
||||||
headers:
|
|
||||||
Accept: application/json
|
|
||||||
response:
|
|
||||||
statusCode: 503
|
|
||||||
statusText: Service Unavailable
|
|
||||||
headers:
|
|
||||||
Content-Type: application/json; charset=utf-8
|
|
||||||
body:
|
|
||||||
type: json
|
|
||||||
content: |
|
|
||||||
{ "message": "This instance is shutting down, retry in a moment", "error": "Service Unavailable", "statusCode": 503 }
|
|
||||||
order: 4000
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
$kind: http-example
|
|
||||||
request:
|
|
||||||
url: "{{baseUrl}}/sync"
|
|
||||||
method: POST
|
|
||||||
headers:
|
|
||||||
Accept: application/json
|
|
||||||
response:
|
|
||||||
statusCode: 429
|
|
||||||
statusText: Too Many Requests
|
|
||||||
headers:
|
|
||||||
Content-Type: application/json; charset=utf-8
|
|
||||||
Retry-After: "30"
|
|
||||||
body:
|
|
||||||
type: json
|
|
||||||
content: |
|
|
||||||
{ "statusCode": 429, "message": "ThrottlerException: Too Many Requests" }
|
|
||||||
order: 3000
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
$kind: http-example
|
|
||||||
request:
|
|
||||||
url: "{{baseUrl}}/sync"
|
|
||||||
method: POST
|
|
||||||
headers:
|
|
||||||
Accept: application/json
|
|
||||||
response:
|
|
||||||
statusCode: 409
|
|
||||||
statusText: Conflict
|
|
||||||
headers:
|
|
||||||
Content-Type: application/json; charset=utf-8
|
|
||||||
body:
|
|
||||||
type: json
|
|
||||||
content: |
|
|
||||||
{ "message": "A Hiboutik synchronization is already in progress", "error": "Conflict", "statusCode": 409 }
|
|
||||||
order: 2000
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
$kind: http-example
|
|
||||||
request:
|
|
||||||
url: "{{baseUrl}}/sync"
|
|
||||||
method: POST
|
|
||||||
headers:
|
|
||||||
Accept: application/json
|
|
||||||
response:
|
|
||||||
statusCode: 202
|
|
||||||
statusText: Accepted
|
|
||||||
headers:
|
|
||||||
Content-Type: application/json; charset=utf-8
|
|
||||||
body:
|
|
||||||
type: json
|
|
||||||
content: |
|
|
||||||
{ "message": "Hiboutik synchronization started" }
|
|
||||||
order: 1000
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
$kind: http-example
|
|
||||||
request:
|
|
||||||
url: "{{baseUrl}}/sync"
|
|
||||||
method: POST
|
|
||||||
headers:
|
|
||||||
Accept: application/json
|
|
||||||
response:
|
|
||||||
statusCode: 500
|
|
||||||
statusText: Internal Server Error
|
|
||||||
headers:
|
|
||||||
Content-Type: application/json; charset=utf-8
|
|
||||||
body:
|
|
||||||
type: json
|
|
||||||
content: |
|
|
||||||
{ "message": "Internal Server Error", "statusCode": 500 }
|
|
||||||
order: 5000
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
$kind: http-request
|
|
||||||
description: "Public pages of the storefront (brands enabled, categories displayed on the website, products displayed on the website and not archived) that have a slug. Products carry `updatedAt`, their last update in Hiboutik (null when unknown); brands and categories have no date, as the sync rewrites their rows every time. The storefront builds its sitemap.xml from it. Answered with Cache-Control: public, max-age=600."
|
|
||||||
url: "{{baseUrl}}/sitemap"
|
|
||||||
method: GET
|
|
||||||
headers:
|
|
||||||
Accept: application/json
|
|
||||||
examples: ./.resources/getSitemap.resources/examples
|
|
||||||
order: 2000
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
$kind: http-request
|
|
||||||
description: |-
|
|
||||||
Starts a full Hiboutik → database synchronization **in the background** and answers immediately: the request never waits for the sync to end.
|
|
||||||
|
|
||||||
**What it syncs**, in dependency order: brands → categories → suppliers → products (with their versions and stocks) → customers (with their addresses and orders). New brands, categories and products get their slug at the end of their step.
|
|
||||||
|
|
||||||
**Hiboutik is the source of truth**: brands, categories, suppliers, products and customers missing from Hiboutik are deleted from the database (orders are never deleted). A failed Hiboutik call deletes nothing. A failing item is logged and skipped; the sync goes on and logs a summary at the end. Follow its progress in the server logs.
|
|
||||||
|
|
||||||
**One sync at a time**, across all instances (Postgres advisory lock): a call while a sync is running answers `409`.
|
|
||||||
|
|
||||||
**Cost**: a sync uses roughly one Hiboutik API call per customer, plus a few per entity type. Hiboutik allows 10,000 calls per month per shop; when its rate limit is reached, the sync stops.
|
|
||||||
|
|
||||||
**Responses**
|
|
||||||
- `202` sync started.
|
|
||||||
- `409` a sync is already running (on this instance or another one).
|
|
||||||
- `429` rate limit: 5 calls per minute per IP on this route. `Retry-After` gives the seconds to wait.
|
|
||||||
- `503` this instance is shutting down: retry, another instance can take the call.
|
|
||||||
- `500` unexpected error (e.g. database unreachable); details are in the server logs only.
|
|
||||||
|
|
||||||
No body, no parameters.
|
|
||||||
url: "{{baseUrl}}/sync"
|
|
||||||
method: POST
|
|
||||||
headers:
|
|
||||||
Accept: application/json
|
|
||||||
examples: ./.resources/syncHiboutik.resources/examples
|
|
||||||
order: 1000
|
|
||||||
@@ -2,11 +2,3 @@ import { Prisma } from '../generated/client';
|
|||||||
|
|
||||||
/** Product loaded with its versions (`include: { versions: true }`). */
|
/** Product loaded with its versions (`include: { versions: true }`). */
|
||||||
export type ProductWithVersions = Prisma.ProductGetPayload<{ include: { versions: true } }>;
|
export type ProductWithVersions = Prisma.ProductGetPayload<{ include: { versions: true } }>;
|
||||||
|
|
||||||
/**
|
|
||||||
* Order to save with its lines. Lines are kept apart from the nested Prisma input so that the repository can link
|
|
||||||
* them to the current catalog (`productVersion`) and replace them on update.
|
|
||||||
*/
|
|
||||||
export type OrderWithLinesInput = Omit<Prisma.OrderCreateInput, 'lines'> & {
|
|
||||||
lines: Prisma.OrderLineCreateWithoutOrderInput[];
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -1,25 +1,20 @@
|
|||||||
import { Injectable, OnApplicationShutdown, OnModuleInit } from '@nestjs/common';
|
import { Injectable, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
|
||||||
import { PrismaClient } from './generated/client';
|
import { PrismaClient } from './generated/client';
|
||||||
import { PrismaPg } from '@prisma/adapter-pg';
|
import { PrismaPg } from '@prisma/adapter-pg';
|
||||||
|
|
||||||
/** Prisma client shared by the whole app (global PrismaModule), connected to `DATABASE_URL` through the pg driver adapter. */
|
/** Prisma client shared by the whole app (global PrismaModule), connected to `DATABASE_URL` through the pg driver adapter. */
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class PrismaService extends PrismaClient implements OnModuleInit, OnApplicationShutdown {
|
export class PrismaService extends PrismaClient implements OnModuleInit, OnModuleDestroy {
|
||||||
constructor() {
|
constructor() {
|
||||||
const adapter = new PrismaPg({ connectionString: process.env.DATABASE_URL });
|
const adapter = new PrismaPg({ connectionString: process.env.DATABASE_URL });
|
||||||
// 'minimal': by default, error messages print the query arguments (emails, names, addresses...), which end up in the logs
|
super({ adapter });
|
||||||
super({ adapter, errorFormat: 'minimal' });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async onModuleInit() {
|
async onModuleInit() {
|
||||||
await this.$connect();
|
await this.$connect();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
async onModuleDestroy() {
|
||||||
* Last shutdown hook (after `onModuleDestroy` and `beforeApplicationShutdown`, once the HTTP server is closed):
|
|
||||||
* a synchronization still running is waited for in `beforeApplicationShutdown` and keeps the database until then.
|
|
||||||
*/
|
|
||||||
async onApplicationShutdown() {
|
|
||||||
await this.$disconnect();
|
await this.$disconnect();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,8 +23,6 @@ model Category {
|
|||||||
name String
|
name String
|
||||||
/// Display order among its siblings (category_position)
|
/// Display order among its siblings (category_position)
|
||||||
position Int @default(0)
|
position Int @default(0)
|
||||||
/// URL identifier (SEO): generated once by the sync, never overwritten by it; null until generated
|
|
||||||
slug String? @unique
|
|
||||||
/// Displayed on the website (category_enabled_www)
|
/// Displayed on the website (category_enabled_www)
|
||||||
webEnabled Boolean @default(true)
|
webEnabled Boolean @default(true)
|
||||||
|
|
||||||
@@ -33,8 +31,7 @@ model Category {
|
|||||||
parentId String?
|
parentId String?
|
||||||
children Category[] @relation("CategoryTree")
|
children Category[] @relation("CategoryTree")
|
||||||
|
|
||||||
products Product[]
|
products Product[]
|
||||||
slugRedirects SlugRedirect[]
|
|
||||||
|
|
||||||
@@map("categories")
|
@@map("categories")
|
||||||
}
|
}
|
||||||
@@ -47,11 +44,8 @@ model Brand {
|
|||||||
enabled Boolean @default(true)
|
enabled Boolean @default(true)
|
||||||
hiboutikId Int @unique
|
hiboutikId Int @unique
|
||||||
name String
|
name String
|
||||||
/// URL identifier (SEO): generated once by the sync, never overwritten by it; null until generated
|
|
||||||
slug String? @unique
|
|
||||||
|
|
||||||
products Product[]
|
products Product[]
|
||||||
slugRedirects SlugRedirect[]
|
|
||||||
|
|
||||||
@@map("brands")
|
@@map("brands")
|
||||||
}
|
}
|
||||||
@@ -90,8 +84,6 @@ model Product {
|
|||||||
hiboutikUpdatedAt DateTime?
|
hiboutikUpdatedAt DateTime?
|
||||||
name String
|
name String
|
||||||
price Decimal @db.Decimal(10, 2)
|
price Decimal @db.Decimal(10, 2)
|
||||||
/// URL identifier (SEO): generated once by the sync, never overwritten by it; null until generated
|
|
||||||
slug String? @unique
|
|
||||||
|
|
||||||
brand Brand? @relation(fields: [brandId], references: [id], onDelete: SetNull)
|
brand Brand? @relation(fields: [brandId], references: [id], onDelete: SetNull)
|
||||||
brandId String?
|
brandId String?
|
||||||
@@ -100,42 +92,11 @@ model Product {
|
|||||||
supplier Supplier? @relation(fields: [supplierId], references: [id], onDelete: SetNull)
|
supplier Supplier? @relation(fields: [supplierId], references: [id], onDelete: SetNull)
|
||||||
supplierId String?
|
supplierId String?
|
||||||
|
|
||||||
versions ProductVersion[]
|
versions ProductVersion[]
|
||||||
slugRedirects SlugRedirect[]
|
|
||||||
|
|
||||||
@@map("products")
|
@@map("products")
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Kind of entity a slug identifies. Slugs are unique per kind (each one has its own URL space).
|
|
||||||
enum SlugEntityType {
|
|
||||||
BRAND
|
|
||||||
CATEGORY
|
|
||||||
PRODUCT
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Former slug of a brand, category or product, kept so that its old URL can be answered with a 301 redirect.
|
|
||||||
/// Exactly one of brandId / categoryId / productId is set, matching `entityType`. Deleted with its entity.
|
|
||||||
model SlugRedirect {
|
|
||||||
id String @id @default(uuid())
|
|
||||||
createdAt DateTime @default(now())
|
|
||||||
|
|
||||||
entityType SlugEntityType
|
|
||||||
slug String
|
|
||||||
|
|
||||||
brand Brand? @relation(fields: [brandId], references: [id], onDelete: Cascade)
|
|
||||||
brandId String?
|
|
||||||
category Category? @relation(fields: [categoryId], references: [id], onDelete: Cascade)
|
|
||||||
categoryId String?
|
|
||||||
product Product? @relation(fields: [productId], references: [id], onDelete: Cascade)
|
|
||||||
productId String?
|
|
||||||
|
|
||||||
@@unique([entityType, slug])
|
|
||||||
@@index([brandId])
|
|
||||||
@@index([categoryId])
|
|
||||||
@@index([productId])
|
|
||||||
@@map("slug_redirects")
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Sellable version of a product: one per Hiboutik size, or a single one (hiboutikSizeId 0) for a product without sizes.
|
/// Sellable version of a product: one per Hiboutik size, or a single one (hiboutikSizeId 0) for a product without sizes.
|
||||||
model ProductVersion {
|
model ProductVersion {
|
||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
@@ -169,7 +130,7 @@ model Customer {
|
|||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
dateOfBirth DateTime?
|
dateOfBirth DateTime?
|
||||||
/// Null when the customer has no email in Hiboutik (erased when removed there)
|
/// Kept when removed in Hiboutik (an empty Hiboutik email does not erase it)
|
||||||
email String?
|
email String?
|
||||||
firstName String
|
firstName String
|
||||||
hiboutikId Int @unique
|
hiboutikId Int @unique
|
||||||
@@ -185,30 +146,17 @@ model Customer {
|
|||||||
@@map("customers")
|
@@map("customers")
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Address of a customer, mirrored from Hiboutik. Its recipient (name, company, phone) can differ from the customer.
|
/// Address of a customer, mirrored from Hiboutik (sync not implemented yet).
|
||||||
model CustomerAddress {
|
model CustomerAddress {
|
||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
address String
|
address String
|
||||||
city String
|
city String
|
||||||
company String?
|
country String
|
||||||
/// Company registration number (SIRET...), only for an address with a company
|
hiboutikId Int @unique
|
||||||
companyNumber String?
|
isDefault Boolean @default(false)
|
||||||
country String
|
zipCode String
|
||||||
firstName String
|
|
||||||
hiboutikId Int @unique
|
|
||||||
isDefault Boolean @default(false)
|
|
||||||
lastName String
|
|
||||||
/// Company legal status (SAS, SARL...), only for an address with a company
|
|
||||||
legalStatus String?
|
|
||||||
/// Free complement (building, floor, door code...)
|
|
||||||
other String?
|
|
||||||
phone String?
|
|
||||||
state String?
|
|
||||||
/// Company VAT number, only for an address with a company
|
|
||||||
taxNumber String?
|
|
||||||
zipCode String
|
|
||||||
|
|
||||||
customer Customer @relation(fields: [customerId], references: [id], onDelete: Cascade)
|
customer Customer @relation(fields: [customerId], references: [id], onDelete: Cascade)
|
||||||
customerId String
|
customerId String
|
||||||
@@ -266,19 +214,3 @@ model OrderLine {
|
|||||||
|
|
||||||
@@map("order_lines")
|
@@map("order_lines")
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Rate limiting counter shared by every instance (ThrottlerDatabaseStorage): one fixed window per throttler key
|
|
||||||
/// (route + client IP). Not synced from Hiboutik. Rows whose window and block are over are purged regularly.
|
|
||||||
model ThrottlerRecord {
|
|
||||||
/// Key generated by the ThrottlerGuard (hash of route, throttler name and client IP)
|
|
||||||
key String @id
|
|
||||||
/// Requests counted in the current window
|
|
||||||
hits Int
|
|
||||||
/// End of the current window
|
|
||||||
expiresAt DateTime @db.Timestamptz(3)
|
|
||||||
/// End of the block once the limit was exceeded, null when not blocked
|
|
||||||
blockedUntil DateTime? @db.Timestamptz(3)
|
|
||||||
|
|
||||||
@@index([expiresAt])
|
|
||||||
@@map("throttler_records")
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,59 +0,0 @@
|
|||||||
{
|
|
||||||
"version": 1,
|
|
||||||
"skills": {
|
|
||||||
"prisma-cli": {
|
|
||||||
"source": "prisma/skills",
|
|
||||||
"sourceType": "github",
|
|
||||||
"skillPath": "prisma-cli/SKILL.md",
|
|
||||||
"computedHash": "b92e55aef78f6796d81433c44738a6733211a04c16e65cbad6d42c5f71092aab"
|
|
||||||
},
|
|
||||||
"prisma-client-api": {
|
|
||||||
"source": "prisma/skills",
|
|
||||||
"sourceType": "github",
|
|
||||||
"skillPath": "prisma-client-api/SKILL.md",
|
|
||||||
"computedHash": "5dcc0793337151efa73a444e5adbc7e1c24180778e83b4fe94c9109c391bd333"
|
|
||||||
},
|
|
||||||
"prisma-compute": {
|
|
||||||
"source": "prisma/skills",
|
|
||||||
"sourceType": "github",
|
|
||||||
"skillPath": "prisma-compute/SKILL.md",
|
|
||||||
"computedHash": "e5695a3c6f35428e96df30ae662716bd4c9d9074f72cc9334b3050445a1bd4e2"
|
|
||||||
},
|
|
||||||
"prisma-database-setup": {
|
|
||||||
"source": "prisma/skills",
|
|
||||||
"sourceType": "github",
|
|
||||||
"skillPath": "prisma-database-setup/SKILL.md",
|
|
||||||
"computedHash": "0911d9454bd48df3badd23a50fd90a280eabb15dcbd472c14e7b729075dadefb"
|
|
||||||
},
|
|
||||||
"prisma-driver-adapter-implementation": {
|
|
||||||
"source": "prisma/skills",
|
|
||||||
"sourceType": "github",
|
|
||||||
"skillPath": "prisma-driver-adapter-implementation/SKILL.md",
|
|
||||||
"computedHash": "07484627aea6cce4d0f94b4090ff9acc0caa7e104f9988b95abecf80132f4103"
|
|
||||||
},
|
|
||||||
"prisma-mongodb-upgrade": {
|
|
||||||
"source": "prisma/skills",
|
|
||||||
"sourceType": "github",
|
|
||||||
"skillPath": "prisma-mongodb-upgrade/SKILL.md",
|
|
||||||
"computedHash": "f9ba440e88ca4cec9801d04762296e29e99ca08cb8a8156e4f783ecf90279c8f"
|
|
||||||
},
|
|
||||||
"prisma-postgres": {
|
|
||||||
"source": "prisma/skills",
|
|
||||||
"sourceType": "github",
|
|
||||||
"skillPath": "prisma-postgres/SKILL.md",
|
|
||||||
"computedHash": "d669fbd0d8017d16c967f18b20e1c1345cd4a2a0076d762459bfef79f551f655"
|
|
||||||
},
|
|
||||||
"prisma-postgres-setup": {
|
|
||||||
"source": "prisma/skills",
|
|
||||||
"sourceType": "github",
|
|
||||||
"skillPath": "prisma-postgres-setup/SKILL.md",
|
|
||||||
"computedHash": "c89d3aa91285d8e4964fcaa5238c99a3d3c20b617e032e731cf632330a85a5a6"
|
|
||||||
},
|
|
||||||
"prisma-upgrade-v7": {
|
|
||||||
"source": "prisma/skills",
|
|
||||||
"sourceType": "github",
|
|
||||||
"skillPath": "prisma-upgrade-v7/SKILL.md",
|
|
||||||
"computedHash": "dcc6c71adca6b22f37c5bda5ac7fd63c3bb59495596a22e06a29c7c85371558f"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -16,14 +16,14 @@ describe('AppController', () => {
|
|||||||
controller = module.get(AppController);
|
controller = module.get(AppController);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('starts a synchronization', async () => {
|
it('starts a synchronization', () => {
|
||||||
hiboutikService.startSync.mockResolvedValue(true);
|
hiboutikService.startSync.mockReturnValue(true);
|
||||||
await expect(controller.hiboutikSync()).resolves.toEqual({ message: 'Hiboutik synchronization started' });
|
expect(controller.hiboutikSync()).toEqual({ message: 'Hiboutik synchronization started' });
|
||||||
expect(hiboutikService.startSync).toHaveBeenCalledTimes(1);
|
expect(hiboutikService.startSync).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('rejects a synchronization when one is already running', async () => {
|
it('rejects a synchronization when one is already running', () => {
|
||||||
hiboutikService.startSync.mockResolvedValue(false);
|
hiboutikService.startSync.mockReturnValue(false);
|
||||||
await expect(controller.hiboutikSync()).rejects.toThrow(ConflictException);
|
expect(() => controller.hiboutikSync()).toThrow(ConflictException);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,21 +1,15 @@
|
|||||||
import { ConflictException, Controller, HttpCode, HttpStatus, Post } from '@nestjs/common';
|
import { ConflictException, Controller, HttpCode, HttpStatus, Post } from '@nestjs/common';
|
||||||
import { Throttle } from '@nestjs/throttler';
|
|
||||||
import { HiboutikService } from './services/hiboutik/hiboutik.service';
|
import { HiboutikService } from './services/hiboutik/hiboutik.service';
|
||||||
|
|
||||||
@Controller()
|
@Controller()
|
||||||
export class AppController {
|
export class AppController {
|
||||||
constructor(private readonly hiboutikService: HiboutikService) {}
|
constructor(private readonly hiboutikService: HiboutikService) {}
|
||||||
|
|
||||||
/**
|
/** Starts the synchronization in the background and answers immediately: 202, or 409 if one is already running. */
|
||||||
* Starts the synchronization in the background and answers immediately: 202, 409 if one is already running, or 503
|
|
||||||
* if this instance is shutting down (another instance can take the call).
|
|
||||||
* Rate limited to 5 calls per minute: each one can trigger thousands of Hiboutik API calls.
|
|
||||||
*/
|
|
||||||
@Post('sync')
|
@Post('sync')
|
||||||
@Throttle({ default: { limit: 5, ttl: 60_000 } })
|
|
||||||
@HttpCode(HttpStatus.ACCEPTED)
|
@HttpCode(HttpStatus.ACCEPTED)
|
||||||
async hiboutikSync() {
|
hiboutikSync() {
|
||||||
if (!(await this.hiboutikService.startSync())) {
|
if (!this.hiboutikService.startSync()) {
|
||||||
throw new ConflictException('A Hiboutik synchronization is already in progress');
|
throw new ConflictException('A Hiboutik synchronization is already in progress');
|
||||||
}
|
}
|
||||||
return { message: 'Hiboutik synchronization started' };
|
return { message: 'Hiboutik synchronization started' };
|
||||||
|
|||||||
@@ -1,10 +1,6 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { APP_FILTER, APP_GUARD } from '@nestjs/core';
|
|
||||||
import { AllExceptionsFilter } from './filters/all-exceptions.filter';
|
|
||||||
import { ThrottlerGuard, ThrottlerModule } from '@nestjs/throttler';
|
|
||||||
import { AppController } from './app.controller';
|
import { AppController } from './app.controller';
|
||||||
import { ConfigModule } from '@nestjs/config';
|
import { ConfigModule } from '@nestjs/config';
|
||||||
import { validateEnv } from './config/env.validation';
|
|
||||||
import { HiboutikService } from './services/hiboutik/hiboutik.service';
|
import { HiboutikService } from './services/hiboutik/hiboutik.service';
|
||||||
import { CategoryHiboutikRepository } from './hiboutik/category.hiboutik.repository';
|
import { CategoryHiboutikRepository } from './hiboutik/category.hiboutik.repository';
|
||||||
import { CategoryConverter } from './models/converter/category.converter';
|
import { CategoryConverter } from './models/converter/category.converter';
|
||||||
@@ -12,8 +8,6 @@ import { ProductHiboutikRepository } from './hiboutik/product.hiboutik.repositor
|
|||||||
import { HttpModule } from '@nestjs/axios';
|
import { HttpModule } from '@nestjs/axios';
|
||||||
import { CategoryDatabaseRepository } from './database/category.database.repository';
|
import { CategoryDatabaseRepository } from './database/category.database.repository';
|
||||||
import { PrismaModule } from '../prisma/prisma.module';
|
import { PrismaModule } from '../prisma/prisma.module';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
|
||||||
import { ThrottlerDatabaseStorage } from './database/throttlerStorage.database.repository';
|
|
||||||
import { HiboutikClient } from './hiboutik/hiboutik.client';
|
import { HiboutikClient } from './hiboutik/hiboutik.client';
|
||||||
import { BrandHiboutikRepository } from './hiboutik/brand.hiboutik.repository';
|
import { BrandHiboutikRepository } from './hiboutik/brand.hiboutik.repository';
|
||||||
import { BrandDatabaseRepository } from './database/brand.database.repository';
|
import { BrandDatabaseRepository } from './database/brand.database.repository';
|
||||||
@@ -28,50 +22,22 @@ import { ProductVersionDatabaseRepository } from './database/productVersion.data
|
|||||||
import { CustomerHiboutikRepository } from './hiboutik/customer.hiboutik.repository';
|
import { CustomerHiboutikRepository } from './hiboutik/customer.hiboutik.repository';
|
||||||
import { CustomerDatabaseRepository } from './database/customer.database.repository';
|
import { CustomerDatabaseRepository } from './database/customer.database.repository';
|
||||||
import { CustomerConverter } from './models/converter/customer.converter';
|
import { CustomerConverter } from './models/converter/customer.converter';
|
||||||
import { OrderConverter } from './models/converter/order.converter';
|
|
||||||
import { OrderDatabaseRepository } from './database/order.database.repository';
|
|
||||||
import { SaleHiboutikRepository } from './hiboutik/sale.hiboutik.repository';
|
|
||||||
import { CustomerAddressConverter } from './models/converter/customerAddress.converter';
|
|
||||||
import { CustomerAddressDatabaseRepository } from './database/customerAddress.database.repository';
|
|
||||||
import { BrandHiboutikService } from './services/hiboutik/brand.hiboutik.service';
|
import { BrandHiboutikService } from './services/hiboutik/brand.hiboutik.service';
|
||||||
import { CategoryHiboutikService } from './services/hiboutik/category.hiboutik.service';
|
import { CategoryHiboutikService } from './services/hiboutik/category.hiboutik.service';
|
||||||
import { CustomerHiboutikService } from './services/hiboutik/customer.hiboutik.service';
|
import { CustomerHiboutikService } from './services/hiboutik/customer.hiboutik.service';
|
||||||
import { SupplierHiboutikService } from './services/hiboutik/supplier.hiboutik.service';
|
import { SupplierHiboutikService } from './services/hiboutik/supplier.hiboutik.service';
|
||||||
import { ProductHiboutikService } from './services/hiboutik/product.hiboutik.service';
|
import { ProductHiboutikService } from './services/hiboutik/product.hiboutik.service';
|
||||||
import { SyncLockDatabaseRepository } from './database/syncLock.database.repository';
|
|
||||||
import { SlugDatabaseRepository } from './database/slug.database.repository';
|
|
||||||
import { SlugService } from './services/slug/slug.service';
|
|
||||||
import { SitemapDatabaseRepository } from './database/sitemap.database.repository';
|
|
||||||
import { SitemapController } from './sitemap.controller';
|
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
ConfigModule.forRoot({
|
ConfigModule.forRoot({
|
||||||
isGlobal: true,
|
isGlobal: true,
|
||||||
validate: validateEnv,
|
|
||||||
}),
|
|
||||||
// Default rate limit for every route: 60 requests per minute per IP (stricter limits via @Throttle).
|
|
||||||
// Counters live in PostgreSQL so that every instance shares them (see ThrottlerDatabaseStorage).
|
|
||||||
ThrottlerModule.forRootAsync({
|
|
||||||
inject: [PrismaService],
|
|
||||||
useFactory: (prisma: PrismaService) => ({
|
|
||||||
throttlers: [{ ttl: 60_000, limit: 60 }],
|
|
||||||
storage: new ThrottlerDatabaseStorage(prisma),
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
HttpModule.register({
|
|
||||||
timeout: 30_000,
|
|
||||||
// Hiboutik never redirects: never follow a redirect with the Basic auth credentials
|
|
||||||
maxRedirects: 0,
|
|
||||||
// A runaway answer (bug, faulty proxy) must not be loaded whole in memory
|
|
||||||
maxContentLength: 50 * 1024 * 1024,
|
|
||||||
}),
|
}),
|
||||||
|
HttpModule.register({ timeout: 30_000 }),
|
||||||
PrismaModule,
|
PrismaModule,
|
||||||
],
|
],
|
||||||
controllers: [AppController, SitemapController],
|
controllers: [AppController],
|
||||||
providers: [
|
providers: [
|
||||||
{ provide: APP_GUARD, useClass: ThrottlerGuard },
|
|
||||||
{ provide: APP_FILTER, useClass: AllExceptionsFilter },
|
|
||||||
BrandConverter,
|
BrandConverter,
|
||||||
BrandDatabaseRepository,
|
BrandDatabaseRepository,
|
||||||
BrandHiboutikRepository,
|
BrandHiboutikRepository,
|
||||||
@@ -80,29 +46,20 @@ import { SitemapController } from './sitemap.controller';
|
|||||||
CategoryDatabaseRepository,
|
CategoryDatabaseRepository,
|
||||||
CategoryHiboutikRepository,
|
CategoryHiboutikRepository,
|
||||||
CategoryHiboutikService,
|
CategoryHiboutikService,
|
||||||
CustomerAddressConverter,
|
|
||||||
CustomerAddressDatabaseRepository,
|
|
||||||
CustomerHiboutikRepository,
|
CustomerHiboutikRepository,
|
||||||
CustomerDatabaseRepository,
|
CustomerDatabaseRepository,
|
||||||
CustomerConverter,
|
CustomerConverter,
|
||||||
CustomerHiboutikService,
|
CustomerHiboutikService,
|
||||||
HiboutikClient,
|
HiboutikClient,
|
||||||
HiboutikService,
|
HiboutikService,
|
||||||
OrderConverter,
|
|
||||||
OrderDatabaseRepository,
|
|
||||||
ProductConverter,
|
ProductConverter,
|
||||||
ProductDatabaseRepository,
|
ProductDatabaseRepository,
|
||||||
ProductHiboutikRepository,
|
ProductHiboutikRepository,
|
||||||
ProductHiboutikService,
|
ProductHiboutikService,
|
||||||
ProductVersionDatabaseRepository,
|
ProductVersionDatabaseRepository,
|
||||||
SaleHiboutikRepository,
|
|
||||||
SitemapDatabaseRepository,
|
|
||||||
SlugDatabaseRepository,
|
|
||||||
SlugService,
|
|
||||||
StockHiboutikRepository,
|
StockHiboutikRepository,
|
||||||
SupplierConverter,
|
SupplierConverter,
|
||||||
SupplierDatabaseRepository,
|
SupplierDatabaseRepository,
|
||||||
SyncLockDatabaseRepository,
|
|
||||||
SupplierHiboutikRepository,
|
SupplierHiboutikRepository,
|
||||||
SupplierHiboutikService,
|
SupplierHiboutikService,
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -1,70 +0,0 @@
|
|||||||
import 'reflect-metadata';
|
|
||||||
import { validateEnv } from './env.validation';
|
|
||||||
|
|
||||||
describe('validateEnv', () => {
|
|
||||||
const valid = {
|
|
||||||
DATABASE_URL: 'postgresql://user:secret@localhost:5432/db',
|
|
||||||
HIBOUTIK_BASE_URL: 'https://shop.hiboutik.com/api',
|
|
||||||
HIBOUTIK_USERNAME: 'user',
|
|
||||||
HIBOUTIK_PASSWORD: 'secret-password',
|
|
||||||
};
|
|
||||||
|
|
||||||
it('accepts a valid configuration and keeps every variable', () => {
|
|
||||||
const env = { ...valid, PORT: '3000', CORS_ORIGINS: 'https://shop.example.com', TRUST_PROXY: '1', OTHER: 'x' };
|
|
||||||
expect(validateEnv(env)).toBe(env);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('rejects a missing required variable, naming it', () => {
|
|
||||||
expect(() => validateEnv({ ...valid, HIBOUTIK_PASSWORD: undefined })).toThrow(/HIBOUTIK_PASSWORD/);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('rejects a non-HTTPS Hiboutik URL', () => {
|
|
||||||
expect(() => validateEnv({ ...valid, HIBOUTIK_BASE_URL: 'http://shop.hiboutik.com/api' })).toThrow(/HIBOUTIK_BASE_URL/);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('rejects an invalid port', () => {
|
|
||||||
expect(() => validateEnv({ ...valid, PORT: 'abc' })).toThrow(/PORT/);
|
|
||||||
expect(() => validateEnv({ ...valid, PORT: '70000' })).toThrow(/PORT/);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('never puts secret values in the error message', () => {
|
|
||||||
expect.assertions(2);
|
|
||||||
try {
|
|
||||||
validateEnv({ ...valid, HIBOUTIK_BASE_URL: 'http://insecure', DATABASE_URL: '' });
|
|
||||||
} catch (error) {
|
|
||||||
expect((error as Error).message).not.toContain('secret-password');
|
|
||||||
expect((error as Error).message).not.toContain('secret');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('in production', () => {
|
|
||||||
const production = { ...valid, NODE_ENV: 'production' };
|
|
||||||
|
|
||||||
it('requires an encrypted database connection', () => {
|
|
||||||
expect(() => validateEnv(production)).toThrow(/DATABASE_URL: sslmode/);
|
|
||||||
expect(() => validateEnv({ ...production, DATABASE_URL: `${valid.DATABASE_URL}?sslmode=disable` })).toThrow(/DATABASE_URL/);
|
|
||||||
});
|
|
||||||
|
|
||||||
it.each(['require', 'verify-ca', 'verify-full'])('accepts sslmode=%s', (mode) => {
|
|
||||||
expect(() => validateEnv({ ...production, DATABASE_URL: `${valid.DATABASE_URL}?sslmode=${mode}` })).not.toThrow();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('never puts the connection string in the error message', () => {
|
|
||||||
expect.assertions(2);
|
|
||||||
try {
|
|
||||||
validateEnv(production);
|
|
||||||
} catch (error) {
|
|
||||||
expect((error as Error).message).toContain('DATABASE_URL');
|
|
||||||
expect((error as Error).message).not.toContain('secret');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('does not require TLS outside production', () => {
|
|
||||||
expect(() => validateEnv({ ...valid, NODE_ENV: 'development' })).not.toThrow();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('rejects an unknown NODE_ENV', () => {
|
|
||||||
expect(() => validateEnv({ ...valid, NODE_ENV: 'prod' })).toThrow(/NODE_ENV/);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,83 +0,0 @@
|
|||||||
import { plainToInstance } from 'class-transformer';
|
|
||||||
import { IsIn, IsInt, IsNotEmpty, IsOptional, IsString, IsUrl, Max, Min, validateSync } from 'class-validator';
|
|
||||||
|
|
||||||
/** `sslmode` values that encrypt the database connection (the pg driver treats them all as `verify-full`). */
|
|
||||||
const SECURE_SSL_MODES = ['require', 'verify-ca', 'verify-full'];
|
|
||||||
|
|
||||||
/** Environment variables read by the app. A missing or malformed required one stops the startup. */
|
|
||||||
export class EnvironmentVariables {
|
|
||||||
/** `production` enforces production-only rules (TLS to the database). */
|
|
||||||
@IsOptional()
|
|
||||||
@IsIn(['development', 'production', 'test'])
|
|
||||||
NODE_ENV?: string;
|
|
||||||
|
|
||||||
/** In production, `sslmode` must be require, verify-ca or verify-full: customer data must not travel in clear. */
|
|
||||||
@IsString()
|
|
||||||
@IsNotEmpty()
|
|
||||||
DATABASE_URL!: string;
|
|
||||||
|
|
||||||
/** HTTPS only: the Hiboutik credentials are sent with HTTP Basic auth. */
|
|
||||||
@IsUrl({ protocols: ['https'], require_protocol: true })
|
|
||||||
HIBOUTIK_BASE_URL!: string;
|
|
||||||
|
|
||||||
@IsString()
|
|
||||||
@IsNotEmpty()
|
|
||||||
HIBOUTIK_USERNAME!: string;
|
|
||||||
|
|
||||||
@IsString()
|
|
||||||
@IsNotEmpty()
|
|
||||||
HIBOUTIK_PASSWORD!: string;
|
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@IsInt()
|
|
||||||
@Min(1)
|
|
||||||
@Max(65535)
|
|
||||||
PORT?: number;
|
|
||||||
|
|
||||||
/** Comma-separated list of origins allowed by CORS. Empty or unset: CORS stays disabled. */
|
|
||||||
@IsOptional()
|
|
||||||
@IsString()
|
|
||||||
CORS_ORIGINS?: string;
|
|
||||||
|
|
||||||
/** Number of reverse proxies in front of the app (so the real client IP is used for rate limiting). Unset: no proxy. */
|
|
||||||
@IsOptional()
|
|
||||||
@IsInt()
|
|
||||||
@Min(1)
|
|
||||||
@Max(10)
|
|
||||||
TRUST_PROXY?: number;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Seconds a shutting down instance waits for its running synchronization (default 25). Keep it below the grace period
|
|
||||||
* of the orchestrator (Docker `stop_grace_period`, Kubernetes `terminationGracePeriodSeconds`), which kills afterwards.
|
|
||||||
*/
|
|
||||||
@IsOptional()
|
|
||||||
@IsInt()
|
|
||||||
@Min(0)
|
|
||||||
@Max(3600)
|
|
||||||
SYNC_SHUTDOWN_TIMEOUT?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Whether the connection string asks for an encrypted connection. An unparsable one does not. */
|
|
||||||
function hasSecureSslMode(databaseUrl: string): boolean {
|
|
||||||
try {
|
|
||||||
return SECURE_SSL_MODES.includes(new URL(databaseUrl).searchParams.get('sslmode') ?? '');
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** `ConfigModule` validation hook: fails fast with the names of the faulty variables (never their values, they may be secrets). */
|
|
||||||
export function validateEnv(config: Record<string, unknown>): Record<string, unknown> {
|
|
||||||
const validated = plainToInstance(EnvironmentVariables, config, { enableImplicitConversion: true });
|
|
||||||
const errors = validateSync(validated, { whitelist: false });
|
|
||||||
|
|
||||||
const details = errors.map((error) => `${error.property}: ${Object.values(error.constraints ?? {}).join(', ')}`);
|
|
||||||
if (validated.NODE_ENV === 'production' && validated.DATABASE_URL && !hasSecureSslMode(validated.DATABASE_URL)) {
|
|
||||||
details.push(`DATABASE_URL: sslmode must be one of ${SECURE_SSL_MODES.join(', ')} in production`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (details.length > 0) {
|
|
||||||
throw new Error(`Invalid environment configuration:\n ${details.join('\n ')}`);
|
|
||||||
}
|
|
||||||
return config;
|
|
||||||
}
|
|
||||||
@@ -23,20 +23,13 @@ export class CustomerDatabaseRepository {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async getAllHiboutikIds(): Promise<number[]> {
|
|
||||||
const customers = await this.prisma.customer.findMany({ select: { hiboutikId: true } });
|
|
||||||
return customers.map((customer) => customer.hiboutikId);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Deletes customers that no longer exist in Hiboutik.
|
* Deletes the customers that no longer exist in Hiboutik.
|
||||||
* Their addresses are deleted (onDelete: Cascade), their orders are kept with no customer (onDelete: SetNull).
|
* Their addresses are deleted (onDelete: Cascade), their orders are kept with no customer (onDelete: SetNull).
|
||||||
*/
|
*/
|
||||||
async deleteByHiboutikIds(hiboutikIds: number[]): Promise<number> {
|
async deleteMissing(keptHiboutikIds: number[]): Promise<number> {
|
||||||
if (hiboutikIds.length === 0) return 0;
|
|
||||||
|
|
||||||
const result = await this.prisma.customer.deleteMany({
|
const result = await this.prisma.customer.deleteMany({
|
||||||
where: { hiboutikId: { in: hiboutikIds } },
|
where: { hiboutikId: { notIn: keptHiboutikIds } },
|
||||||
});
|
});
|
||||||
return result.count;
|
return result.count;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,27 +0,0 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
|
||||||
import { PrismaService } from '../../prisma/prisma.service';
|
|
||||||
import { CustomerAddress } from '../../prisma/generated/client';
|
|
||||||
import { CustomerAddressCreateInput } from '../../prisma/generated/models/CustomerAddress';
|
|
||||||
|
|
||||||
/** Database access to customer addresses. Rows are matched with Hiboutik by `hiboutikId`. */
|
|
||||||
@Injectable()
|
|
||||||
export class CustomerAddressDatabaseRepository {
|
|
||||||
constructor(private readonly prisma: PrismaService) {}
|
|
||||||
|
|
||||||
/** Creates or updates the address with the same `hiboutikId`. */
|
|
||||||
async upsert(address: CustomerAddressCreateInput): Promise<CustomerAddress> {
|
|
||||||
return this.prisma.customerAddress.upsert({
|
|
||||||
where: { hiboutikId: address.hiboutikId },
|
|
||||||
create: address,
|
|
||||||
update: address,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Deletes the addresses of a customer that no longer exist in Hiboutik (all of them if `keptHiboutikIds` is empty). */
|
|
||||||
async deleteMissing(customerId: string, keptHiboutikIds: number[]): Promise<number> {
|
|
||||||
const result = await this.prisma.customerAddress.deleteMany({
|
|
||||||
where: { customerId, hiboutikId: { notIn: keptHiboutikIds } },
|
|
||||||
});
|
|
||||||
return result.count;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,68 +0,0 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
|
||||||
import { PrismaService } from '../../prisma/prisma.service';
|
|
||||||
import { Order } from '../../prisma/generated/client';
|
|
||||||
import { OrderWithLinesInput } from '../../prisma/customObjects/prisma.custom.object';
|
|
||||||
import { OrderStatus } from '../models/orderStatus';
|
|
||||||
|
|
||||||
/** Database access to orders. Hiboutik sales are matched by `hiboutikSaleId`. */
|
|
||||||
@Injectable()
|
|
||||||
export class OrderDatabaseRepository {
|
|
||||||
constructor(private readonly prisma: PrismaService) {}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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<Set<number>> {
|
|
||||||
if (hiboutikSaleIds.length === 0) return new Set();
|
|
||||||
|
|
||||||
const orders = await this.prisma.order.findMany({
|
|
||||||
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));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates or updates the order with the same `hiboutikSaleId`, replacing all its lines (atomically).
|
|
||||||
* Each line is linked to the current product version matching its Hiboutik product and size, if it still exists.
|
|
||||||
* On update, `source` is kept: a website order sent to Hiboutik stays WEB.
|
|
||||||
*/
|
|
||||||
async upsert(order: OrderWithLinesInput): Promise<Order> {
|
|
||||||
if (order.hiboutikSaleId == null) throw new Error('Cannot upsert an order without hiboutikSaleId');
|
|
||||||
|
|
||||||
const { lines, source, ...orderFields } = order;
|
|
||||||
const linkedLines = await this.linkToProductVersions(lines);
|
|
||||||
return this.prisma.order.upsert({
|
|
||||||
where: { hiboutikSaleId: order.hiboutikSaleId },
|
|
||||||
create: { ...orderFields, source, lines: { create: linkedLines } },
|
|
||||||
update: {
|
|
||||||
...orderFields,
|
|
||||||
// an undefined relation is ignored by Prisma on update: explicitly unlink a customer removed from the sale
|
|
||||||
customer: orderFields.customer ?? { disconnect: true },
|
|
||||||
lines: { deleteMany: {}, create: linkedLines },
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private async linkToProductVersions(lines: OrderWithLinesInput['lines']): Promise<OrderWithLinesInput['lines']> {
|
|
||||||
if (lines.length === 0) return lines;
|
|
||||||
|
|
||||||
const versions = await this.prisma.productVersion.findMany({
|
|
||||||
where: {
|
|
||||||
OR: lines.map((line) => ({ hiboutikSizeId: line.hiboutikSizeId, product: { hiboutikId: line.hiboutikProductId } })),
|
|
||||||
},
|
|
||||||
select: { id: true, hiboutikSizeId: true, product: { select: { hiboutikId: true } } },
|
|
||||||
});
|
|
||||||
const versionIds = new Map(versions.map((version) => [`${version.product.hiboutikId}:${version.hiboutikSizeId}`, version.id]));
|
|
||||||
|
|
||||||
return lines.map((line) => {
|
|
||||||
const versionId = versionIds.get(`${line.hiboutikProductId}:${line.hiboutikSizeId}`);
|
|
||||||
return versionId ? { ...line, productVersion: { connect: { id: versionId } } } : line;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { PrismaService } from '../../prisma/prisma.service';
|
import { PrismaService } from '../../prisma/prisma.service';
|
||||||
import { Prisma, Product } from '../../prisma/generated/client';
|
import { Product } from '../../prisma/generated/client';
|
||||||
import { ProductCreateInput } from '../../prisma/generated/models/Product';
|
import { ProductCreateInput } from '../../prisma/generated/models/Product';
|
||||||
|
|
||||||
/** Database access to products. Rows are matched with Hiboutik by `hiboutikId`. */
|
/** Database access to products. Rows are matched with Hiboutik by `hiboutikId`. */
|
||||||
@@ -14,12 +14,9 @@ export class ProductDatabaseRepository {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** Creates or updates the product with the same `hiboutikId`. On update, the relations missing from the input are unlinked. */
|
||||||
* Creates or updates the product with the same `hiboutikId`. On update, the relations missing from the input are unlinked.
|
async upsert(product: ProductCreateInput): Promise<Product> {
|
||||||
* @param db transaction client, to run it inside a transaction.
|
return this.prisma.product.upsert({
|
||||||
*/
|
|
||||||
async upsert(product: ProductCreateInput, db: Prisma.TransactionClient = this.prisma): Promise<Product> {
|
|
||||||
return db.product.upsert({
|
|
||||||
where: { hiboutikId: product.hiboutikId },
|
where: { hiboutikId: product.hiboutikId },
|
||||||
create: product,
|
create: product,
|
||||||
update: {
|
update: {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { PrismaService } from '../../prisma/prisma.service';
|
import { PrismaService } from '../../prisma/prisma.service';
|
||||||
import { Prisma, ProductVersion } from '../../prisma/generated/client';
|
import { ProductVersion } from '../../prisma/generated/client';
|
||||||
import { ProductVersionCreateInput } from '../../prisma/generated/models/ProductVersion';
|
import { ProductVersionCreateInput } from '../../prisma/generated/models/ProductVersion';
|
||||||
|
|
||||||
/** Database access to product versions (one per Hiboutik size), identified by their product and `hiboutikSizeId`. */
|
/** Database access to product versions (one per Hiboutik size), identified by their product and `hiboutikSizeId`. */
|
||||||
@@ -14,16 +14,9 @@ export class ProductVersionDatabaseRepository {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** Creates or updates the version of `productId` with the same `hiboutikSizeId`. */
|
||||||
* Creates or updates the version of `productId` with the same `hiboutikSizeId`.
|
async upsert(productId: string, productVersion: ProductVersionCreateInput): Promise<ProductVersion> {
|
||||||
* @param db transaction client, to run it inside a transaction.
|
return this.prisma.productVersion.upsert({
|
||||||
*/
|
|
||||||
async upsert(
|
|
||||||
productId: string,
|
|
||||||
productVersion: ProductVersionCreateInput,
|
|
||||||
db: Prisma.TransactionClient = this.prisma,
|
|
||||||
): Promise<ProductVersion> {
|
|
||||||
return db.productVersion.upsert({
|
|
||||||
where: { productId_hiboutikSizeId: { productId: productId, hiboutikSizeId: productVersion.hiboutikSizeId } },
|
where: { productId_hiboutikSizeId: { productId: productId, hiboutikSizeId: productVersion.hiboutikSizeId } },
|
||||||
create: productVersion,
|
create: productVersion,
|
||||||
update: productVersion,
|
update: productVersion,
|
||||||
@@ -33,10 +26,9 @@ export class ProductVersionDatabaseRepository {
|
|||||||
/**
|
/**
|
||||||
* Deletes the versions of a product that no longer exist in Hiboutik.
|
* 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.
|
* 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[], db: Prisma.TransactionClient = this.prisma): Promise<number> {
|
async deleteMissing(productId: string, keptHiboutikSizeIds: number[]): Promise<number> {
|
||||||
const result = await db.productVersion.deleteMany({
|
const result = await this.prisma.productVersion.deleteMany({
|
||||||
where: { productId, hiboutikSizeId: { notIn: keptHiboutikSizeIds } },
|
where: { productId, hiboutikSizeId: { notIn: keptHiboutikSizeIds } },
|
||||||
});
|
});
|
||||||
return result.count;
|
return result.count;
|
||||||
|
|||||||
@@ -1,53 +0,0 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
|
||||||
import { PrismaService } from '../../prisma/prisma.service';
|
|
||||||
|
|
||||||
/** Public page of the storefront, identified by its slug. */
|
|
||||||
export interface SitemapEntry {
|
|
||||||
slug: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Public product page, with the last change of its content in Hiboutik (null when Hiboutik does not give it). */
|
|
||||||
export interface ProductSitemapEntry extends SitemapEntry {
|
|
||||||
updatedAt: Date | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Sitemap {
|
|
||||||
brands: SitemapEntry[];
|
|
||||||
categories: SitemapEntry[];
|
|
||||||
products: ProductSitemapEntry[];
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Reads the public pages of the storefront, for its sitemap. */
|
|
||||||
@Injectable()
|
|
||||||
export class SitemapDatabaseRepository {
|
|
||||||
constructor(private readonly prisma: PrismaService) {}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Brands enabled, categories displayed on the website and products displayed on the website and not archived, that
|
|
||||||
* have a slug. Only products carry a date, their last update in Hiboutik: the `updatedAt` of the rows is rewritten by
|
|
||||||
* every sync, so it would give every page the date of the last sync, which search engines learn to ignore.
|
|
||||||
*/
|
|
||||||
async getSitemap(): Promise<Sitemap> {
|
|
||||||
const orderBy = { slug: 'asc' } as const;
|
|
||||||
const hasSlug = { slug: { not: null } };
|
|
||||||
const [brands, categories, products] = await Promise.all([
|
|
||||||
this.prisma.brand.findMany({ where: { ...hasSlug, enabled: true }, select: { slug: true }, orderBy }),
|
|
||||||
this.prisma.category.findMany({ where: { ...hasSlug, webEnabled: true }, select: { slug: true }, orderBy }),
|
|
||||||
this.prisma.product.findMany({
|
|
||||||
where: { ...hasSlug, enabled: true, archived: false },
|
|
||||||
select: { slug: true, hiboutikUpdatedAt: true },
|
|
||||||
orderBy,
|
|
||||||
}),
|
|
||||||
]);
|
|
||||||
return {
|
|
||||||
brands: brands.map((row) => ({ slug: toSlug(row.slug) })),
|
|
||||||
categories: categories.map((row) => ({ slug: toSlug(row.slug) })),
|
|
||||||
products: products.map((row) => ({ slug: toSlug(row.slug), updatedAt: row.hiboutikUpdatedAt })),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The query filters out null slugs; the type does not know it. */
|
|
||||||
function toSlug(slug: string | null): string {
|
|
||||||
return slug as string;
|
|
||||||
}
|
|
||||||
@@ -1,57 +0,0 @@
|
|||||||
import { SlugEntityType } from '../../prisma/generated/client';
|
|
||||||
import { SlugDatabaseRepository } from './slug.database.repository';
|
|
||||||
|
|
||||||
describe('SlugDatabaseRepository.changeSlug', () => {
|
|
||||||
const tx = {
|
|
||||||
product: { findUnique: jest.fn(), update: jest.fn() },
|
|
||||||
slugRedirect: { findUnique: jest.fn(), delete: jest.fn(), create: jest.fn() },
|
|
||||||
};
|
|
||||||
const prisma = { $transaction: jest.fn((fn: (client: typeof tx) => unknown) => fn(tx)) };
|
|
||||||
let repository: SlugDatabaseRepository;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
jest.clearAllMocks();
|
|
||||||
repository = new SlugDatabaseRepository(prisma as never);
|
|
||||||
// product-a currently named "ate"; no product currently named with the new slug
|
|
||||||
tx.product.findUnique.mockImplementation(({ where }: { where: { id?: string } }) =>
|
|
||||||
Promise.resolve(where.id === 'product-a' ? { slug: 'ate' } : null),
|
|
||||||
);
|
|
||||||
tx.slugRedirect.findUnique.mockResolvedValue(null);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('keeps the former slug as a redirect', async () => {
|
|
||||||
await expect(repository.changeSlug(SlugEntityType.PRODUCT, 'product-a', 'ate-digipack')).resolves.toBe('CHANGED');
|
|
||||||
|
|
||||||
expect(tx.slugRedirect.delete).not.toHaveBeenCalled();
|
|
||||||
expect(tx.slugRedirect.create).toHaveBeenCalledWith({ data: { entityType: 'PRODUCT', slug: 'ate', productId: 'product-a' } });
|
|
||||||
expect(tx.product.update).toHaveBeenCalledWith({ where: { id: 'product-a' }, data: { slug: 'ate-digipack' } });
|
|
||||||
});
|
|
||||||
|
|
||||||
it('lets an entity take back one of its own former slugs', async () => {
|
|
||||||
tx.slugRedirect.findUnique.mockResolvedValue({ id: 'redirect-1', brandId: null, categoryId: null, productId: 'product-a' });
|
|
||||||
|
|
||||||
await expect(repository.changeSlug(SlugEntityType.PRODUCT, 'product-a', 'ate-old')).resolves.toBe('CHANGED');
|
|
||||||
|
|
||||||
expect(tx.slugRedirect.delete).toHaveBeenCalledWith({ where: { id: 'redirect-1' } });
|
|
||||||
expect(tx.product.update).toHaveBeenCalledWith({ where: { id: 'product-a' }, data: { slug: 'ate-old' } });
|
|
||||||
});
|
|
||||||
|
|
||||||
it("refuses another entity's former slug: its old URL keeps redirecting to it", async () => {
|
|
||||||
tx.slugRedirect.findUnique.mockResolvedValue({ id: 'redirect-1', brandId: null, categoryId: null, productId: 'product-b' });
|
|
||||||
|
|
||||||
await expect(repository.changeSlug(SlugEntityType.PRODUCT, 'product-a', 'maxident')).resolves.toBe('TAKEN');
|
|
||||||
|
|
||||||
expect(tx.slugRedirect.delete).not.toHaveBeenCalled();
|
|
||||||
expect(tx.slugRedirect.create).not.toHaveBeenCalled();
|
|
||||||
expect(tx.product.update).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("refuses another entity's current slug", async () => {
|
|
||||||
tx.product.findUnique.mockImplementation(({ where }: { where: { id?: string; slug?: string } }) =>
|
|
||||||
Promise.resolve(where.id === 'product-a' ? { slug: 'ate' } : where.slug === 'maxident' ? { slug: 'maxident' } : null),
|
|
||||||
);
|
|
||||||
|
|
||||||
await expect(repository.changeSlug(SlugEntityType.PRODUCT, 'product-a', 'maxident')).resolves.toBe('TAKEN');
|
|
||||||
expect(tx.product.update).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,135 +0,0 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
|
||||||
import { PrismaService } from '../../prisma/prisma.service';
|
|
||||||
import { Prisma, SlugEntityType } from '../../prisma/generated/client';
|
|
||||||
|
|
||||||
/** Entity waiting for its first slug, with what is needed to build it. */
|
|
||||||
export interface SlugCandidateRow {
|
|
||||||
id: string;
|
|
||||||
hiboutikId: number;
|
|
||||||
name: string;
|
|
||||||
/** Name of the parent category (categories only), null for a root category or another entity type. */
|
|
||||||
parentName: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Outcome of {@link SlugDatabaseRepository.changeSlug}. */
|
|
||||||
export type ChangeSlugResult = 'CHANGED' | 'UNCHANGED' | 'NOT_FOUND' | 'TAKEN';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Database access to the slugs of brands, categories and products, and to the redirects of their former slugs.
|
|
||||||
* Slugs are unique per entity type: an entity's current slug and the former slugs of the other entities of the same
|
|
||||||
* type (redirects) share one namespace.
|
|
||||||
*/
|
|
||||||
@Injectable()
|
|
||||||
export class SlugDatabaseRepository {
|
|
||||||
constructor(private readonly prisma: PrismaService) {}
|
|
||||||
|
|
||||||
/** Slugs that are in use for this type: current slugs and redirected former slugs. */
|
|
||||||
async getTakenSlugs(type: SlugEntityType): Promise<Set<string>> {
|
|
||||||
const where = { slug: { not: null } };
|
|
||||||
const select = { slug: true } as const;
|
|
||||||
const [current, redirects] = await Promise.all([
|
|
||||||
type === SlugEntityType.BRAND
|
|
||||||
? this.prisma.brand.findMany({ where, select })
|
|
||||||
: type === SlugEntityType.CATEGORY
|
|
||||||
? this.prisma.category.findMany({ where, select })
|
|
||||||
: this.prisma.product.findMany({ where, select }),
|
|
||||||
this.prisma.slugRedirect.findMany({ where: { entityType: type }, select }),
|
|
||||||
]);
|
|
||||||
return new Set([...current, ...redirects].map((row) => row.slug).filter((slug): slug is string => slug !== null));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Entities of this type without a slug yet, oldest in Hiboutik first (so that it gets the plain slug).
|
|
||||||
* Categories come with the name of their parent (the parent's name, not its slug: a younger parent may have no slug yet,
|
|
||||||
* and an admin may have changed it, while the name keeps the generation deterministic).
|
|
||||||
*/
|
|
||||||
async getWithoutSlug(type: SlugEntityType): Promise<SlugCandidateRow[]> {
|
|
||||||
const where = { slug: null };
|
|
||||||
const orderBy = { hiboutikId: 'asc' } as const;
|
|
||||||
const select = { id: true, hiboutikId: true, name: true } as const;
|
|
||||||
if (type === SlugEntityType.CATEGORY) {
|
|
||||||
const rows = await this.prisma.category.findMany({ where, orderBy, select: { ...select, parent: { select: { name: true } } } });
|
|
||||||
return rows.map(({ parent, ...row }) => ({ ...row, parentName: parent?.name ?? null }));
|
|
||||||
}
|
|
||||||
const rows =
|
|
||||||
type === SlugEntityType.BRAND
|
|
||||||
? await this.prisma.brand.findMany({ where, orderBy, select })
|
|
||||||
: await this.prisma.product.findMany({ where, orderBy, select });
|
|
||||||
return rows.map((row) => ({ ...row, parentName: null }));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Sets the first slug of an entity. Never overwrites an existing slug (e.g. one an admin set in the meantime).
|
|
||||||
* @returns false when the entity already had a slug or no longer exists.
|
|
||||||
*/
|
|
||||||
async setFirstSlug(type: SlugEntityType, id: string, slug: string): Promise<boolean> {
|
|
||||||
const args = { where: { id, slug: null }, data: { slug } };
|
|
||||||
const result =
|
|
||||||
type === SlugEntityType.BRAND
|
|
||||||
? await this.prisma.brand.updateMany(args)
|
|
||||||
: type === SlugEntityType.CATEGORY
|
|
||||||
? await this.prisma.category.updateMany(args)
|
|
||||||
: await this.prisma.product.updateMany(args);
|
|
||||||
return result.count === 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Replaces the slug of an entity, in one transaction: its former slug becomes a redirect to it.
|
|
||||||
* The new slug may be one of its own former slugs (that redirect is dropped, the slug naming it again), never another
|
|
||||||
* entity's former slug: that URL keeps redirecting to its entity (`TAKEN`).
|
|
||||||
* A concurrent write of the same slug makes the transaction fail on the unique constraint (Prisma error P2002).
|
|
||||||
*/
|
|
||||||
async changeSlug(type: SlugEntityType, id: string, newSlug: string): Promise<ChangeSlugResult> {
|
|
||||||
return this.prisma.$transaction(async (tx) => {
|
|
||||||
const entity = await this.findSlugOwner(tx, type, { id });
|
|
||||||
if (!entity) return 'NOT_FOUND';
|
|
||||||
if (entity.slug === newSlug) return 'UNCHANGED';
|
|
||||||
|
|
||||||
const owner = await this.findSlugOwner(tx, type, { slug: newSlug });
|
|
||||||
if (owner) return 'TAKEN';
|
|
||||||
|
|
||||||
const redirect = await tx.slugRedirect.findUnique({
|
|
||||||
where: { entityType_slug: { entityType: type, slug: newSlug } },
|
|
||||||
select: { id: true, brandId: true, categoryId: true, productId: true },
|
|
||||||
});
|
|
||||||
if (redirect) {
|
|
||||||
if ((redirect.brandId ?? redirect.categoryId ?? redirect.productId) !== id) return 'TAKEN';
|
|
||||||
await tx.slugRedirect.delete({ where: { id: redirect.id } });
|
|
||||||
}
|
|
||||||
if (entity.slug) {
|
|
||||||
await tx.slugRedirect.create({ data: { entityType: type, slug: entity.slug, ...this.redirectTarget(type, id) } });
|
|
||||||
}
|
|
||||||
const args = { where: { id }, data: { slug: newSlug } };
|
|
||||||
if (type === SlugEntityType.BRAND) await tx.brand.update(args);
|
|
||||||
else if (type === SlugEntityType.CATEGORY) await tx.category.update(args);
|
|
||||||
else await tx.product.update(args);
|
|
||||||
return 'CHANGED';
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @returns the current slug of the entity a former slug redirects to, null when the slug is not a redirect. */
|
|
||||||
async findRedirect(type: SlugEntityType, slug: string): Promise<string | null> {
|
|
||||||
const redirect = await this.prisma.slugRedirect.findUnique({
|
|
||||||
where: { entityType_slug: { entityType: type, slug } },
|
|
||||||
select: { brand: { select: { slug: true } }, category: { select: { slug: true } }, product: { select: { slug: true } } },
|
|
||||||
});
|
|
||||||
return redirect?.brand?.slug ?? redirect?.category?.slug ?? redirect?.product?.slug ?? null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async findSlugOwner(
|
|
||||||
db: Prisma.TransactionClient,
|
|
||||||
type: SlugEntityType,
|
|
||||||
where: { id: string } | { slug: string },
|
|
||||||
): Promise<{ slug: string | null } | null> {
|
|
||||||
const args = { where, select: { slug: true } };
|
|
||||||
if (type === SlugEntityType.BRAND) return db.brand.findUnique(args);
|
|
||||||
if (type === SlugEntityType.CATEGORY) return db.category.findUnique(args);
|
|
||||||
return db.product.findUnique(args);
|
|
||||||
}
|
|
||||||
|
|
||||||
private redirectTarget(type: SlugEntityType, id: string) {
|
|
||||||
if (type === SlugEntityType.BRAND) return { brandId: id };
|
|
||||||
if (type === SlugEntityType.CATEGORY) return { categoryId: id };
|
|
||||||
return { productId: id };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,74 +0,0 @@
|
|||||||
import { Logger } from '@nestjs/common';
|
|
||||||
import { Client } from 'pg';
|
|
||||||
import { SyncLockDatabaseRepository } from './syncLock.database.repository';
|
|
||||||
|
|
||||||
jest.mock('pg', () => ({ Client: jest.fn() }));
|
|
||||||
|
|
||||||
describe('SyncLockDatabaseRepository', () => {
|
|
||||||
const client = { on: jest.fn(), connect: jest.fn(), query: jest.fn(), end: jest.fn() };
|
|
||||||
const configService = { get: jest.fn().mockReturnValue('postgresql://user:pass@localhost:5432/db') };
|
|
||||||
let repository: SyncLockDatabaseRepository;
|
|
||||||
|
|
||||||
beforeAll(() => {
|
|
||||||
jest.spyOn(Logger.prototype, 'warn').mockImplementation(() => undefined);
|
|
||||||
jest.spyOn(Logger.prototype, 'error').mockImplementation(() => undefined);
|
|
||||||
});
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
jest.clearAllMocks();
|
|
||||||
(Client as unknown as jest.Mock).mockImplementation(() => client);
|
|
||||||
client.connect.mockResolvedValue(undefined);
|
|
||||||
client.end.mockResolvedValue(undefined);
|
|
||||||
client.query.mockResolvedValue({ rows: [{ locked: true }] });
|
|
||||||
repository = new SyncLockDatabaseRepository(configService as never);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('takes the advisory lock on a dedicated connection and keeps it open', async () => {
|
|
||||||
const release = await repository.tryAcquire();
|
|
||||||
|
|
||||||
expect(release).toEqual(expect.any(Function));
|
|
||||||
expect(Client).toHaveBeenCalledWith({ connectionString: 'postgresql://user:pass@localhost:5432/db', connectionTimeoutMillis: 10_000 });
|
|
||||||
expect(client.query).toHaveBeenCalledWith('SELECT pg_try_advisory_lock($1) AS locked', [expect.any(Number)]);
|
|
||||||
expect(client.end).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns null and closes the connection when the lock is already held', async () => {
|
|
||||||
client.query.mockResolvedValue({ rows: [{ locked: false }] });
|
|
||||||
|
|
||||||
await expect(repository.tryAcquire()).resolves.toBeNull();
|
|
||||||
|
|
||||||
expect(client.end).toHaveBeenCalledTimes(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('unlocks then closes the connection on release', async () => {
|
|
||||||
const release = await repository.tryAcquire();
|
|
||||||
|
|
||||||
await release!();
|
|
||||||
|
|
||||||
expect(client.query).toHaveBeenLastCalledWith('SELECT pg_advisory_unlock($1)', [expect.any(Number)]);
|
|
||||||
expect(client.end).toHaveBeenCalledTimes(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('still closes the connection, and does not throw, when the explicit unlock fails', async () => {
|
|
||||||
const release = await repository.tryAcquire();
|
|
||||||
client.query.mockRejectedValueOnce(new Error('connection lost'));
|
|
||||||
|
|
||||||
await expect(release!()).resolves.toBeUndefined();
|
|
||||||
|
|
||||||
expect(client.end).toHaveBeenCalledTimes(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('closes the connection and rethrows when the lock query fails', async () => {
|
|
||||||
client.query.mockRejectedValue(new Error('boom'));
|
|
||||||
|
|
||||||
await expect(repository.tryAcquire()).rejects.toThrow('boom');
|
|
||||||
|
|
||||||
expect(client.end).toHaveBeenCalledTimes(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('logs connection errors instead of leaving them unhandled', async () => {
|
|
||||||
await repository.tryAcquire();
|
|
||||||
|
|
||||||
expect(client.on).toHaveBeenCalledWith('error', expect.any(Function));
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
import { Injectable, Logger } from '@nestjs/common';
|
|
||||||
import { ConfigService } from '@nestjs/config';
|
|
||||||
import { Client } from 'pg';
|
|
||||||
|
|
||||||
/** Arbitrary constant identifying the Hiboutik synchronization among the Postgres advisory locks of the database. */
|
|
||||||
const SYNC_LOCK_KEY = 727_001;
|
|
||||||
|
|
||||||
/** Function releasing a lock acquired with `tryAcquire`. Never throws. */
|
|
||||||
export type ReleaseLock = () => Promise<void>;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Cross-instance lock for the synchronization, based on a Postgres session-level advisory lock.
|
|
||||||
* It lives on its own dedicated connection (not the Prisma pool, where lock and unlock could land on different
|
|
||||||
* connections) kept open for as long as the lock is held. If the process dies, Postgres closes the connection and
|
|
||||||
* releases the lock by itself: there is never a stale lock to clean up.
|
|
||||||
*/
|
|
||||||
@Injectable()
|
|
||||||
export class SyncLockDatabaseRepository {
|
|
||||||
private readonly logger = new Logger('SyncLockDatabaseRepository');
|
|
||||||
|
|
||||||
constructor(private readonly configService: ConfigService) {}
|
|
||||||
|
|
||||||
/** @returns the function releasing the lock, or `null` if another instance (or process) already holds it. */
|
|
||||||
async tryAcquire(): Promise<ReleaseLock | null> {
|
|
||||||
// Fails fast instead of holding the POST /sync request until its 30 s timeout when the database does not answer
|
|
||||||
const client = new Client({ connectionString: this.configService.get<string>('DATABASE_URL'), connectionTimeoutMillis: 10_000 });
|
|
||||||
// A lost connection means Postgres released the lock: log it instead of crashing the process on an unhandled 'error'
|
|
||||||
client.on('error', (error) => this.logger.error(`Synchronization lock connection error: ${error.message}`));
|
|
||||||
await client.connect();
|
|
||||||
|
|
||||||
try {
|
|
||||||
const { rows } = await client.query<{ locked: boolean }>('SELECT pg_try_advisory_lock($1) AS locked', [SYNC_LOCK_KEY]);
|
|
||||||
if (!rows[0]?.locked) {
|
|
||||||
await client.end();
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
await client.end().catch(() => undefined);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
|
|
||||||
return async () => {
|
|
||||||
try {
|
|
||||||
await client.query('SELECT pg_advisory_unlock($1)', [SYNC_LOCK_KEY]);
|
|
||||||
} catch (error) {
|
|
||||||
// Ending the connection below releases the lock anyway
|
|
||||||
this.logger.warn(`Failed to unlock the synchronization lock explicitly: ${(error as Error)?.message}`);
|
|
||||||
} finally {
|
|
||||||
await client.end().catch(() => undefined);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
import { Logger } from '@nestjs/common';
|
|
||||||
import { ThrottlerDatabaseStorage } from './throttlerStorage.database.repository';
|
|
||||||
|
|
||||||
// The SQL itself (window, block, reset) was checked on a real Postgres engine; these tests cover the TypeScript side.
|
|
||||||
describe('ThrottlerDatabaseStorage', () => {
|
|
||||||
const record = { totalHits: 2, timeToExpire: 40, isBlocked: false, timeToBlockExpire: 0 };
|
|
||||||
const prisma = { $queryRaw: jest.fn(), throttlerRecord: { deleteMany: jest.fn() } };
|
|
||||||
let logError: jest.SpyInstance;
|
|
||||||
let random: jest.Mock;
|
|
||||||
let storage: ThrottlerDatabaseStorage;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
jest.clearAllMocks();
|
|
||||||
logError = jest.spyOn(Logger.prototype, 'error').mockImplementation(() => undefined);
|
|
||||||
prisma.$queryRaw.mockResolvedValue([record]);
|
|
||||||
prisma.throttlerRecord.deleteMany.mockResolvedValue({ count: 0 });
|
|
||||||
random = jest.fn().mockReturnValue(0.5);
|
|
||||||
storage = new ThrottlerDatabaseStorage(prisma as never, random);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns the record computed by the database, with parameters bound (never interpolated)', async () => {
|
|
||||||
await expect(storage.increment('key-hash', 60_000, 5, 30_000)).resolves.toEqual(record);
|
|
||||||
|
|
||||||
const [strings, ...values] = prisma.$queryRaw.mock.calls[0] as [TemplateStringsArray, ...unknown[]];
|
|
||||||
expect(values).toEqual(['key-hash', 60_000, 5, 30_000]);
|
|
||||||
expect(strings.join('?')).not.toContain('key-hash');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('purges the finished counters on about one request in 100', async () => {
|
|
||||||
await storage.increment('k', 60_000, 5, 60_000);
|
|
||||||
expect(prisma.throttlerRecord.deleteMany).not.toHaveBeenCalled();
|
|
||||||
|
|
||||||
random.mockReturnValue(0.001);
|
|
||||||
await storage.increment('k', 60_000, 5, 60_000);
|
|
||||||
expect(prisma.throttlerRecord.deleteMany).toHaveBeenCalledTimes(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('lets the request through when the database fails, logging at most once a minute', async () => {
|
|
||||||
prisma.$queryRaw.mockRejectedValue(new Error('connection refused'));
|
|
||||||
|
|
||||||
await expect(storage.increment('k', 60_000, 5, 60_000)).resolves.toMatchObject({ isBlocked: false });
|
|
||||||
await storage.increment('k', 60_000, 5, 60_000);
|
|
||||||
|
|
||||||
expect(logError).toHaveBeenCalledTimes(1);
|
|
||||||
expect(logError).toHaveBeenCalledWith(expect.stringContaining('connection refused'));
|
|
||||||
});
|
|
||||||
|
|
||||||
it('does not fail the request when the purge fails', async () => {
|
|
||||||
random.mockReturnValue(0);
|
|
||||||
prisma.throttlerRecord.deleteMany.mockRejectedValue(new Error('boom'));
|
|
||||||
|
|
||||||
await expect(storage.increment('k', 60_000, 5, 60_000)).resolves.toEqual(record);
|
|
||||||
await new Promise((resolve) => setImmediate(resolve));
|
|
||||||
expect(logError).toHaveBeenCalledWith(expect.stringContaining('boom'));
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,90 +0,0 @@
|
|||||||
import { Logger } from '@nestjs/common';
|
|
||||||
import { ThrottlerStorage } from '@nestjs/throttler';
|
|
||||||
import { PrismaService } from '../../prisma/prisma.service';
|
|
||||||
|
|
||||||
/** Answer of {@link ThrottlerStorage.increment} (its type is not exported by the package root). */
|
|
||||||
type ThrottlerStorageRecord = Awaited<ReturnType<ThrottlerStorage['increment']>>;
|
|
||||||
|
|
||||||
/** Share of the requests that also purge the finished counters (about one in 100). */
|
|
||||||
const PURGE_PROBABILITY = 0.01;
|
|
||||||
/** At most one log line per minute while the database is unreachable (one request = one failure). */
|
|
||||||
const ERROR_LOG_INTERVAL_MS = 60_000;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Rate limiting counters stored in PostgreSQL (`throttler_records`), so that every instance shares them: with an
|
|
||||||
* in-memory storage, each instance would count apart and N instances would allow N times the limit.
|
|
||||||
*
|
|
||||||
* One fixed window per key: the first request opens a window of `ttl`; past `limit` requests in it, the key is blocked
|
|
||||||
* for `blockDuration`, then a new window starts. Each request is a single atomic upsert, computed with the database
|
|
||||||
* clock (`now()`), so the instances need neither a lock nor synchronized clocks.
|
|
||||||
*
|
|
||||||
* Fails open: if the database cannot be reached, the request is let through (and the failure logged) rather than
|
|
||||||
* answered with a 500. Every endpoint needs the database anyway.
|
|
||||||
*/
|
|
||||||
export class ThrottlerDatabaseStorage implements ThrottlerStorage {
|
|
||||||
private readonly logger = new Logger('ThrottlerDatabaseStorage');
|
|
||||||
private lastErrorLogAt = 0;
|
|
||||||
|
|
||||||
constructor(
|
|
||||||
private readonly prisma: PrismaService,
|
|
||||||
private readonly random: () => number = Math.random,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
/** @param ttl window length (ms) @param blockDuration block length once the limit is exceeded (ms) */
|
|
||||||
async increment(key: string, ttl: number, limit: number, blockDuration: number): Promise<ThrottlerStorageRecord> {
|
|
||||||
try {
|
|
||||||
const [record] = await this.prisma.$queryRaw<ThrottlerStorageRecord[]>`
|
|
||||||
INSERT INTO throttler_records AS r (key, hits, "expiresAt", "blockedUntil")
|
|
||||||
VALUES (${key}, 1, now() + ${ttl}::int * interval '1 millisecond', NULL)
|
|
||||||
ON CONFLICT (key) DO UPDATE SET
|
|
||||||
hits = CASE
|
|
||||||
WHEN r."blockedUntil" > now() THEN r.hits
|
|
||||||
WHEN r."expiresAt" <= now() OR r."blockedUntil" IS NOT NULL THEN 1
|
|
||||||
ELSE r.hits + 1
|
|
||||||
END,
|
|
||||||
"expiresAt" = CASE
|
|
||||||
WHEN r."blockedUntil" > now() THEN r."expiresAt"
|
|
||||||
WHEN r."expiresAt" <= now() OR r."blockedUntil" IS NOT NULL THEN EXCLUDED."expiresAt"
|
|
||||||
ELSE r."expiresAt"
|
|
||||||
END,
|
|
||||||
"blockedUntil" = CASE
|
|
||||||
WHEN r."blockedUntil" > now() THEN r."blockedUntil"
|
|
||||||
WHEN r."expiresAt" <= now() OR r."blockedUntil" IS NOT NULL THEN NULL
|
|
||||||
WHEN r.hits + 1 > ${limit}::int THEN now() + ${blockDuration}::int * interval '1 millisecond'
|
|
||||||
ELSE NULL
|
|
||||||
END
|
|
||||||
RETURNING
|
|
||||||
hits AS "totalHits",
|
|
||||||
GREATEST(0, CEIL(EXTRACT(EPOCH FROM ("expiresAt" - now()))))::int AS "timeToExpire",
|
|
||||||
COALESCE("blockedUntil" > now(), false) AS "isBlocked",
|
|
||||||
COALESCE(GREATEST(0, CEIL(EXTRACT(EPOCH FROM ("blockedUntil" - now())))), 0)::int AS "timeToBlockExpire"`;
|
|
||||||
|
|
||||||
if (this.random() < PURGE_PROBABILITY) void this.purgeFinished();
|
|
||||||
return record;
|
|
||||||
} catch (error) {
|
|
||||||
this.logFailure('Rate limiting storage unavailable, request let through', error);
|
|
||||||
return { totalHits: 0, timeToExpire: 0, isBlocked: false, timeToBlockExpire: 0 };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Deletes the counters whose window and block are both over (idempotent: every instance may run it). */
|
|
||||||
private async purgeFinished(): Promise<void> {
|
|
||||||
try {
|
|
||||||
await this.prisma.throttlerRecord.deleteMany({
|
|
||||||
where: {
|
|
||||||
expiresAt: { lt: new Date() },
|
|
||||||
OR: [{ blockedUntil: null }, { blockedUntil: { lt: new Date() } }],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
this.logFailure('Failed to purge the finished rate limiting counters', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private logFailure(message: string, error: unknown): void {
|
|
||||||
const now = Date.now();
|
|
||||||
if (now - this.lastErrorLogAt < ERROR_LOG_INTERVAL_MS) return;
|
|
||||||
this.lastErrorLogAt = now;
|
|
||||||
this.logger.error(`${message}: ${(error as Error)?.message}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,58 +0,0 @@
|
|||||||
import { BadRequestException, Logger } from '@nestjs/common';
|
|
||||||
import { AllExceptionsFilter } from './all-exceptions.filter';
|
|
||||||
|
|
||||||
describe('AllExceptionsFilter', () => {
|
|
||||||
const applicationRef = { reply: jest.fn(), isHeadersSent: jest.fn().mockReturnValue(false), end: jest.fn() };
|
|
||||||
const response = {};
|
|
||||||
const host = { getArgByIndex: jest.fn().mockReturnValue(response) };
|
|
||||||
let filter: AllExceptionsFilter;
|
|
||||||
let logError: jest.SpyInstance;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
jest.clearAllMocks();
|
|
||||||
logError = jest.spyOn(Logger.prototype, 'error').mockImplementation(() => undefined);
|
|
||||||
filter = new AllExceptionsFilter(applicationRef as never);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('answers a generic 500 for an unexpected error, without leaking its message', () => {
|
|
||||||
filter.catch(new Error('relation "customer" does not exist'), host as never);
|
|
||||||
|
|
||||||
expect(applicationRef.reply).toHaveBeenCalledTimes(1);
|
|
||||||
const [, body, status] = applicationRef.reply.mock.calls[0] as [unknown, { message: string }, number];
|
|
||||||
expect(status).toBe(500);
|
|
||||||
expect(JSON.stringify(body)).not.toContain('customer');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('logs the message and stack of an unexpected error, never the raw object', () => {
|
|
||||||
const error = Object.assign(new Error('boom'), { config: { headers: { Authorization: 'Basic c2VjcmV0' } } });
|
|
||||||
|
|
||||||
filter.catch(error, host as never);
|
|
||||||
|
|
||||||
expect(logError).toHaveBeenCalledWith('Unhandled exception: boom', error.stack);
|
|
||||||
expect(JSON.stringify(logError.mock.calls)).not.toContain('c2VjcmV0');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('keeps the status and body of deliberate HTTP exceptions', () => {
|
|
||||||
filter.catch(new BadRequestException('limit must be an integer'), host as never);
|
|
||||||
|
|
||||||
const [, body, status] = applicationRef.reply.mock.calls[0] as [unknown, { message: string }, number];
|
|
||||||
expect(status).toBe(400);
|
|
||||||
expect(body.message).toBe('limit must be an integer');
|
|
||||||
expect(logError).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('keeps the 4xx of HTTP-layer errors such as a payload too large', () => {
|
|
||||||
filter.catch(Object.assign(new Error('request entity too large'), { statusCode: 413 }), host as never);
|
|
||||||
|
|
||||||
const [, , status] = applicationRef.reply.mock.calls[0] as [unknown, unknown, number];
|
|
||||||
expect(status).toBe(413);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('treats a 5xx HTTP-layer error as an unexpected one', () => {
|
|
||||||
filter.catch(Object.assign(new Error('upstream detail'), { statusCode: 502 }), host as never);
|
|
||||||
|
|
||||||
const [, body, status] = applicationRef.reply.mock.calls[0] as [unknown, unknown, number];
|
|
||||||
expect(status).toBe(500);
|
|
||||||
expect(JSON.stringify(body)).not.toContain('upstream');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
import { ArgumentsHost, Catch, HttpException, InternalServerErrorException, Logger } from '@nestjs/common';
|
|
||||||
import { BaseExceptionFilter } from '@nestjs/core';
|
|
||||||
|
|
||||||
/** Errors raised by the HTTP layer itself (malformed JSON, payload too large...) carry a 4xx `statusCode` but are not `HttpException`s. */
|
|
||||||
function isClientHttpError(exception: unknown): boolean {
|
|
||||||
const statusCode = (exception as { statusCode?: unknown } | null)?.statusCode;
|
|
||||||
return typeof statusCode === 'number' && statusCode >= 400 && statusCode < 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Last line of defence: anything that is not a deliberate HTTP error (Prisma, axios, bug...) is logged with its stack
|
|
||||||
* (message and stack only, never the raw error object: it may carry credentials) and answered with a generic 500,
|
|
||||||
* so no internal detail (SQL, table names, URLs, stack) ever reaches the client.
|
|
||||||
*/
|
|
||||||
@Catch()
|
|
||||||
export class AllExceptionsFilter extends BaseExceptionFilter {
|
|
||||||
private readonly logger = new Logger('ExceptionsFilter');
|
|
||||||
|
|
||||||
catch(exception: unknown, host: ArgumentsHost): void {
|
|
||||||
if (exception instanceof HttpException || isClientHttpError(exception)) {
|
|
||||||
super.catch(exception, host);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const error = exception as Error | undefined;
|
|
||||||
this.logger.error(`Unhandled exception: ${error?.message}`, error?.stack);
|
|
||||||
super.catch(new InternalServerErrorException(), host);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,15 +1,14 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { HiboutikClient } from './hiboutik.client';
|
import { HiboutikClient } from './hiboutik.client';
|
||||||
import { BrandHiboutikDto, brandHiboutikSchema } from '../models/hiboutikDTO/brand.hiboutik.dto';
|
import { BrandHiboutikDto } from '../models/hiboutikDTO/brand.hiboutik.dto';
|
||||||
|
|
||||||
/** Reads brands from the Hiboutik API. */
|
/** Reads brands from the Hiboutik API. */
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class BrandHiboutikRepository {
|
export class BrandHiboutikRepository {
|
||||||
constructor(private readonly hiboutikClient: HiboutikClient) {}
|
constructor(private readonly hiboutikClient: HiboutikClient) {}
|
||||||
|
|
||||||
/** Walked twice when it spans several pages: it drives the deletion of brands (see `getAllPagesForSync`). */
|
|
||||||
async getBrands(): Promise<BrandHiboutikDto[]> {
|
async getBrands(): Promise<BrandHiboutikDto[]> {
|
||||||
return this.hiboutikClient.getAllPagesForSync('/brands', brandHiboutikSchema, (brand) => brand.brand_id, {
|
return this.hiboutikClient.getAllPages<BrandHiboutikDto>('/brands', {
|
||||||
order_by: 'brand_id',
|
order_by: 'brand_id',
|
||||||
sort: 'ASC',
|
sort: 'ASC',
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { CategoryHiboutikDto, categoryHiboutikSchema } from '../models/hiboutikDTO/category.hiboutik.dto';
|
import { CategoryHiboutikDto } from '../models/hiboutikDTO/category.hiboutik.dto';
|
||||||
import { HiboutikClient } from './hiboutik.client';
|
import { HiboutikClient } from './hiboutik.client';
|
||||||
|
|
||||||
/** Reads categories from the Hiboutik API. */
|
/** Reads categories from the Hiboutik API. */
|
||||||
@@ -7,13 +7,10 @@ import { HiboutikClient } from './hiboutik.client';
|
|||||||
export class CategoryHiboutikRepository {
|
export class CategoryHiboutikRepository {
|
||||||
constructor(private readonly hiboutikClient: HiboutikClient) {}
|
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<CategoryHiboutikDto[]> {
|
async getCategories(): Promise<CategoryHiboutikDto[]> {
|
||||||
return this.hiboutikClient.getAllPagesForSync('/categories', categoryHiboutikSchema, (category) => category.category_id, {
|
return this.hiboutikClient.getAllPages<CategoryHiboutikDto>('/categories', {
|
||||||
order_by: 'category_id',
|
order_by: 'category_id_parent',
|
||||||
sort: 'ASC',
|
sort: 'ASC',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,53 +1,25 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { HiboutikClient } from './hiboutik.client';
|
import { HiboutikClient } from './hiboutik.client';
|
||||||
import {
|
import { CustomerHiboutikDto, FullCustomerHiboutikDto } from '../models/hiboutikDTO/customer.hiboutik.dto';
|
||||||
CustomerAddressDetailsHiboutikDto,
|
|
||||||
customerAddressDetailsHiboutikSchema,
|
|
||||||
CustomerHiboutikDto,
|
|
||||||
customerHiboutikSchema,
|
|
||||||
FullCustomerHiboutikDto,
|
|
||||||
fullCustomerHiboutikSchema,
|
|
||||||
} from '../models/hiboutikDTO/customer.hiboutik.dto';
|
|
||||||
|
|
||||||
/** Reads customers from the Hiboutik API. */
|
/** Reads customers from the Hiboutik API. */
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class CustomerHiboutikRepository {
|
export class CustomerHiboutikRepository {
|
||||||
constructor(private readonly hiboutikClient: HiboutikClient) {}
|
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<CustomerHiboutikDto[]> {
|
async getCustomers(): Promise<CustomerHiboutikDto[]> {
|
||||||
return this.hiboutikClient.getAllPages('/customers', customerHiboutikSchema, {
|
return this.hiboutikClient.getAllPages<CustomerHiboutikDto>('/customers');
|
||||||
order_by: 'customers_id',
|
|
||||||
sort: 'ASC',
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Full customer, with its addresses and sales.
|
* Full customer, with its addresses and sales (Hiboutik answers with a one-element array).
|
||||||
* @returns `null` when Hiboutik says the customer does not exist (404 or empty answer).
|
* @throws if the customer does not exist.
|
||||||
* @throws on any other failure: an unknown state is never taken for a deleted customer.
|
|
||||||
*/
|
*/
|
||||||
async findCustomerById(id: number): Promise<FullCustomerHiboutikDto | null> {
|
|
||||||
return this.hiboutikClient.getOne(`/customer/${id}`, fullCustomerHiboutikSchema);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Same as {@link findCustomerById}, but a missing customer throws. */
|
|
||||||
async getCustomerById(id: number): Promise<FullCustomerHiboutikDto> {
|
async getCustomerById(id: number): Promise<FullCustomerHiboutikDto> {
|
||||||
const customer = await this.findCustomerById(id);
|
const data = await this.hiboutikClient.get<FullCustomerHiboutikDto[]>('/customer/' + String(id));
|
||||||
|
const customer = data[0];
|
||||||
if (!customer) throw new Error(`Hiboutik customer ${id} not found`);
|
if (!customer) throw new Error(`Hiboutik customer ${id} not found`);
|
||||||
return customer;
|
return customer;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Address with its company details.
|
|
||||||
* @throws if the address does not exist.
|
|
||||||
*/
|
|
||||||
async getAddressById(id: number): Promise<CustomerAddressDetailsHiboutikDto> {
|
|
||||||
const address = await this.hiboutikClient.getOne(`/customers_addresses/${id}`, customerAddressDetailsHiboutikSchema);
|
|
||||||
if (!address) throw new Error(`Hiboutik address ${id} not found`);
|
|
||||||
return address;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,214 +0,0 @@
|
|||||||
import { AxiosError, AxiosHeaders } from 'axios';
|
|
||||||
import { of, throwError } from 'rxjs';
|
|
||||||
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(() => {
|
|
||||||
jest.clearAllMocks();
|
|
||||||
client = new HiboutikClient(httpService as never, configService as never);
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('getList', () => {
|
|
||||||
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', 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', 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', schema);
|
|
||||||
|
|
||||||
await expect(result).rejects.toThrow('a list was expected');
|
|
||||||
await expect(result).rejects.not.toThrow('secret details');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('validates and normalizes each item', async () => {
|
|
||||||
httpService.get.mockReturnValueOnce(answer([{ id: '1', extra: true }]));
|
|
||||||
|
|
||||||
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');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('getAllPages', () => {
|
|
||||||
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', 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(answer(page(10)))
|
|
||||||
.mockReturnValueOnce(httpError(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<void> }, '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');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,49 +1,12 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { HttpService } from '@nestjs/axios';
|
import { HttpService } from '@nestjs/axios';
|
||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
import { isAxiosError } from 'axios';
|
|
||||||
import { firstValueFrom } from 'rxjs';
|
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). */
|
/** Results per page of every paginated Hiboutik endpoint (see "Results per page" in the API docs). */
|
||||||
const HIBOUTIK_PAGE_SIZE = 250;
|
const HIBOUTIK_PAGE_SIZE = 250;
|
||||||
|
|
||||||
/** Retries of a request answered 429 (too many requests), with exponential backoff: 2 s, 4 s, 8 s. */
|
/** Low-level HTTP client for the Hiboutik REST API: base URL and Basic auth credentials come from the environment. */
|
||||||
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()
|
@Injectable()
|
||||||
export class HiboutikClient {
|
export class HiboutikClient {
|
||||||
private readonly baseUrl: string;
|
private readonly baseUrl: string;
|
||||||
@@ -59,109 +22,34 @@ export class HiboutikClient {
|
|||||||
this.baseUrl = this.configService.get<string>('HIBOUTIK_BASE_URL') ?? '';
|
this.baseUrl = this.configService.get<string>('HIBOUTIK_BASE_URL') ?? '';
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** GET on a Hiboutik path (relative to `HIBOUTIK_BASE_URL`), returns the response body. */
|
||||||
* GET on a Hiboutik path (relative to `HIBOUTIK_BASE_URL`), returns the raw response body. A 429 is retried with
|
async get<T>(path: string, params?: Record<string, unknown>): Promise<T> {
|
||||||
* backoff (or after the `Retry-After` delay), then turns into a {@link HiboutikRateLimitError}.
|
const response = await firstValueFrom(
|
||||||
*/
|
this.httpService.get<T>(`${this.baseUrl}${path}`, {
|
||||||
private async get(path: string, params?: Record<string, unknown>): Promise<unknown> {
|
headers: { Authorization: this.authHeader },
|
||||||
for (let attempt = 0; ; attempt++) {
|
params,
|
||||||
try {
|
}),
|
||||||
const response = await firstValueFrom(
|
);
|
||||||
this.httpService.get<unknown>(`${this.baseUrl}${path}`, {
|
return response.data;
|
||||||
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<void> {
|
|
||||||
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 or invalid item throws: an empty list triggers deletions in the sync, so it must never be inferred
|
|
||||||
* from a malformed answer.
|
|
||||||
*/
|
|
||||||
async getList<T extends z.ZodType>(path: string, itemSchema: T, params?: Record<string, unknown>): Promise<z.output<T>[]> {
|
|
||||||
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 parseHiboutik(z.array(itemSchema), data, path);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetches every page of a paginated endpoint (`p` param, starting at 1) and concatenates the results.
|
* 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.
|
* Only for paginated endpoints: on a non-paginated one, a full first page would loop forever on the same results.
|
||||||
*/
|
*/
|
||||||
async getAllPages<T extends z.ZodType>(path: string, itemSchema: T, baseParams: Record<string, unknown> = {}): Promise<z.output<T>[]> {
|
async getAllPages<T>(path: string, baseParams: Record<string, unknown> = {}): Promise<T[]> {
|
||||||
return (await this.walkPages(path, itemSchema, baseParams)).items;
|
const results: T[] = [];
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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<T extends z.ZodType>(
|
|
||||||
path: string,
|
|
||||||
itemSchema: T,
|
|
||||||
idOf: (item: z.output<T>) => number,
|
|
||||||
baseParams: Record<string, unknown> = {},
|
|
||||||
): Promise<z.output<T>[]> {
|
|
||||||
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<number, z.output<T>>();
|
|
||||||
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<T extends z.ZodType>(
|
|
||||||
path: string,
|
|
||||||
itemSchema: T,
|
|
||||||
baseParams: Record<string, unknown>,
|
|
||||||
): Promise<{ items: z.output<T>[]; nbPages: number }> {
|
|
||||||
const items: z.output<T>[] = [];
|
|
||||||
let page = 1;
|
let page = 1;
|
||||||
|
|
||||||
while (true) {
|
while (true) {
|
||||||
const pageData = await this.getList(path, itemSchema, { ...baseParams, p: page });
|
const pageData = await this.get<T[]>(path, { ...baseParams, p: page });
|
||||||
if (pageData.length === 0) break;
|
if (!pageData || pageData.length === 0) break;
|
||||||
items.push(...pageData);
|
results.push(...pageData);
|
||||||
// A page smaller than the page size is the last one: no need to request an empty page
|
// 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;
|
if (pageData.length < HIBOUTIK_PAGE_SIZE) break;
|
||||||
page++;
|
page++;
|
||||||
}
|
}
|
||||||
|
|
||||||
return { items, nbPages: Math.ceil(items.length / HIBOUTIK_PAGE_SIZE) };
|
return results;
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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<T extends z.ZodType>(path: string, schema: T): Promise<z.output<T> | 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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,18 +1,15 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { HiboutikClient } from './hiboutik.client';
|
import { HiboutikClient } from './hiboutik.client';
|
||||||
import { ProductHiboutikDto, productHiboutikSchema } from '../models/hiboutikDTO/product.hiboutik.dto';
|
import { ProductHiboutikDto } from '../models/hiboutikDTO/product.hiboutik.dto';
|
||||||
|
|
||||||
/** Reads products from the Hiboutik API. */
|
/** Reads products from the Hiboutik API. */
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class ProductHiboutikRepository {
|
export class ProductHiboutikRepository {
|
||||||
constructor(private readonly hiboutikClient: HiboutikClient) {}
|
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<ProductHiboutikDto[]> {
|
async getProducts(): Promise<ProductHiboutikDto[]> {
|
||||||
return this.hiboutikClient.getAllPagesForSync('/products', productHiboutikSchema, (product) => product.product_id, {
|
return this.hiboutikClient.getAllPages<ProductHiboutikDto>('/products', {
|
||||||
order_by: 'product_id',
|
order_by: 'product_id',
|
||||||
sort: 'ASC',
|
sort: 'ASC',
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,19 +0,0 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
|
||||||
import { HiboutikClient } from './hiboutik.client';
|
|
||||||
import { SaleHiboutikDto, saleHiboutikSchema } from '../models/hiboutikDTO/sale.hiboutik.dto';
|
|
||||||
|
|
||||||
/** Reads sales from the Hiboutik API. */
|
|
||||||
@Injectable()
|
|
||||||
export class SaleHiboutikRepository {
|
|
||||||
constructor(private readonly hiboutikClient: HiboutikClient) {}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Full sale, with its lines.
|
|
||||||
* @throws if the sale does not exist.
|
|
||||||
*/
|
|
||||||
async getSaleById(id: number): Promise<SaleHiboutikDto> {
|
|
||||||
const sale = await this.hiboutikClient.getOne(`/sales/${id}`, saleHiboutikSchema);
|
|
||||||
if (!sale) throw new Error(`Hiboutik sale ${id} not found`);
|
|
||||||
return sale;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { HiboutikClient } from './hiboutik.client';
|
import { HiboutikClient } from './hiboutik.client';
|
||||||
import { StockHiboutikDto, stockHiboutikSchema } from '../models/hiboutikDTO/stock.hiboutik.dto';
|
import { StockHiboutikDto } from '../models/hiboutikDTO/stock.hiboutik.dto';
|
||||||
|
|
||||||
/** Reads available stocks from the Hiboutik API. */
|
/** Reads available stocks from the Hiboutik API. */
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@@ -9,6 +9,12 @@ export class StockHiboutikRepository {
|
|||||||
|
|
||||||
/** Stock of every product and size, all warehouses combined. Not paginated. */
|
/** Stock of every product and size, all warehouses combined. Not paginated. */
|
||||||
async getStocks(): Promise<StockHiboutikDto[]> {
|
async getStocks(): Promise<StockHiboutikDto[]> {
|
||||||
return this.hiboutikClient.getList('/stock_available/all_wh', stockHiboutikSchema);
|
const stocks = await this.hiboutikClient.get<StockHiboutikDto[]>('/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),
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { HiboutikClient } from './hiboutik.client';
|
import { HiboutikClient } from './hiboutik.client';
|
||||||
import { SupplierHiboutikDto, supplierHiboutikSchema } from '../models/hiboutikDTO/supplier.hiboutik.dto';
|
import { SupplierHiboutikDto } from '../models/hiboutikDTO/supplier.hiboutik.dto';
|
||||||
|
|
||||||
/** Reads suppliers from the Hiboutik API. */
|
/** Reads suppliers from the Hiboutik API. */
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@@ -9,7 +9,7 @@ export class SupplierHiboutikRepository {
|
|||||||
|
|
||||||
/** `/suppliers` is not paginated: everything comes in one call. */
|
/** `/suppliers` is not paginated: everything comes in one call. */
|
||||||
async getSuppliers(): Promise<SupplierHiboutikDto[]> {
|
async getSuppliers(): Promise<SupplierHiboutikDto[]> {
|
||||||
return this.hiboutikClient.getList('/suppliers', supplierHiboutikSchema, {
|
return this.hiboutikClient.get<SupplierHiboutikDto[]>('/suppliers', {
|
||||||
order_by: 'supplier_id',
|
order_by: 'supplier_id',
|
||||||
sort: 'ASC',
|
sort: 'ASC',
|
||||||
});
|
});
|
||||||
|
|||||||
32
src/main.ts
32
src/main.ts
@@ -1,36 +1,8 @@
|
|||||||
import { ValidationPipe } from '@nestjs/common';
|
|
||||||
import { ConfigService } from '@nestjs/config';
|
|
||||||
import { NestFactory } from '@nestjs/core';
|
import { NestFactory } from '@nestjs/core';
|
||||||
import { NestExpressApplication } from '@nestjs/platform-express';
|
|
||||||
import helmet from 'helmet';
|
|
||||||
import { AppModule } from './app.module';
|
import { AppModule } from './app.module';
|
||||||
|
|
||||||
async function bootstrap() {
|
async function bootstrap() {
|
||||||
const app = await NestFactory.create<NestExpressApplication>(AppModule);
|
const app = await NestFactory.create(AppModule);
|
||||||
const config = app.get(ConfigService);
|
await app.listen(process.env.PORT ?? 3000);
|
||||||
|
|
||||||
// Security headers (also removes X-Powered-By)
|
|
||||||
app.use(helmet());
|
|
||||||
|
|
||||||
// Behind a reverse proxy, trust its X-Forwarded-For so rate limiting sees the real client IP
|
|
||||||
const trustProxy = config.get<number>('TRUST_PROXY');
|
|
||||||
if (trustProxy) app.set('trust proxy', trustProxy);
|
|
||||||
|
|
||||||
// CORS is disabled unless origins are explicitly allowed
|
|
||||||
const origins = (config.get<string>('CORS_ORIGINS') ?? '')
|
|
||||||
.split(',')
|
|
||||||
.map((origin) => origin.trim())
|
|
||||||
.filter(Boolean);
|
|
||||||
if (origins.length > 0) app.enableCors({ origin: origins });
|
|
||||||
|
|
||||||
app.useGlobalPipes(new ValidationPipe({ whitelist: true, forbidNonWhitelisted: true, transform: true }));
|
|
||||||
|
|
||||||
// Disconnect Prisma cleanly on SIGTERM/SIGINT
|
|
||||||
app.enableShutdownHooks();
|
|
||||||
|
|
||||||
const server = await app.listen(config.get<number>('PORT') ?? 3000);
|
|
||||||
// Drop slow clients holding sockets open (Slowloris): every endpoint answers fast, POST /sync runs in the background
|
|
||||||
server.headersTimeout = 15_000;
|
|
||||||
server.requestTimeout = 30_000;
|
|
||||||
}
|
}
|
||||||
void bootstrap();
|
void bootstrap();
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ export class BrandConverter extends Converter<BrandCreateInput, BrandHiboutikDto
|
|||||||
return {
|
return {
|
||||||
hiboutikId: dto.brand_id,
|
hiboutikId: dto.brand_id,
|
||||||
name: dto.brand_name,
|
name: dto.brand_name,
|
||||||
enabled: dto.brand_enabled === 1,
|
enabled: dto.brand_enabled == 1,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,9 +10,9 @@ export class CategoryConverter extends Converter<CategoryCreateInput, CategoryHi
|
|||||||
return {
|
return {
|
||||||
hiboutikId: hiboutikDto.category_id,
|
hiboutikId: hiboutikDto.category_id,
|
||||||
name: hiboutikDto.category_name,
|
name: hiboutikDto.category_name,
|
||||||
enabled: hiboutikDto.category_enabled === 1,
|
enabled: hiboutikDto.category_enabled == 1,
|
||||||
position: hiboutikDto.category_position,
|
position: Number(hiboutikDto.category_position) || 0,
|
||||||
webEnabled: hiboutikDto.category_enabled_www === 1,
|
webEnabled: hiboutikDto.category_enabled_www == 1,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,16 +1,17 @@
|
|||||||
import { CustomerConverter } from './customer.converter';
|
import { CustomerConverter } from './customer.converter';
|
||||||
import { FullCustomerHiboutikDto } from '../hiboutikDTO/customer.hiboutik.dto';
|
import { FullCustomerHiboutikDto } from '../hiboutikDTO/customer.hiboutik.dto';
|
||||||
|
|
||||||
const hiboutikCustomer = (overrides: Partial<FullCustomerHiboutikDto> = {}): FullCustomerHiboutikDto => ({
|
const hiboutikCustomer = (overrides: Partial<FullCustomerHiboutikDto> = {}): FullCustomerHiboutikDto =>
|
||||||
customers_id: 42,
|
({
|
||||||
first_name: 'Jane',
|
customers_id: 42,
|
||||||
last_name: 'Doe',
|
first_name: 'Jane',
|
||||||
email: 'jane@example.com',
|
last_name: 'Doe',
|
||||||
phone: '0600000000',
|
email: 'jane@example.com',
|
||||||
date_of_birth: '1990-05-12',
|
phone: '0600000000',
|
||||||
updated_at: '2026-06-28 17:02:55',
|
date_of_birth: '1990-05-12',
|
||||||
...overrides,
|
updated_at: '2026-06-28 17:02:55',
|
||||||
});
|
...overrides,
|
||||||
|
}) as FullCustomerHiboutikDto;
|
||||||
|
|
||||||
describe('CustomerConverter', () => {
|
describe('CustomerConverter', () => {
|
||||||
const converter = new CustomerConverter();
|
const converter = new CustomerConverter();
|
||||||
@@ -21,8 +22,7 @@ describe('CustomerConverter', () => {
|
|||||||
email: 'jane@example.com',
|
email: 'jane@example.com',
|
||||||
firstName: 'Jane',
|
firstName: 'Jane',
|
||||||
hiboutikId: 42,
|
hiboutikId: 42,
|
||||||
// French summer time (UTC+2)
|
hiboutikUpdatedAt: new Date('2026-06-28T17:02:55'),
|
||||||
hiboutikUpdatedAt: new Date('2026-06-28T15:02:55Z'),
|
|
||||||
lastName: 'Doe',
|
lastName: 'Doe',
|
||||||
phone: '0600000000',
|
phone: '0600000000',
|
||||||
});
|
});
|
||||||
@@ -32,13 +32,13 @@ describe('CustomerConverter', () => {
|
|||||||
expect(converter.fromHiboutik(hiboutikCustomer({ date_of_birth: '0000-00-00' })).dateOfBirth).toBeUndefined();
|
expect(converter.fromHiboutik(hiboutikCustomer({ date_of_birth: '0000-00-00' })).dateOfBirth).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('erases the email when Hiboutik has none (null)', () => {
|
it('keeps the stored email when Hiboutik has none (undefined)', () => {
|
||||||
expect(converter.fromHiboutik(hiboutikCustomer({ email: '' })).email).toBeNull();
|
expect(converter.fromHiboutik(hiboutikCustomer({ email: '' })).email).toBeUndefined();
|
||||||
expect(converter.fromHiboutik(hiboutikCustomer({ email: ' ' })).email).toBeNull();
|
expect(converter.fromHiboutik(hiboutikCustomer({ email: null as unknown as string })).email).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('erases the phone when Hiboutik has none (null)', () => {
|
it('erases the phone when Hiboutik has none (null)', () => {
|
||||||
expect(converter.fromHiboutik(hiboutikCustomer({ phone: '' })).phone).toBeNull();
|
expect(converter.fromHiboutik(hiboutikCustomer({ phone: '' })).phone).toBeNull();
|
||||||
expect(converter.fromHiboutik(hiboutikCustomer({ phone: ' ' })).phone).toBeNull();
|
expect(converter.fromHiboutik(hiboutikCustomer({ phone: null as unknown as string })).phone).toBeNull();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -9,14 +9,14 @@ export class CustomerConverter extends Converter<CustomerCreateInput, FullCustom
|
|||||||
fromHiboutik(dto: FullCustomerHiboutikDto): CustomerCreateInput {
|
fromHiboutik(dto: FullCustomerHiboutikDto): CustomerCreateInput {
|
||||||
return {
|
return {
|
||||||
dateOfBirth: parseHiboutikDate(dto.date_of_birth),
|
dateOfBirth: parseHiboutikDate(dto.date_of_birth),
|
||||||
// null (not undefined) so that an email removed in Hiboutik is also removed in database (data minimization)
|
// undefined (not null) on purpose: an email removed in Hiboutik must not erase the one stored in database
|
||||||
email: dto.email.trim() || null,
|
email: dto.email?.trim() || undefined,
|
||||||
firstName: dto.first_name,
|
firstName: dto.first_name,
|
||||||
hiboutikId: dto.customers_id,
|
hiboutikId: dto.customers_id,
|
||||||
hiboutikUpdatedAt: parseHiboutikDate(dto.updated_at),
|
hiboutikUpdatedAt: parseHiboutikDate(dto.updated_at),
|
||||||
lastName: dto.last_name,
|
lastName: dto.last_name,
|
||||||
// null (not undefined) so that a phone removed in Hiboutik is also removed in database
|
// null (not undefined) so that a phone removed in Hiboutik is also removed in database
|
||||||
phone: dto.phone.trim() || null,
|
phone: dto.phone?.trim() || null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
toHiboutik(): FullCustomerHiboutikDto {
|
toHiboutik(): FullCustomerHiboutikDto {
|
||||||
|
|||||||
@@ -1,79 +0,0 @@
|
|||||||
import { CustomerAddressConverter } from './customerAddress.converter';
|
|
||||||
import { CustomerAddressHiboutikDto } from '../hiboutikDTO/customer.hiboutik.dto';
|
|
||||||
|
|
||||||
const hiboutikAddress = (overrides: Partial<CustomerAddressHiboutikDto> = {}): CustomerAddressHiboutikDto => ({
|
|
||||||
address_id: 7,
|
|
||||||
customers_id: 42,
|
|
||||||
first_name: 'Jane',
|
|
||||||
last_name: 'Doe',
|
|
||||||
company: 'ACME',
|
|
||||||
phone: '0600000000',
|
|
||||||
address: ' 1 rue de la Paix ',
|
|
||||||
zip_code: '75002',
|
|
||||||
city: 'Paris',
|
|
||||||
state: '',
|
|
||||||
country: 'FRA',
|
|
||||||
other: 'Bât. B',
|
|
||||||
default: 1,
|
|
||||||
...overrides,
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('CustomerAddressConverter', () => {
|
|
||||||
const converter = new CustomerAddressConverter();
|
|
||||||
|
|
||||||
it('maps a Hiboutik address', () => {
|
|
||||||
expect(converter.fromHiboutik(hiboutikAddress())).toEqual({
|
|
||||||
address: '1 rue de la Paix',
|
|
||||||
city: 'Paris',
|
|
||||||
company: 'ACME',
|
|
||||||
country: 'FRA',
|
|
||||||
firstName: 'Jane',
|
|
||||||
hiboutikId: 7,
|
|
||||||
isDefault: true,
|
|
||||||
lastName: 'Doe',
|
|
||||||
other: 'Bât. B',
|
|
||||||
phone: '0600000000',
|
|
||||||
state: null,
|
|
||||||
zipCode: '75002',
|
|
||||||
customer: { connect: { hiboutikId: 42 } },
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('erases empty optional fields (null) and defaults empty required ones to ""', () => {
|
|
||||||
const address = converter.fromHiboutik(hiboutikAddress({ company: ' ', phone: '', other: '', first_name: '' }));
|
|
||||||
expect(address.company).toBeNull();
|
|
||||||
expect(address.phone).toBeNull();
|
|
||||||
expect(address.other).toBeNull();
|
|
||||||
expect(address.firstName).toBe('');
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('company details', () => {
|
|
||||||
const details = { tax_number: ' FR123 ', company_number: '12345678900012', legal_status: 'SAS', updated_at: '2026-11-01 10:00:00' };
|
|
||||||
|
|
||||||
it('maps them when fetched for an address with a company', () => {
|
|
||||||
const address = converter.fromHiboutik({ ...hiboutikAddress(), ...details });
|
|
||||||
expect(address.taxNumber).toBe('FR123');
|
|
||||||
expect(address.companyNumber).toBe('12345678900012');
|
|
||||||
expect(address.legalStatus).toBe('SAS');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('erases empty fetched values (null)', () => {
|
|
||||||
const address = converter.fromHiboutik({ ...hiboutikAddress(), ...details, tax_number: '' });
|
|
||||||
expect(address.taxNumber).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('keeps the stored ones when not fetched (undefined)', () => {
|
|
||||||
const address = converter.fromHiboutik(hiboutikAddress());
|
|
||||||
expect(address.taxNumber).toBeUndefined();
|
|
||||||
expect(address.companyNumber).toBeUndefined();
|
|
||||||
expect(address.legalStatus).toBeUndefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('erases them when the address has no company (null)', () => {
|
|
||||||
const address = converter.fromHiboutik({ ...hiboutikAddress({ company: '' }), ...details });
|
|
||||||
expect(address.taxNumber).toBeNull();
|
|
||||||
expect(address.companyNumber).toBeNull();
|
|
||||||
expect(address.legalStatus).toBeNull();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
|
||||||
import { Converter } from './converter';
|
|
||||||
import { CustomerAddressCreateInput } from '../../../prisma/generated/models/CustomerAddress';
|
|
||||||
import { CustomerAddressDetailsHiboutikDto, CustomerAddressHiboutikDto } from '../hiboutikDTO/customer.hiboutik.dto';
|
|
||||||
|
|
||||||
/** Trimmed value, `null` when empty so that a value removed in Hiboutik is also removed in database. */
|
|
||||||
const optional = (value: string | null | undefined): string | null => value?.trim() || null;
|
|
||||||
const required = (value: string | null | undefined): string => value?.trim() ?? '';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Maps an address. Company details (tax/company number, legal status) are:
|
|
||||||
* - `null` when the address has no company,
|
|
||||||
* - mapped when `dto` comes from `GET /customers_addresses/:id` ({@link CustomerAddressDetailsHiboutikDto}),
|
|
||||||
* - `undefined` (stored values kept) when the details were not fetched.
|
|
||||||
*/
|
|
||||||
@Injectable()
|
|
||||||
export class CustomerAddressConverter extends Converter<
|
|
||||||
CustomerAddressCreateInput,
|
|
||||||
CustomerAddressHiboutikDto | CustomerAddressDetailsHiboutikDto
|
|
||||||
> {
|
|
||||||
fromHiboutik(dto: CustomerAddressHiboutikDto | CustomerAddressDetailsHiboutikDto): CustomerAddressCreateInput {
|
|
||||||
const company = optional(dto.company);
|
|
||||||
const hasDetails = 'tax_number' in dto;
|
|
||||||
const companyDetail = (value: string | null | undefined) => (!company ? null : hasDetails ? optional(value) : undefined);
|
|
||||||
const details = dto as Partial<CustomerAddressDetailsHiboutikDto>;
|
|
||||||
|
|
||||||
return {
|
|
||||||
address: required(dto.address),
|
|
||||||
city: required(dto.city),
|
|
||||||
company,
|
|
||||||
companyNumber: companyDetail(details.company_number),
|
|
||||||
country: required(dto.country),
|
|
||||||
firstName: required(dto.first_name),
|
|
||||||
hiboutikId: dto.address_id,
|
|
||||||
isDefault: dto.default === 1,
|
|
||||||
lastName: required(dto.last_name),
|
|
||||||
legalStatus: companyDetail(details.legal_status),
|
|
||||||
other: optional(dto.other),
|
|
||||||
phone: optional(dto.phone),
|
|
||||||
state: optional(dto.state),
|
|
||||||
taxNumber: companyDetail(details.tax_number),
|
|
||||||
zipCode: required(dto.zip_code),
|
|
||||||
customer: { connect: { hiboutikId: dto.customers_id } },
|
|
||||||
};
|
|
||||||
}
|
|
||||||
toHiboutik(): CustomerAddressHiboutikDto {
|
|
||||||
throw new Error('Method not implemented.');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,78 +0,0 @@
|
|||||||
import { OrderConverter } from './order.converter';
|
|
||||||
import { SaleHiboutikDto, SaleLineHiboutikDto } from '../hiboutikDTO/sale.hiboutik.dto';
|
|
||||||
|
|
||||||
const hiboutikLine = (overrides: Partial<SaleLineHiboutikDto> = {}): SaleLineHiboutikDto => ({
|
|
||||||
product_id: 746,
|
|
||||||
product_size: 1,
|
|
||||||
quantity: 2,
|
|
||||||
product_model: 'KATSEYE - WILD',
|
|
||||||
product_barcode: '2430010007467',
|
|
||||||
size_name: 'Daniela',
|
|
||||||
item_unit_gross: '17.10',
|
|
||||||
...overrides,
|
|
||||||
});
|
|
||||||
|
|
||||||
const hiboutikSale = (overrides: Partial<SaleHiboutikDto> = {}): 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();
|
|
||||||
|
|
||||||
it('maps a completed Hiboutik sale with its lines', () => {
|
|
||||||
expect(converter.fromHiboutik(hiboutikSale())).toEqual({
|
|
||||||
hiboutikSaleId: 5,
|
|
||||||
// French summer time (UTC+2)
|
|
||||||
orderedAt: new Date('2026-10-18T09:01:04Z'),
|
|
||||||
source: 'STORE',
|
|
||||||
status: 'COMPLETED',
|
|
||||||
total: '34.20',
|
|
||||||
customer: { connect: { hiboutikId: 1 } },
|
|
||||||
lines: [
|
|
||||||
{
|
|
||||||
barcode: '2430010007467',
|
|
||||||
hiboutikProductId: 746,
|
|
||||||
hiboutikSizeId: 1,
|
|
||||||
productName: 'KATSEYE - WILD',
|
|
||||||
quantity: 2,
|
|
||||||
unitPrice: '17.10',
|
|
||||||
versionName: 'Daniela',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
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-18T09:00:21Z'));
|
|
||||||
});
|
|
||||||
|
|
||||||
it('throws when the sale has no valid date', () => {
|
|
||||||
expect(() => converter.fromHiboutik(hiboutikSale({ completed_at: '0000-00-00 00:00:00', created_at: '' }))).toThrow();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('leaves an anonymous sale without customer', () => {
|
|
||||||
expect(converter.fromHiboutik(hiboutikSale({ customer_id: 0 })).customer).toBeUndefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('uses the product name as version name and null barcode for a product without sizes', () => {
|
|
||||||
const [line] = converter.fromHiboutik(
|
|
||||||
hiboutikSale({ line_items: [hiboutikLine({ product_size: 0, size_name: '', product_barcode: '' })] }),
|
|
||||||
).lines;
|
|
||||||
expect(line.versionName).toBe('KATSEYE - WILD');
|
|
||||||
expect(line.barcode).toBeNull();
|
|
||||||
expect(line.hiboutikSizeId).toBe(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('accepts a sale without lines', () => {
|
|
||||||
expect(converter.fromHiboutik(hiboutikSale({ line_items: [] })).lines).toEqual([]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
|
||||||
import { Converter } from './converter';
|
|
||||||
import { OrderSource } from '../../../prisma/generated/client';
|
|
||||||
import { OrderWithLinesInput } from '../../../prisma/customObjects/prisma.custom.object';
|
|
||||||
import { SaleHiboutikDto, SaleLineHiboutikDto } from '../hiboutikDTO/sale.hiboutik.dto';
|
|
||||||
import { parseHiboutikDate } from '../../utils/date';
|
|
||||||
import { OrderStatus } from '../orderStatus';
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class OrderConverter extends Converter<OrderWithLinesInput, SaleHiboutikDto> {
|
|
||||||
/**
|
|
||||||
* Maps a Hiboutik sale to an order (source STORE) with a snapshot of its lines.
|
|
||||||
* @throws if the sale has neither a valid completion date nor a valid creation date.
|
|
||||||
*/
|
|
||||||
fromHiboutik(dto: SaleHiboutikDto): OrderWithLinesInput {
|
|
||||||
const completedAt = parseHiboutikDate(dto.completed_at);
|
|
||||||
const orderedAt = completedAt ?? parseHiboutikDate(dto.created_at);
|
|
||||||
if (!orderedAt) throw new Error(`Hiboutik sale ${dto.sale_id} has no valid date`);
|
|
||||||
|
|
||||||
return {
|
|
||||||
hiboutikSaleId: dto.sale_id,
|
|
||||||
orderedAt,
|
|
||||||
source: OrderSource.STORE,
|
|
||||||
status: completedAt ? OrderStatus.COMPLETED : OrderStatus.PENDING,
|
|
||||||
total: dto.total,
|
|
||||||
// 0 for an anonymous sale
|
|
||||||
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: line.product_id,
|
|
||||||
hiboutikSizeId: line.product_size,
|
|
||||||
productName: line.product_model,
|
|
||||||
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,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
toHiboutik(): SaleHiboutikDto {
|
|
||||||
throw new Error('Method not implemented.');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -9,13 +9,13 @@ const hiboutikProduct = (overrides: Partial<ProductHiboutikDto> = {}): ProductHi
|
|||||||
product_supplier: 2,
|
product_supplier: 2,
|
||||||
product_price: '13.99',
|
product_price: '13.99',
|
||||||
product_discount_price: '0.00',
|
product_discount_price: '0.00',
|
||||||
|
product_supply_price: '7.49',
|
||||||
product_category: 19,
|
product_category: 19,
|
||||||
product_display_www: 0,
|
product_display_www: 0,
|
||||||
product_arch: 0,
|
product_arch: 0,
|
||||||
products_desc: '',
|
products_desc: '',
|
||||||
updated_at: '2026-06-28 17:02:55',
|
updated_at: '2026-06-28 17:02:55',
|
||||||
product_specific_rules: [],
|
product_specific_rules: [],
|
||||||
product_size_details: [],
|
|
||||||
...overrides,
|
...overrides,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -31,7 +31,7 @@ describe('ProductConverter', () => {
|
|||||||
description: '',
|
description: '',
|
||||||
discountPrice: null,
|
discountPrice: null,
|
||||||
hiboutikId: 1,
|
hiboutikId: 1,
|
||||||
hiboutikUpdatedAt: new Date('2026-06-28T15:02:55Z'),
|
hiboutikUpdatedAt: new Date('2026-06-28T17:02:55'),
|
||||||
name: 'BOUTEILLE MÉTAL ISOTHERME 260ML BT21',
|
name: 'BOUTEILLE MÉTAL ISOTHERME 260ML BT21',
|
||||||
price: '13.99',
|
price: '13.99',
|
||||||
enabled: false,
|
enabled: false,
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ export class ProductConverter extends Converter<ProductCreateInput, ProductHibou
|
|||||||
/** `globalStock` and versions are not set here: they depend on the stocks endpoint (see ProductHiboutikService). */
|
/** `globalStock` and versions are not set here: they depend on the stocks endpoint (see ProductHiboutikService). */
|
||||||
fromHiboutik(dto: ProductHiboutikDto): ProductCreateInput {
|
fromHiboutik(dto: ProductHiboutikDto): ProductCreateInput {
|
||||||
return {
|
return {
|
||||||
archived: dto.product_arch === 1,
|
archived: dto.product_arch == 1,
|
||||||
barcode: dto.product_barcode || null,
|
barcode: dto.product_barcode || null,
|
||||||
brand: dto.product_brand ? { connect: { hiboutikId: dto.product_brand } } : undefined,
|
brand: dto.product_brand ? { connect: { hiboutikId: dto.product_brand } } : undefined,
|
||||||
category: dto.product_category ? { connect: { hiboutikId: dto.product_category } } : undefined,
|
category: dto.product_category ? { connect: { hiboutikId: dto.product_category } } : undefined,
|
||||||
@@ -20,7 +20,7 @@ export class ProductConverter extends Converter<ProductCreateInput, ProductHibou
|
|||||||
hiboutikUpdatedAt: parseHiboutikDate(dto.updated_at),
|
hiboutikUpdatedAt: parseHiboutikDate(dto.updated_at),
|
||||||
name: dto.product_model,
|
name: dto.product_model,
|
||||||
price: dto.product_price,
|
price: dto.product_price,
|
||||||
enabled: dto.product_display_www === 1,
|
enabled: dto.product_display_www == 1,
|
||||||
supplier: dto.product_supplier ? { connect: { hiboutikId: dto.product_supplier } } : undefined,
|
supplier: dto.product_supplier ? { connect: { hiboutikId: dto.product_supplier } } : undefined,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ export class SupplierConverter extends Converter<SupplierCreateInput, SupplierHi
|
|||||||
return {
|
return {
|
||||||
hiboutikId: dto.supplier_id,
|
hiboutikId: dto.supplier_id,
|
||||||
name: dto.supplier_name,
|
name: dto.supplier_name,
|
||||||
enabled: dto.supplier_enabled === 1,
|
enabled: dto.supplier_enabled == 1,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,7 @@
|
|||||||
import { z } from 'zod';
|
/** Brand as returned by Hiboutik `GET /brands`. */
|
||||||
import { hiboutikId, hiboutikInt, hiboutikText } from './hiboutik.schema';
|
export interface BrandHiboutikDto {
|
||||||
|
brand_id: number;
|
||||||
/** Brand as returned by Hiboutik `GET /brands` (only the fields used by the sync). */
|
brand_name: string;
|
||||||
export const brandHiboutikSchema = z.object({
|
|
||||||
brand_id: hiboutikId,
|
|
||||||
brand_name: hiboutikText,
|
|
||||||
/** 1 = enabled, 0 = disabled. */
|
/** 1 = enabled, 0 = disabled. */
|
||||||
brand_enabled: hiboutikInt,
|
brand_enabled: number;
|
||||||
});
|
}
|
||||||
|
|
||||||
export type BrandHiboutikDto = z.output<typeof brandHiboutikSchema>;
|
|
||||||
|
|||||||
@@ -1,18 +1,18 @@
|
|||||||
import { z } from 'zod';
|
/** Category as returned by Hiboutik `GET /categories`. */
|
||||||
import { hiboutikId, hiboutikInt, hiboutikRef, hiboutikText } from './hiboutik.schema';
|
export interface CategoryHiboutikDto {
|
||||||
|
category_id: number;
|
||||||
/** Category as returned by Hiboutik `GET /categories` (only the fields used by the sync). */
|
category_name: string;
|
||||||
export const categoryHiboutikSchema = z.object({
|
|
||||||
category_id: hiboutikId,
|
|
||||||
category_name: hiboutikText,
|
|
||||||
/** Id of the parent category, 0 for a root category. */
|
/** Id of the parent category, 0 for a root category. */
|
||||||
category_id_parent: hiboutikRef,
|
category_id_parent: number;
|
||||||
/** Enabled in the POS: 1 = yes, 0 = no. */
|
/** Enabled in the POS: 1 = yes, 0 = no. */
|
||||||
category_enabled: hiboutikInt,
|
category_enabled: number;
|
||||||
/** Displayed on the website: 1 = yes, 0 = no. */
|
/** Displayed on the website: 1 = yes, 0 = no. */
|
||||||
category_enabled_www: hiboutikInt,
|
category_enabled_www: number;
|
||||||
/** Display order among its siblings. */
|
/** Display order among its siblings. */
|
||||||
category_position: hiboutikInt,
|
category_position: number;
|
||||||
});
|
category_bck_color: string;
|
||||||
|
category_color: string;
|
||||||
export type CategoryHiboutikDto = z.output<typeof categoryHiboutikSchema>;
|
category_desc: string;
|
||||||
|
accounting_account: string;
|
||||||
|
category_ref_ext: string;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,65 +1,79 @@
|
|||||||
import { z } from 'zod';
|
/** Customer summary as returned by Hiboutik `GET /customers` (no addresses nor sales). */
|
||||||
import { hiboutikId, hiboutikInt, hiboutikOptionalList, hiboutikRef, hiboutikText } from './hiboutik.schema';
|
export interface CustomerHiboutikDto {
|
||||||
|
customers_id: number;
|
||||||
/** Customer summary as returned by Hiboutik `GET /customers`: only its id is used (no addresses nor sales). */
|
last_name: string;
|
||||||
export const customerHiboutikSchema = z.object({
|
first_name: string;
|
||||||
customers_id: hiboutikId,
|
company: string;
|
||||||
});
|
email: string;
|
||||||
|
phone: string;
|
||||||
/** Address of a customer, embedded in the full customer. Name and phone can differ from the customer's. */
|
vat: string;
|
||||||
export const customerAddressHiboutikSchema = z.object({
|
country: string;
|
||||||
address_id: hiboutikId,
|
/** "YYYY-MM-DD", "0000-00-00" when unknown. */
|
||||||
first_name: hiboutikText,
|
date_of_birth: string;
|
||||||
last_name: hiboutikText,
|
customers_code: string;
|
||||||
phone: hiboutikText,
|
loyalty_points: number;
|
||||||
company: hiboutikText,
|
intial_loyalty_points: number;
|
||||||
address: hiboutikText,
|
validity: string;
|
||||||
zip_code: hiboutikText,
|
prepaid_purchases: string;
|
||||||
city: hiboutikText,
|
store_credit: string;
|
||||||
state: hiboutikText,
|
turnover: string;
|
||||||
country: hiboutikText,
|
orders: number;
|
||||||
customers_id: hiboutikRef,
|
customer_ref_ext: string;
|
||||||
/** 1 for the customer's default address. */
|
last_order_date: string;
|
||||||
default: hiboutikInt,
|
/** Local time, "YYYY-MM-DD HH:mm:ss". */
|
||||||
other: hiboutikText,
|
updated_at: string;
|
||||||
});
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Address as returned by Hiboutik `GET /customers_addresses/:address_id`: the embedded address plus the company
|
|
||||||
* details. One API call per address, so only fetched for addresses with a company.
|
|
||||||
*/
|
|
||||||
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 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. */
|
/** Full customer as returned by Hiboutik `GET /customer/:customers_id`, with its addresses and sales. */
|
||||||
export const fullCustomerHiboutikSchema = z.object({
|
export interface FullCustomerHiboutikDto extends CustomerHiboutikDto {
|
||||||
customers_id: hiboutikId,
|
gender: number;
|
||||||
last_name: hiboutikText,
|
comments: string;
|
||||||
first_name: hiboutikText,
|
customers_id_referrer: number;
|
||||||
email: hiboutikText,
|
customers_misc: string;
|
||||||
phone: hiboutikText,
|
accounting_account: string;
|
||||||
/** "YYYY-MM-DD", "0000-00-00" when unknown. */
|
addresses: CustomerAddressHiboutikDto[];
|
||||||
date_of_birth: hiboutikText,
|
sales: CustomerSaleHiboutikDto[];
|
||||||
/** French local time, "YYYY-MM-DD HH:mm:ss". */
|
tags: unknown[];
|
||||||
updated_at: hiboutikText,
|
customers_referee: unknown[];
|
||||||
/** `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<typeof customerHiboutikSchema>;
|
/** Address of a customer, embedded in {@link FullCustomerHiboutikDto}. Name and phone can differ from the customer's. */
|
||||||
export type CustomerAddressHiboutikDto = z.output<typeof customerAddressHiboutikSchema>;
|
export interface CustomerAddressHiboutikDto {
|
||||||
export type CustomerAddressDetailsHiboutikDto = z.output<typeof customerAddressDetailsHiboutikSchema>;
|
address_id: number;
|
||||||
export type CustomerSaleHiboutikDto = z.output<typeof customerSaleHiboutikSchema>;
|
gender: string;
|
||||||
export type FullCustomerHiboutikDto = z.output<typeof fullCustomerHiboutikSchema>;
|
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;
|
||||||
|
/** 1 for the customer's default address. */
|
||||||
|
default: number;
|
||||||
|
other: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,87 +0,0 @@
|
|||||||
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 = <T extends Parameters<typeof parseHiboutik>[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);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,58 +0,0 @@
|
|||||||
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 = <T extends z.ZodType>(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 = <T extends z.ZodType>(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<T extends z.ZodType>(schema: T, data: unknown, path: string): z.output<T> {
|
|
||||||
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}`);
|
|
||||||
}
|
|
||||||
@@ -1,51 +1,46 @@
|
|||||||
import { z } from 'zod';
|
/** Product as returned by Hiboutik `GET /products` (archived products included). */
|
||||||
import { hiboutikDecimal, hiboutikId, hiboutikInt, hiboutikList, hiboutikRef, hiboutikText } from './hiboutik.schema';
|
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[];
|
||||||
|
}
|
||||||
|
|
||||||
/** Override of a product's values for one size. Prices are "0.00" (or missing) when not overridden. */
|
/** Override of a product's values for one size. Prices are "0.00" (or missing) when not overridden. */
|
||||||
export const productSpecificRulesHiboutikSchema = z.object({
|
export interface ProductSpecificRulesHiboutikDto {
|
||||||
size_id: hiboutikRef,
|
size_id: number;
|
||||||
product_price: hiboutikText,
|
barcode: string;
|
||||||
product_discount_price: hiboutikText,
|
product_price?: string;
|
||||||
});
|
product_discount_price?: string;
|
||||||
|
}
|
||||||
|
|
||||||
/** One size of a product (e.g. "S", "M"). */
|
/** One size of a product (e.g. "S", "M"). */
|
||||||
export const productSizeDetailsHiboutikSchema = z.object({
|
export interface ProductSizeDetailsHiboutikDto {
|
||||||
size_id: hiboutikId,
|
size_id: number;
|
||||||
size_name: hiboutikText,
|
size_name: string;
|
||||||
/** Empty when the size has no barcode. */
|
/** Empty string when the size has no barcode. */
|
||||||
barcode: hiboutikText,
|
barcode: string;
|
||||||
});
|
}
|
||||||
|
|
||||||
/** 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<typeof productSpecificRulesHiboutikSchema>;
|
|
||||||
export type ProductSizeDetailsHiboutikDto = z.output<typeof productSizeDetailsHiboutikSchema>;
|
|
||||||
export type ProductHiboutikDto = z.output<typeof productHiboutikSchema>;
|
|
||||||
|
|||||||
@@ -1,39 +0,0 @@
|
|||||||
import { z } from 'zod';
|
|
||||||
import { hiboutikDecimal, hiboutikId, hiboutikInt, hiboutikList, hiboutikRef, hiboutikText } from './hiboutik.schema';
|
|
||||||
|
|
||||||
/** 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: hiboutikRef,
|
|
||||||
/** Negative for a returned item. */
|
|
||||||
quantity: hiboutikInt,
|
|
||||||
product_model: hiboutikText,
|
|
||||||
product_barcode: hiboutikText,
|
|
||||||
/** Size name, empty for a product without sizes. */
|
|
||||||
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<typeof saleLineHiboutikSchema>;
|
|
||||||
export type SaleHiboutikDto = z.output<typeof saleHiboutikSchema>;
|
|
||||||
@@ -1,13 +1,10 @@
|
|||||||
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). */
|
/** Available stock of one product size, as returned by Hiboutik `GET /stock_available/all_wh` (all warehouses). */
|
||||||
export const stockHiboutikSchema = z.object({
|
export interface StockHiboutikDto {
|
||||||
product_id: hiboutikId,
|
product_id: number;
|
||||||
/** Size id, 0 for a product without sizes. */
|
/** Size id, 0 for a product without sizes. */
|
||||||
product_size: hiboutikRef,
|
product_size: number;
|
||||||
/** Sent as a decimal string, e.g. "3". Stays at 0 while stock management is disabled in Hiboutik. */
|
/** Decimal as a string, e.g. "3". Stays at 0 while stock management is disabled in Hiboutik. */
|
||||||
stock_available: hiboutikNumber,
|
stock_available: string;
|
||||||
});
|
product_barcode: string;
|
||||||
|
product_supplier_reference: string;
|
||||||
export type StockHiboutikDto = z.output<typeof stockHiboutikSchema>;
|
}
|
||||||
|
|||||||
@@ -1,12 +1,7 @@
|
|||||||
import { z } from 'zod';
|
/** Supplier as returned by Hiboutik `GET /suppliers`. */
|
||||||
import { hiboutikId, hiboutikInt, hiboutikText } from './hiboutik.schema';
|
export interface SupplierHiboutikDto {
|
||||||
|
supplier_id: number;
|
||||||
/** Supplier as returned by Hiboutik `GET /suppliers` (only the fields used by the sync). */
|
supplier_name: string;
|
||||||
export const supplierHiboutikSchema = z.object({
|
|
||||||
supplier_id: hiboutikId,
|
|
||||||
supplier_name: hiboutikText,
|
|
||||||
/** 1 = enabled, 0 = disabled. */
|
/** 1 = enabled, 0 = disabled. */
|
||||||
supplier_enabled: hiboutikInt,
|
supplier_enabled: number;
|
||||||
});
|
}
|
||||||
|
|
||||||
export type SupplierHiboutikDto = z.output<typeof supplierHiboutikSchema>;
|
|
||||||
|
|||||||
@@ -1,9 +0,0 @@
|
|||||||
/** Values of `Order.status`. */
|
|
||||||
export const OrderStatus = {
|
|
||||||
/** Hiboutik sale not completed (cart in progress, awaiting payment...). */
|
|
||||||
PENDING: 'PENDING',
|
|
||||||
/** Hiboutik sale completed: it no longer changes, so the sync does not fetch it again. */
|
|
||||||
COMPLETED: 'COMPLETED',
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
export type OrderStatus = (typeof OrderStatus)[keyof typeof OrderStatus];
|
|
||||||
@@ -3,8 +3,6 @@ import { BrandHiboutikRepository } from '../../hiboutik/brand.hiboutik.repositor
|
|||||||
import { BrandDatabaseRepository } from '../../database/brand.database.repository';
|
import { BrandDatabaseRepository } from '../../database/brand.database.repository';
|
||||||
import { BrandConverter } from '../../models/converter/brand.converter';
|
import { BrandConverter } from '../../models/converter/brand.converter';
|
||||||
import { forEachSafely } from '../../utils/sync';
|
import { forEachSafely } from '../../utils/sync';
|
||||||
import { SlugService } from '../slug/slug.service';
|
|
||||||
import { SlugEntityType } from '../../../prisma/generated/client';
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class BrandHiboutikService {
|
export class BrandHiboutikService {
|
||||||
@@ -14,10 +12,9 @@ export class BrandHiboutikService {
|
|||||||
private readonly brandHiboutikRepository: BrandHiboutikRepository,
|
private readonly brandHiboutikRepository: BrandHiboutikRepository,
|
||||||
private readonly brandDatabaseRepository: BrandDatabaseRepository,
|
private readonly brandDatabaseRepository: BrandDatabaseRepository,
|
||||||
private readonly brandConverter: BrandConverter,
|
private readonly brandConverter: BrandConverter,
|
||||||
private readonly slugService: SlugService,
|
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/** Mirrors Hiboutik brands: upserts each of them, deletes the brands missing from Hiboutik, then gives a slug to the new ones. */
|
/** Mirrors Hiboutik brands: upserts each of them, then deletes the brands missing from Hiboutik. */
|
||||||
async syncBrands() {
|
async syncBrands() {
|
||||||
const hiboutikBrands = await this.brandHiboutikRepository.getBrands();
|
const hiboutikBrands = await this.brandHiboutikRepository.getBrands();
|
||||||
// One upsert per brand: an invalid brand must not prevent the others from being saved
|
// One upsert per brand: an invalid brand must not prevent the others from being saved
|
||||||
@@ -28,9 +25,12 @@ export class BrandHiboutikService {
|
|||||||
(brand) => `Hiboutik brand ${brand.hiboutikId}`,
|
(brand) => `Hiboutik brand ${brand.hiboutikId}`,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Hiboutik is trusted: a successful (2xx) answer, even an empty list, is the truth. An HTTP error throws before this point.
|
// Safety net: an empty answer from Hiboutik most likely means an API issue
|
||||||
const nbDeleted = await this.brandDatabaseRepository.deleteMissing(hiboutikBrands.map((dto) => dto.brand_id));
|
if (hiboutikBrands.length === 0) {
|
||||||
await this.slugService.assignMissingSlugs(SlugEntityType.BRAND);
|
this.logger.warn('Hiboutik returned no brands: deletion skipped.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const nbDeleted = await this.brandDatabaseRepository.deleteMissing(hiboutikBrands.map((dto) => Number(dto.brand_id)));
|
||||||
this.logger.log(
|
this.logger.log(
|
||||||
`Brands sync ended: ${hiboutikBrands.length - nbFailed}/${hiboutikBrands.length} synced, ${nbFailed} failed, ${nbDeleted} deleted.`,
|
`Brands sync ended: ${hiboutikBrands.length - nbFailed}/${hiboutikBrands.length} synced, ${nbFailed} failed, ${nbDeleted} deleted.`,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -3,20 +3,20 @@ import { CategoryHiboutikService } from './category.hiboutik.service';
|
|||||||
import { CategoryConverter } from '../../models/converter/category.converter';
|
import { CategoryConverter } from '../../models/converter/category.converter';
|
||||||
import { CategoryHiboutikDto } from '../../models/hiboutikDTO/category.hiboutik.dto';
|
import { CategoryHiboutikDto } from '../../models/hiboutikDTO/category.hiboutik.dto';
|
||||||
|
|
||||||
const hiboutikCategory = (id: number, parentId: number, overrides: Partial<CategoryHiboutikDto> = {}): CategoryHiboutikDto => ({
|
const hiboutikCategory = (id: number, parentId: number, overrides: Partial<CategoryHiboutikDto> = {}): CategoryHiboutikDto =>
|
||||||
category_id: id,
|
({
|
||||||
category_name: `Category ${id}`,
|
category_id: id,
|
||||||
category_id_parent: parentId,
|
category_name: `Category ${id}`,
|
||||||
category_enabled: 1,
|
category_id_parent: parentId,
|
||||||
category_enabled_www: 0,
|
category_enabled: 1,
|
||||||
category_position: 3,
|
category_enabled_www: 0,
|
||||||
...overrides,
|
category_position: 3,
|
||||||
});
|
...overrides,
|
||||||
|
}) as CategoryHiboutikDto;
|
||||||
|
|
||||||
describe('CategoryHiboutikService', () => {
|
describe('CategoryHiboutikService', () => {
|
||||||
const categoryHiboutikRepository = { getCategories: jest.fn() };
|
const categoryHiboutikRepository = { getCategories: jest.fn() };
|
||||||
const categoryDatabaseRepository = { upsert: jest.fn(), setParent: jest.fn(), deleteMissing: jest.fn() };
|
const categoryDatabaseRepository = { upsert: jest.fn(), setParent: jest.fn(), deleteMissing: jest.fn() };
|
||||||
const slugService = { assignMissingSlugs: jest.fn() };
|
|
||||||
let service: CategoryHiboutikService;
|
let service: CategoryHiboutikService;
|
||||||
let calls: string[];
|
let calls: string[];
|
||||||
|
|
||||||
@@ -42,7 +42,6 @@ describe('CategoryHiboutikService', () => {
|
|||||||
categoryHiboutikRepository as never,
|
categoryHiboutikRepository as never,
|
||||||
categoryDatabaseRepository as never,
|
categoryDatabaseRepository as never,
|
||||||
new CategoryConverter(),
|
new CategoryConverter(),
|
||||||
slugService as never,
|
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -80,31 +79,11 @@ describe('CategoryHiboutikService', () => {
|
|||||||
expect(categoryDatabaseRepository.deleteMissing).toHaveBeenCalledWith([1, 2]);
|
expect(categoryDatabaseRepository.deleteMissing).toHaveBeenCalledWith([1, 2]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('deletes every category when Hiboutik answers with an empty list', async () => {
|
it('does not delete anything when Hiboutik returns no categories', async () => {
|
||||||
categoryHiboutikRepository.getCategories.mockResolvedValue([]);
|
categoryHiboutikRepository.getCategories.mockResolvedValue([]);
|
||||||
|
|
||||||
await service.syncCategories();
|
await service.syncCategories();
|
||||||
|
|
||||||
expect(categoryDatabaseRepository.deleteMissing).toHaveBeenCalledWith([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('deletes nothing when the Hiboutik call fails', async () => {
|
|
||||||
categoryHiboutikRepository.getCategories.mockRejectedValue(new Error('Request failed with status code 500'));
|
|
||||||
|
|
||||||
await expect(service.syncCategories()).rejects.toThrow('500');
|
|
||||||
|
|
||||||
expect(categoryDatabaseRepository.deleteMissing).not.toHaveBeenCalled();
|
expect(categoryDatabaseRepository.deleteMissing).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
it('generates the slugs of new categories once they are saved', async () => {
|
|
||||||
categoryHiboutikRepository.getCategories.mockResolvedValue([hiboutikCategory(1, 0)]);
|
|
||||||
slugService.assignMissingSlugs.mockImplementation(() => {
|
|
||||||
calls.push('slugs');
|
|
||||||
return Promise.resolve();
|
|
||||||
});
|
|
||||||
|
|
||||||
await service.syncCategories();
|
|
||||||
|
|
||||||
expect(slugService.assignMissingSlugs).toHaveBeenCalledWith('CATEGORY');
|
|
||||||
expect(calls[calls.length - 1]).toBe('slugs');
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,8 +3,6 @@ import { CategoryHiboutikRepository } from '../../hiboutik/category.hiboutik.rep
|
|||||||
import { CategoryDatabaseRepository } from '../../database/category.database.repository';
|
import { CategoryDatabaseRepository } from '../../database/category.database.repository';
|
||||||
import { CategoryConverter } from '../../models/converter/category.converter';
|
import { CategoryConverter } from '../../models/converter/category.converter';
|
||||||
import { forEachSafely } from '../../utils/sync';
|
import { forEachSafely } from '../../utils/sync';
|
||||||
import { SlugService } from '../slug/slug.service';
|
|
||||||
import { SlugEntityType } from '../../../prisma/generated/client';
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class CategoryHiboutikService {
|
export class CategoryHiboutikService {
|
||||||
@@ -14,12 +12,11 @@ export class CategoryHiboutikService {
|
|||||||
private readonly categoryHiboutikRepository: CategoryHiboutikRepository,
|
private readonly categoryHiboutikRepository: CategoryHiboutikRepository,
|
||||||
private readonly categoryDatabaseRepository: CategoryDatabaseRepository,
|
private readonly categoryDatabaseRepository: CategoryDatabaseRepository,
|
||||||
private readonly categoryConverter: CategoryConverter,
|
private readonly categoryConverter: CategoryConverter,
|
||||||
private readonly slugService: SlugService,
|
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Mirrors Hiboutik categories and their tree: upserts each of them, links them to their parent,
|
* Mirrors Hiboutik categories and their tree: upserts each of them, links them to their parent,
|
||||||
* deletes the categories missing from Hiboutik, then gives a slug to the new ones.
|
* then deletes the categories missing from Hiboutik.
|
||||||
*/
|
*/
|
||||||
async syncCategories() {
|
async syncCategories() {
|
||||||
const hiboutikCategories = await this.categoryHiboutikRepository.getCategories();
|
const hiboutikCategories = await this.categoryHiboutikRepository.getCategories();
|
||||||
@@ -35,14 +32,17 @@ export class CategoryHiboutikService {
|
|||||||
// Pass 2: parent links, once every category exists (a child can come before its parent in Hiboutik's list)
|
// Pass 2: parent links, once every category exists (a child can come before its parent in Hiboutik's list)
|
||||||
const nbParentsFailed = await forEachSafely(
|
const nbParentsFailed = await forEachSafely(
|
||||||
hiboutikCategories,
|
hiboutikCategories,
|
||||||
(dto) => this.categoryDatabaseRepository.setParent(dto.category_id, dto.category_id_parent || null),
|
(dto) => this.categoryDatabaseRepository.setParent(Number(dto.category_id), Number(dto.category_id_parent) || null),
|
||||||
this.logger,
|
this.logger,
|
||||||
(dto) => `parent of Hiboutik category ${dto.category_id}`,
|
(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.
|
// Safety net: an empty answer from Hiboutik most likely means an API issue
|
||||||
const nbDeleted = await this.categoryDatabaseRepository.deleteMissing(hiboutikCategories.map((dto) => dto.category_id));
|
if (hiboutikCategories.length === 0) {
|
||||||
await this.slugService.assignMissingSlugs(SlugEntityType.CATEGORY);
|
this.logger.warn('Hiboutik returned no categories: deletion skipped.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const nbDeleted = await this.categoryDatabaseRepository.deleteMissing(hiboutikCategories.map((dto) => Number(dto.category_id)));
|
||||||
this.logger.log(
|
this.logger.log(
|
||||||
`Categories sync ended: ${hiboutikCategories.length - nbFailed}/${hiboutikCategories.length} synced, ${nbFailed} failed, ` +
|
`Categories sync ended: ${hiboutikCategories.length - nbFailed}/${hiboutikCategories.length} synced, ${nbFailed} failed, ` +
|
||||||
`${nbParentsFailed} parent links failed, ${nbDeleted} deleted.`,
|
`${nbParentsFailed} parent links failed, ${nbDeleted} deleted.`,
|
||||||
|
|||||||
@@ -2,19 +2,9 @@ import { Logger } from '@nestjs/common';
|
|||||||
import { CustomerHiboutikService } from './customer.hiboutik.service';
|
import { CustomerHiboutikService } from './customer.hiboutik.service';
|
||||||
|
|
||||||
describe('CustomerHiboutikService', () => {
|
describe('CustomerHiboutikService', () => {
|
||||||
const customerHiboutikRepository = {
|
const customerHiboutikRepository = { getCustomers: jest.fn(), getCustomerById: jest.fn() };
|
||||||
getCustomers: jest.fn(),
|
const customerDatabaseRepository = { upsert: jest.fn(), deleteMissing: 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 customerConverter = { fromHiboutik: jest.fn() };
|
||||||
const customerAddressDatabaseRepository = { upsert: jest.fn(), deleteMissing: jest.fn() };
|
|
||||||
const customerAddressConverter = { fromHiboutik: jest.fn((dto: unknown) => dto) };
|
|
||||||
const saleHiboutikRepository = { getSaleById: jest.fn() };
|
|
||||||
const orderDatabaseRepository = { upsert: jest.fn(), getCompletedSaleIds: jest.fn() };
|
|
||||||
const orderConverter = { fromHiboutik: jest.fn((dto: unknown) => dto) };
|
|
||||||
let service: CustomerHiboutikService;
|
let service: CustomerHiboutikService;
|
||||||
|
|
||||||
beforeAll(() => {
|
beforeAll(() => {
|
||||||
@@ -26,81 +16,28 @@ describe('CustomerHiboutikService', () => {
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
jest.clearAllMocks();
|
jest.clearAllMocks();
|
||||||
customerHiboutikRepository.getCustomers.mockResolvedValue([]);
|
customerHiboutikRepository.getCustomers.mockResolvedValue([]);
|
||||||
customerHiboutikRepository.getCustomerById.mockResolvedValue({ customers_id: 1, addresses: [] });
|
customerHiboutikRepository.getCustomerById.mockResolvedValue({});
|
||||||
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 }));
|
|
||||||
service = new CustomerHiboutikService(
|
service = new CustomerHiboutikService(
|
||||||
customerHiboutikRepository as never,
|
customerHiboutikRepository as never,
|
||||||
customerDatabaseRepository as never,
|
customerDatabaseRepository as never,
|
||||||
customerConverter as never,
|
customerConverter as never,
|
||||||
customerAddressDatabaseRepository as never,
|
|
||||||
customerAddressConverter as never,
|
|
||||||
saleHiboutikRepository as never,
|
|
||||||
orderDatabaseRepository as never,
|
|
||||||
orderConverter as never,
|
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('deletions', () => {
|
it('deletes customers missing from Hiboutik', async () => {
|
||||||
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' }]);
|
||||||
customerHiboutikRepository.getCustomers.mockResolvedValue([{ customers_id: 1 }, { customers_id: 2 }]);
|
|
||||||
customerDatabaseRepository.getAllHiboutikIds.mockResolvedValue([1, 2, 3, 4]);
|
|
||||||
|
|
||||||
await service.syncCustomers();
|
await service.syncCustomers();
|
||||||
|
|
||||||
expect(customerHiboutikRepository.findCustomerById.mock.calls).toEqual([[3], [4]]);
|
expect(customerDatabaseRepository.deleteMissing).toHaveBeenCalledWith([1, 2]);
|
||||||
expect(customerDatabaseRepository.deleteByHiboutikIds).toHaveBeenCalledWith([3, 4]);
|
expect(customerDatabaseRepository.upsert).toHaveBeenCalledTimes(2);
|
||||||
expect(customerDatabaseRepository.upsert).toHaveBeenCalledTimes(2);
|
});
|
||||||
});
|
|
||||||
|
|
||||||
it('keeps and syncs a customer missing from the list (page shift) that still exists in Hiboutik', async () => {
|
it('does not delete customers when Hiboutik returns none', async () => {
|
||||||
customerHiboutikRepository.getCustomers.mockResolvedValue([{ customers_id: 1 }]);
|
await service.syncCustomers();
|
||||||
customerDatabaseRepository.getAllHiboutikIds.mockResolvedValue([1, 2]);
|
|
||||||
customerHiboutikRepository.findCustomerById.mockResolvedValue({ customers_id: 2 });
|
|
||||||
|
|
||||||
await service.syncCustomers();
|
expect(customerDatabaseRepository.deleteMissing).not.toHaveBeenCalled();
|
||||||
|
|
||||||
expect(customerDatabaseRepository.deleteByHiboutikIds).toHaveBeenCalledWith([]);
|
|
||||||
expect(customerHiboutikRepository.getCustomerById.mock.calls).toEqual([[1], [2]]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('keeps a customer whose check fails', async () => {
|
|
||||||
customerDatabaseRepository.getAllHiboutikIds.mockResolvedValue([3, 4]);
|
|
||||||
customerHiboutikRepository.findCustomerById.mockRejectedValueOnce(new Error('Request failed with status code 500'));
|
|
||||||
|
|
||||||
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 () => {
|
it('keeps syncing the other customers when one fails', async () => {
|
||||||
@@ -111,148 +48,4 @@ describe('CustomerHiboutikService', () => {
|
|||||||
|
|
||||||
expect(customerDatabaseRepository.upsert).toHaveBeenCalledTimes(1);
|
expect(customerDatabaseRepository.upsert).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('addresses', () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
customerHiboutikRepository.getCustomers.mockResolvedValue([{ customers_id: 1 }]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('deletes the missing addresses of the customer, then upserts the others linked to it', async () => {
|
|
||||||
customerHiboutikRepository.getCustomerById.mockResolvedValue({
|
|
||||||
customers_id: 1,
|
|
||||||
addresses: [
|
|
||||||
{ address_id: 10, customers_id: 1, company: '' },
|
|
||||||
{ address_id: 11, customers_id: 999, company: '' },
|
|
||||||
],
|
|
||||||
});
|
|
||||||
|
|
||||||
await service.syncCustomers();
|
|
||||||
|
|
||||||
expect(customerAddressDatabaseRepository.deleteMissing).toHaveBeenCalledWith('customer-uuid', [10, 11]);
|
|
||||||
expect(customerAddressDatabaseRepository.upsert).toHaveBeenCalledTimes(2);
|
|
||||||
expect(customerAddressConverter.fromHiboutik).toHaveBeenLastCalledWith({ address_id: 11, customers_id: 1, company: '' });
|
|
||||||
expect(customerAddressDatabaseRepository.deleteMissing.mock.invocationCallOrder[0]).toBeLessThan(
|
|
||||||
customerAddressDatabaseRepository.upsert.mock.invocationCallOrder[0],
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('deletes all the addresses of a customer that has none left', async () => {
|
|
||||||
await service.syncCustomers();
|
|
||||||
|
|
||||||
expect(customerAddressDatabaseRepository.deleteMissing).toHaveBeenCalledWith('customer-uuid', []);
|
|
||||||
expect(customerAddressDatabaseRepository.upsert).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('keeps the stored addresses when the payload has no addresses array', async () => {
|
|
||||||
customerHiboutikRepository.getCustomerById.mockResolvedValue({ customers_id: 1 });
|
|
||||||
|
|
||||||
await service.syncCustomers();
|
|
||||||
|
|
||||||
expect(customerAddressDatabaseRepository.deleteMissing).not.toHaveBeenCalled();
|
|
||||||
expect(customerAddressDatabaseRepository.upsert).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('fetches the company details of addresses with a company only', async () => {
|
|
||||||
customerHiboutikRepository.getCustomerById.mockResolvedValue({
|
|
||||||
customers_id: 1,
|
|
||||||
addresses: [
|
|
||||||
{ address_id: 10, company: 'ACME' },
|
|
||||||
{ address_id: 11, company: ' ' },
|
|
||||||
],
|
|
||||||
});
|
|
||||||
customerHiboutikRepository.getAddressById.mockResolvedValue({
|
|
||||||
address_id: 10,
|
|
||||||
tax_number: 'FR123',
|
|
||||||
company_number: '123',
|
|
||||||
legal_status: 'SAS',
|
|
||||||
updated_at: '2026-11-01 10:00:00',
|
|
||||||
});
|
|
||||||
|
|
||||||
await service.syncCustomers();
|
|
||||||
|
|
||||||
expect(customerHiboutikRepository.getAddressById).toHaveBeenCalledTimes(1);
|
|
||||||
expect(customerHiboutikRepository.getAddressById).toHaveBeenCalledWith(10);
|
|
||||||
expect(customerAddressConverter.fromHiboutik).toHaveBeenCalledWith({
|
|
||||||
address_id: 10,
|
|
||||||
company: 'ACME',
|
|
||||||
customers_id: 1,
|
|
||||||
tax_number: 'FR123',
|
|
||||||
company_number: '123',
|
|
||||||
legal_status: 'SAS',
|
|
||||||
updated_at: '2026-11-01 10:00:00',
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('still syncs an address whose company details cannot be fetched', async () => {
|
|
||||||
customerHiboutikRepository.getCustomerById.mockResolvedValue({ customers_id: 1, addresses: [{ address_id: 10, company: 'ACME' }] });
|
|
||||||
customerHiboutikRepository.getAddressById.mockRejectedValue(new Error('boom'));
|
|
||||||
|
|
||||||
await service.syncCustomers();
|
|
||||||
|
|
||||||
expect(customerAddressConverter.fromHiboutik).toHaveBeenCalledWith({ address_id: 10, company: 'ACME', customers_id: 1 });
|
|
||||||
expect(customerAddressDatabaseRepository.upsert).toHaveBeenCalledTimes(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('keeps syncing the other addresses when one fails', async () => {
|
|
||||||
customerHiboutikRepository.getCustomerById.mockResolvedValue({
|
|
||||||
customers_id: 1,
|
|
||||||
addresses: [
|
|
||||||
{ address_id: 10, company: '' },
|
|
||||||
{ address_id: 11, company: '' },
|
|
||||||
],
|
|
||||||
});
|
|
||||||
customerAddressDatabaseRepository.upsert.mockRejectedValueOnce(new Error('boom'));
|
|
||||||
|
|
||||||
await service.syncCustomers();
|
|
||||||
|
|
||||||
expect(customerAddressDatabaseRepository.upsert).toHaveBeenCalledTimes(2);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('orders', () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
customerHiboutikRepository.getCustomers.mockResolvedValue([{ customers_id: 1 }]);
|
|
||||||
customerHiboutikRepository.getCustomerById.mockResolvedValue({
|
|
||||||
customers_id: 1,
|
|
||||||
addresses: [],
|
|
||||||
sales: [{ sale_id: 5 }, { sale_id: 6 }, { sale_id: 7 }],
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('fetches and upserts the sales not yet stored as completed, linked to the customer', async () => {
|
|
||||||
orderDatabaseRepository.getCompletedSaleIds.mockResolvedValue(new Set([6]));
|
|
||||||
|
|
||||||
await service.syncCustomers();
|
|
||||||
|
|
||||||
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 });
|
|
||||||
});
|
|
||||||
|
|
||||||
it('ignores quotations', async () => {
|
|
||||||
saleHiboutikRepository.getSaleById.mockResolvedValueOnce({ sale_id: 5, quotation: 1 });
|
|
||||||
|
|
||||||
await service.syncCustomers();
|
|
||||||
|
|
||||||
expect(orderDatabaseRepository.upsert).toHaveBeenCalledTimes(2);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('keeps syncing the other sales when one fails', async () => {
|
|
||||||
saleHiboutikRepository.getSaleById.mockRejectedValueOnce(new Error('boom'));
|
|
||||||
|
|
||||||
await service.syncCustomers();
|
|
||||||
|
|
||||||
expect(orderDatabaseRepository.upsert).toHaveBeenCalledTimes(2);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('skips orders when the payload has no sales array', async () => {
|
|
||||||
customerHiboutikRepository.getCustomerById.mockResolvedValue({ customers_id: 1, addresses: [] });
|
|
||||||
|
|
||||||
await service.syncCustomers();
|
|
||||||
|
|
||||||
expect(saleHiboutikRepository.getSaleById).not.toHaveBeenCalled();
|
|
||||||
expect(orderDatabaseRepository.upsert).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,18 +2,6 @@ import { Injectable, Logger } from '@nestjs/common';
|
|||||||
import { CustomerHiboutikRepository } from '../../hiboutik/customer.hiboutik.repository';
|
import { CustomerHiboutikRepository } from '../../hiboutik/customer.hiboutik.repository';
|
||||||
import { CustomerDatabaseRepository } from '../../database/customer.database.repository';
|
import { CustomerDatabaseRepository } from '../../database/customer.database.repository';
|
||||||
import { CustomerConverter } from '../../models/converter/customer.converter';
|
import { CustomerConverter } from '../../models/converter/customer.converter';
|
||||||
import { CustomerAddressDatabaseRepository } from '../../database/customerAddress.database.repository';
|
|
||||||
import { CustomerAddressConverter } from '../../models/converter/customerAddress.converter';
|
|
||||||
import {
|
|
||||||
CustomerAddressDetailsHiboutikDto,
|
|
||||||
CustomerAddressHiboutikDto,
|
|
||||||
FullCustomerHiboutikDto,
|
|
||||||
} from '../../models/hiboutikDTO/customer.hiboutik.dto';
|
|
||||||
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()
|
@Injectable()
|
||||||
export class CustomerHiboutikService {
|
export class CustomerHiboutikService {
|
||||||
@@ -23,157 +11,39 @@ export class CustomerHiboutikService {
|
|||||||
private readonly customerHiboutikRepository: CustomerHiboutikRepository,
|
private readonly customerHiboutikRepository: CustomerHiboutikRepository,
|
||||||
private readonly customerDatabaseRepository: CustomerDatabaseRepository,
|
private readonly customerDatabaseRepository: CustomerDatabaseRepository,
|
||||||
private readonly customerConverter: CustomerConverter,
|
private readonly customerConverter: CustomerConverter,
|
||||||
private readonly customerAddressDatabaseRepository: CustomerAddressDatabaseRepository,
|
|
||||||
private readonly customerAddressConverter: CustomerAddressConverter,
|
|
||||||
private readonly saleHiboutikRepository: SaleHiboutikRepository,
|
|
||||||
private readonly orderDatabaseRepository: OrderDatabaseRepository,
|
|
||||||
private readonly orderConverter: OrderConverter,
|
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Mirrors Hiboutik customers: deletes the customers Hiboutik confirms as deleted, then fetches and upserts each of
|
* Mirrors Hiboutik customers: deletes the customers missing from Hiboutik, then fetches and upserts each of them
|
||||||
* them with its addresses and orders (one API call per customer, as the list endpoint lacks addresses and sales, plus
|
* (one API call per customer, as the list endpoint lacks addresses and sales).
|
||||||
* one per sale not yet stored as completed).
|
|
||||||
*/
|
*/
|
||||||
async syncCustomers() {
|
async syncCustomers() {
|
||||||
const hiboutikCustomers = await this.customerHiboutikRepository.getCustomers();
|
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))];
|
|
||||||
|
|
||||||
const { nbDeleted, unlistedIds } = await this.deleteMissingCustomers(listedIds);
|
// Safety net: an empty answer from Hiboutik most likely means an API issue
|
||||||
const customerIds = [...listedIds, ...unlistedIds];
|
let nbDeleted = 0;
|
||||||
|
if (hiboutikCustomers.length === 0) {
|
||||||
|
this.logger.warn('Hiboutik returned no customers: deletion skipped.');
|
||||||
|
} else {
|
||||||
|
nbDeleted = await this.customerDatabaseRepository.deleteMissing(hiboutikCustomers.map((dto) => Number(dto.customers_id)));
|
||||||
|
}
|
||||||
|
|
||||||
let nbFailed = 0;
|
let nbFailed = 0;
|
||||||
let nbFailedAddresses = 0;
|
for (const hiboutikCustomer of hiboutikCustomers) {
|
||||||
let nbFailedOrders = 0;
|
|
||||||
for (const customerId of customerIds) {
|
|
||||||
try {
|
try {
|
||||||
const hiboutikFullCustomer = await this.customerHiboutikRepository.getCustomerById(customerId);
|
const hiboutikFullCustomer = await this.customerHiboutikRepository.getCustomerById(hiboutikCustomer.customers_id);
|
||||||
const customer = await this.customerDatabaseRepository.upsert(this.customerConverter.fromHiboutik(hiboutikFullCustomer));
|
await this.customerDatabaseRepository.upsert(this.customerConverter.fromHiboutik(hiboutikFullCustomer));
|
||||||
nbFailedAddresses += await this.syncAddresses(customer.id, hiboutikFullCustomer);
|
// retreive addresses DTOs
|
||||||
nbFailedOrders += await this.syncOrders(hiboutikFullCustomer);
|
// save addresses
|
||||||
|
// retreive orders DTOs
|
||||||
|
// save orders
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
rethrowIfRateLimited(error);
|
|
||||||
nbFailed++;
|
nbFailed++;
|
||||||
this.logger.error(`Failed to sync Hiboutik customer ${customerId}`, (error as Error)?.stack);
|
this.logger.error(`Failed to sync Hiboutik customer ${hiboutikCustomer.customers_id}`, (error as Error)?.stack);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
this.logger.log(
|
this.logger.log(
|
||||||
`Customers sync ended: ${customerIds.length - nbFailed}/${customerIds.length} synced, ${nbFailed} failed, ${nbDeleted} deleted, ` +
|
`Customers sync ended: ${hiboutikCustomers.length - nbFailed}/${hiboutikCustomers.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).
|
|
||||||
* @returns the number of failed addresses.
|
|
||||||
*/
|
|
||||||
private async syncAddresses(customerId: string, hiboutikCustomer: FullCustomerHiboutikDto): Promise<number> {
|
|
||||||
// A missing array means an unexpected payload, not a customer without addresses: keep the stored ones
|
|
||||||
if (!Array.isArray(hiboutikCustomer.addresses)) {
|
|
||||||
this.logger.warn(`Hiboutik customer ${hiboutikCustomer.customers_id} has no addresses array: addresses sync skipped.`);
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.customerAddressDatabaseRepository.deleteMissing(
|
|
||||||
customerId,
|
|
||||||
hiboutikCustomer.addresses.map((dto) => 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;
|
|
||||||
await this.customerAddressDatabaseRepository.upsert(
|
|
||||||
this.customerAddressConverter.fromHiboutik({ ...address, customers_id: hiboutikCustomer.customers_id }),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
this.logger,
|
|
||||||
(dto) => `Hiboutik address ${dto.address_id} of customer ${hiboutikCustomer.customers_id}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Completes an embedded address with its company details (one API call). On failure, the embedded address is returned
|
|
||||||
* as is, so the address is still synced and its stored company details are kept.
|
|
||||||
*/
|
|
||||||
private async withCompanyDetails(
|
|
||||||
dto: CustomerAddressHiboutikDto,
|
|
||||||
): Promise<CustomerAddressHiboutikDto | CustomerAddressDetailsHiboutikDto> {
|
|
||||||
try {
|
|
||||||
const details = await this.customerHiboutikRepository.getAddressById(dto.address_id);
|
|
||||||
return {
|
|
||||||
...dto,
|
|
||||||
tax_number: details.tax_number,
|
|
||||||
company_number: details.company_number,
|
|
||||||
legal_status: details.legal_status,
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Mirrors the sales of a full Hiboutik customer as orders: each sale not yet stored as completed is fetched (for its
|
|
||||||
* lines) and upserted (a failing sale is logged and skipped). Quotations are ignored. Orders are never deleted.
|
|
||||||
* @returns the number of failed sales.
|
|
||||||
*/
|
|
||||||
private async syncOrders(hiboutikCustomer: FullCustomerHiboutikDto): Promise<number> {
|
|
||||||
if (!Array.isArray(hiboutikCustomer.sales)) {
|
|
||||||
this.logger.warn(`Hiboutik customer ${hiboutikCustomer.customers_id} has no sales array: orders sync skipped.`);
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
// A completed sale no longer changes in Hiboutik: no need to fetch it again
|
|
||||||
const completedSaleIds = await this.orderDatabaseRepository.getCompletedSaleIds(
|
|
||||||
hiboutikCustomer.customers_id,
|
|
||||||
hiboutikCustomer.sales.map((dto) => 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 (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 }),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
this.logger,
|
|
||||||
(dto) => `Hiboutik sale ${dto.sale_id} of customer ${hiboutikCustomer.customers_id}`,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { Logger, ServiceUnavailableException } from '@nestjs/common';
|
import { Logger } from '@nestjs/common';
|
||||||
import { HiboutikService } from './hiboutik.service';
|
import { HiboutikService } from './hiboutik.service';
|
||||||
import { HiboutikRateLimitError } from '../../hiboutik/hiboutik.client';
|
|
||||||
|
|
||||||
describe('HiboutikService', () => {
|
describe('HiboutikService', () => {
|
||||||
const brandService = { syncBrands: jest.fn() };
|
const brandService = { syncBrands: jest.fn() };
|
||||||
@@ -8,18 +7,11 @@ describe('HiboutikService', () => {
|
|||||||
const supplierService = { syncSuppliers: jest.fn() };
|
const supplierService = { syncSuppliers: jest.fn() };
|
||||||
const productService = { syncProducts: jest.fn() };
|
const productService = { syncProducts: jest.fn() };
|
||||||
const customerService = { syncCustomers: jest.fn() };
|
const customerService = { syncCustomers: jest.fn() };
|
||||||
const syncLock = { tryAcquire: jest.fn() };
|
|
||||||
const release = jest.fn();
|
|
||||||
// SYNC_SHUTDOWN_TIMEOUT of 1 s
|
|
||||||
const configService = { get: jest.fn().mockReturnValue(1) };
|
|
||||||
let service: HiboutikService;
|
let service: HiboutikService;
|
||||||
const flush = () => new Promise((resolve) => setImmediate(resolve));
|
|
||||||
|
|
||||||
let logWarn: jest.SpyInstance;
|
|
||||||
|
|
||||||
beforeAll(() => {
|
beforeAll(() => {
|
||||||
jest.spyOn(Logger.prototype, 'log').mockImplementation(() => undefined);
|
jest.spyOn(Logger.prototype, 'log').mockImplementation(() => undefined);
|
||||||
logWarn = jest.spyOn(Logger.prototype, 'warn').mockImplementation(() => undefined);
|
jest.spyOn(Logger.prototype, 'warn').mockImplementation(() => undefined);
|
||||||
jest.spyOn(Logger.prototype, 'error').mockImplementation(() => undefined);
|
jest.spyOn(Logger.prototype, 'error').mockImplementation(() => undefined);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -30,16 +22,12 @@ describe('HiboutikService', () => {
|
|||||||
supplierService.syncSuppliers.mockResolvedValue(undefined);
|
supplierService.syncSuppliers.mockResolvedValue(undefined);
|
||||||
productService.syncProducts.mockResolvedValue(undefined);
|
productService.syncProducts.mockResolvedValue(undefined);
|
||||||
customerService.syncCustomers.mockResolvedValue(undefined);
|
customerService.syncCustomers.mockResolvedValue(undefined);
|
||||||
release.mockResolvedValue(undefined);
|
|
||||||
syncLock.tryAcquire.mockResolvedValue(release);
|
|
||||||
service = new HiboutikService(
|
service = new HiboutikService(
|
||||||
brandService as never,
|
brandService as never,
|
||||||
categoryService as never,
|
categoryService as never,
|
||||||
customerService as never,
|
customerService as never,
|
||||||
productService as never,
|
productService as never,
|
||||||
supplierService as never,
|
supplierService as never,
|
||||||
syncLock as never,
|
|
||||||
configService as never,
|
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -51,8 +39,7 @@ describe('HiboutikService', () => {
|
|||||||
productService.syncProducts.mockImplementation(() => Promise.resolve(order.push('products')));
|
productService.syncProducts.mockImplementation(() => Promise.resolve(order.push('products')));
|
||||||
customerService.syncCustomers.mockImplementation(() => Promise.resolve(order.push('customers')));
|
customerService.syncCustomers.mockImplementation(() => Promise.resolve(order.push('customers')));
|
||||||
|
|
||||||
// private method, reached through an index access on purpose
|
await service.syncAll();
|
||||||
await service['syncAll']();
|
|
||||||
|
|
||||||
expect(order).toEqual(['brands', 'categories', 'suppliers', 'products', 'customers']);
|
expect(order).toEqual(['brands', 'categories', 'suppliers', 'products', 'customers']);
|
||||||
});
|
});
|
||||||
@@ -60,8 +47,7 @@ describe('HiboutikService', () => {
|
|||||||
it('continues with the next steps when one fails', async () => {
|
it('continues with the next steps when one fails', async () => {
|
||||||
brandService.syncBrands.mockRejectedValue(new Error('boom'));
|
brandService.syncBrands.mockRejectedValue(new Error('boom'));
|
||||||
|
|
||||||
// private method, reached through an index access on purpose
|
await service.syncAll();
|
||||||
await service['syncAll']();
|
|
||||||
|
|
||||||
expect(productService.syncProducts).toHaveBeenCalled();
|
expect(productService.syncProducts).toHaveBeenCalled();
|
||||||
expect(customerService.syncCustomers).toHaveBeenCalled();
|
expect(customerService.syncCustomers).toHaveBeenCalled();
|
||||||
@@ -70,101 +56,13 @@ describe('HiboutikService', () => {
|
|||||||
it('refuses a second synchronization while one is running, and unlocks at the end', async () => {
|
it('refuses a second synchronization while one is running, and unlocks at the end', async () => {
|
||||||
let finishBrands: () => void = () => undefined;
|
let finishBrands: () => void = () => undefined;
|
||||||
brandService.syncBrands.mockReturnValue(new Promise<void>((resolve) => (finishBrands = resolve)));
|
brandService.syncBrands.mockReturnValue(new Promise<void>((resolve) => (finishBrands = resolve)));
|
||||||
// The lock is the only guard: the second request does not get it, even from the same instance
|
|
||||||
syncLock.tryAcquire.mockResolvedValueOnce(release).mockResolvedValueOnce(null);
|
|
||||||
|
|
||||||
await expect(service.startSync()).resolves.toBe(true);
|
expect(service.startSync()).toBe(true);
|
||||||
await expect(service.startSync()).resolves.toBe(false);
|
expect(service.isSyncInProgress).toBe(true);
|
||||||
expect(brandService.syncBrands).toHaveBeenCalledTimes(1);
|
expect(service.startSync()).toBe(false);
|
||||||
expect(release).not.toHaveBeenCalled();
|
|
||||||
|
|
||||||
finishBrands();
|
finishBrands();
|
||||||
await flush();
|
await new Promise((resolve) => setImmediate(resolve));
|
||||||
expect(release).toHaveBeenCalledTimes(1);
|
expect(service.isSyncInProgress).toBe(false);
|
||||||
});
|
|
||||||
|
|
||||||
it('starts nothing when another instance holds the lock', async () => {
|
|
||||||
syncLock.tryAcquire.mockResolvedValue(null);
|
|
||||||
|
|
||||||
await expect(service.startSync()).resolves.toBe(false);
|
|
||||||
|
|
||||||
expect(brandService.syncBrands).not.toHaveBeenCalled();
|
|
||||||
expect(release).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('starts nothing and can be retried when the lock cannot be requested', async () => {
|
|
||||||
syncLock.tryAcquire.mockRejectedValueOnce(new Error('database unreachable'));
|
|
||||||
|
|
||||||
await expect(service.startSync()).rejects.toThrow('database unreachable');
|
|
||||||
expect(brandService.syncBrands).not.toHaveBeenCalled();
|
|
||||||
|
|
||||||
await expect(service.startSync()).resolves.toBe(true);
|
|
||||||
await flush();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('releases the lock even when the synchronization crashes', async () => {
|
|
||||||
jest.spyOn(service as unknown as { syncAll: () => Promise<void> }, 'syncAll').mockRejectedValue(new Error('crash'));
|
|
||||||
|
|
||||||
await expect(service.startSync()).resolves.toBe(true);
|
|
||||||
await flush();
|
|
||||||
|
|
||||||
expect(release).toHaveBeenCalledTimes(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('stops the synchronization when the Hiboutik rate limit is reached', async () => {
|
|
||||||
productService.syncProducts.mockRejectedValue(new HiboutikRateLimitError('/products'));
|
|
||||||
|
|
||||||
await service['syncAll']();
|
|
||||||
|
|
||||||
expect(customerService.syncCustomers).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('shutdown', () => {
|
|
||||||
afterEach(() => {
|
|
||||||
jest.useRealTimers();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('waits for the running synchronization to end, and refuses new ones meanwhile', async () => {
|
|
||||||
let finishBrands: () => void = () => undefined;
|
|
||||||
brandService.syncBrands.mockReturnValue(new Promise<void>((resolve) => (finishBrands = resolve)));
|
|
||||||
await service.startSync();
|
|
||||||
|
|
||||||
let shutDown = false;
|
|
||||||
const shutdown = service.beforeApplicationShutdown().then(() => (shutDown = true));
|
|
||||||
await flush();
|
|
||||||
expect(shutDown).toBe(false);
|
|
||||||
await expect(service.startSync()).rejects.toThrow(ServiceUnavailableException);
|
|
||||||
|
|
||||||
finishBrands();
|
|
||||||
await shutdown;
|
|
||||||
expect(release).toHaveBeenCalledTimes(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('gives up waiting after the timeout', async () => {
|
|
||||||
jest.useFakeTimers();
|
|
||||||
brandService.syncBrands.mockReturnValue(new Promise<void>(() => undefined));
|
|
||||||
await service.startSync();
|
|
||||||
|
|
||||||
const shutdown = service.beforeApplicationShutdown();
|
|
||||||
await jest.advanceTimersByTimeAsync(1_000);
|
|
||||||
|
|
||||||
await expect(shutdown).resolves.toBeUndefined();
|
|
||||||
expect(logWarn).toHaveBeenCalledWith(expect.stringContaining('did not end in time'));
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns at once when no synchronization is running', async () => {
|
|
||||||
await expect(service.beforeApplicationShutdown()).resolves.toBeUndefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('releases the lock and refuses when the shutdown began while the lock was requested', async () => {
|
|
||||||
syncLock.tryAcquire.mockImplementation(async () => {
|
|
||||||
await service.beforeApplicationShutdown();
|
|
||||||
return release;
|
|
||||||
});
|
|
||||||
|
|
||||||
await expect(service.startSync()).rejects.toThrow(ServiceUnavailableException);
|
|
||||||
expect(release).toHaveBeenCalledTimes(1);
|
|
||||||
expect(brandService.syncBrands).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,24 +1,16 @@
|
|||||||
import { BeforeApplicationShutdown, Injectable, Logger, ServiceUnavailableException } from '@nestjs/common';
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
import { ConfigService } from '@nestjs/config';
|
|
||||||
import { BrandHiboutikService } from './brand.hiboutik.service';
|
import { BrandHiboutikService } from './brand.hiboutik.service';
|
||||||
import { CategoryHiboutikService } from './category.hiboutik.service';
|
import { CategoryHiboutikService } from './category.hiboutik.service';
|
||||||
import { CustomerHiboutikService } from './customer.hiboutik.service';
|
import { CustomerHiboutikService } from './customer.hiboutik.service';
|
||||||
import { SupplierHiboutikService } from './supplier.hiboutik.service';
|
import { SupplierHiboutikService } from './supplier.hiboutik.service';
|
||||||
import { ProductHiboutikService } from './product.hiboutik.service';
|
import { ProductHiboutikService } from './product.hiboutik.service';
|
||||||
import { ReleaseLock, SyncLockDatabaseRepository } from '../../database/syncLock.database.repository';
|
|
||||||
import { HiboutikRateLimitError } from '../../hiboutik/hiboutik.client';
|
|
||||||
|
|
||||||
/** Default of `SYNC_SHUTDOWN_TIMEOUT`: below the 30 s grace period that Kubernetes gives before killing a container. */
|
|
||||||
const DEFAULT_SHUTDOWN_TIMEOUT_SECONDS = 25;
|
|
||||||
|
|
||||||
/** Entry point of the Hiboutik → database synchronization: runs each entity sync in dependency order. */
|
/** Entry point of the Hiboutik → database synchronization: runs each entity sync in dependency order. */
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class HiboutikService implements BeforeApplicationShutdown {
|
export class HiboutikService {
|
||||||
private readonly logger = new Logger('HiboutikService');
|
private readonly logger = new Logger('HiboutikService');
|
||||||
private readonly shutdownTimeoutMs: number;
|
/** In-memory lock: only valid while the app runs as a single instance. */
|
||||||
/** Synchronization running on this instance, if any. */
|
private syncInProgress = false;
|
||||||
private running: Promise<void> | null = null;
|
|
||||||
private stopping = false;
|
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly brandHiboutikService: BrandHiboutikService,
|
private readonly brandHiboutikService: BrandHiboutikService,
|
||||||
@@ -26,77 +18,28 @@ export class HiboutikService implements BeforeApplicationShutdown {
|
|||||||
private readonly customerHiboutikService: CustomerHiboutikService,
|
private readonly customerHiboutikService: CustomerHiboutikService,
|
||||||
private readonly productHiboutikService: ProductHiboutikService,
|
private readonly productHiboutikService: ProductHiboutikService,
|
||||||
private readonly supplierHiboutikService: SupplierHiboutikService,
|
private readonly supplierHiboutikService: SupplierHiboutikService,
|
||||||
private readonly syncLock: SyncLockDatabaseRepository,
|
) {}
|
||||||
configService: ConfigService,
|
|
||||||
) {
|
get isSyncInProgress(): boolean {
|
||||||
this.shutdownTimeoutMs = (configService.get<number>('SYNC_SHUTDOWN_TIMEOUT') ?? DEFAULT_SHUTDOWN_TIMEOUT_SECONDS) * 1000;
|
return this.syncInProgress;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Starts a full synchronization in the background, if no other one is running on this instance or any other.
|
* Starts a full synchronization in the background.
|
||||||
* The Postgres advisory lock is the only guard: each request takes it on its own connection (its own session),
|
|
||||||
* so it also refuses concurrent calls on this very instance. It is held until the synchronization ends.
|
|
||||||
* @returns false if a synchronization is already running (nothing is started).
|
* @returns false if a synchronization is already running (nothing is started).
|
||||||
* @throws ServiceUnavailableException if this instance is shutting down (another instance can take it).
|
|
||||||
* @throws if the lock cannot be requested (e.g. database unreachable).
|
|
||||||
*/
|
*/
|
||||||
async startSync(): Promise<boolean> {
|
startSync(): boolean {
|
||||||
this.refuseIfStopping();
|
if (this.syncInProgress) return false;
|
||||||
const release = await this.syncLock.tryAcquire();
|
|
||||||
if (!release) return false;
|
|
||||||
|
|
||||||
// The shutdown may have begun while the lock was requested: it would not wait for this synchronization
|
this.syncInProgress = true;
|
||||||
if (this.stopping) {
|
void this.syncAll()
|
||||||
await release();
|
.catch((error) => this.logger.error('Global Hiboutik synchronization crashed', (error as Error)?.stack))
|
||||||
this.refuseIfStopping();
|
.finally(() => (this.syncInProgress = false));
|
||||||
}
|
|
||||||
|
|
||||||
this.running = this.runLocked(release).finally(() => (this.running = null));
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** Runs every sync step in order. A failing step is logged and does not stop the next ones. */
|
||||||
* Shutdown hook: refuses new synchronizations and waits for the running one to end, up to `SYNC_SHUTDOWN_TIMEOUT`
|
async syncAll() {
|
||||||
* seconds. Runs before Prisma disconnects (`PrismaService.onApplicationShutdown`). Past the timeout, the shutdown goes
|
|
||||||
* on: the synchronization then fails on its next database call, without corrupting anything (one transaction per
|
|
||||||
* product, the lock is released by Postgres with the connection).
|
|
||||||
*/
|
|
||||||
async beforeApplicationShutdown(): Promise<void> {
|
|
||||||
this.stopping = true;
|
|
||||||
const running = this.running;
|
|
||||||
if (!running) return;
|
|
||||||
|
|
||||||
this.logger.log(`Shutdown requested: waiting for the running Hiboutik synchronization (up to ${this.shutdownTimeoutMs / 1000} s)`);
|
|
||||||
let timer: NodeJS.Timeout | undefined;
|
|
||||||
const timedOut = new Promise<true>((resolve) => (timer = setTimeout(() => resolve(true), this.shutdownTimeoutMs)));
|
|
||||||
const result = await Promise.race([running.then(() => false as const), timedOut]);
|
|
||||||
clearTimeout(timer);
|
|
||||||
|
|
||||||
if (result) {
|
|
||||||
this.logger.warn('The Hiboutik synchronization did not end in time: shutting down anyway, it will be interrupted');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private refuseIfStopping(): void {
|
|
||||||
if (this.stopping) throw new ServiceUnavailableException('This instance is shutting down, retry in a moment');
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Runs the synchronization, then always releases the lock. */
|
|
||||||
private async runLocked(release: ReleaseLock): Promise<void> {
|
|
||||||
try {
|
|
||||||
await this.syncAll();
|
|
||||||
} catch (error) {
|
|
||||||
this.logger.error('Global Hiboutik synchronization crashed', (error as Error)?.stack);
|
|
||||||
} finally {
|
|
||||||
await release();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Runs every sync step in order. A failing step is logged and does not stop the next ones, except when the Hiboutik
|
|
||||||
* rate limit is reached: the next steps would only fail too, so the synchronization stops there.
|
|
||||||
*/
|
|
||||||
private async syncAll() {
|
|
||||||
this.logger.log('Starting global Hiboutik synchronization');
|
this.logger.log('Starting global Hiboutik synchronization');
|
||||||
// Order matters: products connect to brands, categories and suppliers by hiboutikId.
|
// Order matters: products connect to brands, categories and suppliers by hiboutikId.
|
||||||
const steps: [string, () => Promise<void>][] = [
|
const steps: [string, () => Promise<void>][] = [
|
||||||
@@ -113,10 +56,6 @@ export class HiboutikService implements BeforeApplicationShutdown {
|
|||||||
await step();
|
await step();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
failedSteps.push(name);
|
failedSteps.push(name);
|
||||||
if (error instanceof HiboutikRateLimitError) {
|
|
||||||
this.logger.error(`Hiboutik synchronization stopped during the ${name} step: ${error.message}`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this.logger.error(`Hiboutik ${name} sync failed`, (error as Error)?.stack);
|
this.logger.error(`Hiboutik ${name} sync failed`, (error as Error)?.stack);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,20 +12,22 @@ const hiboutikProduct = (id: number, overrides: Partial<ProductHiboutikDto> = {}
|
|||||||
product_supplier: 0,
|
product_supplier: 0,
|
||||||
product_price: '10.00',
|
product_price: '10.00',
|
||||||
product_discount_price: '0.00',
|
product_discount_price: '0.00',
|
||||||
|
product_supply_price: '5.00',
|
||||||
product_category: 0,
|
product_category: 0,
|
||||||
product_display_www: 1,
|
product_display_www: 1,
|
||||||
product_arch: 0,
|
product_arch: 0,
|
||||||
products_desc: '',
|
products_desc: '',
|
||||||
updated_at: '2026-06-28 17:02:55',
|
updated_at: '2026-06-28 17:02:55',
|
||||||
product_specific_rules: [],
|
product_specific_rules: [],
|
||||||
product_size_details: [],
|
|
||||||
...overrides,
|
...overrides,
|
||||||
});
|
});
|
||||||
|
|
||||||
const stock = (productId: number, size: number, available: number): StockHiboutikDto => ({
|
const stock = (productId: number, size: number, available: string): StockHiboutikDto => ({
|
||||||
product_id: productId,
|
product_id: productId,
|
||||||
product_size: size,
|
product_size: size,
|
||||||
stock_available: available,
|
stock_available: available,
|
||||||
|
product_barcode: '',
|
||||||
|
product_supplier_reference: '',
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('ProductHiboutikService', () => {
|
describe('ProductHiboutikService', () => {
|
||||||
@@ -35,10 +37,6 @@ describe('ProductHiboutikService', () => {
|
|||||||
const stockHiboutikRepository = { getStocks: jest.fn() };
|
const stockHiboutikRepository = { getStocks: jest.fn() };
|
||||||
const productDatabaseRepository = { getAll: jest.fn(), upsert: jest.fn(), deleteAll: jest.fn() };
|
const productDatabaseRepository = { getAll: jest.fn(), upsert: jest.fn(), deleteAll: jest.fn() };
|
||||||
const productVersionDatabaseRepository = { upsert: jest.fn(), deleteMissing: 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() };
|
|
||||||
const slugService = { assignMissingSlugs: jest.fn() };
|
|
||||||
|
|
||||||
beforeAll(() => {
|
beforeAll(() => {
|
||||||
jest.spyOn(Logger.prototype, 'log').mockImplementation(() => undefined);
|
jest.spyOn(Logger.prototype, 'log').mockImplementation(() => undefined);
|
||||||
@@ -49,7 +47,6 @@ describe('ProductHiboutikService', () => {
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
jest.clearAllMocks();
|
jest.clearAllMocks();
|
||||||
calls = [];
|
calls = [];
|
||||||
prisma.$transaction.mockImplementation((run: (client: unknown) => Promise<unknown>) => run(tx));
|
|
||||||
stockHiboutikRepository.getStocks.mockResolvedValue([]);
|
stockHiboutikRepository.getStocks.mockResolvedValue([]);
|
||||||
productDatabaseRepository.getAll.mockResolvedValue([]);
|
productDatabaseRepository.getAll.mockResolvedValue([]);
|
||||||
productDatabaseRepository.deleteAll.mockImplementation((ids: string[]) => {
|
productDatabaseRepository.deleteAll.mockImplementation((ids: string[]) => {
|
||||||
@@ -76,8 +73,6 @@ describe('ProductHiboutikService', () => {
|
|||||||
productVersionDatabaseRepository as never,
|
productVersionDatabaseRepository as never,
|
||||||
new ProductConverter(),
|
new ProductConverter(),
|
||||||
stockHiboutikRepository as never,
|
stockHiboutikRepository as never,
|
||||||
prisma as never,
|
|
||||||
slugService as never,
|
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -94,37 +89,27 @@ describe('ProductHiboutikService', () => {
|
|||||||
expect(calls[0]).toBe('deleteProducts');
|
expect(calls[0]).toBe('deleteProducts');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('deletes every product when Hiboutik answers with an empty list', async () => {
|
it('does not delete anything when Hiboutik returns no products', async () => {
|
||||||
productHiboutikRepository.getProducts.mockResolvedValue([]);
|
productHiboutikRepository.getProducts.mockResolvedValue([]);
|
||||||
productDatabaseRepository.getAll.mockResolvedValue([{ id: 'uuid-1', hiboutikId: 1 }]);
|
productDatabaseRepository.getAll.mockResolvedValue([{ id: 'uuid-1', hiboutikId: 1 }]);
|
||||||
|
|
||||||
await service.syncProducts();
|
await service.syncProducts();
|
||||||
|
|
||||||
expect(productDatabaseRepository.deleteAll).toHaveBeenCalledWith(['uuid-1']);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('deletes nothing when the Hiboutik call fails', async () => {
|
|
||||||
productHiboutikRepository.getProducts.mockRejectedValue(new Error('Request failed with status code 500'));
|
|
||||||
productDatabaseRepository.getAll.mockResolvedValue([{ id: 'uuid-1', hiboutikId: 1 }]);
|
|
||||||
|
|
||||||
await expect(service.syncProducts()).rejects.toThrow('500');
|
|
||||||
|
|
||||||
expect(productDatabaseRepository.deleteAll).not.toHaveBeenCalled();
|
expect(productDatabaseRepository.deleteAll).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('creates a single version for a product without sizes', async () => {
|
it('creates a single version for a product without sizes', async () => {
|
||||||
productHiboutikRepository.getProducts.mockResolvedValue([hiboutikProduct(1)]);
|
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();
|
await service.syncProducts();
|
||||||
|
|
||||||
expect(productDatabaseRepository.upsert).toHaveBeenCalledWith(expect.objectContaining({ hiboutikId: 1, globalStock: 4 }), tx);
|
expect(productDatabaseRepository.upsert).toHaveBeenCalledWith(expect.objectContaining({ hiboutikId: 1, globalStock: 4 }));
|
||||||
expect(productVersionDatabaseRepository.deleteMissing).toHaveBeenCalledWith('uuid-1', [0], tx);
|
expect(productVersionDatabaseRepository.deleteMissing).toHaveBeenCalledWith('uuid-1', [0]);
|
||||||
expect(productVersionDatabaseRepository.upsert).toHaveBeenCalledTimes(1);
|
expect(productVersionDatabaseRepository.upsert).toHaveBeenCalledTimes(1);
|
||||||
expect(productVersionDatabaseRepository.upsert).toHaveBeenCalledWith(
|
expect(productVersionDatabaseRepository.upsert).toHaveBeenCalledWith(
|
||||||
'uuid-1',
|
'uuid-1',
|
||||||
expect.objectContaining({ hiboutikSizeId: 0, barcode: 'barcode-1', stock: 4, versionName: 'Product 1', discountPrice: null }),
|
expect.objectContaining({ hiboutikSizeId: 0, barcode: 'barcode-1', stock: 4, versionName: 'Product 1', discountPrice: null }),
|
||||||
tx,
|
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -135,44 +120,34 @@ describe('ProductHiboutikService', () => {
|
|||||||
{ size_id: 10, size_name: 'S', barcode: 'b-s' },
|
{ size_id: 10, size_name: 'S', barcode: 'b-s' },
|
||||||
{ size_id: 11, size_name: 'M', barcode: '' },
|
{ size_id: 11, size_name: 'M', barcode: '' },
|
||||||
],
|
],
|
||||||
product_specific_rules: [{ size_id: 10, product_price: '12.00', product_discount_price: '0.00' }],
|
product_specific_rules: [{ size_id: 10, barcode: 'b-s', 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();
|
await service.syncProducts();
|
||||||
|
|
||||||
expect(productDatabaseRepository.upsert).toHaveBeenCalledWith(expect.objectContaining({ globalStock: 5 }), tx);
|
expect(productDatabaseRepository.upsert).toHaveBeenCalledWith(expect.objectContaining({ globalStock: 5 }));
|
||||||
expect(productVersionDatabaseRepository.deleteMissing).toHaveBeenCalledWith('uuid-1', [10, 11], tx);
|
expect(productVersionDatabaseRepository.deleteMissing).toHaveBeenCalledWith('uuid-1', [10, 11]);
|
||||||
expect(calls).toEqual(['deleteProducts', 'upsertProduct', 'deleteVersions', 'upsertVersion', 'upsertVersion']);
|
expect(calls).toEqual(['deleteProducts', 'upsertProduct', 'deleteVersions', 'upsertVersion', 'upsertVersion']);
|
||||||
expect(productVersionDatabaseRepository.upsert).toHaveBeenNthCalledWith(
|
expect(productVersionDatabaseRepository.upsert).toHaveBeenNthCalledWith(1, 'uuid-1', {
|
||||||
1,
|
barcode: 'b-s',
|
||||||
'uuid-1',
|
discountPrice: null,
|
||||||
{
|
hiboutikSizeId: 10,
|
||||||
barcode: 'b-s',
|
price: '12.00',
|
||||||
discountPrice: null,
|
product: { connect: { id: 'uuid-1' } },
|
||||||
hiboutikSizeId: 10,
|
stock: 3,
|
||||||
price: '12.00',
|
versionName: 'S',
|
||||||
product: { connect: { id: 'uuid-1' } },
|
});
|
||||||
stock: 3,
|
expect(productVersionDatabaseRepository.upsert).toHaveBeenNthCalledWith(2, 'uuid-1', {
|
||||||
versionName: 'S',
|
barcode: null,
|
||||||
},
|
discountPrice: null,
|
||||||
tx,
|
hiboutikSizeId: 11,
|
||||||
);
|
price: null,
|
||||||
expect(productVersionDatabaseRepository.upsert).toHaveBeenNthCalledWith(
|
product: { connect: { id: 'uuid-1' } },
|
||||||
2,
|
stock: 2,
|
||||||
'uuid-1',
|
versionName: 'M',
|
||||||
{
|
});
|
||||||
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 () => {
|
it('keeps syncing the other products when one fails', async () => {
|
||||||
@@ -184,28 +159,4 @@ describe('ProductHiboutikService', () => {
|
|||||||
expect(productDatabaseRepository.upsert).toHaveBeenCalledTimes(2);
|
expect(productDatabaseRepository.upsert).toHaveBeenCalledTimes(2);
|
||||||
expect(productVersionDatabaseRepository.upsert).toHaveBeenCalledTimes(1);
|
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();
|
|
||||||
});
|
|
||||||
it('generates the slugs of new products once they are saved', async () => {
|
|
||||||
productHiboutikRepository.getProducts.mockResolvedValue([hiboutikProduct(1)]);
|
|
||||||
slugService.assignMissingSlugs.mockImplementation(() => {
|
|
||||||
calls.push('slugs');
|
|
||||||
return Promise.resolve();
|
|
||||||
});
|
|
||||||
|
|
||||||
await service.syncProducts();
|
|
||||||
|
|
||||||
expect(slugService.assignMissingSlugs).toHaveBeenCalledWith('PRODUCT');
|
|
||||||
expect(calls[calls.length - 1]).toBe('slugs');
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -6,12 +6,9 @@ import { ProductConverter } from '../../models/converter/product.converter';
|
|||||||
import { StockHiboutikRepository } from '../../hiboutik/stock.hiboutik.repository';
|
import { StockHiboutikRepository } from '../../hiboutik/stock.hiboutik.repository';
|
||||||
import { ProductHiboutikDto } from '../../models/hiboutikDTO/product.hiboutik.dto';
|
import { ProductHiboutikDto } from '../../models/hiboutikDTO/product.hiboutik.dto';
|
||||||
import { StockHiboutikDto } from '../../models/hiboutikDTO/stock.hiboutik.dto';
|
import { StockHiboutikDto } from '../../models/hiboutikDTO/stock.hiboutik.dto';
|
||||||
import { Product, SlugEntityType } from '../../../prisma/generated/client';
|
import { Product } from '../../../prisma/generated/client';
|
||||||
import { ProductVersionCreateInput } from '../../../prisma/generated/models/ProductVersion';
|
import { ProductVersionCreateInput } from '../../../prisma/generated/models/ProductVersion';
|
||||||
import { parseOptionalHiboutikPrice } from '../../utils/price';
|
import { parseOptionalHiboutikPrice } from '../../utils/price';
|
||||||
import { PrismaService } from '../../../prisma/prisma.service';
|
|
||||||
import { rethrowIfRateLimited } from '../../hiboutik/hiboutik.client';
|
|
||||||
import { SlugService } from '../slug/slug.service';
|
|
||||||
|
|
||||||
/** Version id used for products that have no sizes in Hiboutik. */
|
/** Version id used for products that have no sizes in Hiboutik. */
|
||||||
const NO_SIZE_ID = 0;
|
const NO_SIZE_ID = 0;
|
||||||
@@ -26,42 +23,32 @@ export class ProductHiboutikService {
|
|||||||
private readonly productVersionDatabaseRepository: ProductVersionDatabaseRepository,
|
private readonly productVersionDatabaseRepository: ProductVersionDatabaseRepository,
|
||||||
private readonly productConverter: ProductConverter,
|
private readonly productConverter: ProductConverter,
|
||||||
private readonly stockHiboutikRepository: StockHiboutikRepository,
|
private readonly stockHiboutikRepository: StockHiboutikRepository,
|
||||||
private readonly prisma: PrismaService,
|
|
||||||
private readonly slugService: SlugService,
|
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/** Mirrors Hiboutik products, their versions (sizes) and stocks. Products missing from Hiboutik are deleted, new ones get a slug. */
|
/** Mirrors Hiboutik products, their versions (sizes) and stocks. Products missing from Hiboutik are deleted. */
|
||||||
async syncProducts() {
|
async syncProducts() {
|
||||||
|
this.logger.log('Starting products sync');
|
||||||
const [productsHiboutik, hiboutikStocks, existingProducts] = await Promise.all([
|
const [productsHiboutik, hiboutikStocks, existingProducts] = await Promise.all([
|
||||||
this.productHiboutikRepository.getProducts(),
|
this.productHiboutikRepository.getProducts(),
|
||||||
this.stockHiboutikRepository.getStocks(),
|
this.stockHiboutikRepository.getStocks(),
|
||||||
this.productDatabaseRepository.getAll(),
|
this.productDatabaseRepository.getAll(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Deletion happens before the upserts, so that order lines of a deleted product are unlinked before anything else changes
|
// 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.
|
||||||
const nbDeleted = await this.deleteMissingProducts(productsHiboutik, existingProducts);
|
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<number, StockHiboutikDto[]>();
|
|
||||||
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;
|
let nbFailed = 0;
|
||||||
for (const productHiboutik of productsHiboutik) {
|
for (const productHiboutik of productsHiboutik) {
|
||||||
const hiboutikProductStocks = stocksByProduct.get(productHiboutik.product_id) ?? [];
|
const hiboutikProductStocks = hiboutikStocks.filter((stock) => stock.product_id === Number(productHiboutik.product_id));
|
||||||
try {
|
try {
|
||||||
await this.updateProduct(productHiboutik, hiboutikProductStocks);
|
await this.updateProduct(productHiboutik, hiboutikProductStocks);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
rethrowIfRateLimited(error);
|
|
||||||
nbFailed++;
|
nbFailed++;
|
||||||
this.logger.error(`Failed to sync Hiboutik product ${productHiboutik.product_id}`, (error as Error)?.stack);
|
this.logger.error(`Failed to sync Hiboutik product ${productHiboutik.product_id}`, (error as Error)?.stack);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.slugService.assignMissingSlugs(SlugEntityType.PRODUCT);
|
|
||||||
this.logger.log(
|
this.logger.log(
|
||||||
`Products sync ended: ${productsHiboutik.length - nbFailed}/${productsHiboutik.length} synced, ${nbFailed} failed, ${nbDeleted} deleted.`,
|
`Products sync ended: ${productsHiboutik.length - nbFailed}/${productsHiboutik.length} synced, ${nbFailed} failed, ${nbDeleted} deleted.`,
|
||||||
);
|
);
|
||||||
@@ -69,36 +56,33 @@ export class ProductHiboutikService {
|
|||||||
|
|
||||||
/** @returns the number of deleted products. */
|
/** @returns the number of deleted products. */
|
||||||
private async deleteMissingProducts(productsHiboutik: ProductHiboutikDto[], existingProducts: Product[]): Promise<number> {
|
private async deleteMissingProducts(productsHiboutik: ProductHiboutikDto[], existingProducts: Product[]): Promise<number> {
|
||||||
// Hiboutik is trusted: a successful (2xx) answer, even an empty list, is the truth. An HTTP error throws before this point.
|
// Safety net: an empty answer from Hiboutik most likely means an API issue, not an empty catalog
|
||||||
const hiboutikIds = new Set(productsHiboutik.map((product) => product.product_id));
|
if (productsHiboutik.length === 0 && existingProducts.length > 0) {
|
||||||
|
this.logger.warn(`Hiboutik returned no products while ${existingProducts.length} exist in database: deletion skipped.`);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
const hiboutikIds = new Set(productsHiboutik.map((product) => Number(product.product_id)));
|
||||||
const missingProductIds = existingProducts.filter((product) => !hiboutikIds.has(product.hiboutikId)).map((product) => product.id);
|
const missingProductIds = existingProducts.filter((product) => !hiboutikIds.has(product.hiboutikId)).map((product) => product.id);
|
||||||
return this.productDatabaseRepository.deleteAll(missingProductIds);
|
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<void> {
|
private async updateProduct(productHiboutik: ProductHiboutikDto, productStocks: StockHiboutikDto[]): Promise<void> {
|
||||||
const newProduct = this.productConverter.fromHiboutik(productHiboutik);
|
const newProduct = this.productConverter.fromHiboutik(productHiboutik);
|
||||||
newProduct.globalStock = productStocks.reduce((sum, stock) => sum + stock.stock_available, 0);
|
newProduct.globalStock = productStocks.map((stock) => Number(stock.stock_available)).reduce((sum, stock) => sum + stock, 0);
|
||||||
|
|
||||||
await this.prisma.$transaction(
|
const savedProduct: Product = await this.productDatabaseRepository.upsert(newProduct);
|
||||||
async (tx) => {
|
const versions = this.buildVersions(productHiboutik, productStocks, savedProduct);
|
||||||
const savedProduct: Product = await this.productDatabaseRepository.upsert(newProduct, tx);
|
|
||||||
const versions = this.buildVersions(productHiboutik, productStocks, savedProduct);
|
|
||||||
|
|
||||||
await this.productVersionDatabaseRepository.deleteMissing(
|
// Same reason as for products: delete removed versions first to free their barcodes
|
||||||
savedProduct.id,
|
await this.productVersionDatabaseRepository.deleteMissing(
|
||||||
versions.map((version) => version.hiboutikSizeId),
|
savedProduct.id,
|
||||||
tx,
|
versions.map((version) => version.hiboutikSizeId),
|
||||||
);
|
|
||||||
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -110,8 +94,8 @@ export class ProductHiboutikService {
|
|||||||
productStocks: StockHiboutikDto[],
|
productStocks: StockHiboutikDto[],
|
||||||
savedProduct: Product,
|
savedProduct: Product,
|
||||||
): ProductVersionCreateInput[] {
|
): ProductVersionCreateInput[] {
|
||||||
const specificRules = productHiboutik.product_specific_rules;
|
const specificRules = productHiboutik.product_specific_rules ?? [];
|
||||||
const sizesDetails = productHiboutik.product_size_details;
|
const sizesDetails = productHiboutik.product_size_details ?? [];
|
||||||
|
|
||||||
if (sizesDetails.length === 0) {
|
if (sizesDetails.length === 0) {
|
||||||
return [
|
return [
|
||||||
@@ -128,8 +112,8 @@ export class ProductHiboutikService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return sizesDetails.map((sizeDetails) => {
|
return sizesDetails.map((sizeDetails) => {
|
||||||
const sizeId = sizeDetails.size_id;
|
const sizeId = Number(sizeDetails.size_id);
|
||||||
const specificRule = specificRules.find((rule) => rule.size_id === sizeId);
|
const specificRule = specificRules.find((rule) => Number(rule.size_id) === sizeId);
|
||||||
const stock = productStocks.find((stock) => stock.product_size === sizeId);
|
const stock = productStocks.find((stock) => stock.product_size === sizeId);
|
||||||
// Values are always set explicitly (null / 0 rather than undefined), otherwise Prisma would keep
|
// 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.
|
// the previous value on update when a rule or a stock is removed in Hiboutik.
|
||||||
@@ -139,7 +123,7 @@ export class ProductHiboutikService {
|
|||||||
hiboutikSizeId: sizeId,
|
hiboutikSizeId: sizeId,
|
||||||
price: parseOptionalHiboutikPrice(specificRule?.product_price),
|
price: parseOptionalHiboutikPrice(specificRule?.product_price),
|
||||||
product: { connect: { id: savedProduct.id } },
|
product: { connect: { id: savedProduct.id } },
|
||||||
stock: stock?.stock_available ?? 0,
|
stock: stock ? Number(stock.stock_available) : 0,
|
||||||
versionName: sizeDetails.size_name,
|
versionName: sizeDetails.size_name,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -25,8 +25,12 @@ export class SupplierHiboutikService {
|
|||||||
(supplier) => `Hiboutik supplier ${supplier.hiboutikId}`,
|
(supplier) => `Hiboutik supplier ${supplier.hiboutikId}`,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Hiboutik is trusted: a successful (2xx) answer, even an empty list, is the truth. An HTTP error throws before this point.
|
// Safety net: an empty answer from Hiboutik most likely means an API issue
|
||||||
const nbDeleted = await this.supplierDatabaseRepository.deleteMissing(hiboutikSuppliers.map((dto) => dto.supplier_id));
|
if (hiboutikSuppliers.length === 0) {
|
||||||
|
this.logger.warn('Hiboutik returned no suppliers: deletion skipped.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const nbDeleted = await this.supplierDatabaseRepository.deleteMissing(hiboutikSuppliers.map((dto) => Number(dto.supplier_id)));
|
||||||
this.logger.log(
|
this.logger.log(
|
||||||
`Suppliers sync ended: ${hiboutikSuppliers.length - nbFailed}/${hiboutikSuppliers.length} synced, ${nbFailed} failed, ${nbDeleted} deleted.`,
|
`Suppliers sync ended: ${hiboutikSuppliers.length - nbFailed}/${hiboutikSuppliers.length} synced, ${nbFailed} failed, ${nbDeleted} deleted.`,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,156 +0,0 @@
|
|||||||
import { BadRequestException, ConflictException, Logger, NotFoundException } from '@nestjs/common';
|
|
||||||
import { SlugService } from './slug.service';
|
|
||||||
import { Prisma, SlugEntityType } from '../../../prisma/generated/client';
|
|
||||||
import { SlugCandidateRow } from '../../database/slug.database.repository';
|
|
||||||
|
|
||||||
const row = (hiboutikId: number, name: string, parentName: string | null = null): SlugCandidateRow => ({
|
|
||||||
id: `uuid-${hiboutikId}`,
|
|
||||||
hiboutikId,
|
|
||||||
name,
|
|
||||||
parentName,
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('SlugService', () => {
|
|
||||||
const slugDatabaseRepository = {
|
|
||||||
getWithoutSlug: jest.fn(),
|
|
||||||
getTakenSlugs: jest.fn(),
|
|
||||||
setFirstSlug: jest.fn(),
|
|
||||||
changeSlug: jest.fn(),
|
|
||||||
findRedirect: jest.fn(),
|
|
||||||
};
|
|
||||||
let service: SlugService;
|
|
||||||
|
|
||||||
beforeAll(() => {
|
|
||||||
jest.spyOn(Logger.prototype, 'log').mockImplementation(() => undefined);
|
|
||||||
jest.spyOn(Logger.prototype, 'warn').mockImplementation(() => undefined);
|
|
||||||
jest.spyOn(Logger.prototype, 'error').mockImplementation(() => undefined);
|
|
||||||
});
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
jest.clearAllMocks();
|
|
||||||
slugDatabaseRepository.getTakenSlugs.mockResolvedValue(new Set());
|
|
||||||
slugDatabaseRepository.setFirstSlug.mockResolvedValue(true);
|
|
||||||
service = new SlugService(slugDatabaseRepository as never);
|
|
||||||
});
|
|
||||||
|
|
||||||
/** Slugs given by assignMissingSlugs, by entity id. */
|
|
||||||
const assigned = (): Record<string, string> =>
|
|
||||||
Object.fromEntries((slugDatabaseRepository.setFirstSlug.mock.calls as [string, string, string][]).map(([, id, slug]) => [id, slug]));
|
|
||||||
|
|
||||||
describe('assignMissingSlugs', () => {
|
|
||||||
it('gives the plain slug to the oldest of two homonyms, the Hiboutik id to the other', async () => {
|
|
||||||
slugDatabaseRepository.getWithoutSlug.mockResolvedValue([row(3, 'Stray Kids'), row(8, 'STRAY KIDS')]);
|
|
||||||
|
|
||||||
await service.assignMissingSlugs(SlugEntityType.BRAND);
|
|
||||||
|
|
||||||
expect(assigned()).toEqual({ 'uuid-3': 'stray-kids', 'uuid-8': 'stray-kids-8' });
|
|
||||||
});
|
|
||||||
|
|
||||||
it('does not take a slug already used, by an entity or a redirect', async () => {
|
|
||||||
slugDatabaseRepository.getTakenSlugs.mockResolvedValue(new Set(['bts']));
|
|
||||||
slugDatabaseRepository.getWithoutSlug.mockResolvedValue([row(5, 'BTS')]);
|
|
||||||
|
|
||||||
await service.assignMissingSlugs(SlugEntityType.BRAND);
|
|
||||||
|
|
||||||
expect(assigned()).toEqual({ 'uuid-5': 'bts-5' });
|
|
||||||
});
|
|
||||||
|
|
||||||
it('suffixes a homonym category or product with its Hiboutik id', async () => {
|
|
||||||
slugDatabaseRepository.getTakenSlugs.mockResolvedValue(new Set(['albums']));
|
|
||||||
slugDatabaseRepository.getWithoutSlug.mockResolvedValue([row(4, 'Albums'), row(6, 'ATE (Digipack)')]);
|
|
||||||
|
|
||||||
await service.assignMissingSlugs(SlugEntityType.CATEGORY);
|
|
||||||
|
|
||||||
expect(assigned()).toEqual({ 'uuid-4': 'albums-4', 'uuid-6': 'ate-digipack' });
|
|
||||||
});
|
|
||||||
|
|
||||||
it('prefixes a homonym sub-category with its parent before falling back to the Hiboutik id', async () => {
|
|
||||||
slugDatabaseRepository.getTakenSlugs.mockResolvedValue(new Set(['albums']));
|
|
||||||
slugDatabaseRepository.getWithoutSlug.mockResolvedValue([
|
|
||||||
row(4, 'Albums', 'Stray Kids'),
|
|
||||||
row(5, 'Albums', 'BTS'),
|
|
||||||
row(6, 'Albums', 'Stray Kids'),
|
|
||||||
row(7, 'Albums'),
|
|
||||||
]);
|
|
||||||
|
|
||||||
await service.assignMissingSlugs(SlugEntityType.CATEGORY);
|
|
||||||
|
|
||||||
expect(assigned()).toEqual({
|
|
||||||
'uuid-4': 'stray-kids-albums',
|
|
||||||
'uuid-5': 'bts-albums',
|
|
||||||
'uuid-6': 'albums-6',
|
|
||||||
'uuid-7': 'albums-7',
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('skips the parent prefix when it does not help', async () => {
|
|
||||||
slugDatabaseRepository.getTakenSlugs.mockResolvedValue(new Set(['bts-albums', 'goods', 'photocards']));
|
|
||||||
slugDatabaseRepository.getWithoutSlug.mockResolvedValue([
|
|
||||||
row(1, 'BTS Albums', 'BTS'), // already starts with the parent
|
|
||||||
row(2, 'Goods', '방탄소년단'), // parent without latin letters
|
|
||||||
row(3, 'Photocards', 'x'.repeat(75)), // too long to keep the whole name
|
|
||||||
]);
|
|
||||||
|
|
||||||
await service.assignMissingSlugs(SlugEntityType.CATEGORY);
|
|
||||||
|
|
||||||
expect(assigned()).toEqual({ 'uuid-1': 'bts-albums-1', 'uuid-2': 'goods-2', 'uuid-3': 'photocards-3' });
|
|
||||||
});
|
|
||||||
|
|
||||||
it('falls back to the type and Hiboutik id for a name without latin letters', async () => {
|
|
||||||
slugDatabaseRepository.getWithoutSlug.mockResolvedValue([row(9, '방탄소년단')]);
|
|
||||||
|
|
||||||
await service.assignMissingSlugs(SlugEntityType.BRAND);
|
|
||||||
|
|
||||||
expect(assigned()).toEqual({ 'uuid-9': 'brand-9' });
|
|
||||||
});
|
|
||||||
|
|
||||||
it('keeps going when one slug fails, and never throws', async () => {
|
|
||||||
slugDatabaseRepository.getWithoutSlug.mockResolvedValue([row(1, 'A'), row(2, 'B')]);
|
|
||||||
slugDatabaseRepository.setFirstSlug.mockRejectedValueOnce(new Error('boom'));
|
|
||||||
|
|
||||||
await expect(service.assignMissingSlugs(SlugEntityType.BRAND)).resolves.toBeUndefined();
|
|
||||||
expect(slugDatabaseRepository.setFirstSlug).toHaveBeenCalledTimes(2);
|
|
||||||
|
|
||||||
slugDatabaseRepository.getWithoutSlug.mockRejectedValue(new Error('database down'));
|
|
||||||
await expect(service.assignMissingSlugs(SlugEntityType.BRAND)).resolves.toBeUndefined();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('changeSlug', () => {
|
|
||||||
it('rejects an invalid slug without touching the database', async () => {
|
|
||||||
await expect(service.changeSlug(SlugEntityType.BRAND, 'uuid-1', 'Stray Kids')).rejects.toThrow(BadRequestException);
|
|
||||||
await expect(service.changeSlug(SlugEntityType.BRAND, 'uuid-1', 'a'.repeat(81))).rejects.toThrow(BadRequestException);
|
|
||||||
expect(slugDatabaseRepository.changeSlug).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('answers 404 for an unknown entity and 409 for a slug in use, even when written concurrently', async () => {
|
|
||||||
slugDatabaseRepository.changeSlug.mockResolvedValueOnce('NOT_FOUND');
|
|
||||||
await expect(service.changeSlug(SlugEntityType.BRAND, 'uuid-1', 'bts')).rejects.toThrow(NotFoundException);
|
|
||||||
|
|
||||||
slugDatabaseRepository.changeSlug.mockResolvedValueOnce('TAKEN');
|
|
||||||
await expect(service.changeSlug(SlugEntityType.BRAND, 'uuid-1', 'bts')).rejects.toThrow(ConflictException);
|
|
||||||
|
|
||||||
slugDatabaseRepository.changeSlug.mockRejectedValueOnce(
|
|
||||||
new Prisma.PrismaClientKnownRequestError('Unique constraint failed', { code: 'P2002', clientVersion: 'test' }),
|
|
||||||
);
|
|
||||||
await expect(service.changeSlug(SlugEntityType.BRAND, 'uuid-1', 'bts')).rejects.toThrow(ConflictException);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('rethrows any other error, even one carrying a P2002 code without being a Prisma error', async () => {
|
|
||||||
const notPrisma = Object.assign(new Error('boom'), { code: 'P2002' });
|
|
||||||
slugDatabaseRepository.changeSlug.mockRejectedValueOnce(notPrisma);
|
|
||||||
await expect(service.changeSlug(SlugEntityType.BRAND, 'uuid-1', 'bts')).rejects.toBe(notPrisma);
|
|
||||||
|
|
||||||
const otherPrismaError = new Prisma.PrismaClientKnownRequestError('Record not found', { code: 'P2025', clientVersion: 'test' });
|
|
||||||
slugDatabaseRepository.changeSlug.mockRejectedValueOnce(otherPrismaError);
|
|
||||||
await expect(service.changeSlug(SlugEntityType.BRAND, 'uuid-1', 'bts')).rejects.toBe(otherPrismaError);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('accepts a valid new slug', async () => {
|
|
||||||
slugDatabaseRepository.changeSlug.mockResolvedValue('CHANGED');
|
|
||||||
|
|
||||||
await expect(service.changeSlug(SlugEntityType.PRODUCT, 'uuid-1', 'stray-kids-ate')).resolves.toBeUndefined();
|
|
||||||
expect(slugDatabaseRepository.changeSlug).toHaveBeenCalledWith('PRODUCT', 'uuid-1', 'stray-kids-ate');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,113 +0,0 @@
|
|||||||
import { BadRequestException, ConflictException, Injectable, Logger, NotFoundException } from '@nestjs/common';
|
|
||||||
import { Prisma, SlugEntityType } from '../../../prisma/generated/client';
|
|
||||||
import { SlugCandidateRow, SlugDatabaseRepository } from '../../database/slug.database.repository';
|
|
||||||
import { forEachSafely } from '../../utils/sync';
|
|
||||||
import { MAX_SLUG_LENGTH, pickAvailableSlug, SLUG_PATTERN, slugify, withSlugSuffix } from '../../utils/slug';
|
|
||||||
|
|
||||||
/** Prefix of the slug of an entity whose name gives no slug (no latin letter or digit, e.g. a name in hangul). */
|
|
||||||
const FALLBACK_PREFIX: Record<SlugEntityType, string> = {
|
|
||||||
[SlugEntityType.BRAND]: 'brand',
|
|
||||||
[SlugEntityType.CATEGORY]: 'category',
|
|
||||||
[SlugEntityType.PRODUCT]: 'product',
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Slugs (SEO URL identifiers) of brands, categories and products.
|
|
||||||
*
|
|
||||||
* A slug is generated once, when an entity has none, and is never regenerated: a URL must not change when the name
|
|
||||||
* changes in Hiboutik. Generation is deterministic: entities are handled oldest first (by `hiboutikId`), so that a
|
|
||||||
* database rebuilt from scratch gets the same slugs, the oldest of two homonyms keeping the plain slug.
|
|
||||||
* A rebuild only gives back the stored slugs as long as their history did not change them: a slug replaced by an admin,
|
|
||||||
* an entity renamed in Hiboutik after its slug was generated, or a homonym deleted since (the younger one, suffixed,
|
|
||||||
* would now get the plain slug) all give a different slug on a rebuild, and the redirects are lost with the database.
|
|
||||||
* An admin can replace a slug ({@link changeSlug}): the former one is kept as a redirect.
|
|
||||||
*/
|
|
||||||
@Injectable()
|
|
||||||
export class SlugService {
|
|
||||||
private readonly logger = new Logger('SlugService');
|
|
||||||
|
|
||||||
constructor(private readonly slugDatabaseRepository: SlugDatabaseRepository) {}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Gives a slug to every entity of this type that has none. Never throws: a failure is logged, the entities left
|
|
||||||
* without a slug get one on the next sync.
|
|
||||||
*/
|
|
||||||
async assignMissingSlugs(type: SlugEntityType): Promise<void> {
|
|
||||||
try {
|
|
||||||
const [rows, taken] = await Promise.all([
|
|
||||||
this.slugDatabaseRepository.getWithoutSlug(type),
|
|
||||||
this.slugDatabaseRepository.getTakenSlugs(type),
|
|
||||||
]);
|
|
||||||
const nbFailed = await forEachSafely(
|
|
||||||
rows,
|
|
||||||
async (row) => {
|
|
||||||
const candidates = this.buildCandidates(type, row);
|
|
||||||
const slug = pickAvailableSlug(candidates, taken);
|
|
||||||
if (slug !== candidates[0]) {
|
|
||||||
// Often a duplicate in Hiboutik ("Stray Kids" and "STRAY KIDS"): worth a look in the back office
|
|
||||||
this.logger.warn(`Slug "${candidates[0]}" already taken: Hiboutik ${type.toLowerCase()} ${row.hiboutikId} gets "${slug}"`);
|
|
||||||
}
|
|
||||||
if (await this.slugDatabaseRepository.setFirstSlug(type, row.id, slug)) taken.add(slug);
|
|
||||||
},
|
|
||||||
this.logger,
|
|
||||||
(row) => `slug of Hiboutik ${type.toLowerCase()} ${row.hiboutikId}`,
|
|
||||||
);
|
|
||||||
if (rows.length > 0) {
|
|
||||||
this.logger.log(`Slugs of ${type.toLowerCase()} entities: ${rows.length - nbFailed}/${rows.length} generated, ${nbFailed} failed.`);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
this.logger.error(`Failed to generate the slugs of ${type.toLowerCase()} entities`, (error as Error)?.stack);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Candidate slugs by order of preference: the name ("albums"); for a sub-category, the name prefixed with its parent's
|
|
||||||
* ("stray-kids-albums"), homonyms being common across the tree; then the name suffixed with the Hiboutik id
|
|
||||||
* ("albums-12"), unique as the id is.
|
|
||||||
*/
|
|
||||||
private buildCandidates(type: SlugEntityType, row: SlugCandidateRow): string[] {
|
|
||||||
const base = slugify(row.name);
|
|
||||||
if (!base) return [`${FALLBACK_PREFIX[type]}-${row.hiboutikId}`];
|
|
||||||
const withParent = this.withParentPrefix(base, row.parentName);
|
|
||||||
return [base, ...(withParent ? [withParent] : []), withSlugSuffix(base, row.hiboutikId)];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* `<parent>-<name>`, or null when it would not help: no parent, a parent without latin letters, a name that already
|
|
||||||
* starts with the parent's ("BTS Albums" under "BTS"), or a result too long to keep the whole name.
|
|
||||||
*/
|
|
||||||
private withParentPrefix(base: string, parentName: string | null): string | null {
|
|
||||||
const parent = parentName ? slugify(parentName) : '';
|
|
||||||
if (!parent || base === parent || base.startsWith(`${parent}-`)) return null;
|
|
||||||
const slug = `${parent}-${base}`;
|
|
||||||
return slug.length <= MAX_SLUG_LENGTH ? slug : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Replaces the slug of an entity (admin choice); its former slug keeps answering through a redirect.
|
|
||||||
* @throws BadRequestException for an invalid slug, NotFoundException for an unknown entity,
|
|
||||||
* ConflictException when another entity of the same type uses this slug.
|
|
||||||
*/
|
|
||||||
async changeSlug(type: SlugEntityType, id: string, slug: string): Promise<void> {
|
|
||||||
if (slug.length > MAX_SLUG_LENGTH || !SLUG_PATTERN.test(slug)) {
|
|
||||||
throw new BadRequestException(
|
|
||||||
`A slug is made of lowercase letters, digits and single hyphens, ${MAX_SLUG_LENGTH} characters at most`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
let result: Awaited<ReturnType<SlugDatabaseRepository['changeSlug']>>;
|
|
||||||
try {
|
|
||||||
result = await this.slugDatabaseRepository.changeSlug(type, id, slug);
|
|
||||||
} catch (error) {
|
|
||||||
// Unique constraint: the same slug was written at the same time by someone else
|
|
||||||
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === 'P2002') result = 'TAKEN';
|
|
||||||
else throw error;
|
|
||||||
}
|
|
||||||
if (result === 'NOT_FOUND') throw new NotFoundException();
|
|
||||||
if (result === 'TAKEN') throw new ConflictException('This slug is already used');
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @returns the current slug of the entity that `slug` used to name, null when it is not a former slug. */
|
|
||||||
async findRedirect(type: SlugEntityType, slug: string): Promise<string | null> {
|
|
||||||
return this.slugDatabaseRepository.findRedirect(type, slug);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
import { Controller, Get, Header } from '@nestjs/common';
|
|
||||||
import { Sitemap, SitemapDatabaseRepository } from './database/sitemap.database.repository';
|
|
||||||
|
|
||||||
@Controller()
|
|
||||||
export class SitemapController {
|
|
||||||
constructor(private readonly sitemapDatabaseRepository: SitemapDatabaseRepository) {}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Public pages (slug and date of the last change of their content) of brands, categories and products, for the
|
|
||||||
* storefront to build its sitemap.xml. Pages without a slug yet are left out.
|
|
||||||
*/
|
|
||||||
@Get('sitemap')
|
|
||||||
@Header('Cache-Control', 'public, max-age=600')
|
|
||||||
async getSitemap(): Promise<Sitemap> {
|
|
||||||
return this.sitemapDatabaseRepository.getSitemap();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,35 +1,15 @@
|
|||||||
import { parseHiboutikDate } from './date';
|
import { parseHiboutikDate } from './date';
|
||||||
|
|
||||||
describe('parseHiboutikDate', () => {
|
describe('parseHiboutikDate', () => {
|
||||||
it('parses a Hiboutik datetime as French summer time (UTC+2)', () => {
|
it('parses a Hiboutik datetime', () => {
|
||||||
expect(parseHiboutikDate('2026-06-28 17:02:55')).toEqual(new Date('2026-06-28T15:02:55Z'));
|
expect(parseHiboutikDate('2026-06-28 17:02:55')).toEqual(new Date('2026-06-28T17:02:55'));
|
||||||
});
|
});
|
||||||
|
|
||||||
it('parses a Hiboutik datetime as French winter time (UTC+1)', () => {
|
it('parses a date without time', () => {
|
||||||
expect(parseHiboutikDate('2026-01-15 10:00:00')).toEqual(new Date('2026-01-15T09:00:00Z'));
|
expect(parseHiboutikDate('1990-05-12')).toEqual(new Date('1990-05-12'));
|
||||||
});
|
});
|
||||||
|
|
||||||
it('switches offset at the DST changes', () => {
|
it.each(['', '0000-00-00', '0000-00-00 00:00:00', 'not a date', null, undefined])('returns undefined for %p', (value) => {
|
||||||
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();
|
expect(parseHiboutikDate(value)).toBeUndefined();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,53 +1,10 @@
|
|||||||
/** 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. Returns undefined for empty or invalid dates (e.g. "0000-00-00", "2026-02-30").
|
* Parses a Hiboutik date ("YYYY-MM-DD HH:mm:ss" or "YYYY-MM-DD").
|
||||||
* - "YYYY-MM-DD HH:mm:ss": French local time ({@link HIBOUTIK_TIME_ZONE}), whatever the time zone of the server.
|
* Returns undefined for empty or invalid dates (e.g. "0000-00-00").
|
||||||
* 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 {
|
export function parseHiboutikDate(value: string | null | undefined): Date | undefined {
|
||||||
const match = HIBOUTIK_DATE.exec(value?.trim() ?? '');
|
if (!value) return undefined;
|
||||||
if (!match) return undefined;
|
|
||||||
|
|
||||||
const [year, month, day, hour, minute, second] = match.slice(1).map((part) => Number(part ?? 0));
|
const date = new Date(value.trim().replace(' ', 'T'));
|
||||||
const wallClockAsUtc = Date.UTC(year, month - 1, day, hour, minute, second);
|
return isNaN(date.getTime()) ? undefined : date;
|
||||||
|
|
||||||
// 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,48 +0,0 @@
|
|||||||
import { MAX_SLUG_LENGTH, pickAvailableSlug, SLUG_PATTERN, slugify, withSlugSuffix } from './slug';
|
|
||||||
|
|
||||||
describe('slugify', () => {
|
|
||||||
it.each([
|
|
||||||
['Stray Kids', 'stray-kids'],
|
|
||||||
['STRAY KIDS', 'stray-kids'],
|
|
||||||
['Stray-Kids', 'stray-kids'],
|
|
||||||
['(G)I-DLE', 'g-i-dle'],
|
|
||||||
['NCT 127', 'nct-127'],
|
|
||||||
['&TEAM', 'and-team'],
|
|
||||||
['Œuvres complètes été', 'oeuvres-completes-ete'],
|
|
||||||
[' BTS - Proof ★ ', 'bts-proof'],
|
|
||||||
['NCT DREAM', 'nct-dream'],
|
|
||||||
['방탄소년단', ''],
|
|
||||||
])('%s -> "%s"', (name, slug) => {
|
|
||||||
expect(slugify(name)).toBe(slug);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('cuts a long name at a word boundary', () => {
|
|
||||||
const slug = slugify('word '.repeat(40));
|
|
||||||
expect(slug.length).toBeLessThanOrEqual(MAX_SLUG_LENGTH);
|
|
||||||
expect(slug).toMatch(SLUG_PATTERN);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('keeps the last word when the cut falls right before a hyphen', () => {
|
|
||||||
expect(slugify('aa bbb ccc', 6)).toBe('aa-bbb');
|
|
||||||
expect(slugify('aa bbb ccc', 7)).toBe('aa-bbb');
|
|
||||||
expect(slugify('aa bbb ccc', 5)).toBe('aa');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('withSlugSuffix', () => {
|
|
||||||
it('keeps the result within the maximum length', () => {
|
|
||||||
const slug = withSlugSuffix('a'.repeat(MAX_SLUG_LENGTH), 12345);
|
|
||||||
expect(slug.length).toBeLessThanOrEqual(MAX_SLUG_LENGTH);
|
|
||||||
expect(slug.endsWith('-12345')).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('pickAvailableSlug', () => {
|
|
||||||
it('picks the first free candidate', () => {
|
|
||||||
expect(pickAvailableSlug(['albums', 'bts-albums', 'albums-12'], new Set(['albums']))).toBe('bts-albums');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('numbers the last candidate when they are all taken', () => {
|
|
||||||
expect(pickAvailableSlug(['albums', 'albums-12'], new Set(['albums', 'albums-12', 'albums-12-2']))).toBe('albums-12-3');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,59 +0,0 @@
|
|||||||
/** Longest slug generated, so that URLs stay readable. */
|
|
||||||
export const MAX_SLUG_LENGTH = 80;
|
|
||||||
|
|
||||||
/** Valid slug: lowercase ASCII words separated by single hyphens. */
|
|
||||||
export const SLUG_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
|
||||||
|
|
||||||
/** Letters that Unicode normalization does not split into a base letter and an accent. */
|
|
||||||
const SPECIAL_LETTERS: Record<string, string> = { æ: 'ae', œ: 'oe', ø: 'o', ß: 'ss', đ: 'd', ł: 'l', þ: 'th' };
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Turns a name into a slug: accents removed, lowercase, every run of other characters replaced by one hyphen,
|
|
||||||
* cut at a word boundary to {@link MAX_SLUG_LENGTH}. "&" becomes "and" ("&TEAM" -> "and-team", not "team").
|
|
||||||
* @returns an empty string when nothing is left (name without any latin letter or digit, e.g. in hangul).
|
|
||||||
*/
|
|
||||||
export function slugify(name: string, maxLength = MAX_SLUG_LENGTH): string {
|
|
||||||
const slug = name
|
|
||||||
.normalize('NFKD')
|
|
||||||
.replace(/[\u0300-\u036f]/g, '')
|
|
||||||
.toLowerCase()
|
|
||||||
.replace(/[æœøßđłþ]/g, (letter) => SPECIAL_LETTERS[letter])
|
|
||||||
.replace(/&/g, ' and ')
|
|
||||||
.replace(/[^a-z0-9]+/g, '-')
|
|
||||||
.replace(/^-+|-+$/g, '');
|
|
||||||
return truncateSlug(slug, maxLength);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Cuts a slug to `maxLength` at a word boundary: keeps the cut as is when it ends on a whole word, else cuts at its last
|
|
||||||
* hyphen when there is one. Never leaves a trailing hyphen.
|
|
||||||
*/
|
|
||||||
function truncateSlug(slug: string, maxLength: number): string {
|
|
||||||
if (slug.length <= maxLength) return slug;
|
|
||||||
const cut = slug.slice(0, maxLength);
|
|
||||||
if (slug[maxLength] === '-') return cut;
|
|
||||||
const lastHyphen = cut.lastIndexOf('-');
|
|
||||||
return (lastHyphen > 0 ? cut.slice(0, lastHyphen) : cut).replace(/-+$/, '');
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Appends `-suffix` to a slug, shortening the slug so that the result fits in {@link MAX_SLUG_LENGTH}. */
|
|
||||||
export function withSlugSuffix(slug: string, suffix: string | number): string {
|
|
||||||
const end = `-${suffix}`;
|
|
||||||
return truncateSlug(slug, MAX_SLUG_LENGTH - end.length) + end;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Picks the first candidate not in `taken`. When they are all taken, numbers the last one (`-2`, `-3`...)
|
|
||||||
* until a free slug is found.
|
|
||||||
* @param candidates slugs by order of preference, at least one, none empty.
|
|
||||||
*/
|
|
||||||
export function pickAvailableSlug(candidates: string[], taken: ReadonlySet<string>): string {
|
|
||||||
const free = candidates.find((candidate) => !taken.has(candidate));
|
|
||||||
if (free) return free;
|
|
||||||
|
|
||||||
const last = candidates[candidates.length - 1];
|
|
||||||
for (let n = 2; ; n++) {
|
|
||||||
const numbered = withSlugSuffix(last, n);
|
|
||||||
if (!taken.has(numbered)) return numbered;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,12 +1,7 @@
|
|||||||
import { Logger } from '@nestjs/common';
|
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.
|
* 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
|
|
||||||
* @param describe label of an item in the error log, e.g. "Hiboutik brand 12".
|
* @param describe label of an item in the error log, e.g. "Hiboutik brand 12".
|
||||||
* @returns the number of failed items.
|
* @returns the number of failed items.
|
||||||
*/
|
*/
|
||||||
@@ -21,7 +16,6 @@ export async function forEachSafely<T>(
|
|||||||
try {
|
try {
|
||||||
await action(item);
|
await action(item);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
rethrowIfRateLimited(error);
|
|
||||||
nbFailed++;
|
nbFailed++;
|
||||||
logger.error(`Failed to sync ${describe(item)}`, (error as Error)?.stack);
|
logger.error(`Failed to sync ${describe(item)}`, (error as Error)?.stack);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,24 +10,14 @@ import { PrismaService } from './../prisma/prisma.service';
|
|||||||
describe('AppController (e2e)', () => {
|
describe('AppController (e2e)', () => {
|
||||||
let app: INestApplication<App>;
|
let app: INestApplication<App>;
|
||||||
const hiboutikService = { startSync: jest.fn() };
|
const hiboutikService = { startSync: jest.fn() };
|
||||||
// Rate limiting counters (ThrottlerDatabaseStorage): the database answers "not blocked" unless a test says otherwise
|
|
||||||
const notBlocked = { totalHits: 1, timeToExpire: 60, isBlocked: false, timeToBlockExpire: 0 };
|
|
||||||
const prisma = {
|
|
||||||
$queryRaw: jest.fn(),
|
|
||||||
throttlerRecord: { deleteMany: jest.fn().mockResolvedValue({ count: 0 }) },
|
|
||||||
brand: { findMany: jest.fn() },
|
|
||||||
category: { findMany: jest.fn() },
|
|
||||||
product: { findMany: jest.fn() },
|
|
||||||
};
|
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
hiboutikService.startSync.mockReset();
|
hiboutikService.startSync.mockReset();
|
||||||
prisma.$queryRaw.mockReset().mockResolvedValue([notBlocked]);
|
|
||||||
const moduleFixture: TestingModule = await Test.createTestingModule({
|
const moduleFixture: TestingModule = await Test.createTestingModule({
|
||||||
imports: [AppModule],
|
imports: [AppModule],
|
||||||
})
|
})
|
||||||
.overrideProvider(PrismaService)
|
.overrideProvider(PrismaService)
|
||||||
.useValue(prisma)
|
.useValue({})
|
||||||
.overrideProvider(HiboutikService)
|
.overrideProvider(HiboutikService)
|
||||||
.useValue(hiboutikService)
|
.useValue(hiboutikService)
|
||||||
.compile();
|
.compile();
|
||||||
@@ -37,53 +27,15 @@ describe('AppController (e2e)', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('POST /sync starts a synchronization (202)', () => {
|
it('POST /sync starts a synchronization (202)', () => {
|
||||||
hiboutikService.startSync.mockResolvedValue(true);
|
hiboutikService.startSync.mockReturnValue(true);
|
||||||
return request(app.getHttpServer()).post('/sync').expect(202).expect({ message: 'Hiboutik synchronization started' });
|
return request(app.getHttpServer()).post('/sync').expect(202).expect({ message: 'Hiboutik synchronization started' });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('POST /sync answers 409 when a synchronization is already running', () => {
|
it('POST /sync answers 409 when a synchronization is already running', () => {
|
||||||
hiboutikService.startSync.mockResolvedValue(false);
|
hiboutikService.startSync.mockReturnValue(false);
|
||||||
return request(app.getHttpServer()).post('/sync').expect(409);
|
return request(app.getHttpServer()).post('/sync').expect(409);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('POST /sync answers 429 when the shared rate limiting counter is blocked', async () => {
|
|
||||||
prisma.$queryRaw.mockResolvedValue([{ totalHits: 6, timeToExpire: 30, isBlocked: true, timeToBlockExpire: 30 }]);
|
|
||||||
|
|
||||||
await request(app.getHttpServer()).post('/sync').expect(429).expect('Retry-After', '30');
|
|
||||||
expect(hiboutikService.startSync).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('GET /sitemap lists the public pages', async () => {
|
|
||||||
prisma.brand.findMany.mockResolvedValue([{ slug: 'bts' }]);
|
|
||||||
prisma.category.findMany.mockResolvedValue([{ slug: 'albums' }]);
|
|
||||||
prisma.product.findMany.mockResolvedValue([
|
|
||||||
{ slug: 'bts-proof', hiboutikUpdatedAt: new Date('2026-11-05T08:00:00Z') },
|
|
||||||
{ slug: 'bts-wings', hiboutikUpdatedAt: null },
|
|
||||||
]);
|
|
||||||
|
|
||||||
await request(app.getHttpServer())
|
|
||||||
.get('/sitemap')
|
|
||||||
.expect(200)
|
|
||||||
.expect('Cache-Control', 'public, max-age=600')
|
|
||||||
.expect({
|
|
||||||
brands: [{ slug: 'bts' }],
|
|
||||||
categories: [{ slug: 'albums' }],
|
|
||||||
products: [
|
|
||||||
{ slug: 'bts-proof', updatedAt: '2026-11-05T08:00:00.000Z' },
|
|
||||||
{ slug: 'bts-wings', updatedAt: null },
|
|
||||||
],
|
|
||||||
});
|
|
||||||
// only public rows with a slug are read; products are dated by Hiboutik, not by the last sync
|
|
||||||
expect(prisma.brand.findMany).toHaveBeenCalledWith(expect.objectContaining({ where: { slug: { not: null }, enabled: true } }));
|
|
||||||
expect(prisma.category.findMany).toHaveBeenCalledWith(expect.objectContaining({ where: { slug: { not: null }, webEnabled: true } }));
|
|
||||||
expect(prisma.product.findMany).toHaveBeenCalledWith(
|
|
||||||
expect.objectContaining({
|
|
||||||
where: { slug: { not: null }, enabled: true, archived: false },
|
|
||||||
select: { slug: true, hiboutikUpdatedAt: true },
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(async () => {
|
afterEach(async () => {
|
||||||
await app.close();
|
await app.close();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,7 +3,6 @@
|
|||||||
"rootDir": ".",
|
"rootDir": ".",
|
||||||
"testEnvironment": "node",
|
"testEnvironment": "node",
|
||||||
"testRegex": ".e2e-spec.ts$",
|
"testRegex": ".e2e-spec.ts$",
|
||||||
"setupFiles": ["<rootDir>/setup-env.ts"],
|
|
||||||
"transform": {
|
"transform": {
|
||||||
"^.+.ts$": "ts-jest",
|
"^.+.ts$": "ts-jest",
|
||||||
"^.+.js$": [
|
"^.+.js$": [
|
||||||
@@ -19,6 +18,6 @@
|
|||||||
},
|
},
|
||||||
"transformIgnorePatterns": ["/node_modules/(?!@nestjs/config/)"],
|
"transformIgnorePatterns": ["/node_modules/(?!@nestjs/config/)"],
|
||||||
"moduleNameMapper": {
|
"moduleNameMapper": {
|
||||||
"^(\\.{1,2}/.*)\\.js$": "$1"
|
"^(.{1,2}/.*).js$": "$1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +0,0 @@
|
|||||||
// The e2e test needs neither a database nor Hiboutik, but the app validates its environment at startup: provide dummy values.
|
|
||||||
process.env.DATABASE_URL ??= 'postgresql://test:test@localhost:5432/test';
|
|
||||||
process.env.HIBOUTIK_BASE_URL ??= 'https://example.hiboutik.test/api';
|
|
||||||
process.env.HIBOUTIK_USERNAME ??= 'test';
|
|
||||||
process.env.HIBOUTIK_PASSWORD ??= 'test';
|
|
||||||
@@ -16,8 +16,10 @@
|
|||||||
"baseUrl": "./",
|
"baseUrl": "./",
|
||||||
"incremental": true,
|
"incremental": true,
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
"strict": true,
|
"strictNullChecks": true,
|
||||||
"forceConsistentCasingInFileNames": true,
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"noImplicitAny": false,
|
||||||
|
"strictBindCallApply": false,
|
||||||
"noFallthroughCasesInSwitch": false
|
"noFallthroughCasesInSwitch": false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user