diff --git a/backend/logs/audit-immutable.log b/backend/logs/audit-immutable.log
index ea2aa857..e1f1f292 100644
--- a/backend/logs/audit-immutable.log
+++ b/backend/logs/audit-immutable.log
@@ -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"}
diff --git a/backend/logs/combined.log b/backend/logs/combined.log
index cc7da2c4..7d60ac16 100644
--- a/backend/logs/combined.log
+++ b/backend/logs/combined.log
@@ -10450,3 +10450,21 @@
{"correlationId":"test-correlation-id-123","level":"error","message":"Test error message Test error","metadata":{"metadata":{"environment":"test","service":"web3-student-lab-backend","stack":"Error: Test error\n at Object. (/home/knights/Documents/Drips/Web3-Student-Lab/backend/tests/logger.test.ts:96:25)\n at Promise.finally.completed (/home/knights/Documents/Drips/Web3-Student-Lab/backend/node_modules/.pnpm/jest-circus@30.4.2/node_modules/jest-circus/build/jestAdapterInit.js:1561:28)\n at new Promise ()\n at callAsyncCircusFn (/home/knights/Documents/Drips/Web3-Student-Lab/backend/node_modules/.pnpm/jest-circus@30.4.2/node_modules/jest-circus/build/jestAdapterInit.js:1501:10)\n at _callCircusTest (/home/knights/Documents/Drips/Web3-Student-Lab/backend/node_modules/.pnpm/jest-circus@30.4.2/node_modules/jest-circus/build/jestAdapterInit.js:1011:40)\n at processTicksAndRejections (node:internal/process/task_queues:105:5)\n at _runTest (/home/knights/Documents/Drips/Web3-Student-Lab/backend/node_modules/.pnpm/jest-circus@30.4.2/node_modules/jest-circus/build/jestAdapterInit.js:951:3)\n at /home/knights/Documents/Drips/Web3-Student-Lab/backend/node_modules/.pnpm/jest-circus@30.4.2/node_modules/jest-circus/build/jestAdapterInit.js:853:7\n at _runTestsForDescribeBlock (/home/knights/Documents/Drips/Web3-Student-Lab/backend/node_modules/.pnpm/jest-circus@30.4.2/node_modules/jest-circus/build/jestAdapterInit.js:866:11)\n at _runTestsForDescribeBlock (/home/knights/Documents/Drips/Web3-Student-Lab/backend/node_modules/.pnpm/jest-circus@30.4.2/node_modules/jest-circus/build/jestAdapterInit.js:861:11)\n at _runTestsForDescribeBlock (/home/knights/Documents/Drips/Web3-Student-Lab/backend/node_modules/.pnpm/jest-circus@30.4.2/node_modules/jest-circus/build/jestAdapterInit.js:861:11)\n at run (/home/knights/Documents/Drips/Web3-Student-Lab/backend/node_modules/.pnpm/jest-circus@30.4.2/node_modules/jest-circus/build/jestAdapterInit.js:765:3)\n at runAndTransformResultsToJestFormat (/home/knights/Documents/Drips/Web3-Student-Lab/backend/node_modules/.pnpm/jest-circus@30.4.2/node_modules/jest-circus/build/jestAdapterInit.js:1993:21)\n at jestAdapter (/home/knights/Documents/Drips/Web3-Student-Lab/backend/node_modules/.pnpm/jest-circus@30.4.2/node_modules/jest-circus/build/runner.js:111:19)\n at runTestInternal (/home/knights/Documents/Drips/Web3-Student-Lab/backend/node_modules/.pnpm/jest-runner@30.4.2/node_modules/jest-runner/build/testWorker.js:276:16)\n at runTest (/home/knights/Documents/Drips/Web3-Student-Lab/backend/node_modules/.pnpm/jest-runner@30.4.2/node_modules/jest-runner/build/testWorker.js:344:7)\n at Object.worker (/home/knights/Documents/Drips/Web3-Student-Lab/backend/node_modules/.pnpm/jest-runner@30.4.2/node_modules/jest-runner/build/testWorker.js:498:12)"}},"timestamp":"2026-06-24 12:53:46.436"}
{"correlationId":"test-correlation-id-123","level":"error","message":"Error message","metadata":{"metadata":{"environment":"test","service":"web3-student-lab-backend"}},"timestamp":"2026-06-24 12:53:46.469"}
{"level":"error","message":"Redis ping failed: Stream isn't writeable and enableOfflineQueue options is false","metadata":{"metadata":{"environment":"test","service":"web3-student-lab-backend","stack":"Error: Stream isn't writeable and enableOfflineQueue options is false\n at EventEmitter.sendCommand (/home/knights/Documents/Drips/Web3-Student-Lab/backend/node_modules/.pnpm/ioredis@5.11.0/node_modules/ioredis/built/Redis.js:376:32)\n at EventEmitter.ping (/home/knights/Documents/Drips/Web3-Student-Lab/backend/node_modules/.pnpm/ioredis@5.11.0/node_modules/ioredis/built/utils/Commander.js:90:25)\n at RedisClient.ping (/home/knights/Documents/Drips/Web3-Student-Lab/backend/src/cache/RedisClient.ts:94:40)\n at RedisClient.connect (/home/knights/Documents/Drips/Web3-Student-Lab/backend/src/cache/RedisClient.ts:83:18)\n at Object. (/home/knights/Documents/Drips/Web3-Student-Lab/backend/tests/cache.test.ts:7:23)\n at Promise.finally.completed (/home/knights/Documents/Drips/Web3-Student-Lab/backend/node_modules/.pnpm/jest-circus@30.4.2/node_modules/jest-circus/build/jestAdapterInit.js:1561:28)\n at new Promise ()\n at callAsyncCircusFn (/home/knights/Documents/Drips/Web3-Student-Lab/backend/node_modules/.pnpm/jest-circus@30.4.2/node_modules/jest-circus/build/jestAdapterInit.js:1501:10)\n at _callCircusHook (/home/knights/Documents/Drips/Web3-Student-Lab/backend/node_modules/.pnpm/jest-circus@30.4.2/node_modules/jest-circus/build/jestAdapterInit.js:980:40)\n at processTicksAndRejections (node:internal/process/task_queues:105:5)\n at _runTestsForDescribeBlock (/home/knights/Documents/Drips/Web3-Student-Lab/backend/node_modules/.pnpm/jest-circus@30.4.2/node_modules/jest-circus/build/jestAdapterInit.js:804:7)\n at _runTestsForDescribeBlock (/home/knights/Documents/Drips/Web3-Student-Lab/backend/node_modules/.pnpm/jest-circus@30.4.2/node_modules/jest-circus/build/jestAdapterInit.js:861:11)\n at run (/home/knights/Documents/Drips/Web3-Student-Lab/backend/node_modules/.pnpm/jest-circus@30.4.2/node_modules/jest-circus/build/jestAdapterInit.js:765:3)\n at runAndTransformResultsToJestFormat (/home/knights/Documents/Drips/Web3-Student-Lab/backend/node_modules/.pnpm/jest-circus@30.4.2/node_modules/jest-circus/build/jestAdapterInit.js:1993:21)\n at jestAdapter (/home/knights/Documents/Drips/Web3-Student-Lab/backend/node_modules/.pnpm/jest-circus@30.4.2/node_modules/jest-circus/build/runner.js:111:19)\n at runTestInternal (/home/knights/Documents/Drips/Web3-Student-Lab/backend/node_modules/.pnpm/jest-runner@30.4.2/node_modules/jest-runner/build/testWorker.js:276:16)\n at runTest (/home/knights/Documents/Drips/Web3-Student-Lab/backend/node_modules/.pnpm/jest-runner@30.4.2/node_modules/jest-runner/build/testWorker.js:344:7)\n at Object.worker (/home/knights/Documents/Drips/Web3-Student-Lab/backend/node_modules/.pnpm/jest-runner@30.4.2/node_modules/jest-runner/build/testWorker.js:498:12)"}},"timestamp":"2026-06-24 12:53:45.958"}
+{"correlationId":"92563fbb-e37e-4ddd-83c0-0119282aadbf","level":"error","message":"Request completed","metadata":{"metadata":{"duration":"1ms","environment":"test","method":"GET","service":"web3-student-lab-backend","statusCode":500,"url":"/api/test"}},"timestamp":"2026-06-27 09:13:42.764"}
+{"correlationId":"4ed506f9-b714-4fd5-b00e-34529a8d527c","level":"error","message":"Request completed","metadata":{"metadata":{"duration":"0ms","environment":"test","method":"GET","service":"web3-student-lab-backend","statusCode":500,"url":"/api/test"}},"timestamp":"2026-06-27 09:13:42.778"}
+{"level":"error","message":"Redis ping failed: Stream isn't writeable and enableOfflineQueue options is false","metadata":{"metadata":{"environment":"test","service":"web3-student-lab-backend","stack":"Error: Stream isn't writeable and enableOfflineQueue options is false\n at EventEmitter.sendCommand (/workspaces/Web3-Student-Lab/backend/node_modules/.pnpm/ioredis@5.11.0/node_modules/ioredis/built/Redis.js:376:32)\n at EventEmitter.ping (/workspaces/Web3-Student-Lab/backend/node_modules/.pnpm/ioredis@5.11.0/node_modules/ioredis/built/utils/Commander.js:90:25)\n at RedisClient.ping (/workspaces/Web3-Student-Lab/backend/src/cache/RedisClient.ts:94:40)\n at RedisClient.connect (/workspaces/Web3-Student-Lab/backend/src/cache/RedisClient.ts:83:18)\n at Object. (/workspaces/Web3-Student-Lab/backend/tests/cache-distributed.test.ts:11:23)\n at Promise.finally.completed (/workspaces/Web3-Student-Lab/backend/node_modules/.pnpm/jest-circus@30.4.2/node_modules/jest-circus/build/jestAdapterInit.js:1561:28)\n at new Promise ()\n at callAsyncCircusFn (/workspaces/Web3-Student-Lab/backend/node_modules/.pnpm/jest-circus@30.4.2/node_modules/jest-circus/build/jestAdapterInit.js:1501:10)\n at _callCircusHook (/workspaces/Web3-Student-Lab/backend/node_modules/.pnpm/jest-circus@30.4.2/node_modules/jest-circus/build/jestAdapterInit.js:980:40)\n at _runTestsForDescribeBlock (/workspaces/Web3-Student-Lab/backend/node_modules/.pnpm/jest-circus@30.4.2/node_modules/jest-circus/build/jestAdapterInit.js:804:7)\n at _runTestsForDescribeBlock (/workspaces/Web3-Student-Lab/backend/node_modules/.pnpm/jest-circus@30.4.2/node_modules/jest-circus/build/jestAdapterInit.js:861:11)\n at run (/workspaces/Web3-Student-Lab/backend/node_modules/.pnpm/jest-circus@30.4.2/node_modules/jest-circus/build/jestAdapterInit.js:765:3)\n at runAndTransformResultsToJestFormat (/workspaces/Web3-Student-Lab/backend/node_modules/.pnpm/jest-circus@30.4.2/node_modules/jest-circus/build/jestAdapterInit.js:1993:21)\n at jestAdapter (/workspaces/Web3-Student-Lab/backend/node_modules/.pnpm/jest-circus@30.4.2/node_modules/jest-circus/build/runner.js:111:19)\n at runTestInternal (/workspaces/Web3-Student-Lab/backend/node_modules/.pnpm/jest-runner@30.4.2/node_modules/jest-runner/build/index.js:276:16)\n at runTest (/workspaces/Web3-Student-Lab/backend/node_modules/.pnpm/jest-runner@30.4.2/node_modules/jest-runner/build/index.js:344:7)"}},"timestamp":"2026-06-27 09:13:43.586"}
+{"level":"error","message":"Redis (standalone) connection error:","metadata":{"metadata":{"code":"ECONNREFUSED","environment":"test","service":"web3-student-lab-backend","stack":"AggregateError: \n at internalConnectMultiple (node:net:1142:49)\n at afterConnectMultiple (node:net:1723:7)"}},"timestamp":"2026-06-27 09:13:43.638"}
+{"level":"error","message":"Redis (standalone) connection error:","metadata":{"metadata":{"code":"ECONNREFUSED","environment":"test","service":"web3-student-lab-backend","stack":"AggregateError: \n at internalConnectMultiple (node:net:1142:49)\n at afterConnectMultiple (node:net:1723:7)"}},"timestamp":"2026-06-27 09:13:43.690"}
+{"correlationId":"test-correlation-id-123","level":"error","message":"Test error message Test error","metadata":{"metadata":{"environment":"test","service":"web3-student-lab-backend","stack":"Error: Test error\n at Object. (/workspaces/Web3-Student-Lab/backend/tests/logger.test.ts:96:25)\n at Promise.finally.completed (/workspaces/Web3-Student-Lab/backend/node_modules/.pnpm/jest-circus@30.4.2/node_modules/jest-circus/build/jestAdapterInit.js:1561:28)\n at new Promise ()\n at callAsyncCircusFn (/workspaces/Web3-Student-Lab/backend/node_modules/.pnpm/jest-circus@30.4.2/node_modules/jest-circus/build/jestAdapterInit.js:1501:10)\n at _callCircusTest (/workspaces/Web3-Student-Lab/backend/node_modules/.pnpm/jest-circus@30.4.2/node_modules/jest-circus/build/jestAdapterInit.js:1011:40)\n at _runTest (/workspaces/Web3-Student-Lab/backend/node_modules/.pnpm/jest-circus@30.4.2/node_modules/jest-circus/build/jestAdapterInit.js:951:3)\n at /workspaces/Web3-Student-Lab/backend/node_modules/.pnpm/jest-circus@30.4.2/node_modules/jest-circus/build/jestAdapterInit.js:853:7\n at _runTestsForDescribeBlock (/workspaces/Web3-Student-Lab/backend/node_modules/.pnpm/jest-circus@30.4.2/node_modules/jest-circus/build/jestAdapterInit.js:866:11)\n at _runTestsForDescribeBlock (/workspaces/Web3-Student-Lab/backend/node_modules/.pnpm/jest-circus@30.4.2/node_modules/jest-circus/build/jestAdapterInit.js:861:11)\n at _runTestsForDescribeBlock (/workspaces/Web3-Student-Lab/backend/node_modules/.pnpm/jest-circus@30.4.2/node_modules/jest-circus/build/jestAdapterInit.js:861:11)\n at run (/workspaces/Web3-Student-Lab/backend/node_modules/.pnpm/jest-circus@30.4.2/node_modules/jest-circus/build/jestAdapterInit.js:765:3)\n at runAndTransformResultsToJestFormat (/workspaces/Web3-Student-Lab/backend/node_modules/.pnpm/jest-circus@30.4.2/node_modules/jest-circus/build/jestAdapterInit.js:1993:21)\n at jestAdapter (/workspaces/Web3-Student-Lab/backend/node_modules/.pnpm/jest-circus@30.4.2/node_modules/jest-circus/build/runner.js:111:19)\n at runTestInternal (/workspaces/Web3-Student-Lab/backend/node_modules/.pnpm/jest-runner@30.4.2/node_modules/jest-runner/build/index.js:276:16)\n at runTest (/workspaces/Web3-Student-Lab/backend/node_modules/.pnpm/jest-runner@30.4.2/node_modules/jest-runner/build/index.js:344:7)"}},"timestamp":"2026-06-27 09:13:43.783"}
+{"correlationId":"test-correlation-id-123","level":"error","message":"Error message","metadata":{"metadata":{"environment":"test","service":"web3-student-lab-backend"}},"timestamp":"2026-06-27 09:13:43.790"}
+{"level":"error","message":"❌ Environment Configuration Error: Environment validation failed:\n\nMissing required environment variable: DATABASE_URL\nDescription: PostgreSQL connection string for database connectivity\nPlease check your .env file and .env.example for guidance.\n\nPlease copy .env.example to .env and fill in the required values.","metadata":{"metadata":{"environment":"test","service":"web3-student-lab-backend"}},"timestamp":"2026-06-27 09:13:43.854"}
+{"level":"error","message":"❌ Environment Configuration Error: Environment validation failed:\n\nMissing required environment variable: JWT_SECRET\nDescription: Secret key for JWT token signing and verification\nPlease check your .env file and .env.example for guidance.\n\nPlease copy .env.example to .env and fill in the required values.","metadata":{"metadata":{"environment":"test","service":"web3-student-lab-backend"}},"timestamp":"2026-06-27 09:13:43.869"}
+{"level":"error","message":"❌ Environment Configuration Error: Environment validation failed:\n\nInvalid value for environment variable: JWT_SECRET\nDescription: Secret key for JWT token signing and verification\nCurrent value: \"short\"\nPlease check .env.example for the correct format.\n\nPlease copy .env.example to .env and fill in the required values.","metadata":{"metadata":{"environment":"test","service":"web3-student-lab-backend"}},"timestamp":"2026-06-27 09:13:43.875"}
+{"level":"error","message":"❌ Environment Configuration Error: Environment validation failed:\n\nInvalid value for environment variable: JWT_SECRET\nDescription: Secret key for JWT token signing and verification\nCurrent value: \"short\"\nPlease check .env.example for the correct format.\n\nPlease copy .env.example to .env and fill in the required values.","metadata":{"metadata":{"environment":"test","service":"web3-student-lab-backend"}},"timestamp":"2026-06-27 09:13:43.876"}
+{"level":"error","message":"❌ Environment Configuration Error: Environment validation failed:\n\nInvalid value for environment variable: JWT_SECRET\nDescription: Secret key for JWT token signing and verification\nCurrent value: \"your-secret-key-change-in-production\"\nPlease check .env.example for the correct format.\n\nPlease copy .env.example to .env and fill in the required values.","metadata":{"metadata":{"environment":"test","service":"web3-student-lab-backend"}},"timestamp":"2026-06-27 09:13:43.883"}
+{"level":"error","message":"❌ Environment Configuration Error: Environment validation failed:\n\nInvalid value for environment variable: JWT_SECRET\nDescription: Secret key for JWT token signing and verification\nCurrent value: \"your-secret-key-change-in-production\"\nPlease check .env.example for the correct format.\n\nPlease copy .env.example to .env and fill in the required values.","metadata":{"metadata":{"environment":"test","service":"web3-student-lab-backend"}},"timestamp":"2026-06-27 09:13:43.886"}
+{"level":"error","message":"❌ Environment Configuration Error: Environment validation failed:\n\nInvalid value for environment variable: DATABASE_URL\nDescription: PostgreSQL connection string for database connectivity\nCurrent value: \"invalid-url-format\"\nPlease check .env.example for the correct format.\n\nPlease copy .env.example to .env and fill in the required values.","metadata":{"metadata":{"environment":"test","service":"web3-student-lab-backend"}},"timestamp":"2026-06-27 09:13:43.888"}
+{"level":"error","message":"❌ Environment Configuration Error: Environment validation failed:\n\nInvalid value for environment variable: DATABASE_URL\nDescription: PostgreSQL connection string for database connectivity\nCurrent value: \"invalid-url-format\"\nPlease check .env.example for the correct format.\n\nPlease copy .env.example to .env and fill in the required values.","metadata":{"metadata":{"environment":"test","service":"web3-student-lab-backend"}},"timestamp":"2026-06-27 09:13:43.889"}
+{"level":"error","message":"Failed to emit OSCT webhook event","metadata":{"metadata":{"environment":"test","error":"queue down","eventType":"opensource.pr_submitted","service":"web3-student-lab-backend"}},"timestamp":"2026-06-27 09:13:45.247"}
+{"level":"error","message":"DB health check failed","metadata":{"metadata":{"environment":"test","error":"database unavailable","service":"web3-student-lab-backend"}},"timestamp":"2026-06-27 09:13:47.483"}
+{"level":"error","message":"Redis ping failed: Stream isn't writeable and enableOfflineQueue options is false","metadata":{"metadata":{"environment":"test","service":"web3-student-lab-backend","stack":"Error: Stream isn't writeable and enableOfflineQueue options is false\n at EventEmitter.sendCommand (/workspaces/Web3-Student-Lab/backend/node_modules/.pnpm/ioredis@5.11.0/node_modules/ioredis/built/Redis.js:376:32)\n at EventEmitter.ping (/workspaces/Web3-Student-Lab/backend/node_modules/.pnpm/ioredis@5.11.0/node_modules/ioredis/built/utils/Commander.js:90:25)\n at RedisClient.ping (/workspaces/Web3-Student-Lab/backend/src/cache/RedisClient.ts:94:40)\n at RedisClient.connect (/workspaces/Web3-Student-Lab/backend/src/cache/RedisClient.ts:83:18)\n at Object. (/workspaces/Web3-Student-Lab/backend/tests/cache.test.ts:7:23)\n at Promise.finally.completed (/workspaces/Web3-Student-Lab/backend/node_modules/.pnpm/jest-circus@30.4.2/node_modules/jest-circus/build/jestAdapterInit.js:1561:28)\n at new Promise ()\n at callAsyncCircusFn (/workspaces/Web3-Student-Lab/backend/node_modules/.pnpm/jest-circus@30.4.2/node_modules/jest-circus/build/jestAdapterInit.js:1501:10)\n at _callCircusHook (/workspaces/Web3-Student-Lab/backend/node_modules/.pnpm/jest-circus@30.4.2/node_modules/jest-circus/build/jestAdapterInit.js:980:40)\n at _runTestsForDescribeBlock (/workspaces/Web3-Student-Lab/backend/node_modules/.pnpm/jest-circus@30.4.2/node_modules/jest-circus/build/jestAdapterInit.js:804:7)\n at _runTestsForDescribeBlock (/workspaces/Web3-Student-Lab/backend/node_modules/.pnpm/jest-circus@30.4.2/node_modules/jest-circus/build/jestAdapterInit.js:861:11)\n at run (/workspaces/Web3-Student-Lab/backend/node_modules/.pnpm/jest-circus@30.4.2/node_modules/jest-circus/build/jestAdapterInit.js:765:3)\n at runAndTransformResultsToJestFormat (/workspaces/Web3-Student-Lab/backend/node_modules/.pnpm/jest-circus@30.4.2/node_modules/jest-circus/build/jestAdapterInit.js:1993:21)\n at jestAdapter (/workspaces/Web3-Student-Lab/backend/node_modules/.pnpm/jest-circus@30.4.2/node_modules/jest-circus/build/runner.js:111:19)\n at runTestInternal (/workspaces/Web3-Student-Lab/backend/node_modules/.pnpm/jest-runner@30.4.2/node_modules/jest-runner/build/index.js:276:16)\n at runTest (/workspaces/Web3-Student-Lab/backend/node_modules/.pnpm/jest-runner@30.4.2/node_modules/jest-runner/build/index.js:344:7)"}},"timestamp":"2026-06-27 09:13:48.074"}
diff --git a/backend/logs/error.log b/backend/logs/error.log
index 5f4cb594..651b140e 100644
--- a/backend/logs/error.log
+++ b/backend/logs/error.log
@@ -416,3 +416,21 @@
{"correlationId":"test-correlation-id-123","level":"error","message":"Test error message Test error","metadata":{"metadata":{"environment":"test","service":"web3-student-lab-backend","stack":"Error: Test error\n at Object. (/home/knights/Documents/Drips/Web3-Student-Lab/backend/tests/logger.test.ts:96:25)\n at Promise.finally.completed (/home/knights/Documents/Drips/Web3-Student-Lab/backend/node_modules/.pnpm/jest-circus@30.4.2/node_modules/jest-circus/build/jestAdapterInit.js:1561:28)\n at new Promise ()\n at callAsyncCircusFn (/home/knights/Documents/Drips/Web3-Student-Lab/backend/node_modules/.pnpm/jest-circus@30.4.2/node_modules/jest-circus/build/jestAdapterInit.js:1501:10)\n at _callCircusTest (/home/knights/Documents/Drips/Web3-Student-Lab/backend/node_modules/.pnpm/jest-circus@30.4.2/node_modules/jest-circus/build/jestAdapterInit.js:1011:40)\n at processTicksAndRejections (node:internal/process/task_queues:105:5)\n at _runTest (/home/knights/Documents/Drips/Web3-Student-Lab/backend/node_modules/.pnpm/jest-circus@30.4.2/node_modules/jest-circus/build/jestAdapterInit.js:951:3)\n at /home/knights/Documents/Drips/Web3-Student-Lab/backend/node_modules/.pnpm/jest-circus@30.4.2/node_modules/jest-circus/build/jestAdapterInit.js:853:7\n at _runTestsForDescribeBlock (/home/knights/Documents/Drips/Web3-Student-Lab/backend/node_modules/.pnpm/jest-circus@30.4.2/node_modules/jest-circus/build/jestAdapterInit.js:866:11)\n at _runTestsForDescribeBlock (/home/knights/Documents/Drips/Web3-Student-Lab/backend/node_modules/.pnpm/jest-circus@30.4.2/node_modules/jest-circus/build/jestAdapterInit.js:861:11)\n at _runTestsForDescribeBlock (/home/knights/Documents/Drips/Web3-Student-Lab/backend/node_modules/.pnpm/jest-circus@30.4.2/node_modules/jest-circus/build/jestAdapterInit.js:861:11)\n at run (/home/knights/Documents/Drips/Web3-Student-Lab/backend/node_modules/.pnpm/jest-circus@30.4.2/node_modules/jest-circus/build/jestAdapterInit.js:765:3)\n at runAndTransformResultsToJestFormat (/home/knights/Documents/Drips/Web3-Student-Lab/backend/node_modules/.pnpm/jest-circus@30.4.2/node_modules/jest-circus/build/jestAdapterInit.js:1993:21)\n at jestAdapter (/home/knights/Documents/Drips/Web3-Student-Lab/backend/node_modules/.pnpm/jest-circus@30.4.2/node_modules/jest-circus/build/runner.js:111:19)\n at runTestInternal (/home/knights/Documents/Drips/Web3-Student-Lab/backend/node_modules/.pnpm/jest-runner@30.4.2/node_modules/jest-runner/build/testWorker.js:276:16)\n at runTest (/home/knights/Documents/Drips/Web3-Student-Lab/backend/node_modules/.pnpm/jest-runner@30.4.2/node_modules/jest-runner/build/testWorker.js:344:7)\n at Object.worker (/home/knights/Documents/Drips/Web3-Student-Lab/backend/node_modules/.pnpm/jest-runner@30.4.2/node_modules/jest-runner/build/testWorker.js:498:12)"}},"timestamp":"2026-06-24 12:53:46.435"}
{"correlationId":"test-correlation-id-123","level":"error","message":"Error message","metadata":{"metadata":{"environment":"test","service":"web3-student-lab-backend"}},"timestamp":"2026-06-24 12:53:46.469"}
{"level":"error","message":"Redis ping failed: Stream isn't writeable and enableOfflineQueue options is false","metadata":{"metadata":{"environment":"test","service":"web3-student-lab-backend","stack":"Error: Stream isn't writeable and enableOfflineQueue options is false\n at EventEmitter.sendCommand (/home/knights/Documents/Drips/Web3-Student-Lab/backend/node_modules/.pnpm/ioredis@5.11.0/node_modules/ioredis/built/Redis.js:376:32)\n at EventEmitter.ping (/home/knights/Documents/Drips/Web3-Student-Lab/backend/node_modules/.pnpm/ioredis@5.11.0/node_modules/ioredis/built/utils/Commander.js:90:25)\n at RedisClient.ping (/home/knights/Documents/Drips/Web3-Student-Lab/backend/src/cache/RedisClient.ts:94:40)\n at RedisClient.connect (/home/knights/Documents/Drips/Web3-Student-Lab/backend/src/cache/RedisClient.ts:83:18)\n at Object. (/home/knights/Documents/Drips/Web3-Student-Lab/backend/tests/cache.test.ts:7:23)\n at Promise.finally.completed (/home/knights/Documents/Drips/Web3-Student-Lab/backend/node_modules/.pnpm/jest-circus@30.4.2/node_modules/jest-circus/build/jestAdapterInit.js:1561:28)\n at new Promise ()\n at callAsyncCircusFn (/home/knights/Documents/Drips/Web3-Student-Lab/backend/node_modules/.pnpm/jest-circus@30.4.2/node_modules/jest-circus/build/jestAdapterInit.js:1501:10)\n at _callCircusHook (/home/knights/Documents/Drips/Web3-Student-Lab/backend/node_modules/.pnpm/jest-circus@30.4.2/node_modules/jest-circus/build/jestAdapterInit.js:980:40)\n at processTicksAndRejections (node:internal/process/task_queues:105:5)\n at _runTestsForDescribeBlock (/home/knights/Documents/Drips/Web3-Student-Lab/backend/node_modules/.pnpm/jest-circus@30.4.2/node_modules/jest-circus/build/jestAdapterInit.js:804:7)\n at _runTestsForDescribeBlock (/home/knights/Documents/Drips/Web3-Student-Lab/backend/node_modules/.pnpm/jest-circus@30.4.2/node_modules/jest-circus/build/jestAdapterInit.js:861:11)\n at run (/home/knights/Documents/Drips/Web3-Student-Lab/backend/node_modules/.pnpm/jest-circus@30.4.2/node_modules/jest-circus/build/jestAdapterInit.js:765:3)\n at runAndTransformResultsToJestFormat (/home/knights/Documents/Drips/Web3-Student-Lab/backend/node_modules/.pnpm/jest-circus@30.4.2/node_modules/jest-circus/build/jestAdapterInit.js:1993:21)\n at jestAdapter (/home/knights/Documents/Drips/Web3-Student-Lab/backend/node_modules/.pnpm/jest-circus@30.4.2/node_modules/jest-circus/build/runner.js:111:19)\n at runTestInternal (/home/knights/Documents/Drips/Web3-Student-Lab/backend/node_modules/.pnpm/jest-runner@30.4.2/node_modules/jest-runner/build/testWorker.js:276:16)\n at runTest (/home/knights/Documents/Drips/Web3-Student-Lab/backend/node_modules/.pnpm/jest-runner@30.4.2/node_modules/jest-runner/build/testWorker.js:344:7)\n at Object.worker (/home/knights/Documents/Drips/Web3-Student-Lab/backend/node_modules/.pnpm/jest-runner@30.4.2/node_modules/jest-runner/build/testWorker.js:498:12)"}},"timestamp":"2026-06-24 12:53:45.957"}
+{"correlationId":"92563fbb-e37e-4ddd-83c0-0119282aadbf","level":"error","message":"Request completed","metadata":{"metadata":{"duration":"1ms","environment":"test","method":"GET","service":"web3-student-lab-backend","statusCode":500,"url":"/api/test"}},"timestamp":"2026-06-27 09:13:42.763"}
+{"correlationId":"4ed506f9-b714-4fd5-b00e-34529a8d527c","level":"error","message":"Request completed","metadata":{"metadata":{"duration":"0ms","environment":"test","method":"GET","service":"web3-student-lab-backend","statusCode":500,"url":"/api/test"}},"timestamp":"2026-06-27 09:13:42.778"}
+{"level":"error","message":"Redis ping failed: Stream isn't writeable and enableOfflineQueue options is false","metadata":{"metadata":{"environment":"test","service":"web3-student-lab-backend","stack":"Error: Stream isn't writeable and enableOfflineQueue options is false\n at EventEmitter.sendCommand (/workspaces/Web3-Student-Lab/backend/node_modules/.pnpm/ioredis@5.11.0/node_modules/ioredis/built/Redis.js:376:32)\n at EventEmitter.ping (/workspaces/Web3-Student-Lab/backend/node_modules/.pnpm/ioredis@5.11.0/node_modules/ioredis/built/utils/Commander.js:90:25)\n at RedisClient.ping (/workspaces/Web3-Student-Lab/backend/src/cache/RedisClient.ts:94:40)\n at RedisClient.connect (/workspaces/Web3-Student-Lab/backend/src/cache/RedisClient.ts:83:18)\n at Object. (/workspaces/Web3-Student-Lab/backend/tests/cache-distributed.test.ts:11:23)\n at Promise.finally.completed (/workspaces/Web3-Student-Lab/backend/node_modules/.pnpm/jest-circus@30.4.2/node_modules/jest-circus/build/jestAdapterInit.js:1561:28)\n at new Promise ()\n at callAsyncCircusFn (/workspaces/Web3-Student-Lab/backend/node_modules/.pnpm/jest-circus@30.4.2/node_modules/jest-circus/build/jestAdapterInit.js:1501:10)\n at _callCircusHook (/workspaces/Web3-Student-Lab/backend/node_modules/.pnpm/jest-circus@30.4.2/node_modules/jest-circus/build/jestAdapterInit.js:980:40)\n at _runTestsForDescribeBlock (/workspaces/Web3-Student-Lab/backend/node_modules/.pnpm/jest-circus@30.4.2/node_modules/jest-circus/build/jestAdapterInit.js:804:7)\n at _runTestsForDescribeBlock (/workspaces/Web3-Student-Lab/backend/node_modules/.pnpm/jest-circus@30.4.2/node_modules/jest-circus/build/jestAdapterInit.js:861:11)\n at run (/workspaces/Web3-Student-Lab/backend/node_modules/.pnpm/jest-circus@30.4.2/node_modules/jest-circus/build/jestAdapterInit.js:765:3)\n at runAndTransformResultsToJestFormat (/workspaces/Web3-Student-Lab/backend/node_modules/.pnpm/jest-circus@30.4.2/node_modules/jest-circus/build/jestAdapterInit.js:1993:21)\n at jestAdapter (/workspaces/Web3-Student-Lab/backend/node_modules/.pnpm/jest-circus@30.4.2/node_modules/jest-circus/build/runner.js:111:19)\n at runTestInternal (/workspaces/Web3-Student-Lab/backend/node_modules/.pnpm/jest-runner@30.4.2/node_modules/jest-runner/build/index.js:276:16)\n at runTest (/workspaces/Web3-Student-Lab/backend/node_modules/.pnpm/jest-runner@30.4.2/node_modules/jest-runner/build/index.js:344:7)"}},"timestamp":"2026-06-27 09:13:43.586"}
+{"level":"error","message":"Redis (standalone) connection error:","metadata":{"metadata":{"code":"ECONNREFUSED","environment":"test","service":"web3-student-lab-backend","stack":"AggregateError: \n at internalConnectMultiple (node:net:1142:49)\n at afterConnectMultiple (node:net:1723:7)"}},"timestamp":"2026-06-27 09:13:43.638"}
+{"level":"error","message":"Redis (standalone) connection error:","metadata":{"metadata":{"code":"ECONNREFUSED","environment":"test","service":"web3-student-lab-backend","stack":"AggregateError: \n at internalConnectMultiple (node:net:1142:49)\n at afterConnectMultiple (node:net:1723:7)"}},"timestamp":"2026-06-27 09:13:43.690"}
+{"correlationId":"test-correlation-id-123","level":"error","message":"Test error message Test error","metadata":{"metadata":{"environment":"test","service":"web3-student-lab-backend","stack":"Error: Test error\n at Object. (/workspaces/Web3-Student-Lab/backend/tests/logger.test.ts:96:25)\n at Promise.finally.completed (/workspaces/Web3-Student-Lab/backend/node_modules/.pnpm/jest-circus@30.4.2/node_modules/jest-circus/build/jestAdapterInit.js:1561:28)\n at new Promise ()\n at callAsyncCircusFn (/workspaces/Web3-Student-Lab/backend/node_modules/.pnpm/jest-circus@30.4.2/node_modules/jest-circus/build/jestAdapterInit.js:1501:10)\n at _callCircusTest (/workspaces/Web3-Student-Lab/backend/node_modules/.pnpm/jest-circus@30.4.2/node_modules/jest-circus/build/jestAdapterInit.js:1011:40)\n at _runTest (/workspaces/Web3-Student-Lab/backend/node_modules/.pnpm/jest-circus@30.4.2/node_modules/jest-circus/build/jestAdapterInit.js:951:3)\n at /workspaces/Web3-Student-Lab/backend/node_modules/.pnpm/jest-circus@30.4.2/node_modules/jest-circus/build/jestAdapterInit.js:853:7\n at _runTestsForDescribeBlock (/workspaces/Web3-Student-Lab/backend/node_modules/.pnpm/jest-circus@30.4.2/node_modules/jest-circus/build/jestAdapterInit.js:866:11)\n at _runTestsForDescribeBlock (/workspaces/Web3-Student-Lab/backend/node_modules/.pnpm/jest-circus@30.4.2/node_modules/jest-circus/build/jestAdapterInit.js:861:11)\n at _runTestsForDescribeBlock (/workspaces/Web3-Student-Lab/backend/node_modules/.pnpm/jest-circus@30.4.2/node_modules/jest-circus/build/jestAdapterInit.js:861:11)\n at run (/workspaces/Web3-Student-Lab/backend/node_modules/.pnpm/jest-circus@30.4.2/node_modules/jest-circus/build/jestAdapterInit.js:765:3)\n at runAndTransformResultsToJestFormat (/workspaces/Web3-Student-Lab/backend/node_modules/.pnpm/jest-circus@30.4.2/node_modules/jest-circus/build/jestAdapterInit.js:1993:21)\n at jestAdapter (/workspaces/Web3-Student-Lab/backend/node_modules/.pnpm/jest-circus@30.4.2/node_modules/jest-circus/build/runner.js:111:19)\n at runTestInternal (/workspaces/Web3-Student-Lab/backend/node_modules/.pnpm/jest-runner@30.4.2/node_modules/jest-runner/build/index.js:276:16)\n at runTest (/workspaces/Web3-Student-Lab/backend/node_modules/.pnpm/jest-runner@30.4.2/node_modules/jest-runner/build/index.js:344:7)"}},"timestamp":"2026-06-27 09:13:43.783"}
+{"level":"error","message":"❌ Environment Configuration Error: Environment validation failed:\n\nMissing required environment variable: DATABASE_URL\nDescription: PostgreSQL connection string for database connectivity\nPlease check your .env file and .env.example for guidance.\n\nPlease copy .env.example to .env and fill in the required values.","metadata":{"metadata":{"environment":"test","service":"web3-student-lab-backend"}},"timestamp":"2026-06-27 09:13:43.854"}
+{"correlationId":"test-correlation-id-123","level":"error","message":"Error message","metadata":{"metadata":{"environment":"test","service":"web3-student-lab-backend"}},"timestamp":"2026-06-27 09:13:43.790"}
+{"level":"error","message":"❌ Environment Configuration Error: Environment validation failed:\n\nMissing required environment variable: JWT_SECRET\nDescription: Secret key for JWT token signing and verification\nPlease check your .env file and .env.example for guidance.\n\nPlease copy .env.example to .env and fill in the required values.","metadata":{"metadata":{"environment":"test","service":"web3-student-lab-backend"}},"timestamp":"2026-06-27 09:13:43.869"}
+{"level":"error","message":"❌ Environment Configuration Error: Environment validation failed:\n\nInvalid value for environment variable: JWT_SECRET\nDescription: Secret key for JWT token signing and verification\nCurrent value: \"short\"\nPlease check .env.example for the correct format.\n\nPlease copy .env.example to .env and fill in the required values.","metadata":{"metadata":{"environment":"test","service":"web3-student-lab-backend"}},"timestamp":"2026-06-27 09:13:43.874"}
+{"level":"error","message":"❌ Environment Configuration Error: Environment validation failed:\n\nInvalid value for environment variable: JWT_SECRET\nDescription: Secret key for JWT token signing and verification\nCurrent value: \"short\"\nPlease check .env.example for the correct format.\n\nPlease copy .env.example to .env and fill in the required values.","metadata":{"metadata":{"environment":"test","service":"web3-student-lab-backend"}},"timestamp":"2026-06-27 09:13:43.876"}
+{"level":"error","message":"❌ Environment Configuration Error: Environment validation failed:\n\nInvalid value for environment variable: JWT_SECRET\nDescription: Secret key for JWT token signing and verification\nCurrent value: \"your-secret-key-change-in-production\"\nPlease check .env.example for the correct format.\n\nPlease copy .env.example to .env and fill in the required values.","metadata":{"metadata":{"environment":"test","service":"web3-student-lab-backend"}},"timestamp":"2026-06-27 09:13:43.881"}
+{"level":"error","message":"❌ Environment Configuration Error: Environment validation failed:\n\nInvalid value for environment variable: JWT_SECRET\nDescription: Secret key for JWT token signing and verification\nCurrent value: \"your-secret-key-change-in-production\"\nPlease check .env.example for the correct format.\n\nPlease copy .env.example to .env and fill in the required values.","metadata":{"metadata":{"environment":"test","service":"web3-student-lab-backend"}},"timestamp":"2026-06-27 09:13:43.886"}
+{"level":"error","message":"❌ Environment Configuration Error: Environment validation failed:\n\nInvalid value for environment variable: DATABASE_URL\nDescription: PostgreSQL connection string for database connectivity\nCurrent value: \"invalid-url-format\"\nPlease check .env.example for the correct format.\n\nPlease copy .env.example to .env and fill in the required values.","metadata":{"metadata":{"environment":"test","service":"web3-student-lab-backend"}},"timestamp":"2026-06-27 09:13:43.888"}
+{"level":"error","message":"❌ Environment Configuration Error: Environment validation failed:\n\nInvalid value for environment variable: DATABASE_URL\nDescription: PostgreSQL connection string for database connectivity\nCurrent value: \"invalid-url-format\"\nPlease check .env.example for the correct format.\n\nPlease copy .env.example to .env and fill in the required values.","metadata":{"metadata":{"environment":"test","service":"web3-student-lab-backend"}},"timestamp":"2026-06-27 09:13:43.889"}
+{"level":"error","message":"Failed to emit OSCT webhook event","metadata":{"metadata":{"environment":"test","error":"queue down","eventType":"opensource.pr_submitted","service":"web3-student-lab-backend"}},"timestamp":"2026-06-27 09:13:45.247"}
+{"level":"error","message":"DB health check failed","metadata":{"metadata":{"environment":"test","error":"database unavailable","service":"web3-student-lab-backend"}},"timestamp":"2026-06-27 09:13:47.482"}
+{"level":"error","message":"Redis ping failed: Stream isn't writeable and enableOfflineQueue options is false","metadata":{"metadata":{"environment":"test","service":"web3-student-lab-backend","stack":"Error: Stream isn't writeable and enableOfflineQueue options is false\n at EventEmitter.sendCommand (/workspaces/Web3-Student-Lab/backend/node_modules/.pnpm/ioredis@5.11.0/node_modules/ioredis/built/Redis.js:376:32)\n at EventEmitter.ping (/workspaces/Web3-Student-Lab/backend/node_modules/.pnpm/ioredis@5.11.0/node_modules/ioredis/built/utils/Commander.js:90:25)\n at RedisClient.ping (/workspaces/Web3-Student-Lab/backend/src/cache/RedisClient.ts:94:40)\n at RedisClient.connect (/workspaces/Web3-Student-Lab/backend/src/cache/RedisClient.ts:83:18)\n at Object. (/workspaces/Web3-Student-Lab/backend/tests/cache.test.ts:7:23)\n at Promise.finally.completed (/workspaces/Web3-Student-Lab/backend/node_modules/.pnpm/jest-circus@30.4.2/node_modules/jest-circus/build/jestAdapterInit.js:1561:28)\n at new Promise ()\n at callAsyncCircusFn (/workspaces/Web3-Student-Lab/backend/node_modules/.pnpm/jest-circus@30.4.2/node_modules/jest-circus/build/jestAdapterInit.js:1501:10)\n at _callCircusHook (/workspaces/Web3-Student-Lab/backend/node_modules/.pnpm/jest-circus@30.4.2/node_modules/jest-circus/build/jestAdapterInit.js:980:40)\n at _runTestsForDescribeBlock (/workspaces/Web3-Student-Lab/backend/node_modules/.pnpm/jest-circus@30.4.2/node_modules/jest-circus/build/jestAdapterInit.js:804:7)\n at _runTestsForDescribeBlock (/workspaces/Web3-Student-Lab/backend/node_modules/.pnpm/jest-circus@30.4.2/node_modules/jest-circus/build/jestAdapterInit.js:861:11)\n at run (/workspaces/Web3-Student-Lab/backend/node_modules/.pnpm/jest-circus@30.4.2/node_modules/jest-circus/build/jestAdapterInit.js:765:3)\n at runAndTransformResultsToJestFormat (/workspaces/Web3-Student-Lab/backend/node_modules/.pnpm/jest-circus@30.4.2/node_modules/jest-circus/build/jestAdapterInit.js:1993:21)\n at jestAdapter (/workspaces/Web3-Student-Lab/backend/node_modules/.pnpm/jest-circus@30.4.2/node_modules/jest-circus/build/runner.js:111:19)\n at runTestInternal (/workspaces/Web3-Student-Lab/backend/node_modules/.pnpm/jest-runner@30.4.2/node_modules/jest-runner/build/index.js:276:16)\n at runTest (/workspaces/Web3-Student-Lab/backend/node_modules/.pnpm/jest-runner@30.4.2/node_modules/jest-runner/build/index.js:344:7)"}},"timestamp":"2026-06-27 09:13:48.074"}
diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma
index 71cca819..778f7687 100644
--- a/backend/prisma/schema.prisma
+++ b/backend/prisma/schema.prisma
@@ -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")
+}
diff --git a/backend/src/db/index.ts b/backend/src/db/index.ts
index 159c1778..e8b86a39 100644
--- a/backend/src/db/index.ts
+++ b/backend/src/db/index.ts
@@ -18,6 +18,7 @@ const workspaceModels = new Set([
'AuditLog',
'Canvas',
'WebhookSubscription',
+ 'TranslationEntry',
]);
diff --git a/backend/src/index.ts b/backend/src/index.ts
index 5090e94e..6e39a417 100644
--- a/backend/src/index.ts
+++ b/backend/src/index.ts
@@ -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';
@@ -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
@@ -158,7 +159,7 @@ async function setupGraphQL() {
try {
graphqlServer = await createGraphQLServer();
const { expressMiddleware } = await import('@apollo/server/express4');
-
+
app.use(
'/graphql',
express.json(),
@@ -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, {
diff --git a/backend/src/middleware/i18n.ts b/backend/src/middleware/i18n.ts
new file mode 100644
index 00000000..048c7b6b
--- /dev/null
+++ b/backend/src/middleware/i18n.ts
@@ -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 };
diff --git a/backend/src/routes/i18n.routes.ts b/backend/src/routes/i18n.routes.ts
new file mode 100644
index 00000000..5c96ef6d
--- /dev/null
+++ b/backend/src/routes/i18n.routes.ts
@@ -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;
diff --git a/backend/src/routes/index.ts b/backend/src/routes/index.ts
index 7a1160e2..a92b9d73 100644
--- a/backend/src/routes/index.ts
+++ b/backend/src/routes/index.ts
@@ -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';
@@ -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);
diff --git a/backend/src/routes/seo.routes.ts b/backend/src/routes/seo.routes.ts
new file mode 100644
index 00000000..4fffd8d2
--- /dev/null
+++ b/backend/src/routes/seo.routes.ts
@@ -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;
diff --git a/backend/src/services/i18n/translation.repository.ts b/backend/src/services/i18n/translation.repository.ts
new file mode 100644
index 00000000..fa38cf8c
--- /dev/null
+++ b/backend/src/services/i18n/translation.repository.ts
@@ -0,0 +1,59 @@
+export interface TranslationRecord {
+ locale: string;
+ namespace: string;
+ key: string;
+ value: string;
+}
+
+export interface TranslationRepository {
+ getTranslations(locale: string, namespace: string): Promise>;
+}
+
+export class PrismaTranslationRepository implements TranslationRepository {
+ async getTranslations(locale: string, namespace: string): Promise> {
+ 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>((acc, row) => {
+ acc[row.key] = row.value;
+ return acc;
+ }, {});
+ }
+}
+
+export class InMemoryTranslationRepository implements TranslationRepository {
+ private readonly table = new Map();
+
+ 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> {
+ const prefix = `${locale}:${namespace}:`;
+ const result: Record = {};
+
+ 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}`;
+ }
+}
diff --git a/backend/src/services/seo/simulatorSeo.service.ts b/backend/src/services/seo/simulatorSeo.service.ts
new file mode 100644
index 00000000..45d3e278
--- /dev/null
+++ b/backend/src/services/seo/simulatorSeo.service.ts
@@ -0,0 +1,217 @@
+import { redisConnection } from '../../utils/redis.js';
+
+export interface SimulatorAsset {
+ slug: string;
+ title: string;
+ summary: string;
+ tags: string[];
+ updatedAt: string;
+}
+
+export interface SimulatorMetaTags {
+ title: string;
+ description: string;
+ keywords: string;
+ canonical: string;
+ ogType: 'article';
+}
+
+export interface SeoCacheClient {
+ get(key: string): Promise;
+ setex(key: string, ttlSeconds: number, value: string): Promise;
+ del?(key: string): Promise;
+}
+
+export interface SeoServiceDependencies {
+ cache?: SeoCacheClient;
+ fetchAssetIndex?: () => Promise;
+ fetchSitemapXml?: () => Promise;
+ now?: () => number;
+ baseUrl?: string;
+}
+
+interface MemoryCacheEntry {
+ value: string;
+ expiresAt: number;
+}
+
+const DEFAULT_ASSETS: SimulatorAsset[] = [
+ {
+ slug: 'consensus-lab',
+ title: 'Consensus Failure Recovery Simulator',
+ summary: 'Model validator partitions and compare protocol convergence behavior in real time.',
+ tags: ['consensus', 'validators', 'resilience'],
+ updatedAt: '2026-01-10T00:00:00.000Z',
+ },
+ {
+ slug: 'gas-optimization-arena',
+ title: 'Gas Optimization Arena',
+ summary: 'Benchmark contract patterns and detect expensive opcode paths before deployment.',
+ tags: ['gas', 'smart-contracts', 'performance'],
+ updatedAt: '2026-02-14T00:00:00.000Z',
+ },
+ {
+ slug: 'fork-choice-playground',
+ title: 'Fork Choice Playground',
+ summary: 'Explore chain reorg scenarios and observe fork choice rules against adversarial peers.',
+ tags: ['fork-choice', 'chain-reorg', 'security'],
+ updatedAt: '2026-03-02T00:00:00.000Z',
+ },
+];
+
+const DEFAULT_SITEMAP_XML = `
+
+ https://web3studentlab.dev/simulator/consensus-lab
+ https://web3studentlab.dev/simulator/gas-optimization-arena
+ https://web3studentlab.dev/simulator/fork-choice-playground
+ `;
+
+const ASSET_INDEX_CACHE_KEY = 'seo:simulator:asset-index:v1';
+const SITEMAP_CACHE_KEY = 'seo:simulator:sitemap:v1';
+
+function parseSitemapXml(xml: string): string[] {
+ const matches = xml.matchAll(/(.*?)<\/loc>/g);
+ return Array.from(matches, (match) => match[1].trim()).filter(Boolean);
+}
+
+function buildMetaTags(asset: SimulatorAsset, baseUrl: string): SimulatorMetaTags {
+ return {
+ title: `${asset.title} | Blockchain Learning Simulator`,
+ description: asset.summary,
+ keywords: asset.tags.join(', '),
+ canonical: `${baseUrl.replace(/\/$/, '')}/simulator/${asset.slug}`,
+ ogType: 'article',
+ };
+}
+
+export class SimulatorSeoService {
+ private readonly cache: SeoCacheClient;
+ private readonly fetchAssetIndexImpl: () => Promise;
+ private readonly fetchSitemapXmlImpl: () => Promise;
+ private readonly now: () => number;
+ private readonly baseUrl: string;
+ private readonly memoryFallback = new Map();
+
+ private hits = 0;
+ private misses = 0;
+ private fallbackReads = 0;
+ private fallbackWrites = 0;
+
+ constructor(dependencies: SeoServiceDependencies = {}) {
+ this.cache = dependencies.cache ?? (redisConnection as SeoCacheClient);
+ this.fetchAssetIndexImpl = dependencies.fetchAssetIndex ?? (async () => DEFAULT_ASSETS);
+ this.fetchSitemapXmlImpl = dependencies.fetchSitemapXml ?? (async () => DEFAULT_SITEMAP_XML);
+ this.now = dependencies.now ?? (() => Date.now());
+ this.baseUrl = dependencies.baseUrl ?? 'https://web3studentlab.dev';
+ }
+
+ async getAssetIndex(): Promise {
+ return this.getOrCache(
+ ASSET_INDEX_CACHE_KEY,
+ 120,
+ this.fetchAssetIndexImpl
+ );
+ }
+
+ async getMetaTags(slug: string): Promise {
+ const assets = await this.getAssetIndex();
+ const found = assets.find((asset) => asset.slug === slug);
+ if (!found) {
+ return null;
+ }
+
+ return buildMetaTags(found, this.baseUrl);
+ }
+
+ async getSitemapUrls(): Promise {
+ const xml = await this.getOrCache(
+ SITEMAP_CACHE_KEY,
+ 180,
+ this.fetchSitemapXmlImpl
+ );
+
+ return parseSitemapXml(xml);
+ }
+
+ getCacheStats() {
+ return {
+ hits: this.hits,
+ misses: this.misses,
+ fallbackReads: this.fallbackReads,
+ fallbackWrites: this.fallbackWrites,
+ };
+ }
+
+ async clearCache(): Promise {
+ this.memoryFallback.clear();
+ if (typeof this.cache.del === 'function') {
+ await Promise.all([
+ this.cache.del(ASSET_INDEX_CACHE_KEY),
+ this.cache.del(SITEMAP_CACHE_KEY),
+ ]).catch(() => undefined);
+ }
+ }
+
+ private getFromMemoryFallback(key: string): string | null {
+ const item = this.memoryFallback.get(key);
+ if (!item) {
+ return null;
+ }
+
+ if (item.expiresAt <= this.now()) {
+ this.memoryFallback.delete(key);
+ return null;
+ }
+
+ this.fallbackReads += 1;
+ return item.value;
+ }
+
+ private setMemoryFallback(key: string, value: string, ttlSeconds: number): void {
+ this.fallbackWrites += 1;
+ this.memoryFallback.set(key, {
+ value,
+ expiresAt: this.now() + ttlSeconds * 1000,
+ });
+ }
+
+ private async getOrCache(
+ key: string,
+ ttlSeconds: number,
+ fetcher: () => Promise
+ ): Promise {
+ const fallbackValue = this.getFromMemoryFallback(key);
+ if (fallbackValue) {
+ this.hits += 1;
+ return JSON.parse(fallbackValue) as T;
+ }
+
+ try {
+ const cached = await this.cache.get(key);
+ if (cached) {
+ this.hits += 1;
+ return JSON.parse(cached) as T;
+ }
+ } catch {
+ const memory = this.getFromMemoryFallback(key);
+ if (memory) {
+ this.hits += 1;
+ return JSON.parse(memory) as T;
+ }
+ }
+
+ this.misses += 1;
+ const fresh = await fetcher();
+ const serialized = JSON.stringify(fresh);
+
+ try {
+ await this.cache.setex(key, ttlSeconds, serialized);
+ } catch {
+ this.setMemoryFallback(key, serialized, ttlSeconds);
+ }
+
+ return fresh;
+ }
+}
+
+export const simulatorSeoService = new SimulatorSeoService();
diff --git a/backend/tests/i18n.middleware.test.ts b/backend/tests/i18n.middleware.test.ts
new file mode 100644
index 00000000..9100c36e
--- /dev/null
+++ b/backend/tests/i18n.middleware.test.ts
@@ -0,0 +1,63 @@
+import express from 'express';
+import request from 'supertest';
+import { createI18nMiddleware } from '../src/middleware/i18n.js';
+import { InMemoryTranslationRepository } from '../src/services/i18n/translation.repository.js';
+
+describe('i18n middleware locale routing', () => {
+ const repository = new InMemoryTranslationRepository([
+ { locale: 'en', namespace: 'platform', key: 'welcome', value: 'Welcome' },
+ { locale: 'es', namespace: 'platform', key: 'welcome', value: 'Bienvenido' },
+ { locale: 'en', namespace: 'platform', key: 'dashboard', value: 'Dashboard' },
+ ]);
+
+ function buildApp() {
+ const app = express();
+ app.use(
+ createI18nMiddleware({
+ repository,
+ defaultLocale: 'en',
+ supportedLocales: ['en', 'es'],
+ })
+ );
+
+ app.get('/message', (req, res) => {
+ const key = typeof req.query.key === 'string' ? req.query.key : 'welcome';
+ res.json({ locale: req.locale, value: req.t?.(key) });
+ });
+
+ return app;
+ }
+
+ it('uses locale from query param over headers', async () => {
+ const app = buildApp();
+
+ const response = await request(app)
+ .get('/message?locale=es&key=welcome')
+ .set('accept-language', 'en-US,en;q=0.9');
+
+ expect(response.status).toBe(200);
+ expect(response.body).toEqual({ locale: 'es', value: 'Bienvenido' });
+ });
+
+ it('falls back to default locale when unsupported language requested', async () => {
+ const app = buildApp();
+
+ const response = await request(app)
+ .get('/message?locale=pt&key=welcome')
+ .set('accept-language', 'pt-BR,pt;q=0.9');
+
+ expect(response.status).toBe(200);
+ expect(response.body).toEqual({ locale: 'en', value: 'Welcome' });
+ });
+
+ it('falls back to english key when translation is missing in locale', async () => {
+ const app = buildApp();
+
+ const response = await request(app)
+ .get('/message?locale=es&key=dashboard')
+ .set('accept-language', 'es-ES,es;q=0.9');
+
+ expect(response.status).toBe(200);
+ expect(response.body).toEqual({ locale: 'es', value: 'Dashboard' });
+ });
+});
diff --git a/backend/tests/seo-cache.integration.test.ts b/backend/tests/seo-cache.integration.test.ts
new file mode 100644
index 00000000..900add71
--- /dev/null
+++ b/backend/tests/seo-cache.integration.test.ts
@@ -0,0 +1,115 @@
+import {
+ SeoCacheClient,
+ SimulatorSeoService,
+} from '../src/services/seo/simulatorSeo.service.js';
+
+class FakeRedisCache implements SeoCacheClient {
+ private readonly store = new Map();
+
+ constructor(
+ private readonly now: () => number,
+ private shouldFail = false
+ ) {}
+
+ setFailureMode(shouldFail: boolean) {
+ this.shouldFail = shouldFail;
+ }
+
+ async get(key: string): Promise {
+ if (this.shouldFail) {
+ throw new Error('simulated packet loss');
+ }
+
+ const item = this.store.get(key);
+ if (!item) {
+ return null;
+ }
+
+ if (item.expiresAt <= this.now()) {
+ this.store.delete(key);
+ return null;
+ }
+
+ return item.value;
+ }
+
+ async setex(key: string, ttlSeconds: number, value: string): Promise {
+ if (this.shouldFail) {
+ throw new Error('simulated packet loss');
+ }
+
+ this.store.set(key, { value, expiresAt: this.now() + ttlSeconds * 1000 });
+ return 'OK';
+ }
+
+ async del(key: string): Promise {
+ return this.store.delete(key) ? 1 : 0;
+ }
+}
+
+describe('SimulatorSeoService cache integration', () => {
+ let now = 0;
+ let fetchCount = 0;
+ let cache: FakeRedisCache;
+ let service: SimulatorSeoService;
+
+ const assetFactory = () => [
+ {
+ slug: 'consensus-lab',
+ title: 'Consensus Lab',
+ summary: 'Stress test finality assumptions.',
+ tags: ['consensus'],
+ updatedAt: '2026-01-10T00:00:00.000Z',
+ },
+ ];
+
+ beforeEach(() => {
+ now = 0;
+ fetchCount = 0;
+ cache = new FakeRedisCache(() => now);
+
+ service = new SimulatorSeoService({
+ cache,
+ now: () => now,
+ fetchAssetIndex: async () => {
+ fetchCount += 1;
+ return assetFactory();
+ },
+ fetchSitemapXml: async () =>
+ `https://example.com/simulator/consensus-lab `,
+ baseUrl: 'https://example.com',
+ });
+ });
+
+ it('records cache miss then hit for asset index', async () => {
+ await service.getAssetIndex();
+ await service.getAssetIndex();
+
+ const stats = service.getCacheStats();
+
+ expect(fetchCount).toBe(1);
+ expect(stats.misses).toBe(1);
+ expect(stats.hits).toBe(1);
+ });
+
+ it('re-fetches data after key expiration', async () => {
+ await service.getAssetIndex();
+ now += 121_000;
+ await service.getAssetIndex();
+
+ expect(fetchCount).toBe(2);
+ });
+
+ it('falls back gracefully when redis operations fail', async () => {
+ cache.setFailureMode(true);
+
+ const meta = await service.getMetaTags('consensus-lab');
+ const urls = await service.getSitemapUrls();
+
+ const stats = service.getCacheStats();
+
+ expect(meta?.canonical).toBe('https://example.com/simulator/consensus-lab');
+ expect(urls).toEqual(['https://example.com/simulator/consensus-lab']);
+ expect(stats.fallbackWrites).toBeGreaterThan(0);
+ });
+});
diff --git a/docs/technical/MVP_ISSUES_815_818_819_823.md b/docs/technical/MVP_ISSUES_815_818_819_823.md
new file mode 100644
index 00000000..649f3737
--- /dev/null
+++ b/docs/technical/MVP_ISSUES_815_818_819_823.md
@@ -0,0 +1,78 @@
+# MVP Feature Delivery Notes
+
+This document summarizes implementation coverage for four MVP-critical issues.
+
+## #815 Frontend Git Conflict Resolution Tutorial
+
+Implemented in the Open Source Contribution Trainer area with a responsive, interactive sandbox that teaches students to:
+
+- detect merge conflict markers (`<<<<<<<`, `=======`, `>>>>>>>`)
+- interpret conflicting branches
+- submit and validate a manual clean resolution
+
+Key additions:
+
+- `frontend/src/components/version-control/GitConflictResolutionTutorial.tsx`
+- `frontend/src/components/version-control/__tests__/GitConflictResolutionTutorial.test.tsx`
+- wiring in `frontend/src/app/version-control/page.tsx`
+
+PR note: closes #815.
+
+## #819 Backend SEO Optimization and Redis Caching
+
+Implemented modular SEO pipelines for simulator assets and sitemap parsing with Redis-backed caching and memory fallback when Redis is unavailable.
+
+Key additions:
+
+- `backend/src/services/seo/simulatorSeo.service.ts`
+- `backend/src/routes/seo.routes.ts`
+- `backend/tests/seo-cache.integration.test.ts`
+
+Capabilities:
+
+- cached simulator asset-index retrieval
+- dynamic sitemap XML parsing to URL entries
+- cache hit/miss/fallback metrics
+- graceful fallback behavior on Redis packet-loss scenarios
+
+PR note: closes #819.
+
+## #818 Backend Runtime i18n Middleware
+
+Implemented locale-aware middleware that resolves locale from query params or request headers and provides runtime translation lookup with fallback.
+
+Key additions:
+
+- `backend/src/middleware/i18n.ts`
+- `backend/src/services/i18n/translation.repository.ts`
+- `backend/src/routes/i18n.routes.ts`
+- `backend/tests/i18n.middleware.test.ts`
+- `backend/prisma/schema.prisma` TranslationEntry model
+- `pending/003_create_translation_entries_table.sql`
+
+Capabilities:
+
+- locale detection (`locale`/`lang` query, then `Accept-Language`)
+- fallback to default locale (`en`)
+- translation table loading via repository abstraction
+- PostgreSQL-friendly indexed relational structure for translations
+
+PR note: closes #818.
+
+## #823 Frontend RBAC for Simulator UI
+
+Implemented role-based guard boundaries for simulator access and denied-state rendering.
+
+Key additions:
+
+- `frontend/src/components/auth/RoleGuard.tsx`
+- `frontend/src/components/auth/__tests__/RoleGuard.test.tsx`
+- RBAC guard integration in `frontend/src/app/simulator/page.tsx`
+
+Capabilities:
+
+- role and permission profile evaluation
+- access-denied fallback templates
+- reusable React component guard plus higher-order guard pattern
+
+PR note: closes #823.
diff --git a/frontend/src/app/simulator/page.tsx b/frontend/src/app/simulator/page.tsx
index 427033c3..3106b11f 100644
--- a/frontend/src/app/simulator/page.tsx
+++ b/frontend/src/app/simulator/page.tsx
@@ -1,10 +1,11 @@
'use client';
+import { RoleGuard } from '@/components/auth/RoleGuard';
import { NetworkGraph } from '@/components/simulator/NetworkGraph';
-import { useEffect, useState } from 'react';
import { WithSkeleton } from '@/components/ui/WithSkeleton';
import { GraphSkeleton } from '@/components/ui/skeletons/GraphSkeleton';
import { useTutorial } from '@/contexts/TutorialContext';
+import { useEffect, useState } from 'react';
interface Transaction {
id: string;
@@ -79,7 +80,8 @@ export default function SimulatorPage() {
}, [ledgers, isLive]);
return (
-
+
+
{/* Background Grid Accent */}
@@ -241,6 +243,7 @@ export default function SimulatorPage() {
background: #ef4444;
}
`}
-
+
+
);
}
diff --git a/frontend/src/app/version-control/page.tsx b/frontend/src/app/version-control/page.tsx
index 82877044..ba4a429e 100644
--- a/frontend/src/app/version-control/page.tsx
+++ b/frontend/src/app/version-control/page.tsx
@@ -1,10 +1,11 @@
'use client';
-import { useState, useCallback, useMemo } from 'react';
-import { VersionControl, type DocumentEntry, type Version } from '@/lib/version-control/engine';
+import { GitConflictResolutionTutorial } from '@/components/version-control/GitConflictResolutionTutorial';
import { VersionHistory } from '@/components/version-control/VersionHistory';
-import { Plus, FileText, Trash2, Clock, RotateCcw } from 'lucide-react';
import { formatDistanceToNow } from '@/lib/utils';
+import { VersionControl, type DocumentEntry, type Version } from '@/lib/version-control/engine';
+import { Clock, FileText, Plus, RotateCcw, Trash2 } from 'lucide-react';
+import { useCallback, useMemo, useState } from 'react';
export default function VersionControlPage() {
const [documents, setDocuments] = useState(() =>
@@ -98,6 +99,10 @@ export default function VersionControlPage() {
+
+
+
+
diff --git a/frontend/src/components/auth/RoleGuard.tsx b/frontend/src/components/auth/RoleGuard.tsx
new file mode 100644
index 00000000..5f49186e
--- /dev/null
+++ b/frontend/src/components/auth/RoleGuard.tsx
@@ -0,0 +1,125 @@
+'use client';
+
+import { useAuth } from '@/contexts/AuthContext';
+import type { ComponentType, ReactNode } from 'react';
+import { useMemo } from 'react';
+
+export type AppRole = 'student' | 'administrator' | 'instructor';
+
+export interface AuthorizationProfile {
+ roles: AppRole[];
+ permissions: string[];
+}
+
+export interface RoleGuardProps {
+ requiredRoles?: AppRole[];
+ requiredPermissions?: string[];
+ fallback?: ReactNode;
+ children: ReactNode;
+}
+
+function normalizeRoles(rawUser: unknown): AppRole[] {
+ if (!rawUser || typeof rawUser !== 'object') {
+ return ['student'];
+ }
+
+ const user = rawUser as {
+ role?: string;
+ roles?: string[];
+ };
+
+ if (Array.isArray(user.roles) && user.roles.length > 0) {
+ return user.roles.filter(Boolean) as AppRole[];
+ }
+
+ if (typeof user.role === 'string' && user.role.length > 0) {
+ return [user.role as AppRole];
+ }
+
+ return ['student'];
+}
+
+function normalizePermissions(rawUser: unknown): string[] {
+ if (!rawUser || typeof rawUser !== 'object') {
+ return [];
+ }
+
+ const user = rawUser as {
+ permissions?: string[];
+ };
+
+ return Array.isArray(user.permissions) ? user.permissions.filter(Boolean) : [];
+}
+
+export function buildAuthorizationProfile(user: unknown): AuthorizationProfile {
+ return {
+ roles: normalizeRoles(user),
+ permissions: normalizePermissions(user),
+ };
+}
+
+function isAuthorized(
+ profile: AuthorizationProfile,
+ requiredRoles: AppRole[],
+ requiredPermissions: string[]
+): boolean {
+ const hasRole = requiredRoles.length === 0 || requiredRoles.some((role) => profile.roles.includes(role));
+ const hasPermissions =
+ requiredPermissions.length === 0 ||
+ requiredPermissions.every((permission) => profile.permissions.includes(permission));
+
+ return hasRole && hasPermissions;
+}
+
+export function AccessDeniedView() {
+ return (
+
+
Access denied
+
This simulator view is restricted
+
+ Your current account does not have the required permissions for this route. Sign in with an
+ administrator profile or request simulator access from the platform team.
+
+
+ );
+}
+
+export function RoleGuard({
+ requiredRoles = [],
+ requiredPermissions = [],
+ fallback,
+ children,
+}: RoleGuardProps) {
+ const { user, isLoading } = useAuth();
+
+ const profile = useMemo(() => buildAuthorizationProfile(user), [user]);
+
+ if (isLoading) {
+ return (
+
+ Checking permissions...
+
+ );
+ }
+
+ if (!isAuthorized(profile, requiredRoles, requiredPermissions)) {
+ return <>{fallback ??
}>;
+ }
+
+ return <>{children}>;
+}
+
+export function withRoleGuard
(
+ WrappedComponent: ComponentType
,
+ guard: Omit
+) {
+ const GuardedComponent = (props: P) => (
+
+
+
+ );
+
+ GuardedComponent.displayName = `WithRoleGuard(${WrappedComponent.displayName || WrappedComponent.name || 'Component'})`;
+
+ return GuardedComponent;
+}
diff --git a/frontend/src/components/auth/__tests__/RoleGuard.test.tsx b/frontend/src/components/auth/__tests__/RoleGuard.test.tsx
new file mode 100644
index 00000000..5bc39759
--- /dev/null
+++ b/frontend/src/components/auth/__tests__/RoleGuard.test.tsx
@@ -0,0 +1,86 @@
+import { render, screen } from '@testing-library/react';
+import { describe, expect, it, vi } from 'vitest';
+import { RoleGuard } from '../RoleGuard';
+
+vi.mock('@/contexts/AuthContext', () => ({
+ useAuth: vi.fn(),
+}));
+
+import { useAuth } from '@/contexts/AuthContext';
+
+const mockedUseAuth = vi.mocked(useAuth);
+
+describe('RoleGuard', () => {
+ it('renders children for a student route when user is student', () => {
+ mockedUseAuth.mockReturnValue({
+ user: { id: '1', email: 'student@example.com', name: 'Student', role: 'student' },
+ isLoading: false,
+ } as any);
+
+ render(
+
+ Student Content
+
+ );
+
+ expect(screen.getByText('Student Content')).toBeInTheDocument();
+ });
+
+ it('renders fallback for administrator route when user is student', () => {
+ mockedUseAuth.mockReturnValue({
+ user: { id: '1', email: 'student@example.com', name: 'Student', role: 'student' },
+ isLoading: false,
+ } as any);
+
+ render(
+ No Access
}
+ >
+
Admin Content
+
+ );
+
+ expect(screen.getByText('No Access')).toBeInTheDocument();
+ expect(screen.queryByText('Admin Content')).not.toBeInTheDocument();
+ });
+
+ it('allows administrator to access protected route', () => {
+ mockedUseAuth.mockReturnValue({
+ user: { id: '2', email: 'admin@example.com', name: 'Admin', role: 'administrator' },
+ isLoading: false,
+ } as any);
+
+ render(
+
+ Admin Content
+
+ );
+
+ expect(screen.getByText('Admin Content')).toBeInTheDocument();
+ });
+
+ it('blocks when required permissions are missing', () => {
+ mockedUseAuth.mockReturnValue({
+ user: {
+ id: '1',
+ email: 'student@example.com',
+ name: 'Student',
+ role: 'student',
+ permissions: ['simulator.read'],
+ },
+ isLoading: false,
+ } as any);
+
+ render(
+
Permission Missing }
+ >
+
Protected Simulator
+
+ );
+
+ expect(screen.getByText('Permission Missing')).toBeInTheDocument();
+ });
+});
diff --git a/frontend/src/components/version-control/GitConflictResolutionTutorial.tsx b/frontend/src/components/version-control/GitConflictResolutionTutorial.tsx
new file mode 100644
index 00000000..d13a5c82
--- /dev/null
+++ b/frontend/src/components/version-control/GitConflictResolutionTutorial.tsx
@@ -0,0 +1,214 @@
+'use client';
+
+import { useMemo, useState } from 'react';
+
+const CONFLICT_SNIPPET = `function calculateReward(points) {
+<<<<<<< HEAD
+ return points * 2;
+=======
+ return Math.round(points * 2.5);
+>>>>>>> feature/reward-multiplier
+}`;
+
+const ACCEPT_MINE = `function calculateReward(points) {
+ return points * 2;
+}`;
+
+const ACCEPT_THEIRS = `function calculateReward(points) {
+ return Math.round(points * 2.5);
+}`;
+
+const ACCEPT_BOTH = `function calculateReward(points) {
+ const baseReward = points * 2;
+ return Math.max(baseReward, Math.round(points * 2.5));
+}`;
+
+const REQUIRED_MARKERS = ['<<<<<<<', '=======', '>>>>>>>'] as const;
+
+type ResolutionStatus = 'idle' | 'passed' | 'failed';
+
+export interface ResolutionValidation {
+ passed: boolean;
+ reason: string;
+}
+
+export function validateConflictResolution(input: string): ResolutionValidation {
+ const normalized = input.trim();
+ if (!normalized) {
+ return { passed: false, reason: 'Add your resolved code before validating.' };
+ }
+
+ const hasMarkers = REQUIRED_MARKERS.some((marker) => normalized.includes(marker));
+ if (hasMarkers) {
+ return {
+ passed: false,
+ reason: 'Conflict markers are still present. Remove <<<<<<<, =======, and >>>>>>> lines.',
+ };
+ }
+
+ if (!normalized.includes('calculateReward')) {
+ return {
+ passed: false,
+ reason: 'The function signature was removed. Keep calculateReward(points) in the final result.',
+ };
+ }
+
+ const keepsKnownLogic =
+ normalized.includes('points * 2') || normalized.includes('Math.round(points * 2.5)');
+
+ if (!keepsKnownLogic) {
+ return {
+ passed: false,
+ reason: 'Expected reward logic is missing. Keep at least one valid branch of the implementation.',
+ };
+ }
+
+ return {
+ passed: true,
+ reason: 'Great work. You removed all markers and kept a valid implementation.',
+ };
+}
+
+export function GitConflictResolutionTutorial() {
+ const [draft, setDraft] = useState(CONFLICT_SNIPPET);
+ const [status, setStatus] = useState
('idle');
+ const [feedback, setFeedback] = useState('');
+ const [attempts, setAttempts] = useState(0);
+
+ const markerChecklist = useMemo(
+ () =>
+ REQUIRED_MARKERS.map((marker) => ({
+ marker,
+ cleared: !draft.includes(marker),
+ })),
+ [draft]
+ );
+
+ const handleValidate = () => {
+ const result = validateConflictResolution(draft);
+ setAttempts((count) => count + 1);
+ setStatus(result.passed ? 'passed' : 'failed');
+ setFeedback(result.reason);
+ };
+
+ const applyExample = (example: string) => {
+ setDraft(example);
+ setStatus('idle');
+ setFeedback('');
+ };
+
+ const resetScenario = () => {
+ setDraft(CONFLICT_SNIPPET);
+ setStatus('idle');
+ setFeedback('');
+ setAttempts(0);
+ };
+
+ return (
+
+
+
+ Open Source Contribution Trainer
+
+
+ Git Conflict Resolution Sandbox
+
+
+ Practice identifying and removing merge markers. Keep only the final code you want to ship.
+
+
+
+
+
+
+ Merge File Preview
+
+
+
+
+
+ Resolution Checklist
+
+
+ {markerChecklist.map(({ marker, cleared }) => (
+
+ {cleared ? 'Cleared' : 'Still present'}: {marker}
+
+ ))}
+
+
+
+ Validate Resolution
+
+
+
+ Attempts: {attempts}
+
+
+ {status !== 'idle' && (
+
+ {feedback}
+
+ )}
+
+
+
+ );
+}
diff --git a/frontend/src/components/version-control/__tests__/GitConflictResolutionTutorial.test.tsx b/frontend/src/components/version-control/__tests__/GitConflictResolutionTutorial.test.tsx
new file mode 100644
index 00000000..7d63a262
--- /dev/null
+++ b/frontend/src/components/version-control/__tests__/GitConflictResolutionTutorial.test.tsx
@@ -0,0 +1,52 @@
+import { fireEvent, render, screen } from '@testing-library/react';
+import { describe, expect, it } from 'vitest';
+import {
+ GitConflictResolutionTutorial,
+ validateConflictResolution,
+} from '../GitConflictResolutionTutorial';
+
+describe('validateConflictResolution', () => {
+ it('fails when markers are still present', () => {
+ const result = validateConflictResolution('<<<<<<< HEAD\ncode\n=======\nmore\n>>>>>>> branch');
+ expect(result.passed).toBe(false);
+ expect(result.reason).toMatch(/Conflict markers are still present/i);
+ });
+
+ it('passes when markers are removed and expected logic remains', () => {
+ const result = validateConflictResolution(
+ 'function calculateReward(points) {\n return points * 2;\n}'
+ );
+
+ expect(result.passed).toBe(true);
+ });
+});
+
+describe('GitConflictResolutionTutorial', () => {
+ it('shows failed state when unresolved content is validated', () => {
+ render( );
+
+ fireEvent.click(screen.getByRole('button', { name: /validate resolution/i }));
+
+ const status = screen.getByTestId('resolution-status');
+ expect(status).toHaveTextContent(/Conflict markers are still present/i);
+ });
+
+ it('shows passed state when cleaned content is validated', () => {
+ render( );
+
+ fireEvent.click(screen.getByRole('button', { name: /accept current branch/i }));
+ fireEvent.click(screen.getByRole('button', { name: /validate resolution/i }));
+
+ const status = screen.getByTestId('resolution-status');
+ expect(status).toHaveTextContent(/Great work/i);
+ });
+
+ it('increments attempt count after each validation', () => {
+ render( );
+
+ fireEvent.click(screen.getByRole('button', { name: /validate resolution/i }));
+ fireEvent.click(screen.getByRole('button', { name: /validate resolution/i }));
+
+ expect(screen.getByTestId('attempt-count')).toHaveTextContent('Attempts: 2');
+ });
+});
diff --git a/frontend/src/components/version-control/index.ts b/frontend/src/components/version-control/index.ts
index dbf051f1..f469216d 100644
--- a/frontend/src/components/version-control/index.ts
+++ b/frontend/src/components/version-control/index.ts
@@ -1 +1,2 @@
-export { VersionHistory } from './VersionHistory';
\ No newline at end of file
+export { GitConflictResolutionTutorial } from './GitConflictResolutionTutorial';
+export { VersionHistory } from './VersionHistory';
diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts
index 502629a4..e110d29b 100644
--- a/frontend/src/lib/api.ts
+++ b/frontend/src/lib/api.ts
@@ -1,6 +1,6 @@
+import { apiRequestCache } from './api-cache';
import apiClient from './api-client';
import { API_BASE_URL } from './api-config';
-import { apiRequestCache } from './api-cache';
export interface User {
id: string;
@@ -8,6 +8,9 @@ export interface User {
name: string;
address?: string;
walletAddress?: string | null;
+ role?: 'student' | 'administrator' | 'instructor';
+ roles?: Array<'student' | 'administrator' | 'instructor'>;
+ permissions?: string[];
}
export interface AuthResponse {
diff --git a/pending/003_create_translation_entries_table.sql b/pending/003_create_translation_entries_table.sql
new file mode 100644
index 00000000..146216e4
--- /dev/null
+++ b/pending/003_create_translation_entries_table.sql
@@ -0,0 +1,14 @@
+CREATE TABLE IF NOT EXISTS translation_entries (
+ id TEXT PRIMARY KEY,
+ workspace_id TEXT NOT NULL DEFAULT 'default',
+ locale TEXT NOT NULL,
+ namespace TEXT NOT NULL DEFAULT 'platform',
+ key TEXT NOT NULL,
+ value TEXT NOT NULL,
+ created_at TIMESTAMP NOT NULL DEFAULT NOW(),
+ updated_at TIMESTAMP NOT NULL DEFAULT NOW(),
+ UNIQUE (workspace_id, locale, namespace, key)
+);
+
+CREATE INDEX IF NOT EXISTS idx_translation_entries_workspace_locale_namespace
+ ON translation_entries (workspace_id, locale, namespace);