From 8e447b381c46f4bb7d651b11c43808a0debdf4f5 Mon Sep 17 00:00:00 2001 From: TheLux Date: Sat, 27 Jun 2026 13:07:32 +0000 Subject: [PATCH] feat: implement activity log, error logging, TOS modal, privacy policy enforcer - #829 Activity Log for User Dashboard: service (auditLog-backed) + CRUD routes - #833 Error Logging Dashboard for Blockchain Learning Simulator: in-memory log service + REST routes - #834 Terms of Service Modal for Web3 Learning Roadmap: audit-persisted acceptance service + routes - #835 Privacy Policy Enforcer for Smart Contract Playground: audit-persisted consent service + routes - Register all new routes in backend/src/routes/index.ts --- backend/src/dashboard/activityLog.routes.ts | 51 +++++++++ backend/src/dashboard/activityLog.service.ts | 101 +++++++++++++++++ backend/src/routes/index.ts | 8 ++ backend/src/routes/privacyPolicy.routes.ts | 61 ++++++++++ backend/src/routes/simulatorErrors.routes.ts | 105 ++++++++++++++++++ backend/src/routes/termsOfService.routes.ts | 61 ++++++++++ backend/src/services/privacyPolicy.service.ts | 92 +++++++++++++++ .../src/services/simulatorErrorLog.service.ts | 89 +++++++++++++++ .../src/services/termsOfService.service.ts | 75 +++++++++++++ 9 files changed, 643 insertions(+) create mode 100644 backend/src/dashboard/activityLog.routes.ts create mode 100644 backend/src/dashboard/activityLog.service.ts create mode 100644 backend/src/routes/privacyPolicy.routes.ts create mode 100644 backend/src/routes/simulatorErrors.routes.ts create mode 100644 backend/src/routes/termsOfService.routes.ts create mode 100644 backend/src/services/privacyPolicy.service.ts create mode 100644 backend/src/services/simulatorErrorLog.service.ts create mode 100644 backend/src/services/termsOfService.service.ts diff --git a/backend/src/dashboard/activityLog.routes.ts b/backend/src/dashboard/activityLog.routes.ts new file mode 100644 index 00000000..a6d42d85 --- /dev/null +++ b/backend/src/dashboard/activityLog.routes.ts @@ -0,0 +1,51 @@ +import { Request, Response, Router } from 'express'; +import { authenticate } from '../auth/auth.middleware.js'; +import logger from '../utils/logger.js'; +import { getUserActivityLog, recordActivity } from './activityLog.service.js'; + +const router = Router(); + +/** + * @route GET /api/v1/dashboard/activity-log + * @desc Get paginated activity log for the authenticated user + * @access Private + */ +router.get('/', authenticate, async (req: Request, res: Response): Promise => { + try { + const userId = req.user!.id; + const page = Math.max(1, parseInt(req.query.page as string) || 1); + const pageSize = Math.min(100, Math.max(1, parseInt(req.query.pageSize as string) || 20)); + + const result = await getUserActivityLog(userId, page, pageSize); + res.json({ status: 'success', data: result }); + } catch (error) { + logger.error('GET /dashboard/activity-log failed', error); + res.status(500).json({ status: 'error', message: 'Failed to fetch activity log' }); + } +}); + +/** + * @route POST /api/v1/dashboard/activity-log + * @desc Record a manual activity entry for the authenticated user + * @access Private + */ +router.post('/', authenticate, async (req: Request, res: Response): Promise => { + try { + const userId = req.user!.id; + const { action, entity, entityId, details } = req.body; + + if (!action || typeof action !== 'string') { + res.status(400).json({ status: 'error', message: 'action is required' }); + return; + } + + const ipAddress = (req.headers['x-forwarded-for'] as string)?.split(',')[0] ?? req.ip; + const entry = await recordActivity(userId, action, entity, entityId, details, ipAddress); + res.status(201).json({ status: 'success', data: entry }); + } catch (error) { + logger.error('POST /dashboard/activity-log failed', error); + res.status(500).json({ status: 'error', message: 'Failed to record activity' }); + } +}); + +export default router; diff --git a/backend/src/dashboard/activityLog.service.ts b/backend/src/dashboard/activityLog.service.ts new file mode 100644 index 00000000..b36fec29 --- /dev/null +++ b/backend/src/dashboard/activityLog.service.ts @@ -0,0 +1,101 @@ +import prisma from '../db/index.js'; +import logger from '../utils/logger.js'; + +export interface ActivityLogEntry { + id: string; + userId: string; + action: string; + entity: string | null; + entityId: string | null; + details: Record | null; + ipAddress: string | null; + createdAt: Date; +} + +export interface ActivityLogResponse { + entries: ActivityLogEntry[]; + total: number; + page: number; + pageSize: number; +} + +/** + * Fetch paginated activity log entries for a specific user. + */ +export async function getUserActivityLog( + userId: string, + page = 1, + pageSize = 20 +): Promise { + const skip = (page - 1) * pageSize; + + try { + const [entries, total] = await Promise.all([ + prisma.auditLog.findMany({ + where: { userId }, + orderBy: { createdAt: 'desc' }, + skip, + take: pageSize, + select: { + id: true, + userId: true, + action: true, + entity: true, + entityId: true, + details: true, + ipAddress: true, + createdAt: true, + }, + }), + prisma.auditLog.count({ where: { userId } }), + ]); + + return { + entries: entries.map((e) => ({ + ...e, + userId: e.userId ?? userId, + details: (e.details as Record) ?? null, + })), + total, + page, + pageSize, + }; + } catch (error) { + logger.error('Failed to fetch activity log', { userId, error }); + throw error; + } +} + +/** + * Record a new activity log entry. + */ +export async function recordActivity( + userId: string, + action: string, + entity?: string, + entityId?: string, + details?: Record, + ipAddress?: string +): Promise { + try { + const entry = await prisma.auditLog.create({ + data: { + userId, + action, + entity: entity ?? null, + entityId: entityId ?? null, + details: details ?? undefined, + ipAddress: ipAddress ?? null, + }, + }); + + return { + ...entry, + userId: entry.userId ?? userId, + details: (entry.details as Record) ?? null, + }; + } catch (error) { + logger.error('Failed to record activity', { userId, action, error }); + throw error; + } +} diff --git a/backend/src/routes/index.ts b/backend/src/routes/index.ts index 7a1160e2..d8e2e3b8 100644 --- a/backend/src/routes/index.ts +++ b/backend/src/routes/index.ts @@ -1,6 +1,7 @@ // @ts-nocheck import { Router } from 'express'; import dashboardRoutes from '../dashboard/dashboard.routes.js'; +import activityLogRouter from '../dashboard/activityLog.routes.js'; import feedbackRouter from '../feedback/feedback.routes.js'; import userRouter from '../user/routes.js'; import analyticsRouter from './analytics.routes.js'; @@ -15,6 +16,9 @@ import healthRouter from './health.routes.js'; import learningRoutes from './learning/learning.routes.js'; import securityRouter from './security.routes.js'; import studentsRouter from './students.js'; +import simulatorErrorsRouter from './simulatorErrors.routes.js'; +import termsOfServiceRouter from './termsOfService.routes.js'; +import privacyPolicyRouter from './privacyPolicy.routes.js'; import notificationRouter from '../notifications/notification.routes.js'; import notificationPreferencesRouter from '../notifications/preferences.routes.js'; @@ -31,6 +35,7 @@ router.use('/certificates', certificatesRouter); router.use('/courses', coursesRouter); router.use('/enrollments', enrollmentsRouter); router.use('/dashboard', dashboardRoutes); +router.use('/dashboard/activity-log', activityLogRouter); router.use('/feedback', feedbackRouter); router.use('/auth', authRoutes); router.use('/learning', learningRoutes); @@ -43,5 +48,8 @@ router.use('/export', exportRouter); router.use('/webhooks', webhooksRouter); router.use('/user', userRouter); router.use('/metrics', metricsRouter); +router.use('/simulator/errors', simulatorErrorsRouter); +router.use('/roadmap/tos', termsOfServiceRouter); +router.use('/playground/privacy-policy', privacyPolicyRouter); export default router; diff --git a/backend/src/routes/privacyPolicy.routes.ts b/backend/src/routes/privacyPolicy.routes.ts new file mode 100644 index 00000000..de91794a --- /dev/null +++ b/backend/src/routes/privacyPolicy.routes.ts @@ -0,0 +1,61 @@ +import { Request, Response, Router } from 'express'; +import { authenticate } from '../auth/auth.middleware.js'; +import logger from '../utils/logger.js'; +import { + getCurrentPolicyVersion, + hasConsented, + recordPolicyConsent, +} from '../services/privacyPolicy.service.js'; + +const router = Router(); + +/** + * @route GET /api/v1/playground/privacy-policy + * @desc Get the current privacy policy version + * @access Public + */ +router.get('/', (_req: Request, res: Response): void => { + res.json({ + status: 'success', + data: { version: getCurrentPolicyVersion() }, + }); +}); + +/** + * @route GET /api/v1/playground/privacy-policy/status + * @desc Check whether the authenticated user has consented to the privacy policy + * @access Private + */ +router.get('/status', authenticate, async (req: Request, res: Response): Promise => { + try { + const userId = req.user!.id; + const policyVersion = (req.query.version as string) || getCurrentPolicyVersion(); + const consented = await hasConsented(userId, policyVersion); + res.json({ status: 'success', data: { consented, policyVersion } }); + } catch (error) { + logger.error('GET /playground/privacy-policy/status failed', error); + res.status(500).json({ status: 'error', message: 'Failed to check policy consent' }); + } +}); + +/** + * @route POST /api/v1/playground/privacy-policy/consent + * @desc Record privacy policy consent for the authenticated user + * @access Private + */ +router.post('/consent', authenticate, async (req: Request, res: Response): Promise => { + try { + const userId = req.user!.id; + const policyVersion = req.body.policyVersion || getCurrentPolicyVersion(); + const ipAddress = + (req.headers['x-forwarded-for'] as string)?.split(',')[0] ?? req.ip; + + const record = await recordPolicyConsent(userId, policyVersion, ipAddress); + res.status(201).json({ status: 'success', data: record }); + } catch (error) { + logger.error('POST /playground/privacy-policy/consent failed', error); + res.status(500).json({ status: 'error', message: 'Failed to record policy consent' }); + } +}); + +export default router; diff --git a/backend/src/routes/simulatorErrors.routes.ts b/backend/src/routes/simulatorErrors.routes.ts new file mode 100644 index 00000000..9fd0b340 --- /dev/null +++ b/backend/src/routes/simulatorErrors.routes.ts @@ -0,0 +1,105 @@ +import { Request, Response, Router } from 'express'; +import { authenticate } from '../auth/auth.middleware.js'; +import logger from '../utils/logger.js'; +import { + clearSessionErrors, + ErrorSeverity, + getSessionErrors, + getUserErrors, + logSimulatorError, +} from '../services/simulatorErrorLog.service.js'; + +const router = Router(); + +const VALID_SEVERITIES: ErrorSeverity[] = ['low', 'medium', 'high', 'critical']; + +/** + * @route POST /api/v1/simulator/errors + * @desc Log a new error from the blockchain learning simulator + * @access Private + */ +router.post('/', authenticate, async (req: Request, res: Response): Promise => { + try { + const userId = req.user!.id; + const { sessionId, severity, code, message, context } = req.body; + + if (!sessionId || typeof sessionId !== 'string') { + res.status(400).json({ status: 'error', message: 'sessionId is required' }); + return; + } + if (!VALID_SEVERITIES.includes(severity)) { + res.status(400).json({ + status: 'error', + message: `severity must be one of: ${VALID_SEVERITIES.join(', ')}`, + }); + return; + } + if (!code || typeof code !== 'string') { + res.status(400).json({ status: 'error', message: 'code is required' }); + return; + } + if (!message || typeof message !== 'string') { + res.status(400).json({ status: 'error', message: 'message is required' }); + return; + } + + const entry = logSimulatorError( + sessionId, + severity, + code, + message, + typeof context === 'object' && context !== null ? context : {}, + userId + ); + + res.status(201).json({ status: 'success', data: entry }); + } catch (error) { + logger.error('POST /simulator/errors failed', error); + res.status(500).json({ status: 'error', message: 'Failed to log simulator error' }); + } +}); + +/** + * @route GET /api/v1/simulator/errors/session/:sessionId + * @desc Get errors for a specific simulator session + * @access Private + */ +router.get('/session/:sessionId', authenticate, (req: Request, res: Response): void => { + const { sessionId } = req.params; + const severity = req.query.severity as ErrorSeverity | undefined; + + if (severity && !VALID_SEVERITIES.includes(severity)) { + res.status(400).json({ + status: 'error', + message: `severity must be one of: ${VALID_SEVERITIES.join(', ')}`, + }); + return; + } + + const errors = getSessionErrors(sessionId, severity); + res.json({ status: 'success', data: { errors, total: errors.length } }); +}); + +/** + * @route GET /api/v1/simulator/errors/me + * @desc Get all errors logged by the authenticated user + * @access Private + */ +router.get('/me', authenticate, (req: Request, res: Response): void => { + const userId = req.user!.id; + const errors = getUserErrors(userId); + res.json({ status: 'success', data: { errors, total: errors.length } }); +}); + +/** + * @route DELETE /api/v1/simulator/errors/session/:sessionId + * @desc Clear all errors for a simulator session + * @access Private + */ +router.delete('/session/:sessionId', authenticate, (req: Request, res: Response): void => { + const { sessionId } = req.params; + const cleared = clearSessionErrors(sessionId); + res.json({ status: 'success', data: { cleared } }); +}); + +export default router; diff --git a/backend/src/routes/termsOfService.routes.ts b/backend/src/routes/termsOfService.routes.ts new file mode 100644 index 00000000..2eba257f --- /dev/null +++ b/backend/src/routes/termsOfService.routes.ts @@ -0,0 +1,61 @@ +import { Request, Response, Router } from 'express'; +import { authenticate } from '../auth/auth.middleware.js'; +import logger from '../utils/logger.js'; +import { + acceptTermsOfService, + getCurrentTosVersion, + hasTosAcceptance, +} from '../services/termsOfService.service.js'; + +const router = Router(); + +/** + * @route GET /api/v1/roadmap/tos + * @desc Get the current Terms of Service version + * @access Public + */ +router.get('/', (_req: Request, res: Response): void => { + res.json({ + status: 'success', + data: { version: getCurrentTosVersion() }, + }); +}); + +/** + * @route GET /api/v1/roadmap/tos/status + * @desc Check whether the authenticated user has accepted the current TOS + * @access Private + */ +router.get('/status', authenticate, async (req: Request, res: Response): Promise => { + try { + const userId = req.user!.id; + const version = (req.query.version as string) || getCurrentTosVersion(); + const accepted = await hasTosAcceptance(userId, version); + res.json({ status: 'success', data: { accepted, version } }); + } catch (error) { + logger.error('GET /roadmap/tos/status failed', error); + res.status(500).json({ status: 'error', message: 'Failed to check TOS status' }); + } +}); + +/** + * @route POST /api/v1/roadmap/tos/accept + * @desc Record acceptance of the Terms of Service for the authenticated user + * @access Private + */ +router.post('/accept', authenticate, async (req: Request, res: Response): Promise => { + try { + const userId = req.user!.id; + const version = req.body.version || getCurrentTosVersion(); + const ipAddress = + (req.headers['x-forwarded-for'] as string)?.split(',')[0] ?? req.ip; + + const record = await acceptTermsOfService(userId, version, ipAddress); + res.status(201).json({ status: 'success', data: record }); + } catch (error) { + logger.error('POST /roadmap/tos/accept failed', error); + res.status(500).json({ status: 'error', message: 'Failed to record TOS acceptance' }); + } +}); + +export default router; diff --git a/backend/src/services/privacyPolicy.service.ts b/backend/src/services/privacyPolicy.service.ts new file mode 100644 index 00000000..81b54d63 --- /dev/null +++ b/backend/src/services/privacyPolicy.service.ts @@ -0,0 +1,92 @@ +import prisma from '../db/index.js'; +import logger from '../utils/logger.js'; + +const CURRENT_POLICY_VERSION = '1.0.0'; + +export interface PolicyConsentRecord { + userId: string; + policyVersion: string; + consentGiven: boolean; + consentAt: Date; + ipAddress: string | null; +} + +/** + * Record a user's privacy policy consent. + */ +export async function recordPolicyConsent( + userId: string, + policyVersion: string, + ipAddress?: string +): Promise { + try { + await prisma.auditLog.create({ + data: { + userId, + action: 'PRIVACY_POLICY_ACCEPTED', + entity: 'privacy_policy', + entityId: policyVersion, + details: { policyVersion, consentAt: new Date().toISOString() }, + ipAddress: ipAddress ?? null, + }, + }); + + logger.info('Privacy policy accepted', { userId, policyVersion }); + + return { + userId, + policyVersion, + consentGiven: true, + consentAt: new Date(), + ipAddress: ipAddress ?? null, + }; + } catch (error) { + logger.error('Failed to record privacy policy consent', { userId, policyVersion, error }); + throw error; + } +} + +/** + * Check whether a user has consented to the current (or a specific) policy version. + */ +export async function hasConsented( + userId: string, + policyVersion = CURRENT_POLICY_VERSION +): Promise { + try { + const record = await prisma.auditLog.findFirst({ + where: { + userId, + action: 'PRIVACY_POLICY_ACCEPTED', + entityId: policyVersion, + }, + }); + return record !== null; + } catch (error) { + logger.error('Failed to check policy consent', { userId, policyVersion, error }); + throw error; + } +} + +/** + * Enforce privacy policy: throw if the user has not consented. + */ +export async function enforceConsent( + userId: string, + policyVersion = CURRENT_POLICY_VERSION +): Promise { + const consented = await hasConsented(userId, policyVersion); + if (!consented) { + throw Object.assign( + new Error(`User ${userId} has not accepted privacy policy version ${policyVersion}`), + { code: 'PRIVACY_POLICY_NOT_ACCEPTED', policyVersion } + ); + } +} + +/** + * Return the current privacy policy version the platform requires. + */ +export function getCurrentPolicyVersion(): string { + return CURRENT_POLICY_VERSION; +} diff --git a/backend/src/services/simulatorErrorLog.service.ts b/backend/src/services/simulatorErrorLog.service.ts new file mode 100644 index 00000000..286d81af --- /dev/null +++ b/backend/src/services/simulatorErrorLog.service.ts @@ -0,0 +1,89 @@ +import logger from '../utils/logger.js'; + +export type ErrorSeverity = 'low' | 'medium' | 'high' | 'critical'; + +export interface SimulatorError { + id: string; + sessionId: string; + userId?: string; + severity: ErrorSeverity; + code: string; + message: string; + context: Record; + timestamp: Date; +} + +// In-memory store for the simulator session (not persisted to DB — +// simulator errors are ephemeral by design). +const errorStore: SimulatorError[] = []; + +const MAX_ERRORS = 500; + +function generateId(): string { + return `${Date.now()}-${Math.random().toString(36).slice(2, 9)}`; +} + +/** + * Record a new simulator error. + */ +export function logSimulatorError( + sessionId: string, + severity: ErrorSeverity, + code: string, + message: string, + context: Record = {}, + userId?: string +): SimulatorError { + const entry: SimulatorError = { + id: generateId(), + sessionId, + userId, + severity, + code, + message, + context, + timestamp: new Date(), + }; + + errorStore.push(entry); + + // Evict oldest when the cap is reached + if (errorStore.length > MAX_ERRORS) { + errorStore.splice(0, errorStore.length - MAX_ERRORS); + } + + logger.warn('Simulator error logged', { id: entry.id, sessionId, severity, code }); + return entry; +} + +/** + * Retrieve errors for a session, optionally filtered by severity. + */ +export function getSessionErrors( + sessionId: string, + severity?: ErrorSeverity +): SimulatorError[] { + return errorStore.filter( + (e) => e.sessionId === sessionId && (severity == null || e.severity === severity) + ); +} + +/** + * Retrieve all errors for a user across sessions. + */ +export function getUserErrors(userId: string): SimulatorError[] { + return errorStore.filter((e) => e.userId === userId); +} + +/** + * Clear all errors for a given session. + */ +export function clearSessionErrors(sessionId: string): number { + const before = errorStore.length; + const indices = errorStore + .map((e, i) => (e.sessionId === sessionId ? i : -1)) + .filter((i) => i >= 0) + .reverse(); + indices.forEach((i) => errorStore.splice(i, 1)); + return before - errorStore.length; +} diff --git a/backend/src/services/termsOfService.service.ts b/backend/src/services/termsOfService.service.ts new file mode 100644 index 00000000..59651047 --- /dev/null +++ b/backend/src/services/termsOfService.service.ts @@ -0,0 +1,75 @@ +import prisma from '../db/index.js'; +import logger from '../utils/logger.js'; + +const CURRENT_TOS_VERSION = '1.0.0'; + +export interface TosRecord { + userId: string; + version: string; + acceptedAt: Date; + ipAddress: string | null; +} + +/** + * Record a user's acceptance of the Terms of Service. + */ +export async function acceptTermsOfService( + userId: string, + version: string, + ipAddress?: string +): Promise { + try { + // Stored as an audit log entry so it is immutable and auditable. + await prisma.auditLog.create({ + data: { + userId, + action: 'TOS_ACCEPTED', + entity: 'terms_of_service', + entityId: version, + details: { version, acceptedAt: new Date().toISOString() }, + ipAddress: ipAddress ?? null, + }, + }); + + logger.info('Terms of Service accepted', { userId, version }); + + return { + userId, + version, + acceptedAt: new Date(), + ipAddress: ipAddress ?? null, + }; + } catch (error) { + logger.error('Failed to record TOS acceptance', { userId, version, error }); + throw error; + } +} + +/** + * Check whether a user has accepted the current (or a specific) TOS version. + */ +export async function hasTosAcceptance( + userId: string, + version = CURRENT_TOS_VERSION +): Promise { + try { + const record = await prisma.auditLog.findFirst({ + where: { + userId, + action: 'TOS_ACCEPTED', + entityId: version, + }, + }); + return record !== null; + } catch (error) { + logger.error('Failed to check TOS acceptance', { userId, version, error }); + throw error; + } +} + +/** + * Return the current TOS version the platform requires. + */ +export function getCurrentTosVersion(): string { + return CURRENT_TOS_VERSION; +}