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

Merged
quentin_duwa merged 4 commits from feature/slugs into main 2026-11-14 13:08:16 +00:00
27 changed files with 832 additions and 12 deletions
Showing only changes of commit 290d4baf26 - Show all commits

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. `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/`. - Shared Prisma payload types (e.g. `ProductWithVersions`) live in `prisma/customObjects/`.
## Input validation ## Input validation

View File

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

View File

@@ -1,5 +1,5 @@
$kind: http-request $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" url: "{{baseUrl}}/sitemap"
method: GET method: GET
headers: headers:

View File

@@ -1,16 +1,20 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { PrismaService } from '../../prisma/prisma.service'; 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 { export interface SitemapEntry {
slug: string; 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 { export interface Sitemap {
brands: SitemapEntry[]; brands: SitemapEntry[];
categories: SitemapEntry[]; categories: SitemapEntry[];
products: SitemapEntry[]; products: ProductSitemapEntry[];
} }
/** Reads the public pages of the storefront, for its sitemap. */ /** 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 * 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> { async getSitemap(): Promise<Sitemap> {
const select = { slug: true, updatedAt: true } as const;
const orderBy = { slug: 'asc' } as const; const orderBy = { slug: 'asc' } as const;
const hasSlug = { slug: { not: null } }; const hasSlug = { slug: { not: null } };
const [brands, categories, products] = await Promise.all([ const [brands, categories, products] = await Promise.all([
this.prisma.brand.findMany({ where: { ...hasSlug, enabled: true }, select, orderBy }), this.prisma.brand.findMany({ where: { ...hasSlug, enabled: true }, select: { slug: true }, orderBy }),
this.prisma.category.findMany({ where: { ...hasSlug, webEnabled: true }, select, orderBy }), this.prisma.category.findMany({ where: { ...hasSlug, webEnabled: true }, select: { slug: true }, orderBy }),
this.prisma.product.findMany({ where: { ...hasSlug, enabled: true, archived: false }, select, orderBy }), this.prisma.product.findMany({
where: { ...hasSlug, enabled: true, archived: false },
select: { slug: true, hiboutikUpdatedAt: true },
orderBy,
}),
]); ]);
return { return {
brands: brands.map(toEntry), brands: brands.map((row) => ({ slug: toSlug(row.slug) })),
categories: categories.map(toEntry), categories: categories.map((row) => ({ slug: toSlug(row.slug) })),
products: products.map(toEntry), products: products.map((row) => ({ slug: toSlug(row.slug), updatedAt: row.hiboutikUpdatedAt })),
}; };
} }
} }
/** The query filters out null slugs; the type does not know it. */ /** The query filters out null slugs; the type does not know it. */
function toEntry(row: { slug: string | null; updatedAt: Date }): SitemapEntry { function toSlug(slug: string | null): string {
return { slug: row.slug as string, updatedAt: row.updatedAt }; 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 * Replaces the slug of an entity, in one transaction: its former slug becomes a redirect to it.
* held the new slug (one of its own former slugs, or another entity's) is dropped, the slug now naming this entity. * 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). * 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> { 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 }); const owner = await this.findSlugOwner(tx, type, { slug: newSlug });
if (owner) return 'TAKEN'; 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) { if (entity.slug) {
await tx.slugRedirect.create({ data: { entityType: type, slug: entity.slug, ...this.redirectTarget(type, id) } }); 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 () => { it('GET /sitemap lists the public pages', async () => {
prisma.brand.findMany.mockResolvedValue([{ slug: 'bts', updatedAt: new Date('2026-11-01T10:00:00Z') }]); prisma.brand.findMany.mockResolvedValue([{ slug: 'bts' }]);
prisma.category.findMany.mockResolvedValue([{ slug: 'albums', updatedAt: new Date('2026-10-01T10:00:00Z') }]); prisma.category.findMany.mockResolvedValue([{ slug: 'albums' }]);
prisma.product.findMany.mockResolvedValue([{ slug: 'bts-proof', updatedAt: new Date('2026-11-05T08:00:00Z') }]); prisma.product.findMany.mockResolvedValue([
{ slug: 'bts-proof', hiboutikUpdatedAt: new Date('2026-11-05T08:00:00Z') },
{ slug: 'bts-wings', hiboutikUpdatedAt: null },
]);
await request(app.getHttpServer()) await request(app.getHttpServer())
.get('/sitemap') .get('/sitemap')
.expect(200) .expect(200)
.expect('Cache-Control', 'public, max-age=600') .expect('Cache-Control', 'public, max-age=600')
.expect({ .expect({
brands: [{ slug: 'bts', updatedAt: '2026-11-01T10:00:00.000Z' }], brands: [{ slug: 'bts' }],
categories: [{ slug: 'albums', updatedAt: '2026-10-01T10:00:00.000Z' }], categories: [{ slug: 'albums' }],
products: [{ slug: 'bts-proof', updatedAt: '2026-11-05T08:00:00.000Z' }], 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.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.category.findMany).toHaveBeenCalledWith(expect.objectContaining({ where: { slug: { not: null }, webEnabled: true } }));
expect(prisma.product.findMany).toHaveBeenCalledWith( 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 },
}),
); );
}); });