feat(seo): generate slugs for brands, categories and products, add GET /sitemap and the Postman collection #5

Merged
quentin_duwa merged 4 commits from feature/slugs into main 2026-11-14 13:08:16 +00:00
26 changed files with 750 additions and 12 deletions
Showing only changes of commit 657e8a6b30 - Show all commits

9
.gitignore vendored
View File

@@ -63,7 +63,12 @@ report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
# Claude Code personal settings (.claude/settings.json stays shareable)
/.claude/settings.local.json
# Postman (skills fetched by `postman init`, personal workspace binding)
/postman/
# 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

View File

@@ -65,6 +65,7 @@ Domain notes:
- 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 `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. `GET /sitemap` lists the public slugs (brands `enabled`, categories `webEnabled`, products `enabled` and not `archived`) for the storefront's sitemap.xml.
- Shared Prisma payload types (e.g. `ProductWithVersions`) live in `prisma/customObjects/`.
## Input validation
@@ -83,7 +84,7 @@ All data coming from API clients (`@Body()`, `@Query()`, `@Param()`) must be val
## 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.
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
@@ -92,3 +93,5 @@ 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.
- 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").

View File

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

View File

@@ -0,0 +1,21 @@
$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", "updatedAt": "2026-11-08T10:00:00.000Z" }],
"categories": [{ "slug": "albums", "updatedAt": "2026-11-08T10:00:00.000Z" }],
"products": [{ "slug": "ate-digipack", "updatedAt": "2026-11-08T10:00:00.000Z" }]
}
order: 1000

View File

@@ -0,0 +1,16 @@
$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

@@ -0,0 +1,17 @@
$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

@@ -0,0 +1,16 @@
$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

@@ -0,0 +1,16 @@
$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

@@ -0,0 +1,16 @@
$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

@@ -0,0 +1,8 @@
$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, with the date of the last write of their row. 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

@@ -0,0 +1,26 @@
$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,6 +23,8 @@ model Category {
name String
/// Display order among its siblings (category_position)
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)
webEnabled Boolean @default(true)
@@ -32,6 +34,7 @@ model Category {
children Category[] @relation("CategoryTree")
products Product[]
slugRedirects SlugRedirect[]
@@map("categories")
}
@@ -44,8 +47,11 @@ model Brand {
enabled Boolean @default(true)
hiboutikId Int @unique
name String
/// URL identifier (SEO): generated once by the sync, never overwritten by it; null until generated
slug String? @unique
products Product[]
slugRedirects SlugRedirect[]
@@map("brands")
}
@@ -84,6 +90,8 @@ model Product {
hiboutikUpdatedAt DateTime?
name String
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)
brandId String?
@@ -93,10 +101,41 @@ model Product {
supplierId String?
versions ProductVersion[]
slugRedirects SlugRedirect[]
@@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.
model ProductVersion {
id String @id @default(uuid())

View File

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

View File

@@ -0,0 +1,45 @@
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../../prisma/prisma.service';
/** Public page of the storefront: its slug and the last change of its content. */
export interface SitemapEntry {
slug: string;
updatedAt: Date;
}
export interface Sitemap {
brands: SitemapEntry[];
categories: SitemapEntry[];
products: SitemapEntry[];
}
/** 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. Dates are the last write of the row: the sync rewrites every row, so it is the date of the last sync.
*/
async getSitemap(): Promise<Sitemap> {
const select = { slug: true, updatedAt: true } as const;
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, orderBy }),
this.prisma.category.findMany({ where: { ...hasSlug, webEnabled: true }, select, orderBy }),
this.prisma.product.findMany({ where: { ...hasSlug, enabled: true, archived: false }, select, orderBy }),
]);
return {
brands: brands.map(toEntry),
categories: categories.map(toEntry),
products: products.map(toEntry),
};
}
}
/** The query filters out null slugs; the type does not know it. */
function toEntry(row: { slug: string | null; updatedAt: Date }): SitemapEntry {
return { slug: row.slug as string, updatedAt: row.updatedAt };
}

View File

@@ -0,0 +1,115 @@
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;
}
/** 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). */
async getWithoutSlug(type: SlugEntityType): Promise<SlugCandidateRow[]> {
const where = { slug: null };
const orderBy = { hiboutikId: 'asc' } as const;
const args = { where, orderBy, select: { id: true, hiboutikId: true, name: true } };
if (type === SlugEntityType.BRAND) return this.prisma.brand.findMany(args);
if (type === SlugEntityType.CATEGORY) return this.prisma.category.findMany(args);
return this.prisma.product.findMany(args);
}
/**
* 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, and a redirect that
* held the new slug (one of its own former slugs, or another entity's) is dropped, the slug now naming this entity.
* 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';
await tx.slugRedirect.deleteMany({ where: { entityType: type, slug: newSlug } });
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,6 +3,8 @@ import { BrandHiboutikRepository } from '../../hiboutik/brand.hiboutik.repositor
import { BrandDatabaseRepository } from '../../database/brand.database.repository';
import { BrandConverter } from '../../models/converter/brand.converter';
import { forEachSafely } from '../../utils/sync';
import { SlugService } from '../slug/slug.service';
import { SlugEntityType } from '../../../prisma/generated/client';
@Injectable()
export class BrandHiboutikService {
@@ -12,9 +14,10 @@ export class BrandHiboutikService {
private readonly brandHiboutikRepository: BrandHiboutikRepository,
private readonly brandDatabaseRepository: BrandDatabaseRepository,
private readonly brandConverter: BrandConverter,
private readonly slugService: SlugService,
) {}
/** Mirrors Hiboutik brands: upserts each of them, then deletes the brands missing from Hiboutik. */
/** Mirrors Hiboutik brands: upserts each of them, deletes the brands missing from Hiboutik, then gives a slug to the new ones. */
async syncBrands() {
const hiboutikBrands = await this.brandHiboutikRepository.getBrands();
// One upsert per brand: an invalid brand must not prevent the others from being saved
@@ -27,6 +30,7 @@ export class BrandHiboutikService {
// 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));
await this.slugService.assignMissingSlugs(SlugEntityType.BRAND);
this.logger.log(
`Brands sync ended: ${hiboutikBrands.length - nbFailed}/${hiboutikBrands.length} synced, ${nbFailed} failed, ${nbDeleted} deleted.`,
);

View File

@@ -16,6 +16,7 @@ const hiboutikCategory = (id: number, parentId: number, overrides: Partial<Categ
describe('CategoryHiboutikService', () => {
const categoryHiboutikRepository = { getCategories: jest.fn() };
const categoryDatabaseRepository = { upsert: jest.fn(), setParent: jest.fn(), deleteMissing: jest.fn() };
const slugService = { assignMissingSlugs: jest.fn() };
let service: CategoryHiboutikService;
let calls: string[];
@@ -41,6 +42,7 @@ describe('CategoryHiboutikService', () => {
categoryHiboutikRepository as never,
categoryDatabaseRepository as never,
new CategoryConverter(),
slugService as never,
);
});
@@ -93,4 +95,16 @@ describe('CategoryHiboutikService', () => {
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,6 +3,8 @@ import { CategoryHiboutikRepository } from '../../hiboutik/category.hiboutik.rep
import { CategoryDatabaseRepository } from '../../database/category.database.repository';
import { CategoryConverter } from '../../models/converter/category.converter';
import { forEachSafely } from '../../utils/sync';
import { SlugService } from '../slug/slug.service';
import { SlugEntityType } from '../../../prisma/generated/client';
@Injectable()
export class CategoryHiboutikService {
@@ -12,11 +14,12 @@ export class CategoryHiboutikService {
private readonly categoryHiboutikRepository: CategoryHiboutikRepository,
private readonly categoryDatabaseRepository: CategoryDatabaseRepository,
private readonly categoryConverter: CategoryConverter,
private readonly slugService: SlugService,
) {}
/**
* Mirrors Hiboutik categories and their tree: upserts each of them, links them to their parent,
* then deletes the categories missing from Hiboutik.
* deletes the categories missing from Hiboutik, then gives a slug to the new ones.
*/
async syncCategories() {
const hiboutikCategories = await this.categoryHiboutikRepository.getCategories();
@@ -39,6 +42,7 @@ export class CategoryHiboutikService {
// 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));
await this.slugService.assignMissingSlugs(SlugEntityType.CATEGORY);
this.logger.log(
`Categories sync ended: ${hiboutikCategories.length - nbFailed}/${hiboutikCategories.length} synced, ${nbFailed} failed, ` +
`${nbParentsFailed} parent links failed, ${nbDeleted} deleted.`,

View File

@@ -38,6 +38,7 @@ describe('ProductHiboutikService', () => {
// The transaction client handed to the repositories inside $transaction
const tx = { transaction: true };
const prisma = { $transaction: jest.fn() };
const slugService = { assignMissingSlugs: jest.fn() };
beforeAll(() => {
jest.spyOn(Logger.prototype, 'log').mockImplementation(() => undefined);
@@ -76,6 +77,7 @@ describe('ProductHiboutikService', () => {
new ProductConverter(),
stockHiboutikRepository as never,
prisma as never,
slugService as never,
);
});
@@ -194,4 +196,16 @@ describe('ProductHiboutikService', () => {
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');
});
});

View File

@@ -6,11 +6,12 @@ import { ProductConverter } from '../../models/converter/product.converter';
import { StockHiboutikRepository } from '../../hiboutik/stock.hiboutik.repository';
import { ProductHiboutikDto } from '../../models/hiboutikDTO/product.hiboutik.dto';
import { StockHiboutikDto } from '../../models/hiboutikDTO/stock.hiboutik.dto';
import { Product } from '../../../prisma/generated/client';
import { Product, SlugEntityType } from '../../../prisma/generated/client';
import { ProductVersionCreateInput } from '../../../prisma/generated/models/ProductVersion';
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. */
const NO_SIZE_ID = 0;
@@ -26,9 +27,10 @@ export class ProductHiboutikService {
private readonly productConverter: ProductConverter,
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. */
/** Mirrors Hiboutik products, their versions (sizes) and stocks. Products missing from Hiboutik are deleted, new ones get a slug. */
async syncProducts() {
const [productsHiboutik, hiboutikStocks, existingProducts] = await Promise.all([
this.productHiboutikRepository.getProducts(),
@@ -59,6 +61,7 @@ export class ProductHiboutikService {
}
}
await this.slugService.assignMissingSlugs(SlugEntityType.PRODUCT);
this.logger.log(
`Products sync ended: ${productsHiboutik.length - nbFailed}/${productsHiboutik.length} synced, ${nbFailed} failed, ${nbDeleted} deleted.`,
);

View File

@@ -0,0 +1,107 @@
import { BadRequestException, ConflictException, Logger, NotFoundException } from '@nestjs/common';
import { SlugService } from './slug.service';
import { SlugEntityType } from '../../../prisma/generated/client';
import { SlugCandidateRow } from '../../database/slug.database.repository';
const row = (hiboutikId: number, name: string): SlugCandidateRow => ({ id: `uuid-${hiboutikId}`, hiboutikId, name });
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('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(Object.assign(new Error('Unique constraint failed'), { code: 'P2002' }));
await expect(service.changeSlug(SlugEntityType.BRAND, 'uuid-1', 'bts')).rejects.toThrow(ConflictException);
});
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

@@ -0,0 +1,97 @@
import { BadRequestException, ConflictException, Injectable, Logger, NotFoundException } from '@nestjs/common';
import { 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.
* 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"), then, when it is taken, 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}`];
return [base, withSlugSuffix(base, row.hiboutikId)];
}
/**
* 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 as { code?: string })?.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);
}
}

17
src/sitemap.controller.ts Normal file
View File

@@ -0,0 +1,17 @@
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();
}
}

42
src/utils/slug.spec.ts Normal file
View File

@@ -0,0 +1,42 @@
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);
});
});
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');
});
});

55
src/utils/slug.ts Normal file
View File

@@ -0,0 +1,55 @@
/** 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(/[̀-ͯ]/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 the last hyphen when there is one, never leaving a trailing hyphen. */
function truncateSlug(slug: string, maxLength: number): string {
if (slug.length <= maxLength) return slug;
const cut = slug.slice(0, maxLength);
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,7 +12,13 @@ describe('AppController (e2e)', () => {
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 }) } };
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 () => {
hiboutikService.startSync.mockReset();
@@ -47,6 +53,28 @@ describe('AppController (e2e)', () => {
expect(hiboutikService.startSync).not.toHaveBeenCalled();
});
it('GET /sitemap lists the public pages', async () => {
prisma.brand.findMany.mockResolvedValue([{ slug: 'bts', updatedAt: new Date('2026-11-01T10:00:00Z') }]);
prisma.category.findMany.mockResolvedValue([{ slug: 'albums', updatedAt: new Date('2026-10-01T10:00:00Z') }]);
prisma.product.findMany.mockResolvedValue([{ slug: 'bts-proof', updatedAt: new Date('2026-11-05T08:00:00Z') }]);
await request(app.getHttpServer())
.get('/sitemap')
.expect(200)
.expect('Cache-Control', 'public, max-age=600')
.expect({
brands: [{ slug: 'bts', updatedAt: '2026-11-01T10:00:00.000Z' }],
categories: [{ slug: 'albums', updatedAt: '2026-10-01T10:00:00.000Z' }],
products: [{ slug: 'bts-proof', updatedAt: '2026-11-05T08:00:00.000Z' }],
});
// only public rows with a slug are read
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 } }),
);
});
afterEach(async () => {
await app.close();
});