fix(seo): keep former slugs reserved to their entity, date sitemap products from Hiboutik

This commit is contained in:
MastersGame
2026-11-14 12:23:47 +01:00
parent 657e8a6b30
commit 290d4baf26
7 changed files with 111 additions and 29 deletions

View File

@@ -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-<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. `GET /sitemap` lists the public slugs (brands `enabled`, categories `webEnabled`, products `enabled` and not `archived`) for the storefront's sitemap.xml.
- 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).
- Shared Prisma payload types (e.g. `ProductWithVersions`) live in `prisma/customObjects/`.
## Input validation

View File

@@ -14,8 +14,8 @@ response:
type: json
content: |
{
"brands": [{ "slug": "stray-kids", "updatedAt": "2026-11-08T10:00:00.000Z" }],
"categories": [{ "slug": "albums", "updatedAt": "2026-11-08T10:00:00.000Z" }],
"products": [{ "slug": "ate-digipack", "updatedAt": "2026-11-08T10:00:00.000Z" }]
"brands": [{ "slug": "stray-kids" }],
"categories": [{ "slug": "albums" }],
"products": [{ "slug": "ate-digipack", "updatedAt": "2026-10-28T14:32:00.000Z" }]
}
order: 1000

View File

@@ -1,5 +1,5 @@
$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, with the date of the last write of their row. The storefront builds its sitemap.xml from it. Answered with Cache-Control: public, max-age=600."
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:

View File

@@ -1,16 +1,20 @@
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../../prisma/prisma.service';
/** Public page of the storefront: its slug and the last change of its content. */
/** Public page of the storefront, identified by its slug. */
export interface SitemapEntry {
slug: string;
updatedAt: Date;
}
/** 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: SitemapEntry[];
products: ProductSitemapEntry[];
}
/** Reads the public pages of the storefront, for its sitemap. */
@@ -20,26 +24,30 @@ export class SitemapDatabaseRepository {
/**
* Brands enabled, categories displayed on the website and products displayed on the website and not archived, that
* have a slug. Dates are the last write of the row: the sync rewrites every row, so it is the date of the last sync.
* 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<Sitemap> {
const select = { slug: true, updatedAt: true } as const;
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, orderBy }),
this.prisma.category.findMany({ where: { ...hasSlug, webEnabled: true }, select, orderBy }),
this.prisma.product.findMany({ where: { ...hasSlug, enabled: true, archived: false }, select, orderBy }),
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(toEntry),
categories: categories.map(toEntry),
products: products.map(toEntry),
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 toEntry(row: { slug: string | null; updatedAt: Date }): SitemapEntry {
return { slug: row.slug as string, updatedAt: row.updatedAt };
function toSlug(slug: string | null): string {
return slug as string;
}

View File

@@ -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();
});
});

View File

@@ -62,8 +62,9 @@ export class SlugDatabaseRepository {
}
/**
* Replaces the slug of an entity, in one transaction: its former slug becomes a redirect to it, and a redirect that
* held the new slug (one of its own former slugs, or another entity's) is dropped, the slug now naming this entity.
* 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<ChangeSlugResult> {
@@ -75,7 +76,14 @@ export class SlugDatabaseRepository {
const owner = await this.findSlugOwner(tx, type, { slug: newSlug });
if (owner) return 'TAKEN';
await tx.slugRedirect.deleteMany({ where: { entityType: type, slug: newSlug } });
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) } });
}

View File

@@ -54,24 +54,33 @@ describe('AppController (e2e)', () => {
});
it('GET /sitemap lists the public pages', async () => {
prisma.brand.findMany.mockResolvedValue([{ slug: 'bts', updatedAt: new Date('2026-11-01T10:00:00Z') }]);
prisma.category.findMany.mockResolvedValue([{ slug: 'albums', updatedAt: new Date('2026-10-01T10:00:00Z') }]);
prisma.product.findMany.mockResolvedValue([{ slug: 'bts-proof', updatedAt: new Date('2026-11-05T08:00:00Z') }]);
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', updatedAt: '2026-11-01T10:00:00.000Z' }],
categories: [{ slug: 'albums', updatedAt: '2026-10-01T10:00:00.000Z' }],
products: [{ slug: 'bts-proof', updatedAt: '2026-11-05T08:00:00.000Z' }],
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
// 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 } }),
expect.objectContaining({
where: { slug: { not: null }, enabled: true, archived: false },
select: { slug: true, hiboutikUpdatedAt: true },
}),
);
});