From ee4d4e031dd05d99958a1c89e53e8f901fc8c0f5 Mon Sep 17 00:00:00 2001 From: Rotimi Best Date: Tue, 4 Aug 2026 15:24:24 +0100 Subject: [PATCH] fix(course): coerce metadata.discount to number before save (#830) * fix(course): coerce metadata.discount to number before save Normalize string discount values from the landing page pricing form and coerce legacy string metadata on ZCourseUpdate validation so course settings saves do not fail with a Zod type error. Fixes classroomio/classroomio#828 Co-authored-by: Rotimi Best * refactor(utils): add shared toFiniteNumber for metadata discount coercion Co-authored-by: Rotimi Best * fix(utils): only coerce string discount values in metadata preprocess Reject broad Number() coercion on booleans and arrays. String and finite number discounts are normalized; invalid types are left for Zod to reject. Addresses Greptile review on #830 Co-authored-by: Rotimi Best * refactor(dashboard): use writable $derived in pricing form again Keep toFiniteNumber coercion in setter effect; derived fields stay in sync when course loads or updates (Svelte 5 writable derived). Co-authored-by: Rotimi Best * fix(utils): reject invalid metadata.discount instead of clearing it Only coerce numeric strings in preprocess; leave invalid strings, non-finite numbers, and other types for Zod to reject. Co-authored-by: Rotimi Best --------- Co-authored-by: Cursor Agent Co-authored-by: Rotimi Best --- .../components/editor/pricing-form.svelte | 9 +++++---- packages/utils/src/functions/index.ts | 1 + packages/utils/src/functions/number.ts | 19 +++++++++++++++++++ .../utils/src/validation/course/course.ts | 10 ++++++++++ 4 files changed, 35 insertions(+), 4 deletions(-) create mode 100644 packages/utils/src/functions/number.ts diff --git a/apps/dashboard/src/lib/features/ui/course-landing-page/components/editor/pricing-form.svelte b/apps/dashboard/src/lib/features/ui/course-landing-page/components/editor/pricing-form.svelte index 3ff1906f..5367a37a 100644 --- a/apps/dashboard/src/lib/features/ui/course-landing-page/components/editor/pricing-form.svelte +++ b/apps/dashboard/src/lib/features/ui/course-landing-page/components/editor/pricing-form.svelte @@ -7,6 +7,7 @@ import type { Course } from '$features/course/utils/types'; import { t } from '$lib/utils/functions/translations'; import { isCourseFree } from '$lib/utils/functions/course'; + import { toFiniteNumber } from '@cio/utils/functions'; import { InputField } from '@cio/ui/custom/input-field'; import { TextEditor } from '$features/ui'; @@ -18,10 +19,10 @@ let { course = $bindable(), setter }: Props = $props(); - let discount = $derived(get(course, 'metadata.discount', 0)); let paymentLink = $derived(get(course, 'metadata.paymentLink', '')); - let showDiscount = $derived(get(course, 'metadata.showDiscount', false)); - let giftToggled = $derived(get(course, 'metadata.reward.show', false)); + let showDiscount = $derived(Boolean(get(course, 'metadata.showDiscount', false))); + let discount = $derived(toFiniteNumber(get(course, 'metadata.discount', 0)) ?? 0); + let giftToggled = $derived(Boolean(get(course, 'metadata.reward.show', false))); function handleChange(content: string) { setter(content, 'metadata.reward.description'); @@ -34,7 +35,7 @@ setter(paymentLink, 'metadata.paymentLink'); }); $effect(() => { - setter(discount, 'metadata.discount'); + setter(toFiniteNumber(discount) ?? 0, 'metadata.discount'); }); $effect(() => { setter(giftToggled, 'metadata.reward.show'); diff --git a/packages/utils/src/functions/index.ts b/packages/utils/src/functions/index.ts index c1c70430..8b1c5698 100644 --- a/packages/utils/src/functions/index.ts +++ b/packages/utils/src/functions/index.ts @@ -1,6 +1,7 @@ export * from './array'; export * from './currency'; export * from './fileValidation'; +export * from './number'; export * from './sanitize'; export * from './slug'; export * from './transcript-vtt'; diff --git a/packages/utils/src/functions/number.ts b/packages/utils/src/functions/number.ts new file mode 100644 index 00000000..f9a0cfee --- /dev/null +++ b/packages/utils/src/functions/number.ts @@ -0,0 +1,19 @@ +/** + * Coerces a number or numeric string to a finite number. + * Does not coerce booleans, arrays, or other types (unlike bare `Number()`). + * Empty, null, and invalid values return undefined. + */ +export function toFiniteNumber(value: unknown): number | undefined { + if (typeof value === 'number' && Number.isFinite(value)) { + return value; + } + + if (typeof value === 'string' && value.trim()) { + const parsed = Number(value); + if (Number.isFinite(parsed)) { + return parsed; + } + } + + return undefined; +} diff --git a/packages/utils/src/validation/course/course.ts b/packages/utils/src/validation/course/course.ts index 90604126..10efaf21 100644 --- a/packages/utils/src/validation/course/course.ts +++ b/packages/utils/src/validation/course/course.ts @@ -1,5 +1,6 @@ import * as z from 'zod'; +import { toFiniteNumber } from '../../functions/number'; import { ALLOWED_CONTENT_TYPES, ALLOWED_DOCUMENT_TYPES } from '../constants'; import { ZCourseCalloutInput } from './callout'; import { ZCourseType } from './course-type'; @@ -321,6 +322,15 @@ function preprocessCourseMetadata(value: unknown): unknown { metadata.allowNewStudent = true; } + if (metadata.discount === null || metadata.discount === '') { + metadata.discount = undefined; + } else if (typeof metadata.discount === 'string') { + const parsedDiscount = toFiniteNumber(metadata.discount); + if (parsedDiscount !== undefined) { + metadata.discount = parsedDiscount; + } + } + return metadata; }