- Sync deletions: a 2xx Hiboutik answer is the truth, even an empty list; a failed call (HTTP error or non-list body) deletes nothing (HiboutikClient.getList). - Validate the environment at startup (HIBOUTIK_BASE_URL must be HTTPS), global ValidationPipe, Helmet, CORS opt-in, HTTP timeouts, rate limiting (60 req/min/IP, 5 on POST /sync), AllExceptionsFilter. - Postgres advisory lock so only one synchronization runs at a time across instances; syncAll is private, syncs start through startSync. - Fix the Jest moduleNameMapper; document the validation and security rules in CLAUDE.md. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
10 KiB
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
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.
Prisma: the config file is prisma7.config.ts (non-default name), so pass it explicitly:
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 time out after 30 s (HttpModule.register in AppModule).
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: PORT, CORS_ORIGINS (comma-separated; CORS stays disabled when unset) and TRUST_PROXY (number of reverse proxies, so rate limiting sees the real client IP). 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), AllExceptionsFilter (unexpected errors are logged with message + stack and answered with a generic 500; deliberate HttpExceptions and 4xx HTTP-layer errors pass through), HTTP header/request timeouts (15 s / 30 s), shutdown hooks. 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:
src/hiboutik/*.hiboutik.repository.ts– fetch raw data from Hiboutik throughHiboutikClient(get, andgetAllPages, which walks theppage param until a page smaller than 250 results, Hiboutik's page size)./suppliersand/stock_available/all_whare not paginated.src/models/hiboutikDTO/– snake_case types mirroring Hiboutik API payloads.src/models/converter/– classes extending abstractConverter<Model, Dto>;fromHiboutikmaps a DTO to a Prisma*CreateInput. Relations useconnect: { hiboutikId }.src/database/*.database.repository.ts– Prisma access, mainlyupsertkeyed on the uniquehiboutikId.src/services/hiboutik/*.hiboutik.service.ts– per-entity sync orchestration;HiboutikService.syncAllruns them in dependency order: brands → categories → suppliers → products → customers (productsconnectto the first three byhiboutikId, so order matters).
Domain notes:
- Synced models carry a unique
hiboutikId(join key with Hiboutik), exceptOrder(nullablehiboutikSaleId),ProductVersion(unique[productId, hiboutikSizeId]) andOrderLine; the internalidis a UUID. Empty Hiboutik barcodes are stored asnull.ProductVersion.barcodeis 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:
HiboutikClientthrows on HTTP errors (4xx/5xx) andgetList/getAllPagesalso throw on a 2xx body that is not a list (a 2xx without body is an empty list), so a failed step deletes nothing. List endpoints must go throughgetList/getAllPages, never a bareget. Products are deleted before the upserts (so a recreated product can reuse its barcode); their versions go by cascade, and versions missing from a product are deleted before its versions are upserted. 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).enabledmirrorscategory_enabled(POS),webEnabledmirrorscategory_enabled_www. - Products:
ProductVersionrows come fromproduct_size_details(barcode, price/discount overrides fromproduct_specific_rules, stock from the stocks endpoint); no sizes means one version withhiboutikSizeId: 0.globalStockis the sum of available stock.archivedmirrorsproduct_arch. GET /customer/:idembedsaddressesandsales(typed incustomer.hiboutik.dto.ts);salesonly has headers, lines come fromGET /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 asnull. Company details (taxNumber,companyNumber,legalStatus) come fromGET /customers_addresses/:id, only for addresses with acompany(nullotherwise, untouched if the call fails). - Orders come from the customer's
sales: sales already stored asCOMPLETEDare skipped, the others are fetched and upserted onhiboutikSaleId(source: STOREon creation only).statusisCOMPLETEDorPENDING,orderedAtis the completion date else the creation date. Lines are replaced on each upsert and snapshot name, version, barcode, price and Hiboutik ids; theProductVersionlink is optional (SetNull).unitPriceis 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 inprisma/customObjects/.
Input validation
All data coming from API clients (@Body(), @Query(), @Param()) must be validated with NestJS ValidationPipe and class-validator DTO classes.
- Register
ValidationPipeglobally inmain.tswithwhitelist: true,forbidNonWhitelisted: trueandtransform: true(noifchecks 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-validatorrules (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
Authorizationheader (credentials). Log(error as Error).messageand.stackonly, as the sync services do. No personal data (emails, names, addresses, phone numbers, tax numbers) in logs: ids only. - SQL: use the Prisma query API.
$queryRaw/$executeRaware allowed only as tagged templates with parameters ($queryRaw`... ${value}`); never$queryRawUnsafe/$executeRawUnsafenor string concatenation or interpolation into SQL text. - Sync lock: only one synchronization runs at a time across all instances.
HiboutikService.startSynctakes a Postgres advisory lock (SyncLockDatabaseRepository, dedicatedpgconnection held for the whole sync, released by Postgres itself if the process dies). Start syncs only throughstartSync, never callsyncAlldirectly 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. Untildevelopexists (early project phase), they go intomain. - Commit messages follow Conventional Commits (
feat(sync): ...,fix(db): ...).