From 7022537471e74f0ba4ca6742d5d036d109e84969 Mon Sep 17 00:00:00 2001 From: Rotimi Best Date: Tue, 16 Jun 2026 22:44:26 +0100 Subject: [PATCH] fix(course): stop capping public course slug length Public course slugs carry a - uniqueness suffix that pushes them past the 80-char ZSlug cap, so the org-site lookup returned a 400 and the page fell through to the landing page instead of redirecting to the first lesson. The course.slug column is unbounded, so validate the public-course route param with a no-max ZCourseSlug. --- packages/utils/src/validation/course/public-course.ts | 6 +++--- packages/utils/src/validation/shared/slug.ts | 10 ++++++++++ 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/packages/utils/src/validation/course/public-course.ts b/packages/utils/src/validation/course/public-course.ts index a4d6a454..379f7074 100644 --- a/packages/utils/src/validation/course/public-course.ts +++ b/packages/utils/src/validation/course/public-course.ts @@ -1,6 +1,6 @@ import * as z from 'zod'; -import { ZSlug } from '../shared/slug'; +import { ZCourseSlug, ZSlug } from '../shared/slug'; /** * Route params + query schemas for the anonymous, SEO-indexable public course @@ -8,12 +8,12 @@ import { ZSlug } from '../shared/slug'; */ export const ZPublicCourseBySlugParam = z.object({ - courseSlug: ZSlug + courseSlug: ZCourseSlug }); export type TPublicCourseBySlugParam = z.infer; export const ZPublicCourseItemParam = z.object({ - courseSlug: ZSlug, + courseSlug: ZCourseSlug, itemSlug: ZSlug }); export type TPublicCourseItemParam = z.infer; diff --git a/packages/utils/src/validation/shared/slug.ts b/packages/utils/src/validation/shared/slug.ts index 2274e6f8..f0d0b12e 100644 --- a/packages/utils/src/validation/shared/slug.ts +++ b/packages/utils/src/validation/shared/slug.ts @@ -15,6 +15,16 @@ export const ZSlug = z }); export type TSlug = z.infer; +/** + * Course slugs are `slugify(title)` (capped at 80) plus a `-` uniqueness + * suffix, so they routinely exceed the 80-char `ZSlug` cap. The `course.slug` column is + * unbounded `varchar` and there's no technical reason to cap the URL, so don't. + */ +export const ZCourseSlug = z.string().min(1, { message: 'Slug is required' }).regex(SLUG_REGEX, { + message: 'Slug must be lowercase letters, numbers, and single dashes' +}); +export type TCourseSlug = z.infer; + /** * Generate a kebab-case slug from a free-form title. Does not enforce uniqueness; * callers must resolve collisions against the target namespace (e.g. per-course).