From 37ba41b33e3f24c3020f5315a5bc06c1621e6345 Mon Sep 17 00:00:00 2001 From: MastersGame Date: Sat, 14 Nov 2026 13:00:13 +0100 Subject: [PATCH] feat(seo): prefix homonym sub-category slugs with their parent name --- CLAUDE.md | 2 +- src/database/slug.database.repository.ts | 22 ++++++++++--- src/services/slug/slug.service.spec.ts | 39 +++++++++++++++++++++++- src/services/slug/slug.service.ts | 19 ++++++++++-- 4 files changed, 72 insertions(+), 10 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 9ffa50a..e192701 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -65,7 +65,7 @@ Domain notes: - Customers: an email or phone removed in Hiboutik is erased in database (`null`, data minimization). - Dates: Hiboutik sends French local time without offset; `parseHiboutikDate` reads datetimes as `Europe/Paris` whatever the server time zone, and date-only values (date of birth) as midnight UTC. - Orders come from the customer's `sales`: sales already stored as `COMPLETED` and linked to this customer are skipped (an order that lost its customer is fetched again to be linked back), the others are fetched and upserted on `hiboutikSaleId` (`source: STORE` on creation only). `status` is `COMPLETED` or `PENDING`, `orderedAt` is the completion date else the creation date. Lines are replaced on each upsert and snapshot name, version, barcode, price and Hiboutik ids; the `ProductVersion` link is optional (`SetNull`). `unitPrice` is the paid price, taxes and discount included. Quotations are skipped, anonymous sales are never synced (only customers' sales are read), and orders are never deleted. -- Slugs (SEO): `Brand`, `Category` and `Product` have a unique `slug`, generated once by `SlugService.assignMissingSlugs` at the end of their sync step and never overwritten by the sync (an admin may replace it). Generation handles the oldest `hiboutikId` first: the slugified name, else `name-` 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). +- 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 diff --git a/src/database/slug.database.repository.ts b/src/database/slug.database.repository.ts index 4c70bba..ab40a57 100644 --- a/src/database/slug.database.repository.ts +++ b/src/database/slug.database.repository.ts @@ -7,6 +7,8 @@ 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}. */ @@ -36,14 +38,24 @@ export class SlugDatabaseRepository { 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). */ + /** + * 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 args = { where, orderBy, select: { id: true, hiboutikId: true, name: true } }; - if (type === SlugEntityType.BRAND) return this.prisma.brand.findMany(args); - if (type === SlugEntityType.CATEGORY) return this.prisma.category.findMany(args); - return this.prisma.product.findMany(args); + 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 })); } /** diff --git a/src/services/slug/slug.service.spec.ts b/src/services/slug/slug.service.spec.ts index 46091f2..d6cd7dc 100644 --- a/src/services/slug/slug.service.spec.ts +++ b/src/services/slug/slug.service.spec.ts @@ -3,7 +3,12 @@ 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): SlugCandidateRow => ({ id: `uuid-${hiboutikId}`, hiboutikId, name }); +const row = (hiboutikId: number, name: string, parentName: string | null = null): SlugCandidateRow => ({ + id: `uuid-${hiboutikId}`, + hiboutikId, + name, + parentName, +}); describe('SlugService', () => { const slugDatabaseRepository = { @@ -59,6 +64,38 @@ describe('SlugService', () => { 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, '방탄소년단')]); diff --git a/src/services/slug/slug.service.ts b/src/services/slug/slug.service.ts index 4e11c95..0627ca2 100644 --- a/src/services/slug/slug.service.ts +++ b/src/services/slug/slug.service.ts @@ -61,13 +61,26 @@ export class SlugService { } /** - * Candidate slugs by order of preference: the name ("albums"), then, when it is taken, the name suffixed with the - * Hiboutik id ("albums-12"), unique as the id is. + * 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}`]; - return [base, withSlugSuffix(base, 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; } /**