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 <rotimi-best@users.noreply.github.com>

* refactor(utils): add shared toFiniteNumber for metadata discount coercion

Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com>

* 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 <rotimi-best@users.noreply.github.com>

* 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 <rotimi-best@users.noreply.github.com>

* 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 <rotimi-best@users.noreply.github.com>

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com>
This commit is contained in:
Rotimi Best
2026-08-04 15:24:24 +01:00
committed by GitHub
co-authored by Rotimi Best Cursor Agent
parent 0c295580f1
commit ee4d4e031d
4 changed files with 35 additions and 4 deletions
@@ -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');
+1
View File
@@ -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';
+19
View File
@@ -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;
}
@@ -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;
}