Compare commits

..

11 Commits

Author SHA1 Message Date
cc3dcd3f0a feat(sync): harden the Hiboutik sync, share rate limits across instances, update dependencies (#4)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 13:45:08 +00:00
8c8a2ccf15 feat(security): harden the API, trust Hiboutik for deletions, lock syncs (#3)
- 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>
2026-11-08 09:36:50 +00:00
8b341c4933 chore: add Claude Code configuration (#2)
## Summary
- Add `CLAUDE.md`: project description (K-pop shop backend, buffer in front of Hiboutik), architecture, domain notes, git workflow.
- Add shared `.claude/settings.json` (allowed dev commands, `.env` read protection).
- Pin the Prisma agent skills in `skills-lock.json` (restore with `npx skills experimental_install`, documented in the README).
- Update `.gitignore` (generated skills, local settings, Postman skills) and add `.gitattributes` (LF line endings).

## Note
This branch is based on `feat/customer-addresses-orders`, so the PR also contains the 3 feature commits (addresses, company details, orders) that `CLAUDE.md` describes.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: MastersGame <quentin.duwa1210@gmail.com>
Reviewed-on: #2
2026-11-07 12:14:08 +00:00
9dddf7562a feat(sync): synchronize customer adresses and orders from Hiboutik sales (#1)
Co-authored-by: MastersGame <quentin.duwa1210@gmail.com>
Reviewed-on: #1
2026-11-07 09:56:53 +00:00
MastersGame
c5f65dcb62 feat(sync): synchronize Hiboutik data through POST /sync
POST /sync starts the full synchronization in the background (202, or 409 when one is already running). Entities are synced in dependency order: brands, categories, suppliers, products (with versions and stocks), customers. Each row is synced independently so that a failure is logged without stopping the rest. Replaces the default AppService.
2026-11-05 23:00:49 +01:00
MastersGame
27d0e0149d feat(db): add database repositories
Upserts keyed on hiboutikId and deletion of the rows that no longer exist in Hiboutik.
2026-11-05 23:00:48 +01:00
MastersGame
db699822f9 feat(sync): add converters from Hiboutik DTOs to Prisma inputs
Includes helpers for Hiboutik dates ("0000-00-00" is ignored) and optional prices ("0.00" means no value).
2026-11-05 23:00:48 +01:00
MastersGame
2424a086e0 feat(hiboutik): add Hiboutik API client, repositories and DTOs
HiboutikClient handles Basic auth and walks paginated endpoints. One read-only repository per entity (brands, categories, suppliers, products, stocks, customers).
2026-11-05 23:00:47 +01:00
MastersGame
8f377a1fe9 feat(db): add Prisma schema and global PrismaModule
Models mirror Hiboutik entities (categories tree, brands, suppliers, products and their versions, customers) plus addresses and orders for the upcoming sync. Every synced model is matched with Hiboutik through a unique hiboutikId. The generated client is ignored by git and env files other than .env.example are ignored.
2026-11-05 23:00:46 +01:00
MastersGame
a0ddef240c build: add Prisma 7, PostgreSQL driver and @nestjs/config
Generate the Prisma client on postinstall. Jest now transpiles the ESM-only @nestjs/config and maps the .js imports of the generated Prisma client, in both the unit and e2e configurations.
2026-11-05 23:00:45 +01:00
MastersGame
24b699cfc4 chore: update prettier and eslint configuration
Use a 140 characters print width and let typescript-eslint resolve types through tsconfig.json.
2026-11-05 23:00:44 +01:00
27 changed files with 12 additions and 919 deletions

9
.gitignore vendored
View File

@@ -63,12 +63,7 @@ report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
# Claude Code personal settings (.claude/settings.json stays shareable) # Claude Code personal settings (.claude/settings.json stays shareable)
/.claude/settings.local.json /.claude/settings.local.json
# Postman: collections, specs and flows are versioned with the code. # Postman (skills fetched by `postman init`, personal workspace binding)
# Ignored: skills and SDKs (generated by the CLI), globals and environments (may hold personal values or secrets), /postman/
# and .postman/ (binding to each developer's own workspace).
/postman/*
!/postman/collections/
!/postman/specs/
!/postman/flows/
/.postman/ /.postman/
/AGENTS.md /AGENTS.md

View File

@@ -65,7 +65,6 @@ Domain notes:
- Customers: an email or phone removed in Hiboutik is erased in database (`null`, data minimization). - 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. - 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. - 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/`. - Shared Prisma payload types (e.g. `ProductWithVersions`) live in `prisma/customObjects/`.
## Input validation ## Input validation
@@ -84,7 +83,7 @@ All data coming from API clients (`@Body()`, `@Query()`, `@Param()`) must be val
## API ## 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). 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 ## Git workflow
@@ -93,5 +92,3 @@ The remote is a Gitea instance (`origin`). Use plain `git` or the Gitea CLI (`gt
- Never commit or push directly to the integration branch: always push changes on a `feature/<name>` branch. - 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`. - 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 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").

View File

@@ -1,3 +0,0 @@
$kind: collection
variables:
baseUrl: "http://localhost:3000"

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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)
@@ -34,7 +32,6 @@ model Category {
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?
@@ -101,41 +93,10 @@ model Product {
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())

View File

@@ -39,10 +39,6 @@ import { CustomerHiboutikService } from './services/hiboutik/customer.hiboutik.s
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 { 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: [
@@ -68,7 +64,7 @@ import { SitemapController } from './sitemap.controller';
}), }),
PrismaModule, PrismaModule,
], ],
controllers: [AppController, SitemapController], controllers: [AppController],
providers: [ providers: [
{ provide: APP_GUARD, useClass: ThrottlerGuard }, { provide: APP_GUARD, useClass: ThrottlerGuard },
{ provide: APP_FILTER, useClass: AllExceptionsFilter }, { provide: APP_FILTER, useClass: AllExceptionsFilter },
@@ -96,9 +92,6 @@ import { SitemapController } from './sitemap.controller';
ProductHiboutikService, ProductHiboutikService,
ProductVersionDatabaseRepository, ProductVersionDatabaseRepository,
SaleHiboutikRepository, SaleHiboutikRepository,
SitemapDatabaseRepository,
SlugDatabaseRepository,
SlugService,
StockHiboutikRepository, StockHiboutikRepository,
SupplierConverter, SupplierConverter,
SupplierDatabaseRepository, SupplierDatabaseRepository,

View File

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

View File

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

View File

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

View File

@@ -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
@@ -30,7 +27,6 @@ export class BrandHiboutikService {
// Hiboutik is trusted: a successful (2xx) answer, even an empty list, is the truth. An HTTP error throws before this point. // Hiboutik is trusted: a successful (2xx) answer, even an empty list, is the truth. An HTTP error throws before this point.
const nbDeleted = await this.brandDatabaseRepository.deleteMissing(hiboutikBrands.map((dto) => dto.brand_id)); const nbDeleted = await this.brandDatabaseRepository.deleteMissing(hiboutikBrands.map((dto) => dto.brand_id));
await this.slugService.assignMissingSlugs(SlugEntityType.BRAND);
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.`,
); );

View File

@@ -16,7 +16,6 @@ const hiboutikCategory = (id: number, parentId: number, overrides: Partial<Categ
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 +41,6 @@ describe('CategoryHiboutikService', () => {
categoryHiboutikRepository as never, categoryHiboutikRepository as never,
categoryDatabaseRepository as never, categoryDatabaseRepository as never,
new CategoryConverter(), new CategoryConverter(),
slugService as never,
); );
}); });
@@ -95,16 +93,4 @@ describe('CategoryHiboutikService', () => {
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');
});
}); });

View File

@@ -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();
@@ -42,7 +39,6 @@ export class CategoryHiboutikService {
// Hiboutik is trusted: a successful (2xx) answer, even an empty list, is the truth. An HTTP error throws before this point. // Hiboutik is trusted: a successful (2xx) answer, even an empty list, is the truth. An HTTP error throws before this point.
const nbDeleted = await this.categoryDatabaseRepository.deleteMissing(hiboutikCategories.map((dto) => dto.category_id)); const nbDeleted = await this.categoryDatabaseRepository.deleteMissing(hiboutikCategories.map((dto) => dto.category_id));
await this.slugService.assignMissingSlugs(SlugEntityType.CATEGORY);
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.`,

View File

@@ -38,7 +38,6 @@ describe('ProductHiboutikService', () => {
// The transaction client handed to the repositories inside $transaction // The transaction client handed to the repositories inside $transaction
const tx = { transaction: true }; const tx = { transaction: true };
const prisma = { $transaction: jest.fn() }; 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);
@@ -77,7 +76,6 @@ describe('ProductHiboutikService', () => {
new ProductConverter(), new ProductConverter(),
stockHiboutikRepository as never, stockHiboutikRepository as never,
prisma as never, prisma as never,
slugService as never,
); );
}); });
@@ -196,16 +194,4 @@ describe('ProductHiboutikService', () => {
await expect(prisma.$transaction.mock.results[0].value).rejects.toThrow('boom'); await expect(prisma.$transaction.mock.results[0].value).rejects.toThrow('boom');
await expect(prisma.$transaction.mock.results[1].value).resolves.toBeUndefined(); 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');
});
}); });

View File

@@ -6,12 +6,11 @@ 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 { PrismaService } from '../../../prisma/prisma.service';
import { rethrowIfRateLimited } from '../../hiboutik/hiboutik.client'; 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;
@@ -27,10 +26,9 @@ export class ProductHiboutikService {
private readonly productConverter: ProductConverter, private readonly productConverter: ProductConverter,
private readonly stockHiboutikRepository: StockHiboutikRepository, private readonly stockHiboutikRepository: StockHiboutikRepository,
private readonly prisma: PrismaService, 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() {
const [productsHiboutik, hiboutikStocks, existingProducts] = await Promise.all([ const [productsHiboutik, hiboutikStocks, existingProducts] = await Promise.all([
this.productHiboutikRepository.getProducts(), this.productHiboutikRepository.getProducts(),
@@ -61,7 +59,6 @@ export class ProductHiboutikService {
} }
} }
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.`,
); );

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -12,13 +12,7 @@ describe('AppController (e2e)', () => {
const hiboutikService = { startSync: jest.fn() }; const hiboutikService = { startSync: jest.fn() };
// Rate limiting counters (ThrottlerDatabaseStorage): the database answers "not blocked" unless a test says otherwise // 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 notBlocked = { totalHits: 1, timeToExpire: 60, isBlocked: false, timeToBlockExpire: 0 };
const prisma = { const prisma = { $queryRaw: jest.fn(), throttlerRecord: { deleteMany: jest.fn().mockResolvedValue({ count: 0 }) } };
$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();
@@ -53,37 +47,6 @@ describe('AppController (e2e)', () => {
expect(hiboutikService.startSync).not.toHaveBeenCalled(); 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();
}); });