feat(seo): generate slugs for brands, categories and products, add GET /sitemap and the Postman collection

This commit is contained in:
MastersGame
2026-11-14 11:08:51 +01:00
parent d07c4df52d
commit 657e8a6b30
26 changed files with 750 additions and 12 deletions

View File

@@ -0,0 +1,107 @@
import { BadRequestException, ConflictException, Logger, NotFoundException } from '@nestjs/common';
import { SlugService } from './slug.service';
import { SlugEntityType } from '../../../prisma/generated/client';
import { SlugCandidateRow } from '../../database/slug.database.repository';
const row = (hiboutikId: number, name: string): SlugCandidateRow => ({ id: `uuid-${hiboutikId}`, hiboutikId, name });
describe('SlugService', () => {
const slugDatabaseRepository = {
getWithoutSlug: jest.fn(),
getTakenSlugs: jest.fn(),
setFirstSlug: jest.fn(),
changeSlug: jest.fn(),
findRedirect: jest.fn(),
};
let service: SlugService;
beforeAll(() => {
jest.spyOn(Logger.prototype, 'log').mockImplementation(() => undefined);
jest.spyOn(Logger.prototype, 'warn').mockImplementation(() => undefined);
jest.spyOn(Logger.prototype, 'error').mockImplementation(() => undefined);
});
beforeEach(() => {
jest.clearAllMocks();
slugDatabaseRepository.getTakenSlugs.mockResolvedValue(new Set());
slugDatabaseRepository.setFirstSlug.mockResolvedValue(true);
service = new SlugService(slugDatabaseRepository as never);
});
/** Slugs given by assignMissingSlugs, by entity id. */
const assigned = (): Record<string, string> =>
Object.fromEntries((slugDatabaseRepository.setFirstSlug.mock.calls as [string, string, string][]).map(([, id, slug]) => [id, slug]));
describe('assignMissingSlugs', () => {
it('gives the plain slug to the oldest of two homonyms, the Hiboutik id to the other', async () => {
slugDatabaseRepository.getWithoutSlug.mockResolvedValue([row(3, 'Stray Kids'), row(8, 'STRAY KIDS')]);
await service.assignMissingSlugs(SlugEntityType.BRAND);
expect(assigned()).toEqual({ 'uuid-3': 'stray-kids', 'uuid-8': 'stray-kids-8' });
});
it('does not take a slug already used, by an entity or a redirect', async () => {
slugDatabaseRepository.getTakenSlugs.mockResolvedValue(new Set(['bts']));
slugDatabaseRepository.getWithoutSlug.mockResolvedValue([row(5, 'BTS')]);
await service.assignMissingSlugs(SlugEntityType.BRAND);
expect(assigned()).toEqual({ 'uuid-5': 'bts-5' });
});
it('suffixes a homonym category or product with its Hiboutik id', async () => {
slugDatabaseRepository.getTakenSlugs.mockResolvedValue(new Set(['albums']));
slugDatabaseRepository.getWithoutSlug.mockResolvedValue([row(4, 'Albums'), row(6, 'ATE (Digipack)')]);
await service.assignMissingSlugs(SlugEntityType.CATEGORY);
expect(assigned()).toEqual({ 'uuid-4': 'albums-4', 'uuid-6': 'ate-digipack' });
});
it('falls back to the type and Hiboutik id for a name without latin letters', async () => {
slugDatabaseRepository.getWithoutSlug.mockResolvedValue([row(9, '방탄소년단')]);
await service.assignMissingSlugs(SlugEntityType.BRAND);
expect(assigned()).toEqual({ 'uuid-9': 'brand-9' });
});
it('keeps going when one slug fails, and never throws', async () => {
slugDatabaseRepository.getWithoutSlug.mockResolvedValue([row(1, 'A'), row(2, 'B')]);
slugDatabaseRepository.setFirstSlug.mockRejectedValueOnce(new Error('boom'));
await expect(service.assignMissingSlugs(SlugEntityType.BRAND)).resolves.toBeUndefined();
expect(slugDatabaseRepository.setFirstSlug).toHaveBeenCalledTimes(2);
slugDatabaseRepository.getWithoutSlug.mockRejectedValue(new Error('database down'));
await expect(service.assignMissingSlugs(SlugEntityType.BRAND)).resolves.toBeUndefined();
});
});
describe('changeSlug', () => {
it('rejects an invalid slug without touching the database', async () => {
await expect(service.changeSlug(SlugEntityType.BRAND, 'uuid-1', 'Stray Kids')).rejects.toThrow(BadRequestException);
await expect(service.changeSlug(SlugEntityType.BRAND, 'uuid-1', 'a'.repeat(81))).rejects.toThrow(BadRequestException);
expect(slugDatabaseRepository.changeSlug).not.toHaveBeenCalled();
});
it('answers 404 for an unknown entity and 409 for a slug in use, even when written concurrently', async () => {
slugDatabaseRepository.changeSlug.mockResolvedValueOnce('NOT_FOUND');
await expect(service.changeSlug(SlugEntityType.BRAND, 'uuid-1', 'bts')).rejects.toThrow(NotFoundException);
slugDatabaseRepository.changeSlug.mockResolvedValueOnce('TAKEN');
await expect(service.changeSlug(SlugEntityType.BRAND, 'uuid-1', 'bts')).rejects.toThrow(ConflictException);
slugDatabaseRepository.changeSlug.mockRejectedValueOnce(Object.assign(new Error('Unique constraint failed'), { code: 'P2002' }));
await expect(service.changeSlug(SlugEntityType.BRAND, 'uuid-1', 'bts')).rejects.toThrow(ConflictException);
});
it('accepts a valid new slug', async () => {
slugDatabaseRepository.changeSlug.mockResolvedValue('CHANGED');
await expect(service.changeSlug(SlugEntityType.PRODUCT, 'uuid-1', 'stray-kids-ate')).resolves.toBeUndefined();
expect(slugDatabaseRepository.changeSlug).toHaveBeenCalledWith('PRODUCT', 'uuid-1', 'stray-kids-ate');
});
});
});

View File

@@ -0,0 +1,97 @@
import { BadRequestException, ConflictException, Injectable, Logger, NotFoundException } from '@nestjs/common';
import { SlugEntityType } from '../../../prisma/generated/client';
import { SlugCandidateRow, SlugDatabaseRepository } from '../../database/slug.database.repository';
import { forEachSafely } from '../../utils/sync';
import { MAX_SLUG_LENGTH, pickAvailableSlug, SLUG_PATTERN, slugify, withSlugSuffix } from '../../utils/slug';
/** Prefix of the slug of an entity whose name gives no slug (no latin letter or digit, e.g. a name in hangul). */
const FALLBACK_PREFIX: Record<SlugEntityType, string> = {
[SlugEntityType.BRAND]: 'brand',
[SlugEntityType.CATEGORY]: 'category',
[SlugEntityType.PRODUCT]: 'product',
};
/**
* Slugs (SEO URL identifiers) of brands, categories and products.
*
* A slug is generated once, when an entity has none, and is never regenerated: a URL must not change when the name
* changes in Hiboutik. Generation is deterministic: entities are handled oldest first (by `hiboutikId`), so that a
* database rebuilt from scratch gets the same slugs, the oldest of two homonyms keeping the plain slug.
* An admin can replace a slug ({@link changeSlug}): the former one is kept as a redirect.
*/
@Injectable()
export class SlugService {
private readonly logger = new Logger('SlugService');
constructor(private readonly slugDatabaseRepository: SlugDatabaseRepository) {}
/**
* Gives a slug to every entity of this type that has none. Never throws: a failure is logged, the entities left
* without a slug get one on the next sync.
*/
async assignMissingSlugs(type: SlugEntityType): Promise<void> {
try {
const [rows, taken] = await Promise.all([
this.slugDatabaseRepository.getWithoutSlug(type),
this.slugDatabaseRepository.getTakenSlugs(type),
]);
const nbFailed = await forEachSafely(
rows,
async (row) => {
const candidates = this.buildCandidates(type, row);
const slug = pickAvailableSlug(candidates, taken);
if (slug !== candidates[0]) {
// Often a duplicate in Hiboutik ("Stray Kids" and "STRAY KIDS"): worth a look in the back office
this.logger.warn(`Slug "${candidates[0]}" already taken: Hiboutik ${type.toLowerCase()} ${row.hiboutikId} gets "${slug}"`);
}
if (await this.slugDatabaseRepository.setFirstSlug(type, row.id, slug)) taken.add(slug);
},
this.logger,
(row) => `slug of Hiboutik ${type.toLowerCase()} ${row.hiboutikId}`,
);
if (rows.length > 0) {
this.logger.log(`Slugs of ${type.toLowerCase()} entities: ${rows.length - nbFailed}/${rows.length} generated, ${nbFailed} failed.`);
}
} catch (error) {
this.logger.error(`Failed to generate the slugs of ${type.toLowerCase()} entities`, (error as Error)?.stack);
}
}
/**
* Candidate slugs by order of preference: the name ("albums"), then, when it is taken, the name suffixed with the
* Hiboutik id ("albums-12"), unique as the id is.
*/
private buildCandidates(type: SlugEntityType, row: SlugCandidateRow): string[] {
const base = slugify(row.name);
if (!base) return [`${FALLBACK_PREFIX[type]}-${row.hiboutikId}`];
return [base, withSlugSuffix(base, row.hiboutikId)];
}
/**
* Replaces the slug of an entity (admin choice); its former slug keeps answering through a redirect.
* @throws BadRequestException for an invalid slug, NotFoundException for an unknown entity,
* ConflictException when another entity of the same type uses this slug.
*/
async changeSlug(type: SlugEntityType, id: string, slug: string): Promise<void> {
if (slug.length > MAX_SLUG_LENGTH || !SLUG_PATTERN.test(slug)) {
throw new BadRequestException(
`A slug is made of lowercase letters, digits and single hyphens, ${MAX_SLUG_LENGTH} characters at most`,
);
}
let result: Awaited<ReturnType<SlugDatabaseRepository['changeSlug']>>;
try {
result = await this.slugDatabaseRepository.changeSlug(type, id, slug);
} catch (error) {
// Unique constraint: the same slug was written at the same time by someone else
if ((error as { code?: string })?.code === 'P2002') result = 'TAKEN';
else throw error;
}
if (result === 'NOT_FOUND') throw new NotFoundException();
if (result === 'TAKEN') throw new ConflictException('This slug is already used');
}
/** @returns the current slug of the entity that `slug` used to name, null when it is not a former slug. */
async findRedirect(type: SlugEntityType, slug: string): Promise<string | null> {
return this.slugDatabaseRepository.findRedirect(type, slug);
}
}