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
1 change: 1 addition & 0 deletions backend/logs/audit-immutable.log
Original file line number Diff line number Diff line change
Expand Up @@ -153,3 +153,4 @@
{"level":"info","logType":"audit","message":{"action":"USER_REGISTER","details":{"body":{"email":"timestamp@example.com","firstName":"Test","lastName":"Student","password":"SecurePass123!"},"method":"POST","path":"/register","query":{}},"entity":"User","entityId":null,"hash":"d7cd26b249d63d2037ea6c927cbf57214690ff3e14654eaa6cdcefdd307090d8","ipAddress":"::ffff:127.0.0.1","timestamp":"2026-06-24T11:53:47.387Z","userAgent":null,"userEmail":"timestamp@example.com","userId":null},"service":"web3-student-lab-backend","timestamp":"2026-06-24 12:53:47.387"}
{"level":"info","logType":"audit","message":{"action":"USER_REGISTER","details":{"body":{"email":"journeystudent@example.com","firstName":"Test","lastName":"Student","password":"SecurePass123!"},"method":"POST","path":"/register","query":{}},"entity":"User","entityId":null,"hash":"6b6873394be2f87659c0a06df894f5c62302194ffedda6b45e84ad1180e423e7","ipAddress":"::ffff:127.0.0.1","timestamp":"2026-06-24T11:53:47.599Z","userAgent":null,"userEmail":"journeystudent@example.com","userId":null},"service":"web3-student-lab-backend","timestamp":"2026-06-24 12:53:47.599"}
{"level":"info","logType":"audit","message":{"action":"TEST_MIDDLEWARE_ACTION","details":{"body":{"data":"test"},"method":"POST","path":"/test"},"entity":"TestEntity","entityId":"entity-1","hash":"5bae9c298321b23dd2f20880f6e664733b6ed0dc0f5ebff3c22d30269b891c17","ipAddress":"127.0.0.1","timestamp":"2026-06-24T11:54:19.538Z","userAgent":null,"userEmail":"test@example.com","userId":"user-1"},"service":"web3-student-lab-backend","timestamp":"2026-06-24 12:54:19.539"}
{"level":"info","logType":"audit","message":{"action":"USER_LOGIN","timestamp":"2026-06-27T09:13:43.791Z","userId":"user-123"},"service":"web3-student-lab-backend","timestamp":"2026-06-27 09:13:43.792"}
18 changes: 18 additions & 0 deletions backend/logs/combined.log

Large diffs are not rendered by default.

18 changes: 18 additions & 0 deletions backend/logs/error.log

Large diffs are not rendered by default.

14 changes: 14 additions & 0 deletions backend/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -294,3 +294,17 @@ model StudentActivity {
@@map("student_activities")
}

model TranslationEntry {
id String @id @default(cuid())
workspaceId String @default("default")
locale String
namespace String @default("platform")
key String
value String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt

@@unique([workspaceId, locale, namespace, key])
@@index([workspaceId, locale, namespace])
@@map("translation_entries")
}
1 change: 1 addition & 0 deletions backend/src/db/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ const workspaceModels = new Set([
'AuditLog',
'Canvas',
'WebhookSubscription',
'TranslationEntry',
]);


Expand Down
7 changes: 4 additions & 3 deletions backend/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,11 @@ import config from './config/env.config.js';
import { setRateLimitEnvOverrides } from './config/rateLimit.config.js';
import { swaggerSpec } from './config/swagger.js';
import prisma from './db/index.js';
import { createGraphQLServer } from './graphql/server.js';
import { dbRoutingMiddleware } from './middleware/dbRouting.js';
import { decryptionMiddleware } from './middleware/encryptionMiddleware.js';
import { errorHandler } from './middleware/errorHandler.js';
import { createI18nMiddleware } from './middleware/i18n.js';
import { rateLimiter } from './middleware/rateLimiter.js';
import { requestLogger } from './middleware/requestLogger.js';
import { requireWorkspaceMiddleware } from './middleware/WorkspaceContext.js';
Expand All @@ -26,7 +28,6 @@ import logger from './utils/logger.js';
import { pubClient, redisConnection, subClient } from './utils/redis.js';
import { getSentryErrorHandler, getSentryRequestHandler, initializeSentry } from './utils/sentry.js';
import { initializeWebSocket } from './websocket/WebSocketServer.js';
import { createGraphQLServer } from './graphql/server.js';

// Load environment variables
// dotenv.config(); // Skip in Docker Compose - use environment variables instead
Expand Down Expand Up @@ -158,7 +159,7 @@ async function setupGraphQL() {
try {
graphqlServer = await createGraphQLServer();
const { expressMiddleware } = await import('@apollo/server/express4');

app.use(
'/graphql',
express.json(),
Expand All @@ -176,7 +177,7 @@ async function setupGraphQL() {
setupGraphQL().catch(() => {});

// API Routes - with workspace isolation
app.use('/api/v1', requireWorkspaceMiddleware, routes);
app.use('/api/v1', requireWorkspaceMiddleware, createI18nMiddleware(), routes);

// Swagger Documentation
app.use('/api-docs', swaggerUi.serve, swaggerUi.setup(swaggerSpec, {
Expand Down
97 changes: 97 additions & 0 deletions backend/src/middleware/i18n.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import { NextFunction, Request, Response } from 'express';
import {
PrismaTranslationRepository,
TranslationRepository,
} from '../services/i18n/translation.repository.js';

declare global {
namespace Express {
interface Request {
locale?: string;
translationNamespace?: string;
t?: (key: string) => string;
}
}
}

export interface I18nOptions {
defaultLocale?: string;
supportedLocales?: string[];
namespace?: string;
repository?: TranslationRepository;
}

const FALLBACK_LOCALE = 'en';

function parseAcceptLanguage(headerValue: string | undefined): string | null {
if (!headerValue) {
return null;
}

const first = headerValue.split(',')[0]?.trim();
if (!first) {
return null;
}

const language = first.split(';')[0]?.trim();
if (!language) {
return null;
}

const normalized = language.split('-')[0]?.toLowerCase();
return normalized || null;
}

function resolveLocale(req: Request, supportedLocales: string[], defaultLocale: string): string {
const queryLocale =
typeof req.query.locale === 'string'
? req.query.locale
: typeof req.query.lang === 'string'
? req.query.lang
: undefined;

if (queryLocale) {
const normalizedQuery = queryLocale.toLowerCase();
if (supportedLocales.includes(normalizedQuery)) {
return normalizedQuery;
}
}

const fromHeader = parseAcceptLanguage(req.headers['accept-language']);
if (fromHeader && supportedLocales.includes(fromHeader)) {
return fromHeader;
}

return defaultLocale;
}

export function createI18nMiddleware(options: I18nOptions = {}) {
const defaultLocale = options.defaultLocale ?? FALLBACK_LOCALE;
const supportedLocales = options.supportedLocales ?? ['en', 'es', 'fr', 'de'];
const namespace = options.namespace ?? 'platform';
const repository = options.repository ?? new PrismaTranslationRepository();

return async (req: Request, _res: Response, next: NextFunction) => {
const locale = resolveLocale(req, supportedLocales, defaultLocale);

try {
const localeTable = await repository.getTranslations(locale, namespace);
const fallbackTable =
locale === defaultLocale
? localeTable
: await repository.getTranslations(defaultLocale, namespace);

req.locale = locale;
req.translationNamespace = namespace;
req.t = (key: string) => localeTable[key] ?? fallbackTable[key] ?? key;
} catch {
req.locale = defaultLocale;
req.translationNamespace = namespace;
req.t = (key: string) => key;
}

next();
};
}

export { resolveLocale };
16 changes: 16 additions & 0 deletions backend/src/routes/i18n.routes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { Router } from 'express';

const router = Router();

router.get('/resolve', (req, res) => {
const key = typeof req.query.key === 'string' ? req.query.key : 'welcome';

res.status(200).json({
locale: req.locale ?? 'en',
namespace: req.translationNamespace ?? 'platform',
key,
translation: req.t ? req.t(key) : key,
});
});

export default router;
4 changes: 4 additions & 0 deletions backend/src/routes/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,10 @@ import enrollmentsRouter from './enrollments.js';
import exportRouter from './export.routes.js';
import generatorRouter from './generator/generator.routes.js';
import healthRouter from './health.routes.js';
import i18nRouter from './i18n.routes.js';
import learningRoutes from './learning/learning.routes.js';
import securityRouter from './security.routes.js';
import seoRouter from './seo.routes.js';
import studentsRouter from './students.js';

import notificationRouter from '../notifications/notification.routes.js';
Expand All @@ -38,6 +40,8 @@ router.use('/contracts', contractRouter);
router.use('/notifications', notificationRouter);
router.use('/notifications/preferences', notificationPreferencesRouter);
router.use('/security', securityRouter);
router.use('/seo', seoRouter);
router.use('/i18n', i18nRouter);
router.use('/generator', generatorRouter);
router.use('/export', exportRouter);
router.use('/webhooks', webhooksRouter);
Expand Down
27 changes: 27 additions & 0 deletions backend/src/routes/seo.routes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { Router } from 'express';
import { simulatorSeoService } from '../services/seo/simulatorSeo.service.js';

const router = Router();

router.get('/simulator/meta/:slug', async (req, res) => {
const { slug } = req.params;
const meta = await simulatorSeoService.getMetaTags(slug);

if (!meta) {
res.status(404).json({ error: `No simulator metadata found for slug: ${slug}` });
return;
}

res.status(200).json({ slug, meta });
});

router.get('/simulator/sitemap', async (_req, res) => {
const urls = await simulatorSeoService.getSitemapUrls();
res.status(200).json({ count: urls.length, urls });
});

router.get('/simulator/cache-stats', (_req, res) => {
res.status(200).json(simulatorSeoService.getCacheStats());
});

export default router;
59 changes: 59 additions & 0 deletions backend/src/services/i18n/translation.repository.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
export interface TranslationRecord {
locale: string;
namespace: string;
key: string;
value: string;
}

export interface TranslationRepository {
getTranslations(locale: string, namespace: string): Promise<Record<string, string>>;
}

export class PrismaTranslationRepository implements TranslationRepository {
async getTranslations(locale: string, namespace: string): Promise<Record<string, string>> {
const { default: prisma } = await import('../../db/index.js');

const rows = await prisma.translationEntry.findMany({
where: {
locale,
namespace,
},
select: {
key: true,
value: true,
},
});

return rows.reduce<Record<string, string>>((acc, row) => {
acc[row.key] = row.value;
return acc;
}, {});
}
}

export class InMemoryTranslationRepository implements TranslationRepository {
private readonly table = new Map<string, string>();

constructor(rows: TranslationRecord[] = []) {
for (const row of rows) {
this.table.set(this.makeKey(row.locale, row.namespace, row.key), row.value);
}
}

async getTranslations(locale: string, namespace: string): Promise<Record<string, string>> {
const prefix = `${locale}:${namespace}:`;
const result: Record<string, string> = {};

for (const [compositeKey, value] of this.table.entries()) {
if (compositeKey.startsWith(prefix)) {
result[compositeKey.slice(prefix.length)] = value;
}
}

return result;
}

private makeKey(locale: string, namespace: string, key: string): string {
return `${locale}:${namespace}:${key}`;
}
}
Loading