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
+89
View File
@@ -0,0 +1,89 @@
import { getDashboardBaseUrl } from '@api/config/dashboard-url';
import {
getCourseProgress as getCourseProgressQuery,
type TCourseCertificationRow
} from '@cio/db/queries/course/course';
import { setMemberCertificateEarned, setMemberCertificationEmailSent } from '@cio/db/queries/course/people';
import { getProfileById } from '@cio/db/queries/auth';
import { buildEmailFromName, sendEmail } from '@cio/email';
export function calcCourseProgressPercent(params: {
lessonsCompleted: number;
totalLessons: number;
exercisesCompleted: number;
exercisesCount: number;
}): number {
const totalItems = params.totalLessons + params.exercisesCount;
if (totalItems === 0) return 0;
const completedItems = params.lessonsCompleted + params.exercisesCompleted;
return Math.round((completedItems / totalItems) * 100);
}
export function isBeforeOrEqualDeadline(deadlineIso: string | null | undefined, now: Date): boolean {
if (!deadlineIso) return true;
const deadlineDate = new Date(deadlineIso);
if (Number.isNaN(deadlineDate.getTime())) return true;
return now.getTime() <= deadlineDate.getTime();
}
/**
* Persists certificate earned time, then sends the completion email and records
* {@link setMemberCertificationEmailSent}, without blocking the HTTP handler.
* Re-fetches progress so concurrent jobs can skip work already done.
*/
export function scheduleCertificationCompletionWork(params: {
courseId: string;
profileId: string;
groupMemberId: string;
courseRow: TCourseCertificationRow;
earnedAt: string;
}): void {
const { courseId, profileId, groupMemberId, courseRow, earnedAt } = params;
void (async () => {
try {
let latest = await getCourseProgressQuery(courseId, profileId);
if (!latest.certificateEarnedAt) {
await setMemberCertificateEarned(groupMemberId, earnedAt);
latest = await getCourseProgressQuery(courseId, profileId);
}
if (latest.certificationEmailSentAt) {
return;
}
const profile = await getProfileById(profileId);
const studentEmail = profile?.email;
if (!studentEmail) {
return;
}
const base = getDashboardBaseUrl(courseRow.orgSiteName ?? undefined);
const certificateUrl = `${base}/courses/${courseId}/certificates`;
await sendEmail('studentCourseCompletion', {
to: studentEmail,
fields: {
orgName: courseRow.orgName,
courseName: courseRow.title,
studentName: profile?.fullname || studentEmail,
certificateUrl,
customMessage: courseRow.certificate?.emailMessage ?? null
},
from: buildEmailFromName(`${courseRow.orgName} (via ClassroomIO.com)`)
});
await setMemberCertificationEmailSent(groupMemberId, new Date().toISOString());
} catch (error) {
console.error('scheduleCertificationCompletionWork: failed:', error);
}
})();
}
+1
View File
@@ -60,6 +60,7 @@ export const ErrorCodes = {
ORG_PLAN_NOT_FOUND: 'ORG_PLAN_NOT_FOUND',
ORG_TEAM_INVITE_FAILED: 'ORG_TEAM_INVITE_FAILED',
ORG_TEAM_REMOVE_FAILED: 'ORG_TEAM_REMOVE_FAILED',
ORG_AUDIENCE_REMOVE_FAILED: 'ORG_AUDIENCE_REMOVE_FAILED',
ORG_TEAM_EMAIL_EXISTS: 'ORG_TEAM_EMAIL_EXISTS',
ORG_TEAM_NOT_AUTHORIZED: 'ORG_TEAM_NOT_AUTHORIZED',
ORGANIZATION_API_KEY_NOT_FOUND: 'ORGANIZATION_API_KEY_NOT_FOUND',
+20 -7
View File
@@ -6,25 +6,38 @@ const client: RedisClientType = createClient({
url: env.REDIS_URL
});
let hasLoggedRedisUnavailable = false;
export function logRedisUnavailableOnce(message: string, error?: unknown): void {
if (hasLoggedRedisUnavailable) {
return;
}
hasLoggedRedisUnavailable = true;
if (error) {
console.warn(message, error);
return;
}
console.warn(message);
}
// Handle connection events
client.on('error', (err) => {
console.error('Redis Client Error:', err);
logRedisUnavailableOnce('Redis unavailable, Redis-backed features disabled', err);
});
client.on('connect', () => {
console.log('Redis Client Connected');
});
client.on('reconnecting', () => {
console.log('Redis Client Reconnecting');
});
// Track connection state
let isConnected = false;
export async function connectRedis(): Promise<void> {
if (!env.REDIS_URL) {
console.warn('REDIS_URL not set, Redis features disabled');
logRedisUnavailableOnce('REDIS_URL not set, Redis features disabled');
return;
}
if (isConnected) return;
@@ -32,7 +45,7 @@ export async function connectRedis(): Promise<void> {
await client.connect();
isConnected = true;
} catch (error) {
console.error('Redis connection failed, API will run without Redis:', error);
logRedisUnavailableOnce('Redis connection failed, API will run without Redis', error);
// Don't throw - allow API to start; rate limiting and caching will degrade gracefully
}
}
+3 -3
View File
@@ -9,7 +9,7 @@ import {
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
import { getPresignS3Client, getS3Client, getStorageConfig } from '@api/config/storage';
import { redis } from '@api/utils/redis/redis';
import { logRedisUnavailableOnce, redis } from '@api/utils/redis/redis';
export type GetSignedUrlParameters = Parameters<typeof getSignedUrl>;
@@ -70,7 +70,7 @@ export async function generateDownloadPresignedUrls(
try {
cachedValues = await redis.mGet(cacheKeys);
} catch (error) {
console.error('Redis mGet failed for presigned URL cache, falling back to S3:', error);
logRedisUnavailableOnce('Redis cache unavailable for presigned URLs, falling back to S3', error);
}
const keysToGenerate: string[] = [];
@@ -121,7 +121,7 @@ export async function generateDownloadPresignedUrls(
});
await pipeline.exec();
} catch (error) {
console.error('Redis setEx failed for presigned URL cache:', error);
logRedisUnavailableOnce('Redis cache write unavailable for presigned URLs, continuing without cache', error);
// URLs are already in signedUrls; cache write failure is non-fatal
}
+39
View File
@@ -0,0 +1,39 @@
import { createSanitizeHtmlConfig, stripSvgDataUrls } from '@cio/utils/functions';
import DOMPurify from 'isomorphic-dompurify';
export function sanitizeHtml(html: string): string {
let sanitized = DOMPurify.sanitize(html, createSanitizeHtmlConfig());
sanitized = stripSvgDataUrls(sanitized);
return sanitized;
}
export function sanitizeOptionalHtml(html: string | undefined): string | undefined;
export function sanitizeOptionalHtml(html: string | null | undefined): string | null | undefined;
export function sanitizeOptionalHtml(html: string | null | undefined): string | null | undefined {
if (html == null) return html;
return sanitizeHtml(html);
}
export function sanitizeUnknownStrings<T>(value: T): T {
if (typeof value === 'string') {
return sanitizeHtml(value) as T;
}
if (Array.isArray(value)) {
return value.map((item) => sanitizeUnknownStrings(item)) as T;
}
if (value && typeof value === 'object') {
const sanitizedEntries = Object.entries(value).map(([key, entryValue]) => [
key,
sanitizeUnknownStrings(entryValue)
]);
return Object.fromEntries(sanitizedEntries) as T;
}
return value;
}