feat(seo): generate slugs for brands, categories and products, add GET /sitemap and the Postman collection (#5)
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 (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/`.
|
||||
|
||||
## 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" }],
|
||||
"categories": [{ "slug": "albums" }],
|
||||
"products": [{ "slug": "ate-digipack", "updatedAt": "2026-10-28T14:32: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. 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
|
||||
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,
|
||||
|
||||
53
src/database/sitemap.database.repository.ts
Normal file
53
src/database/sitemap.database.repository.ts
Normal 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;
|
||||
}
|
||||
57
src/database/slug.database.repository.spec.ts
Normal file
57
src/database/slug.database.repository.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
135
src/database/slug.database.repository.ts
Normal file
135
src/database/slug.database.repository.ts
Normal 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 };
|
||||
}
|
||||
}
|
||||
@@ -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.`,
|
||||
);
|
||||
|
||||
156
src/services/slug/slug.service.spec.ts
Normal file
156
src/services/slug/slug.service.spec.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
113
src/services/slug/slug.service.ts
Normal file
113
src/services/slug/slug.service.ts
Normal 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
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();
|
||||
}
|
||||
}
|
||||
48
src/utils/slug.spec.ts
Normal file
48
src/utils/slug.spec.ts
Normal 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
59
src/utils/slug.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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,37 @@ describe('AppController (e2e)', () => {
|
||||
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 () => {
|
||||
await app.close();
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user