diff --git a/.gitignore b/.gitignore index f388715..111b601 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/CLAUDE.md b/CLAUDE.md index e41389c..e192701 100644 --- a/CLAUDE.md +++ b/CLAUDE.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) `-`, else `name-` on a collision (logged as a warning), else `brand|category|product-` 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 ` 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/` 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"). diff --git a/postman/collections/DinggaDingga/.resources/definition.yaml b/postman/collections/DinggaDingga/.resources/definition.yaml new file mode 100644 index 0000000..843893c --- /dev/null +++ b/postman/collections/DinggaDingga/.resources/definition.yaml @@ -0,0 +1,3 @@ +$kind: collection +variables: + baseUrl: "http://localhost:3000" diff --git a/postman/collections/DinggaDingga/.resources/getSitemap.resources/examples/Public pages.example.yaml b/postman/collections/DinggaDingga/.resources/getSitemap.resources/examples/Public pages.example.yaml new file mode 100644 index 0000000..b5d4729 --- /dev/null +++ b/postman/collections/DinggaDingga/.resources/getSitemap.resources/examples/Public pages.example.yaml @@ -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 diff --git a/postman/collections/DinggaDingga/.resources/syncHiboutik.resources/examples/Instance shutting down.example.yaml b/postman/collections/DinggaDingga/.resources/syncHiboutik.resources/examples/Instance shutting down.example.yaml new file mode 100644 index 0000000..85a5d10 --- /dev/null +++ b/postman/collections/DinggaDingga/.resources/syncHiboutik.resources/examples/Instance shutting down.example.yaml @@ -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 diff --git a/postman/collections/DinggaDingga/.resources/syncHiboutik.resources/examples/Rate limited.example.yaml b/postman/collections/DinggaDingga/.resources/syncHiboutik.resources/examples/Rate limited.example.yaml new file mode 100644 index 0000000..62c9ca8 --- /dev/null +++ b/postman/collections/DinggaDingga/.resources/syncHiboutik.resources/examples/Rate limited.example.yaml @@ -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 diff --git a/postman/collections/DinggaDingga/.resources/syncHiboutik.resources/examples/Sync already running.example.yaml b/postman/collections/DinggaDingga/.resources/syncHiboutik.resources/examples/Sync already running.example.yaml new file mode 100644 index 0000000..5825e69 --- /dev/null +++ b/postman/collections/DinggaDingga/.resources/syncHiboutik.resources/examples/Sync already running.example.yaml @@ -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 diff --git a/postman/collections/DinggaDingga/.resources/syncHiboutik.resources/examples/Sync started.example.yaml b/postman/collections/DinggaDingga/.resources/syncHiboutik.resources/examples/Sync started.example.yaml new file mode 100644 index 0000000..46109de --- /dev/null +++ b/postman/collections/DinggaDingga/.resources/syncHiboutik.resources/examples/Sync started.example.yaml @@ -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 diff --git a/postman/collections/DinggaDingga/.resources/syncHiboutik.resources/examples/Unexpected error.example.yaml b/postman/collections/DinggaDingga/.resources/syncHiboutik.resources/examples/Unexpected error.example.yaml new file mode 100644 index 0000000..4564dc4 --- /dev/null +++ b/postman/collections/DinggaDingga/.resources/syncHiboutik.resources/examples/Unexpected error.example.yaml @@ -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 diff --git a/postman/collections/DinggaDingga/getSitemap.request.yaml b/postman/collections/DinggaDingga/getSitemap.request.yaml new file mode 100644 index 0000000..2116542 --- /dev/null +++ b/postman/collections/DinggaDingga/getSitemap.request.yaml @@ -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 diff --git a/postman/collections/DinggaDingga/syncHiboutik.request.yaml b/postman/collections/DinggaDingga/syncHiboutik.request.yaml new file mode 100644 index 0000000..c58f3c6 --- /dev/null +++ b/postman/collections/DinggaDingga/syncHiboutik.request.yaml @@ -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 diff --git a/prisma/schema.prisma b/prisma/schema.prisma index efb2ecf..f4c3eb1 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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()) diff --git a/src/app.module.ts b/src/app.module.ts index 3a8ac81..ef24e89 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -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, diff --git a/src/database/sitemap.database.repository.ts b/src/database/sitemap.database.repository.ts new file mode 100644 index 0000000..8f4add7 --- /dev/null +++ b/src/database/sitemap.database.repository.ts @@ -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 { + 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; +} diff --git a/src/database/slug.database.repository.spec.ts b/src/database/slug.database.repository.spec.ts new file mode 100644 index 0000000..7bf408e --- /dev/null +++ b/src/database/slug.database.repository.spec.ts @@ -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(); + }); +}); diff --git a/src/database/slug.database.repository.ts b/src/database/slug.database.repository.ts new file mode 100644 index 0000000..ab40a57 --- /dev/null +++ b/src/database/slug.database.repository.ts @@ -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> { + 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 { + 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 { + 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 { + 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 { + 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 }; + } +} diff --git a/src/services/hiboutik/brand.hiboutik.service.ts b/src/services/hiboutik/brand.hiboutik.service.ts index 7bc896b..1cce022 100644 --- a/src/services/hiboutik/brand.hiboutik.service.ts +++ b/src/services/hiboutik/brand.hiboutik.service.ts @@ -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.`, ); diff --git a/src/services/hiboutik/category.hiboutik.service.spec.ts b/src/services/hiboutik/category.hiboutik.service.spec.ts index e60b69e..47b2617 100644 --- a/src/services/hiboutik/category.hiboutik.service.spec.ts +++ b/src/services/hiboutik/category.hiboutik.service.spec.ts @@ -16,6 +16,7 @@ const hiboutikCategory = (id: number, parentId: number, overrides: Partial { 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'); + }); }); diff --git a/src/services/hiboutik/category.hiboutik.service.ts b/src/services/hiboutik/category.hiboutik.service.ts index a9c9585..394f7b7 100644 --- a/src/services/hiboutik/category.hiboutik.service.ts +++ b/src/services/hiboutik/category.hiboutik.service.ts @@ -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.`, diff --git a/src/services/hiboutik/product.hiboutik.service.spec.ts b/src/services/hiboutik/product.hiboutik.service.spec.ts index a4c2229..66c24df 100644 --- a/src/services/hiboutik/product.hiboutik.service.spec.ts +++ b/src/services/hiboutik/product.hiboutik.service.spec.ts @@ -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'); + }); }); diff --git a/src/services/hiboutik/product.hiboutik.service.ts b/src/services/hiboutik/product.hiboutik.service.ts index a9d2ad0..788ce04 100644 --- a/src/services/hiboutik/product.hiboutik.service.ts +++ b/src/services/hiboutik/product.hiboutik.service.ts @@ -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.`, ); diff --git a/src/services/slug/slug.service.spec.ts b/src/services/slug/slug.service.spec.ts new file mode 100644 index 0000000..d6cd7dc --- /dev/null +++ b/src/services/slug/slug.service.spec.ts @@ -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 => + 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'); + }); + }); +}); diff --git a/src/services/slug/slug.service.ts b/src/services/slug/slug.service.ts new file mode 100644 index 0000000..0627ca2 --- /dev/null +++ b/src/services/slug/slug.service.ts @@ -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.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 { + 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)]; + } + + /** + * `-`, 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 { + 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>; + 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 { + return this.slugDatabaseRepository.findRedirect(type, slug); + } +} diff --git a/src/sitemap.controller.ts b/src/sitemap.controller.ts new file mode 100644 index 0000000..53e5f87 --- /dev/null +++ b/src/sitemap.controller.ts @@ -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 { + return this.sitemapDatabaseRepository.getSitemap(); + } +} diff --git a/src/utils/slug.spec.ts b/src/utils/slug.spec.ts new file mode 100644 index 0000000..b30bd5e --- /dev/null +++ b/src/utils/slug.spec.ts @@ -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'); + }); +}); diff --git a/src/utils/slug.ts b/src/utils/slug.ts new file mode 100644 index 0000000..2a02331 --- /dev/null +++ b/src/utils/slug.ts @@ -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 = { æ: '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 { + 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; + } +} diff --git a/test/app.e2e-spec.ts b/test/app.e2e-spec.ts index 8fe3687..b7408a6 100644 --- a/test/app.e2e-spec.ts +++ b/test/app.e2e-spec.ts @@ -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(); });