feat(seo): generate slugs for brands, categories and products, add GET /sitemap and the Postman collection
This commit is contained in:
9
.gitignore
vendored
9
.gitignore
vendored
@@ -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
|
||||
|
||||
@@ -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").
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
$kind: collection
|
||||
variables:
|
||||
baseUrl: "http://localhost:3000"
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
8
postman/collections/DinggaDingga/getSitemap.request.yaml
Normal file
8
postman/collections/DinggaDingga/getSitemap.request.yaml
Normal 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
|
||||
26
postman/collections/DinggaDingga/syncHiboutik.request.yaml
Normal file
26
postman/collections/DinggaDingga/syncHiboutik.request.yaml
Normal 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
|
||||
@@ -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)
|
||||
|
||||
@@ -31,7 +33,8 @@ model Category {
|
||||
parentId String?
|
||||
children Category[] @relation("CategoryTree")
|
||||
|
||||
products Product[]
|
||||
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[]
|
||||
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?
|
||||
@@ -92,11 +100,42 @@ model Product {
|
||||
supplier Supplier? @relation(fields: [supplierId], references: [id], onDelete: SetNull)
|
||||
supplierId String?
|
||||
|
||||
versions ProductVersion[]
|
||||
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())
|
||||
|
||||
@@ -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,
|
||||
|
||||
45
src/database/sitemap.database.repository.ts
Normal file
45
src/database/sitemap.database.repository.ts
Normal 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 };
|
||||
}
|
||||
115
src/database/slug.database.repository.ts
Normal file
115
src/database/slug.database.repository.ts
Normal 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 };
|
||||
}
|
||||
}
|
||||
@@ -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.`,
|
||||
);
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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.`,
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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.`,
|
||||
);
|
||||
|
||||
107
src/services/slug/slug.service.spec.ts
Normal file
107
src/services/slug/slug.service.spec.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
97
src/services/slug/slug.service.ts
Normal file
97
src/services/slug/slug.service.ts
Normal 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
17
src/sitemap.controller.ts
Normal 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
42
src/utils/slug.spec.ts
Normal 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
55
src/utils/slug.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user