Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 51 additions & 0 deletions backend/src/dashboard/activityLog.routes.ts
Original file line number Diff line number Diff line change
@@ -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<void> => {
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<void> => {
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;
101 changes: 101 additions & 0 deletions backend/src/dashboard/activityLog.service.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown> | 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<ActivityLogResponse> {
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<string, unknown>) ?? 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<string, unknown>,
ipAddress?: string
): Promise<ActivityLogEntry> {
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<string, unknown>) ?? null,
};
} catch (error) {
logger.error('Failed to record activity', { userId, action, error });
throw error;
}
}
8 changes: 8 additions & 0 deletions backend/src/routes/index.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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';
Expand All @@ -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);
Expand All @@ -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;
61 changes: 61 additions & 0 deletions backend/src/routes/privacyPolicy.routes.ts
Original file line number Diff line number Diff line change
@@ -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<void> => {
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<void> => {
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;
105 changes: 105 additions & 0 deletions backend/src/routes/simulatorErrors.routes.ts
Original file line number Diff line number Diff line change
@@ -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<void> => {
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;
Loading