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

This commit is contained in:
2026-11-14 13:08:16 +00:00
parent d07c4df52d
commit fa3361ebd7
27 changed files with 919 additions and 12 deletions

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 Code personal settings (.claude/settings.json stays shareable)
/.claude/settings.local.json /.claude/settings.local.json
# Postman (skills fetched by `postman init`, personal workspace binding) # Postman: collections, specs and flows are versioned with the code.
/postman/ # 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/ /.postman/
/AGENTS.md /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). - 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
@@ -83,7 +84,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 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 ## 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. - 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

@@ -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" }],
"categories": [{ "slug": "albums" }],
"products": [{ "slug": "ate-digipack", "updatedAt": "2026-10-28T14:32: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. 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

@@ -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 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)
@@ -31,7 +33,8 @@ model Category {
parentId String? parentId String?
children Category[] @relation("CategoryTree") children Category[] @relation("CategoryTree")
products Product[] products Product[]
slugRedirects SlugRedirect[]
@@map("categories") @@map("categories")
} }
@@ -44,8 +47,11 @@ 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")
} }
@@ -84,6 +90,8 @@ 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?
@@ -92,11 +100,42 @@ model Product {
supplier Supplier? @relation(fields: [supplierId], references: [id], onDelete: SetNull) supplier Supplier? @relation(fields: [supplierId], references: [id], onDelete: SetNull)
supplierId String? supplierId String?
versions ProductVersion[] versions ProductVersion[]
slugRedirects SlugRedirect[]
@@map("products") @@map("products")
} }
/// Kind of entity a slug identifies. Slugs are unique per kind (each one has its own URL space).
enum SlugEntityType {
BRAND
CATEGORY
PRODUCT
}
/// Former slug of a brand, category or product, kept so that its old URL can be answered with a 301 redirect.
/// Exactly one of brandId / categoryId / productId is set, matching `entityType`. Deleted with its entity.
model SlugRedirect {
id String @id @default(uuid())
createdAt DateTime @default(now())
entityType SlugEntityType
slug String
brand Brand? @relation(fields: [brandId], references: [id], onDelete: Cascade)
brandId String?
category Category? @relation(fields: [categoryId], references: [id], onDelete: Cascade)
categoryId String?
product Product? @relation(fields: [productId], references: [id], onDelete: Cascade)
productId String?
@@unique([entityType, slug])
@@index([brandId])
@@index([categoryId])
@@index([productId])
@@map("slug_redirects")
}
/// Sellable version of a product: one per Hiboutik size, or a single one (hiboutikSizeId 0) for a product without sizes. /// Sellable version of a product: one per Hiboutik size, or a single one (hiboutikSizeId 0) for a product without sizes.
model ProductVersion { model ProductVersion {
id String @id @default(uuid()) id String @id @default(uuid())

View File

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

View File

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

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

@@ -0,0 +1,135 @@
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,6 +3,8 @@ 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 {
@@ -12,9 +14,10 @@ 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, 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() { 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
@@ -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. // 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,6 +16,7 @@ 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[];
@@ -41,6 +42,7 @@ describe('CategoryHiboutikService', () => {
categoryHiboutikRepository as never, categoryHiboutikRepository as never,
categoryDatabaseRepository as never, categoryDatabaseRepository as never,
new CategoryConverter(), new CategoryConverter(),
slugService as never,
); );
}); });
@@ -93,4 +95,16 @@ 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,6 +3,8 @@ 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 {
@@ -12,11 +14,12 @@ 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,
* then deletes the categories missing from Hiboutik. * deletes the categories missing from Hiboutik, then gives a slug to the new ones.
*/ */
async syncCategories() { async syncCategories() {
const hiboutikCategories = await this.categoryHiboutikRepository.getCategories(); 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. // 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,6 +38,7 @@ 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);
@@ -76,6 +77,7 @@ describe('ProductHiboutikService', () => {
new ProductConverter(), new ProductConverter(),
stockHiboutikRepository as never, stockHiboutikRepository as never,
prisma 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[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,11 +6,12 @@ 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 } from '../../../prisma/generated/client'; import { Product, SlugEntityType } 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;
@@ -26,9 +27,10 @@ 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. */ /** Mirrors Hiboutik products, their versions (sizes) and stocks. Products missing from Hiboutik are deleted, new ones get a slug. */
async syncProducts() { async syncProducts() {
const [productsHiboutik, hiboutikStocks, existingProducts] = await Promise.all([ const [productsHiboutik, hiboutikStocks, existingProducts] = await Promise.all([
this.productHiboutikRepository.getProducts(), this.productHiboutikRepository.getProducts(),
@@ -59,6 +61,7 @@ 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

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

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

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

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

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

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

@@ -0,0 +1,59 @@
/** 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,7 +12,13 @@ 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 = { $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 () => { beforeEach(async () => {
hiboutikService.startSync.mockReset(); hiboutikService.startSync.mockReset();
@@ -47,6 +53,37 @@ 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();
}); });