feat(seo): prefix homonym sub-category slugs with their parent name

This commit is contained in:
MastersGame
2026-11-14 13:00:13 +01:00
parent dac8a1b7a1
commit 37ba41b33e
4 changed files with 72 additions and 10 deletions

View File

@@ -65,7 +65,7 @@ Domain notes:
- Customers: an email or phone removed in Hiboutik is erased in database (`null`, data minimization). - Customers: an email or phone removed in Hiboutik is erased in database (`null`, data minimization).
- Dates: Hiboutik sends French local time without offset; `parseHiboutikDate` reads datetimes as `Europe/Paris` whatever the server time zone, and date-only values (date of birth) as midnight UTC. - Dates: Hiboutik sends French local time without offset; `parseHiboutikDate` reads datetimes as `Europe/Paris` whatever the server time zone, and date-only values (date of birth) as midnight UTC.
- Orders come from the customer's `sales`: sales already stored as `COMPLETED` and linked to this customer are skipped (an order that lost its customer is fetched again to be linked back), the others are fetched and upserted on `hiboutikSaleId` (`source: STORE` on creation only). `status` is `COMPLETED` or `PENDING`, `orderedAt` is the completion date else the creation date. Lines are replaced on each upsert and snapshot name, version, barcode, price and Hiboutik ids; the `ProductVersion` link is optional (`SetNull`). `unitPrice` is the paid price, taxes and discount included. Quotations are skipped, anonymous sales are never synced (only customers' sales are read), and orders are never deleted. - Orders come from the customer's `sales`: sales already stored as `COMPLETED` and linked to this customer are skipped (an order that lost its customer is fetched again to be linked back), the others are fetched and upserted on `hiboutikSaleId` (`source: STORE` on creation only). `status` is `COMPLETED` or `PENDING`, `orderedAt` is the completion date else the creation date. Lines are replaced on each upsert and snapshot name, version, barcode, price and Hiboutik ids; the `ProductVersion` link is optional (`SetNull`). `unitPrice` is the paid price, taxes and discount included. Quotations are skipped, anonymous sales are never synced (only customers' sales are read), and orders are never deleted.
- Slugs (SEO): `Brand`, `Category` and `Product` have a unique `slug`, generated once by `SlugService.assignMissingSlugs` at the end of their sync step and never overwritten by the sync (an admin may replace it). Generation handles the oldest `hiboutikId` first: the slugified name, else `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). - Slugs (SEO): `Brand`, `Category` and `Product` have a unique `slug`, generated once by `SlugService.assignMissingSlugs` at the end of their sync step and never overwritten by the sync (an admin may replace it). Generation handles the oldest `hiboutikId` first: the slugified name, else (sub-categories only) `<parent name>-<name>`, else `name-<hiboutikId>` on a collision (logged as a warning), else `brand|category|product-<hiboutikId>` for a name without latin letters. `SlugService.changeSlug` keeps the former slug in `SlugRedirect` (for a 301); slugs of redirects stay reserved (an entity may only take back its own former slug). `GET /sitemap` lists the public slugs (brands `enabled`, categories `webEnabled`, products `enabled` and not `archived`) for the storefront's sitemap.xml; only products carry a date, `hiboutikUpdatedAt` (the row's `updatedAt` changes on every sync).
- Shared Prisma payload types (e.g. `ProductWithVersions`) live in `prisma/customObjects/`. - Shared Prisma payload types (e.g. `ProductWithVersions`) live in `prisma/customObjects/`.
## Input validation ## Input validation

View File

@@ -7,6 +7,8 @@ export interface SlugCandidateRow {
id: string; id: string;
hiboutikId: number; hiboutikId: number;
name: string; 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}. */ /** 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)); 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<SlugCandidateRow[]> { async getWithoutSlug(type: SlugEntityType): Promise<SlugCandidateRow[]> {
const where = { slug: null }; const where = { slug: null };
const orderBy = { hiboutikId: 'asc' } as const; const orderBy = { hiboutikId: 'asc' } as const;
const args = { where, orderBy, select: { id: true, hiboutikId: true, name: true } }; const select = { id: true, hiboutikId: true, name: true } as const;
if (type === SlugEntityType.BRAND) return this.prisma.brand.findMany(args); if (type === SlugEntityType.CATEGORY) {
if (type === SlugEntityType.CATEGORY) return this.prisma.category.findMany(args); const rows = await this.prisma.category.findMany({ where, orderBy, select: { ...select, parent: { select: { name: true } } } });
return this.prisma.product.findMany(args); 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 }));
} }
/** /**

View File

@@ -3,7 +3,12 @@ import { SlugService } from './slug.service';
import { Prisma, SlugEntityType } from '../../../prisma/generated/client'; import { Prisma, SlugEntityType } from '../../../prisma/generated/client';
import { SlugCandidateRow } from '../../database/slug.database.repository'; 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', () => { describe('SlugService', () => {
const slugDatabaseRepository = { const slugDatabaseRepository = {
@@ -59,6 +64,38 @@ describe('SlugService', () => {
expect(assigned()).toEqual({ 'uuid-4': 'albums-4', 'uuid-6': 'ate-digipack' }); 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 () => { it('falls back to the type and Hiboutik id for a name without latin letters', async () => {
slugDatabaseRepository.getWithoutSlug.mockResolvedValue([row(9, '방탄소년단')]); slugDatabaseRepository.getWithoutSlug.mockResolvedValue([row(9, '방탄소년단')]);

View File

@@ -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 * Candidate slugs by order of preference: the name ("albums"); for a sub-category, the name prefixed with its parent's
* Hiboutik id ("albums-12"), unique as the id is. * ("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[] { private buildCandidates(type: SlugEntityType, row: SlugCandidateRow): string[] {
const base = slugify(row.name); const base = slugify(row.name);
if (!base) return [`${FALLBACK_PREFIX[type]}-${row.hiboutikId}`]; 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)];
}
/**
* `<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;
} }
/** /**