feat: certification rules, content sanitization, handle draft course updates and reset content, better redis logging
This commit is contained in:
@@ -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);
|
||||
}
|
||||
})();
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user