feat(seo): generate slugs for brands, categories and products, add GET /sitemap and the Postman collection #5
@@ -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
|
||||||
|
|||||||
@@ -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 }));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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, '방탄소년단')]);
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Reference in New Issue
Block a user