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. +

+
+ +
+
+ +