feat: certification rules, content sanitization, handle draft course updates and reset content, better redis logging

This commit is contained in:
Rotimi Best
2026-03-30 08:24:55 +01:00
parent ff2ad59377
commit 5a8f50f67b
131 changed files with 5643 additions and 2134 deletions
+1
View File
@@ -1,3 +1,4 @@
export * from './array';
export * from './fileValidation';
export * from './sanitize';
export * from './slug';
+50
View File
@@ -0,0 +1,50 @@
export const FORBID_TAGS = [
'script',
'iframe',
'object',
'embed',
'form',
'input',
'textarea',
'select',
'button',
'meta',
'link',
'svg',
'math'
] as const;
export const FORBID_ATTR = [
'onerror',
'onload',
'onclick',
'onmouseover',
'onfocus',
'onblur',
'onchange',
'onsubmit',
'onreset',
'onselect',
'onunload'
] as const;
export const ADD_ATTR = ['data-type', 'data-latex', 'colwidth'] as const;
export const ALLOWED_URI_REGEXP =
/^(?:(?:(?:f|ht)tps?|mailto|tel|callto|sms|cid|xmpp):|[^a-z]|[a-z+.\-]+(?:[^a-z+.\-:]|$))/i;
export function createSanitizeHtmlConfig() {
return {
FORBID_TAGS: [...FORBID_TAGS],
FORBID_ATTR: [...FORBID_ATTR],
ADD_ATTR: [...ADD_ATTR],
KEEP_CONTENT: true,
SANITIZE_DOM: true,
ALLOW_DATA_ATTR: false,
ALLOWED_URI_REGEXP
};
}
export function stripSvgDataUrls(html: string): string {
return html.replace(/src\s*=\s*["']data:image\/svg[^"']*["']/gi, 'src=""');
}
+13 -2
View File
@@ -279,6 +279,18 @@ export const ZCourseLandingPageMetadataUpdate = z.object({
});
export type TCourseLandingPageMetadataUpdate = z.infer<typeof ZCourseLandingPageMetadataUpdate>;
export const ZCertificationSettings = z.object({
isDownloadable: z.boolean().optional(),
theme: z.string().optional(),
/** ISO 8601 datetime string or null to clear */
deadline: z.union([z.string().min(1), z.null()]).optional(),
threshold: z.number().int().min(0).max(100).optional(),
requiredExerciseId: z.union([z.string().uuid(), z.null()]).optional(),
exerciseMinScorePercent: z.number().int().min(0).max(100).nullable().optional(),
emailMessage: z.string().max(5000).nullable().optional()
});
export type TCertificationSettings = z.infer<typeof ZCertificationSettings>;
export const ZCourseUpdate = z.object({
title: z.string().min(1).optional(),
description: z.string().min(1).optional(),
@@ -290,8 +302,7 @@ export const ZCourseUpdate = z.object({
metadata: ZCourseMetadata.optional(),
cost: z.number().int().min(0).optional(),
currency: z.enum(['NGN', 'USD']).optional(),
isCertificateDownloadable: z.boolean().optional(),
certificateTheme: z.string().optional(),
certificate: ZCertificationSettings.optional(),
tagIds: z.array(z.uuid()).max(100).optional()
});
export type TCourseUpdate = z.infer<typeof ZCourseUpdate>;
@@ -155,6 +155,7 @@ export const ZExerciseUpdate = z.object({
order: z.number().int().optional(),
isUnlocked: z.boolean().optional(),
dueBy: z.string().optional(), // Changed from iso.datetime() to string to match frontend format
allowMultipleAttempts: z.boolean().optional(),
questions: z.array(ZExerciseUpdateQuestion).optional()
});
export type TExerciseUpdate = z.infer<typeof ZExerciseUpdate>;
@@ -1,5 +1,20 @@
import * as z from 'zod';
export const AudienceSortBy = z.enum(['createdAt', 'name', 'email']);
export const AudienceSortOrder = z.enum(['asc', 'desc']);
export const ZGetAudienceQuery = z.object({
page: z.coerce.number().int().min(1).default(1),
limit: z.coerce.number().int().min(1).max(100).default(20),
search: z.string().trim().optional(),
sortBy: AudienceSortBy.default('createdAt'),
sortOrder: AudienceSortOrder.default('desc')
});
export type TAudienceSortBy = z.infer<typeof AudienceSortBy>;
export type TAudienceSortOrder = z.infer<typeof AudienceSortOrder>;
export type TGetAudienceQuery = z.infer<typeof ZGetAudienceQuery>;
export const ZImportAudienceMembers = z.object({
recipientCsv: z.string().max(25000),
courseIds: z.array(z.string().uuid()).optional(),