95 lines
13 KiB
Markdown
95 lines
13 KiB
Markdown
# 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.
|
||
- 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 local `postman/` and `.postman/` folders are generated by `postman init` and are git-ignored.
|
||
|
||
## 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): ...`).
|