Merge branch 'dev' into feat/232-assignments-pagina-ui-ux

Merge dev into feat/232-assignments-pagina-ui-ux
This commit is contained in:
Joyelle Ndagijimana 2025-05-18 00:17:41 +02:00
commit 96076844a5
56 changed files with 1265 additions and 867 deletions

44
backend/src/caching.ts Normal file
View file

@ -0,0 +1,44 @@
import { getLogger } from './logging/initalize.js';
import { envVars, getEnvVar } from './util/envVars.js';
import { Client } from 'memjs';
export type CacheClient = Client;
let cacheClient: CacheClient;
async function initializeClient(): Promise<CacheClient> {
if (cacheClient !== undefined) {
return cacheClient;
}
const cachingHost = getEnvVar(envVars.CacheHost);
const cachingPort = getEnvVar(envVars.CachePort);
const cachingUrl = `${cachingHost}:${cachingPort}`;
if (cachingHost === '') {
return cacheClient;
}
cacheClient = Client.create(cachingUrl);
getLogger().info(`Memcached client initialized at ${cachingUrl}`);
return cacheClient;
}
export async function getCacheClient(): Promise<CacheClient> {
cacheClient ||= await initializeClient();
return cacheClient;
}
export async function checkCachingHealth(): Promise<boolean> {
try {
const client = await getCacheClient();
await client.set('health', Buffer.from('ok'), { expires: 60 });
const reply = await cacheClient.get('health');
return reply?.value?.toString() === 'ok';
} catch (error) {
getLogger().error('Caching Health Check Failed:', error);
return false;
}
}

View file

@ -29,7 +29,8 @@ function initializeLogger(): Logger {
format: format.combine(format.cli(), format.simple()),
});
if (getEnvVar(envVars.RunMode) === 'dev') {
const runMode = getEnvVar(envVars.RunMode);
if (runMode === 'dev' || runMode.includes('test')) {
logger = createLogger({
transports: [consoleTransport],
});

View file

@ -7,12 +7,17 @@ let orm: MikroORM | undefined;
export async function initORM(testingMode = false): Promise<MikroORM<IDatabaseDriver, EntityManager>> {
const logger: Logger = getLogger();
logger.info('Initializing ORM');
logger.debug('MikroORM config is', config);
const options = config(testingMode);
logger.info('MikroORM config is', options);
logger.info('Initializing ORM');
orm = await MikroORM.init(options);
logger.info('MikroORM initialized');
orm = await MikroORM.init(config(testingMode));
// Update the database scheme if necessary and enabled.
if (getEnvVar(envVars.DbUpdate)) {
logger.info('MikroORM: Updating database schema');
await orm.schema.updateSchema();
} else {
const diff = await orm.schema.getUpdateSchemaSQL();

View file

@ -1,5 +1,5 @@
import { DWENGO_API_BASE } from '../config.js';
import { fetchWithLogging } from '../util/api-helper.js';
import { fetchRemote } from '../util/api-helper.js';
import {
FilteredLearningObject,
@ -39,10 +39,7 @@ function filterData(data: LearningObjectMetadata, htmlUrl: string): FilteredLear
*/
export async function getLearningObjectById(hruid: string, language: string): Promise<FilteredLearningObject | null> {
const metadataUrl = `${DWENGO_API_BASE}/learningObject/getMetadata?hruid=${hruid}&language=${language}`;
const metadata = await fetchWithLogging<LearningObjectMetadata>(
metadataUrl,
`Metadata for Learning Object HRUID "${hruid}" (language ${language})`
);
const metadata = await fetchRemote<LearningObjectMetadata>(metadataUrl, `Metadata for Learning Object HRUID "${hruid}" (language ${language})`);
if (!metadata) {
getLogger().error(`⚠️ WARNING: Learning object "${hruid}" not found.`);

View file

@ -1,5 +1,5 @@
import { DWENGO_API_BASE } from '../../config.js';
import { fetchWithLogging } from '../../util/api-helper.js';
import { fetchRemote } from '../../util/api-helper.js';
import dwengoApiLearningPathProvider from '../learning-paths/dwengo-api-learning-path-provider.js';
import { LearningObjectProvider } from './learning-object-provider.js';
import { getLogger, Logger } from '../../logging/initalize.js';
@ -88,7 +88,7 @@ const dwengoApiLearningObjectProvider: LearningObjectProvider = {
*/
async getLearningObjectById(id: LearningObjectIdentifierDTO): Promise<FilteredLearningObject | null> {
const metadataUrl = `${DWENGO_API_BASE}/learningObject/getMetadata`;
const metadata = await fetchWithLogging<LearningObjectMetadata>(
const metadata = await fetchRemote<LearningObjectMetadata>(
metadataUrl,
`Metadata for Learning Object HRUID "${id.hruid}" (language ${id.language})`,
{
@ -124,7 +124,7 @@ const dwengoApiLearningObjectProvider: LearningObjectProvider = {
*/
async getLearningObjectHTML(id: LearningObjectIdentifierDTO): Promise<string | null> {
const htmlUrl = `${DWENGO_API_BASE}/learningObject/getRaw`;
const html = await fetchWithLogging<string>(htmlUrl, `Metadata for Learning Object HRUID "${id.hruid}" (language ${id.language})`, {
const html = await fetchRemote<string>(htmlUrl, `Metadata for Learning Object HRUID "${id.hruid}" (language ${id.language})`, {
params: { ...id },
});

View file

@ -1,4 +1,4 @@
import { fetchWithLogging } from '../../util/api-helper.js';
import { fetchRemote } from '../../util/api-helper.js';
import { DWENGO_API_BASE } from '../../config.js';
import { LearningPathProvider } from './learning-path-provider.js';
import { getLogger, Logger } from '../../logging/initalize.js';
@ -42,7 +42,7 @@ const dwengoApiLearningPathProvider: LearningPathProvider = {
const apiUrl = `${DWENGO_API_BASE}/learningPath/getPathsFromIdList`;
const params = { pathIdList: JSON.stringify({ hruids }), language };
const learningPaths = await fetchWithLogging<LearningPath[]>(apiUrl, `Learning paths for ${source}`, { params });
const learningPaths = await fetchRemote<LearningPath[]>(apiUrl, `Learning paths for ${source}`, { params });
if (!learningPaths || learningPaths.length === 0) {
logger.warn(`⚠️ WARNING: No learning paths found for ${source}.`);
@ -66,12 +66,11 @@ const dwengoApiLearningPathProvider: LearningPathProvider = {
const apiUrl = `${DWENGO_API_BASE}/learningPath/search`;
const params = { all: query, language };
const searchResults = await fetchWithLogging<LearningPath[]>(apiUrl, `Search learning paths with query "${query}"`, { params });
const searchResults = await fetchRemote<LearningPath[]>(apiUrl, `Search learning paths with query "${query}"`, { params });
if (searchResults) {
await Promise.all(searchResults?.map(async (it) => addProgressToLearningPath(it, personalizedFor)));
}
return searchResults ?? [];
},

View file

@ -1,28 +1,66 @@
import axios, { AxiosRequestConfig } from 'axios';
import { getLogger, Logger } from '../logging/initalize.js';
import { LearningObjectIdentifier } from '../entities/content/learning-object-identifier.js';
import { getCacheClient } from '../caching.js';
import { envVars, getEnvVar, getNumericEnvVar } from './envVars.js';
import { createHash } from 'crypto';
const cacheClient = await getCacheClient();
const logger: Logger = getLogger();
const runMode: string = getEnvVar(envVars.RunMode);
const prefix: string = getEnvVar(envVars.CacheKeyPrefix);
interface Options {
params?: Record<string, unknown> | LearningObjectIdentifier;
query?: Record<string, unknown>;
responseType?: 'json' | 'text';
}
/**
* Utility function to fetch data from an API endpoint with error handling.
* Utility function to fetch data from an API endpoint with error handling and caching.
* Logs errors but does NOT throw exceptions to keep the system running.
*
* @param url The API endpoint to fetch from.
* @param description A short description of what is being fetched (for logging).
* @param options Contains further options such as params (the query params) and responseType (whether the response
* should be parsed as JSON ("json") or whether it should be returned as plain text ("text")
* @param cacheTTL Time-to-live for the cache in seconds (default: 60 seconds).
* @returns The response data if successful, or null if an error occurs.
*/
export async function fetchWithLogging<T>(
url: string,
description: string,
options?: {
params?: Record<string, unknown> | LearningObjectIdentifier;
query?: Record<string, unknown>;
responseType?: 'json' | 'text';
export async function fetchRemote<T>(url: string, description: string, options?: Options, cacheTTL?: number): Promise<T | null> {
if (runMode !== 'dev' && !runMode.includes('test') && cacheClient !== undefined) {
return fetchWithCache<T>(url, description, options, cacheTTL);
}
): Promise<T | null> {
getLogger().debug(`🔄 INFO: Bypassing cache for ${description} at "${url}".`);
return fetchWithLogging(url, description, options);
}
async function fetchWithCache<T>(url: string, description: string, options?: Options, cacheTTL?: number): Promise<T | null> {
// Combine the URL and parameters to create a unique cache key.
// NOTE Using a hash function to keep the key short, since Memcached has a limit on key size
const urlWithParams = `${url}${options?.params ? JSON.stringify(options.params) : ''}`;
const hashedUrl = createHash('sha256').update(urlWithParams).digest('hex');
const key = `${prefix}:${hashedUrl}`;
const cachedData = await cacheClient.get(key);
if (cachedData?.value) {
logger.debug(`✅ INFO: Cache hit for ${description} at "${url}" (key: "${key}")`);
return JSON.parse(cachedData.value.toString()) as T;
}
logger.debug(`🔄 INFO: Cache miss for ${description} at "${url}". Fetching data...`);
const response = await fetchWithLogging<T>(url, description, options);
const ttl = cacheTTL || getNumericEnvVar(envVars.CacheTTL);
await cacheClient.set(key, JSON.stringify(response), { expires: ttl });
logger.debug(`✅ INFO: Cached response for ${description} at "${url}" for ${ttl} seconds. (key: "${key}")`);
return response;
}
async function fetchWithLogging<T>(url: string, description: string, options?: Options): Promise<T | null> {
try {
const config: AxiosRequestConfig = options || {};
const response = await axios.get<T>(url, config);

View file

@ -5,6 +5,7 @@ const STUDENT_IDP_PREFIX = IDP_PREFIX + 'STUDENT_';
const TEACHER_IDP_PREFIX = IDP_PREFIX + 'TEACHER_';
const CORS_PREFIX = PREFIX + 'CORS_';
const LOGGING_PREFIX = PREFIX + 'LOGGING_';
const CACHE_PREFIX = PREFIX + 'CACHE_';
interface EnvVar {
key: string;
@ -39,6 +40,11 @@ export const envVars: Record<string, EnvVar> = {
LogLevel: { key: LOGGING_PREFIX + 'LEVEL', defaultValue: 'info' },
LokiHost: { key: LOGGING_PREFIX + 'LOKI_HOST', defaultValue: 'http://localhost:3102' },
CacheHost: { key: CACHE_PREFIX + 'HOST' },
CachePort: { key: CACHE_PREFIX + 'PORT', defaultValue: 11211 },
CacheTTL: { key: CACHE_PREFIX + 'TTL', defaultValue: 60 * 60 * 24 }, // 24 hours
CacheKeyPrefix: { key: CACHE_PREFIX + 'KEY_PREFIX', defaultValue: 'dwengo' },
} as const;
/**
@ -56,7 +62,7 @@ export function getEnvVar(envVar: EnvVar): string {
} else if (envVar.required) {
throw new Error(`Missing environment variable: ${envVar.key}`);
} else {
return String(envVar.defaultValue) || '';
return envVar.defaultValue !== undefined ? String(envVar.defaultValue) || '' : '';
}
}