Compare commits

...
50 Commits
Author SHA1 Message Date
LaurensBartelds ecbf842b3b Add detailed implementation plan and tests for 2026 security audit fixes
Test / backend (push) Successful in 15s
Test / frontend (push) Successful in 27s
2026-08-22 00:16:13 +02:00
LaurensBartelds 152a805961 fix: add type annotation for ChatCompletionMessageParam in getHint and tutorChat functions
Test / backend (push) Successful in 53s
Test / frontend (push) Successful in 1m15s
2026-07-02 23:19:26 +02:00
LaurensBartelds 54a0ede92a feat: implement AI usage tracking and quota management with daily usage records
Test / backend (push) Failing after 1m6s
Test / frontend (push) Successful in 34s
2026-05-05 15:37:17 +02:00
LaurensBarteldsandCopilot 1a37209f72 feat: add resend verification email functionality and related schemas
Test / backend (push) Successful in 13s
Test / frontend (push) Successful in 27s
Release Pipeline / build-and-release (push) Successful in 5m55s
Co-authored-by: Copilot <copilot@github.com>
2026-05-02 21:03:56 +02:00
LaurensBartelds 25e590639a fix: update email verification endpoint and improve error handling in apiFetch 2026-05-02 20:58:31 +02:00
LaurensBartelds 7849c295ef feat: update password validation to accept hash as special character in registration and setup schemas
Test / backend (push) Successful in 14s
Test / frontend (push) Successful in 27s
2026-05-02 20:50:28 +02:00
LaurensBartelds f6d97baeb5 fix: rename graded prop to avoid naming conflict in GrammarCorrection component
Test / backend (push) Successful in 14s
Test / frontend (push) Successful in 28s
Release Pipeline / build-and-release (push) Successful in 5m55s
2026-05-02 20:01:27 +02:00
LaurensBartelds b58500901a feat: add markdown rendering support with marked and integrate into Tex component
Test / backend (push) Successful in 13s
Test / frontend (push) Failing after 21s
2026-05-02 19:57:42 +02:00
LaurensBartelds e906951b06 feat: add new question types and components for fill-in-the-blanks, true/false, code submission, dropdown completion, numeric range, dictation, grammar correction, and flashcards
Test / backend (push) Successful in 13s
Test / frontend (push) Failing after 19s
- Updated QuestionRenderer to support new question types.
- Added FillInTheBlanks, TrueFalse, CodeSubmission, DropdownCompletion, NumericRange, Dictation, GrammarCorrection, and Flashcard components.
- Enhanced grading logic to handle new question types.
- Updated translations for new question types in English and Dutch.
- Added CSS styles for new components and question types.
- Extended Question and related types to accommodate new configurations and answers.
2026-05-02 19:25:28 +02:00
LaurensBartelds a78fb8efc3 feat: add download instructions functionality and UI integration in Workspace component
Test / backend (push) Successful in 13s
Test / frontend (push) Successful in 26s
2026-05-02 18:25:03 +02:00
LaurensBartelds a40bfa7838 feat: implement overflow menu for exam actions in MyExams component
Test / backend (push) Successful in 13s
Test / frontend (push) Successful in 27s
Release Pipeline / build-and-release (push) Successful in 5m43s
2026-05-02 17:50:21 +02:00
LaurensBartelds 26041cba1e feat: remove import URL functionality from Overview component
Test / backend (push) Successful in 13s
Test / frontend (push) Successful in 27s
2026-05-02 17:42:50 +02:00
LaurensBartelds 76dc09901a feat: enhance validation schemas with comprehensive tests for user roles, settings, exams, and collections
Test / backend (push) Successful in 13s
Test / frontend (push) Failing after 19s
2026-05-02 17:39:55 +02:00
LaurensBartelds 2715e6f9b3 Remove database file from repo and add to .gitignore
Test / backend (push) Failing after 13s
Test / frontend (push) Failing after 19s
2026-05-02 17:24:56 +02:00
LaurensBartelds 63aa9b572a feat: integrate AI features for question generation and tutoring
Test / backend (push) Failing after 14s
Test / frontend (push) Failing after 19s
- Added AI routes for generating questions, providing hints, and tutor chat functionality.
- Implemented AI client setup and model retrieval in aiClient.ts.
- Created aiAssist.ts for handling AI-related operations including question generation and hint retrieval.
- Updated services to utilize the new AI client and assist functions.
- Enhanced frontend components to support AI question generation and tutor chat, including UI for generating questions and displaying hints.
- Added localization strings for new AI features in English and Dutch.
- Updated exam review process to include AI-generated hints and tutor chat functionality.
2026-05-02 17:22:56 +02:00
LaurensBartelds 1ec1633dd9 feat: add collections management and study coach features
Test / backend (push) Failing after 13s
Test / frontend (push) Successful in 25s
- Implemented collection repository with CRUD operations for user collections.
- Created routes for managing collections, including listing, creating, updating, and deleting collections.
- Added sharing functionality for exams, allowing users to share and clone exams.
- Developed a study coach service that analyzes exam results and provides study tips based on weak areas.
- Introduced frontend components for managing collections, sharing exams, and displaying study coach insights.
- Enhanced user experience with loading states and error handling in shared components.
2026-05-02 16:55:08 +02:00
LaurensBartelds 7755e77039 feat: update validation schemas to remove string length constraints on settings
Test / backend (push) Failing after 13s
Test / frontend (push) Successful in 24s
Release Pipeline / build-and-release (push) Successful in 5m25s
2026-05-02 14:22:32 +02:00
LaurensBartelds 201d21a678 feat: add AI grading test endpoint and update connection logic
Test / backend (push) Successful in 13s
Test / frontend (push) Successful in 24s
Release Pipeline / build-and-release (push) Successful in 5m26s
2026-05-02 13:46:49 +02:00
LaurensBartelds 993265318b feat: integrate AI grading functionality
Test / backend (push) Successful in 16s
Test / frontend (push) Successful in 24s
Release Pipeline / build-and-release (push) Successful in 5m22s
- Added OpenAI package to backend dependencies for AI grading.
- Implemented new grading routes and services to handle AI grading requests.
- Updated admin settings to include AI provider configuration options.
- Enhanced frontend components to support AI grading, including UI for testing AI connection and displaying AI feedback.
- Updated results component to allow for AI-assisted grading and display of suggested grades.
- Added internationalization support for new AI grading features in both English and Dutch.
2026-05-02 12:41:53 +02:00
LaurensBartelds f40f4508fd feat: add missing step to add OrangePi to known_hosts in deployment workflow
Test / backend (push) Successful in 14s
Test / frontend (push) Successful in 24s
Release Pipeline / build-and-release (push) Successful in 5m21s
2026-04-27 20:41:30 +02:00
LaurensBartelds 95cf0db5a4 feat: add retake attempt functionality and update translations for retake button
Test / backend (push) Successful in 15s
Test / frontend (push) Successful in 27s
Release Pipeline / build-and-release (push) Successful in 5m24s
2026-04-27 20:22:17 +02:00
LaurensBartelds 028c732716 feat: add redo functionality for failed exam questions and update translations
Test / backend (push) Successful in 14s
Test / frontend (push) Successful in 24s
2026-04-27 20:15:28 +02:00
LaurensBartelds acbcfde617 feat: add storage management features including user storage quota and usage tracking
Test / backend (push) Successful in 13s
Test / frontend (push) Successful in 24s
- Updated database schema to include `storage_quota_bytes` and `plan` for users.
- Implemented storage usage calculations in `storageRepo` and added tests for storage functionality.
- Created `StorageManager` component to display and manage user storage.
- Enhanced user settings to show storage details and warnings for high usage.
- Added translations for new storage-related strings in English and Dutch.
- Updated styles for storage overview and details in CSS.
2026-04-27 15:29:43 +02:00
LaurensBartelds c002f42459 Merge pull request 'feat: implement retake exam functionality and update translations for exam status' (#2) from fixes into main
Test / backend (push) Successful in 17s
Test / frontend (push) Successful in 34s
Reviewed-on: #2
2026-04-27 13:08:22 +02:00
LaurensBartelds 6dd87972f6 feat: update settings management to use site_name and add batch update schema
Test / backend (push) Successful in 13s
Test / frontend (push) Successful in 33s
2026-04-27 13:06:01 +02:00
LaurensBartelds 1b5fd04040 feat: enhance security with token blacklisting and input sanitization; add helmet for security headers
Test / backend (push) Successful in 15s
Test / frontend (push) Successful in 24s
2026-04-27 12:52:57 +02:00
LaurensBartelds 4935251125 feat: implement retake exam functionality and update translations for exam status
Test / backend (push) Successful in 12s
Test / frontend (push) Successful in 23s
2026-04-27 11:28:01 +02:00
LaurensBartelds f794dacd2d feat: add JWT_SECRET to deployment and create .env file for environment variables
Release Pipeline / build-and-release (push) Successful in 5m4s
Test / backend (push) Successful in 14s
Test / frontend (push) Successful in 23s
2026-04-27 01:02:17 +02:00
LaurensBartelds 99ac7a7fae Merge branch 'main' of https://git.gzmn.nl/LaurensBartelds/brainwave
Test / backend (push) Successful in 13s
Test / frontend (push) Successful in 24s
Release Pipeline / build-and-release (push) Successful in 5m5s
2026-04-27 00:24:42 +02:00
LaurensBartelds 0b62723226 refactor: update Node.js version to 22 and adjust npm install commands for consistency 2026-04-27 00:24:25 +02:00
LaurensBartelds af317058fb Merge pull request 'feat: Implement API modules for admin, auth, exam, result, and setup functionalities' (#1) from refactor into main
Test / backend (push) Successful in 12s
Test / frontend (push) Successful in 23s
Reviewed-on: #1
2026-04-27 00:11:28 +02:00
LaurensBartelds 95603f93a0 refactor: update result schemas and tests for improved state management and feedback modes
Test / backend (push) Successful in 13s
Test / frontend (push) Successful in 24s
Release Pipeline / build-and-release (push) Failing after 6s
2026-04-26 23:54:56 +02:00
LaurensBartelds ae8a01bc54 refactor: remove max-width constraint from workspace main layout 2026-04-26 23:31:20 +02:00
LaurensBartelds 2b7939d478 feat: enhance exam loading functionality with examId support and update UI components
Test / backend (push) Successful in 14s
Test / frontend (push) Successful in 24s
2026-04-26 23:25:24 +02:00
LaurensBartelds 50efba6f34 feat: implement tweaks context for user preferences and remove TweaksPanel component
Test / backend (push) Successful in 12s
Test / frontend (push) Successful in 23s
2026-04-26 23:16:21 +02:00
LaurensBartelds 92c1de650a feat: add language selection feature to settings and update translations
Test / backend (push) Successful in 13s
Test / frontend (push) Successful in 23s
2026-04-26 23:04:39 +02:00
LaurensBartelds 83bb412b35 feat: enhance setup process with SMTP configuration and token generation
Test / backend (push) Successful in 12s
Test / frontend (push) Successful in 23s
fix: require authentication for certain routes and improve exam data handling
chore: update .gitignore to include additional database files
2026-04-26 22:53:00 +02:00
LaurensBartelds ca9c646b65 chore: remove SQLite database files brainwave.db-shm and brainwave.db-wal
Test / backend (push) Successful in 13s
Test / frontend (push) Successful in 23s
2026-04-26 22:52:51 +02:00
LaurensBartelds 51e972d994 fix: add brainwave.db-shm to .gitignore to prevent tracking of local database file
Test / backend (push) Successful in 13s
Test / frontend (push) Successful in 23s
2026-04-26 22:31:04 +02:00
LaurensBartelds 2907197308 fix: update npm ci command to include --legacy-peer-deps for frontend job
Test / backend (push) Successful in 13s
Test / frontend (push) Successful in 24s
2026-04-26 22:01:45 +02:00
LaurensBartelds e9c0a2615a feat: add i18next and react-i18next for internationalization support
Test / backend (push) Successful in 12s
Test / frontend (push) Failing after 5s
2026-04-26 21:59:06 +02:00
LaurensBartelds 98bba73192 fix: update comments in latex utility and refactor variable declaration for clarity
Test / backend (push) Successful in 13s
Test / frontend (push) Failing after 16s
2026-04-26 21:53:37 +02:00
LaurensBartelds 739133e1f4 feat: update dependencies and add dotenv, express-rate-limit, and zod; enhance adminSchemas validation
Test / backend (push) Successful in 14s
Test / frontend (push) Failing after 13s
2026-04-26 21:49:02 +02:00
LaurensBartelds ef8fe6f938 feat: enhance testing framework with new test cases and configurations
Test / backend (push) Failing after 9s
Test / frontend (push) Failing after 14s
2026-04-26 21:45:02 +02:00
LaurensBartelds a37c093cc4 feat: add CI workflow for backend and frontend tests
Test / backend-tests (push) Failing after 9s
Test / frontend-tests (push) Successful in 9s
2026-04-26 21:23:14 +02:00
LaurensBartelds 6277826472 Add internationalization support with English and Dutch translations
- Integrated i18next for language detection and translation support.
- Added English and Dutch JSON translation files for various app sections.
- Updated TweaksPanel and Workspace components to use translated strings.
- Imported i18n configuration in main entry file.
2026-04-26 21:19:47 +02:00
LaurensBartelds c64cdcd9e3 feat: Implement API modules for admin, auth, exam, result, and setup functionalities
- Created adminApi.ts for admin-related operations including user management and site settings.
- Developed authApi.ts for authentication processes such as registration, login, and profile management.
- Added examApi.ts for handling exam CRUD operations.
- Introduced resultApi.ts for managing results submission and retrieval.
- Established setupApi.ts for initial setup processes and status checks.
- Created ErrorBoundary component to handle rendering errors gracefully in the frontend.
- Re-exported shared API utilities in index.ts for easier access.
2026-04-26 20:10:46 +02:00
LaurensBartelds 62af83ce53 feat: add MyExams, MyResults, Settings, and Workspace components
Release Pipeline / build-and-release (push) Failing after 41s
- Implemented MyExams component for managing user exams, including loading, deleting, and creating new exams.
- Created MyResults component to display user results with options to review and delete results.
- Developed Settings component for updating user display name and changing password.
- Introduced Workspace component to serve as a container for MyExams, MyResults, and Settings, with navigation between tabs.
2026-04-25 20:20:23 +02:00
LaurensBartelds ad777ef1c3 feat: implement setup wizard for initial configuration and user registration 2026-04-25 16:48:23 +02:00
LaurensBartelds f7b93f8612 feat: enhance user registration with email verification configuration and status handling 2026-04-25 16:31:27 +02:00
143 changed files with 16208 additions and 1651 deletions

No files matched your search

+11 -1
View File
@@ -33,7 +33,17 @@
"Bash(git -C \"c:/Users/laure/Documents/Gitea/brainwave\" diff .gitea/workflows/deploy.yml)",
"Bash(git -C \"c:/Users/laure/Documents/Gitea/brainwave\" diff HEAD -- .gitea/workflows/deploy.yml)",
"Bash(git -C \"c:/Users/laure/Documents/Gitea/brainwave\" log --oneline -3 -- .gitea/workflows/deploy.yml)",
"Bash(git -C \"c:/Users/laure/Documents/Gitea/brainwave\" log --oneline origin/main..HEAD)"
"Bash(git -C \"c:/Users/laure/Documents/Gitea/brainwave\" log --oneline origin/main..HEAD)",
"Bash(grep -E \"\\\\.tsx$\")",
"Bash(Get-ChildItem -Path \"c:\\\\Users\\\\Laurens Bartelds\\\\Documents\\\\Gitea\\\\brainwave\" -Force)",
"Bash(Select-Object Name)",
"Bash(git -C \"c:/Users/Laurens Bartelds/Documents/Gitea/brainwave\" stash list)",
"Bash(git -C \"c:/Users/Laurens Bartelds/Documents/Gitea/brainwave\" stash pop)",
"Bash(git -C \"c:/Users/Laurens Bartelds/Documents/Gitea/brainwave\" diff --stat HEAD)",
"Bash(git -C \"c:/Users/Laurens Bartelds/Documents/Gitea/brainwave\" checkout -- .claude/settings.local.json)",
"Bash(git -C \"c:/Users/Laurens Bartelds/Documents/Gitea/brainwave\" diff HEAD -- frontend/src/components/Overview.tsx)",
"Bash(npm test *)",
"Bash(git -C \"c:/Users/Laurens Bartelds/Documents/Gitea/brainwave\" diff HEAD -- backend/src/validators/adminSchemas.ts backend/src/validators/adminSchemas.test.ts)"
]
}
}
+19 -6
View File
@@ -1,4 +1,4 @@
name: Deploy ARM to OrangePi
name: Deploy ARM to OrangePi
on:
workflow_run:
@@ -26,11 +26,23 @@ jobs:
echo "SSH_AUTH_SOCK=$SSH_AUTH_SOCK" >> "$GITHUB_ENV"
echo "SSH_AGENT_PID=$SSH_AGENT_PID" >> "$GITHUB_ENV"
- name: Write Inventory
# StrictHostKeyChecking=no is intentional for a known home-network host.
# To harden, add the host fingerprint as a secret and use a known_hosts file.
- name: Pin server host key to known_hosts
env:
HOST_KEY: ${{ vars.SSH_HOST_PUBLIC_KEY }}
run: |
printf '[orangepi]\norangepi ansible_host=%s ansible_user=%s ansible_ssh_common_args="-o StrictHostKeyChecking=no -o ForwardAgent=yes"\n' \
mkdir -p ~/.ssh
chmod 700 ~/.ssh
if [ -z "$HOST_KEY" ]; then
echo "::error::Repository variable SSH_HOST_PUBLIC_KEY is not set."
echo "::error::Pin the server's host key instead of trusting ssh-keyscan over the network:"
echo "::error::run 'ssh-keyscan -t ed25519 <host>' once from a trusted network and store the full output line in SSH_HOST_PUBLIC_KEY."
exit 1
fi
printf '%s\n' "$HOST_KEY" >> ~/.ssh/known_hosts
- name: Write Inventory
run: |
printf '[orangepi]\norangepi ansible_host=%s ansible_user=%s\n' \
"${{ secrets.ANSIBLE_HOST }}" \
"${{ secrets.ANSIBLE_USER }}" > /tmp/inventory.ini
@@ -55,7 +67,8 @@ jobs:
run: |
ansible-playbook ansible/deploy.yml \
-i /tmp/inventory.ini \
-e "artifact_path=${WORKSPACE}"
-e "artifact_path=${WORKSPACE}" \
-e "jwt_secret=${{ secrets.JWT_SECRET }}"
- name: Cleanup
if: always()
+10 -2
View File
@@ -17,11 +17,11 @@ jobs:
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "20"
node-version: "22"
- name: Install Frontend Dependencies
working-directory: ./frontend
run: npm ci
run: npm ci --legacy-peer-deps
- name: Run Frontend Tests
working-directory: ./frontend
@@ -63,6 +63,10 @@ jobs:
- "3001:3001"
volumes:
- brainwave-db:/app/backend/db
env_file:
- .env
environment:
- NODE_ENV=production
restart: unless-stopped
volumes:
brainwave-db:
@@ -77,6 +81,10 @@ jobs:
- "3001:3001"
volumes:
- brainwave-db:/app/backend/db
env_file:
- .env
environment:
- NODE_ENV=production
restart: unless-stopped
volumes:
brainwave-db:
+55
View File
@@ -0,0 +1,55 @@
name: Test
on:
push:
branches:
- '**'
jobs:
backend:
runs-on: ubuntu-latest
defaults:
run:
working-directory: ./backend
env:
JWT_SECRET: ci-test-secret
NODE_ENV: test
DB_PATH: ':memory:'
CORS_ORIGIN: 'http://localhost:5173'
APP_URL: 'http://localhost:5173'
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '22'
cache: 'npm'
cache-dependency-path: ./backend/package-lock.json
- run: npm ci
- name: Type check
run: npx tsc --noEmit
- name: Build
run: npm run build
- name: Run tests
run: npm test
frontend:
runs-on: ubuntu-latest
defaults:
run:
working-directory: ./frontend
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '22'
cache: 'npm'
cache-dependency-path: ./frontend/package-lock.json
- run: npm ci --legacy-peer-deps
- name: Lint
run: npm run lint
- name: Type check
run: npx tsc -b --noEmit
- name: Build
run: npm run build
- name: Run tests
run: npm run test:run
+11 -1
View File
@@ -1,4 +1,14 @@
backend/brainwave.db-shm
backend/brainwave.db-wal
.claude/settings.local.json
.superpowers/
.superpowers/
backend/db/brainwave.db-shm
backend/db/brainwave.db-wal
node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json
brainwave.db-shm
brainwave.db-wal
node_modules/
brainwave.db
*.db
*.sqlite
*.sqlite3
+4 -4
View File
@@ -1,13 +1,13 @@
# Stage 1: Build the frontend
FROM node:20-alpine AS frontend-builder
FROM node:22-alpine AS frontend-builder
WORKDIR /app/frontend
COPY frontend/package*.json ./
RUN npm ci
RUN npm ci --legacy-peer-deps
COPY frontend/ ./
RUN npm run build
# Stage 2: Build the backend
FROM node:20-alpine AS backend-builder
FROM node:22-alpine AS backend-builder
WORKDIR /app/backend
COPY backend/package*.json ./
RUN npm ci
@@ -15,7 +15,7 @@ COPY backend/ ./
RUN npm run build
# Stage 3: Production
FROM node:20-alpine AS production
FROM node:22-alpine AS production
# Install build tools for native modules like better-sqlite3
RUN apk add --no-cache python3 make g++
WORKDIR /app
+9 -2
View File
@@ -12,7 +12,7 @@
ansible.builtin.file:
path: "{{ deploy_dir }}"
state: directory
mode: '0755'
mode: "0755"
- name: Copy ARM tarball
ansible.builtin.copy:
@@ -24,6 +24,12 @@
src: "{{ artifact_path }}/docker-compose.arm64.yml"
dest: "{{ deploy_dir }}/docker-compose.yml"
- name: Create .env file
ansible.builtin.copy:
content: "JWT_SECRET={{ jwt_secret }}\n"
dest: "{{ deploy_dir }}/.env"
mode: "0600"
- name: Load Docker image
ansible.builtin.command: docker load -i {{ deploy_dir }}/brainwave-arm64.tar.gz
register: docker_load_result
@@ -33,4 +39,5 @@
ansible.builtin.command: docker compose up -d
args:
chdir: "{{ deploy_dir }}"
changed_when: true
register: compose_up
changed_when: "'Started' in compose_up.stdout or 'Created' in compose_up.stdout or 'Recreating' in compose_up.stdout"
+332 -3
View File
@@ -11,9 +11,14 @@
"bcrypt": "^6.0.0",
"better-sqlite3": "^11.9.1",
"cors": "^2.8.5",
"dotenv": "^16.4.7",
"express": "^5.1.0",
"express-rate-limit": "^7.5.0",
"helmet": "^8.1.0",
"jsonwebtoken": "^9.0.2",
"nodemailer": "^7.0.3"
"nodemailer": "^7.0.3",
"openai": "^4.95.0",
"zod": "^3.24.2"
},
"devDependencies": {
"@types/bcrypt": "^5.0.2",
@@ -951,12 +956,21 @@
"version": "25.6.0",
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.6.0.tgz",
"integrity": "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~7.19.0"
}
},
"node_modules/@types/node-fetch": {
"version": "2.6.13",
"resolved": "https://registry.npmjs.org/@types/node-fetch/-/node-fetch-2.6.13.tgz",
"integrity": "sha512-QGpRVpzSaUs30JBSGPjOg4Uveu384erbHBoT1zeONvyCfwQxIkUshLAOqN/k9EjGviPRmWTTe6aH2qySWKTVSw==",
"license": "MIT",
"dependencies": {
"@types/node": "*",
"form-data": "^4.0.4"
}
},
"node_modules/@types/nodemailer": {
"version": "6.4.23",
"resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-6.4.23.tgz",
@@ -1115,6 +1129,18 @@
"url": "https://opencollective.com/vitest"
}
},
"node_modules/abort-controller": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz",
"integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==",
"license": "MIT",
"dependencies": {
"event-target-shim": "^5.0.0"
},
"engines": {
"node": ">=6.5"
}
},
"node_modules/accepts": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz",
@@ -1128,6 +1154,18 @@
"node": ">= 0.6"
}
},
"node_modules/agentkeepalive": {
"version": "4.6.0",
"resolved": "https://registry.npmjs.org/agentkeepalive/-/agentkeepalive-4.6.0.tgz",
"integrity": "sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ==",
"license": "MIT",
"dependencies": {
"humanize-ms": "^1.2.1"
},
"engines": {
"node": ">= 8.0.0"
}
},
"node_modules/assertion-error": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
@@ -1138,6 +1176,12 @@
"node": ">=12"
}
},
"node_modules/asynckit": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
"license": "MIT"
},
"node_modules/base64-js": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
@@ -1311,6 +1355,18 @@
"integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==",
"license": "ISC"
},
"node_modules/combined-stream": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
"license": "MIT",
"dependencies": {
"delayed-stream": "~1.0.0"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/content-disposition": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz",
@@ -1416,6 +1472,15 @@
"node": ">=4.0.0"
}
},
"node_modules/delayed-stream": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
"integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
"license": "MIT",
"engines": {
"node": ">=0.4.0"
}
},
"node_modules/depd": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
@@ -1434,6 +1499,18 @@
"node": ">=8"
}
},
"node_modules/dotenv": {
"version": "16.6.1",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",
"integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==",
"license": "BSD-2-Clause",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://dotenvx.com"
}
},
"node_modules/dunder-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
@@ -1518,6 +1595,21 @@
"node": ">= 0.4"
}
},
"node_modules/es-set-tostringtag": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
"integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"get-intrinsic": "^1.2.6",
"has-tostringtag": "^1.0.2",
"hasown": "^2.0.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/esbuild": {
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz",
@@ -1585,6 +1677,15 @@
"node": ">= 0.6"
}
},
"node_modules/event-target-shim": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz",
"integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/expand-template": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz",
@@ -1647,6 +1748,21 @@
"url": "https://opencollective.com/express"
}
},
"node_modules/express-rate-limit": {
"version": "7.5.1",
"resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-7.5.1.tgz",
"integrity": "sha512-7iN8iPMDzOMHPUYllBEsQdWVB6fPDMPqwjBaFrgr4Jgr/+okjvzAy+UHlYYL/Vs0OsOrMkwS6PJDkFlJwoxUnw==",
"license": "MIT",
"engines": {
"node": ">= 16"
},
"funding": {
"url": "https://github.com/sponsors/express-rate-limit"
},
"peerDependencies": {
"express": ">= 4.11"
}
},
"node_modules/fdir": {
"version": "6.5.0",
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
@@ -1692,6 +1808,62 @@
"url": "https://opencollective.com/express"
}
},
"node_modules/form-data": {
"version": "4.0.5",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz",
"integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==",
"license": "MIT",
"dependencies": {
"asynckit": "^0.4.0",
"combined-stream": "^1.0.8",
"es-set-tostringtag": "^2.1.0",
"hasown": "^2.0.2",
"mime-types": "^2.1.12"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/form-data-encoder": {
"version": "1.7.2",
"resolved": "https://registry.npmjs.org/form-data-encoder/-/form-data-encoder-1.7.2.tgz",
"integrity": "sha512-qfqtYan3rxrnCk1VYaA4H+Ms9xdpPqvLZa6xmMgFvhO32x7/3J/ExcTd6qpxM0vH2GdMI+poehyBZvqfMTto8A==",
"license": "MIT"
},
"node_modules/form-data/node_modules/mime-db": {
"version": "1.52.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/form-data/node_modules/mime-types": {
"version": "2.1.35",
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
"license": "MIT",
"dependencies": {
"mime-db": "1.52.0"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/formdata-node": {
"version": "4.4.1",
"resolved": "https://registry.npmjs.org/formdata-node/-/formdata-node-4.4.1.tgz",
"integrity": "sha512-0iirZp3uVDjVGt9p49aTaqjk84TrglENEDuqfdlZQ1roC9CWlPk6Avf8EEnZNcAqPonwkG35x4n3ww/1THYAeQ==",
"license": "MIT",
"dependencies": {
"node-domexception": "1.0.0",
"web-streams-polyfill": "4.0.0-beta.3"
},
"engines": {
"node": ">= 12.20"
}
},
"node_modules/forwarded": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
@@ -1820,6 +1992,21 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/has-tostringtag": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
"integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
"license": "MIT",
"dependencies": {
"has-symbols": "^1.0.3"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/hasown": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz",
@@ -1832,6 +2019,15 @@
"node": ">= 0.4"
}
},
"node_modules/helmet": {
"version": "8.1.0",
"resolved": "https://registry.npmjs.org/helmet/-/helmet-8.1.0.tgz",
"integrity": "sha512-jOiHyAZsmnr8LqoPGmCjYAaiuWwjAPLgY8ZX2XrmHawt99/u1y6RgrZMTeoPfpUbV96HOalYgz1qzkRbw54Pmg==",
"license": "MIT",
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/http-errors": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
@@ -1852,6 +2048,15 @@
"url": "https://opencollective.com/express"
}
},
"node_modules/humanize-ms": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/humanize-ms/-/humanize-ms-1.2.1.tgz",
"integrity": "sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ==",
"license": "MIT",
"dependencies": {
"ms": "^2.0.0"
}
},
"node_modules/iconv-lite": {
"version": "0.7.2",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz",
@@ -2414,6 +2619,46 @@
"node": "^18 || ^20 || >= 21"
}
},
"node_modules/node-domexception": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz",
"integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==",
"deprecated": "Use your platform's native DOMException instead",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/jimmywarting"
},
{
"type": "github",
"url": "https://paypal.me/jimmywarting"
}
],
"license": "MIT",
"engines": {
"node": ">=10.5.0"
}
},
"node_modules/node-fetch": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz",
"integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==",
"license": "MIT",
"dependencies": {
"whatwg-url": "^5.0.0"
},
"engines": {
"node": "4.x || >=6.0.0"
},
"peerDependencies": {
"encoding": "^0.1.0"
},
"peerDependenciesMeta": {
"encoding": {
"optional": true
}
}
},
"node_modules/node-gyp-build": {
"version": "4.8.4",
"resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz",
@@ -2487,6 +2732,51 @@
"wrappy": "1"
}
},
"node_modules/openai": {
"version": "4.104.0",
"resolved": "https://registry.npmjs.org/openai/-/openai-4.104.0.tgz",
"integrity": "sha512-p99EFNsA/yX6UhVO93f5kJsDRLAg+CTA2RBqdHK4RtK8u5IJw32Hyb2dTGKbnnFmnuoBv5r7Z2CURI9sGZpSuA==",
"license": "Apache-2.0",
"dependencies": {
"@types/node": "^18.11.18",
"@types/node-fetch": "^2.6.4",
"abort-controller": "^3.0.0",
"agentkeepalive": "^4.2.1",
"form-data-encoder": "1.7.2",
"formdata-node": "^4.3.2",
"node-fetch": "^2.6.7"
},
"bin": {
"openai": "bin/cli"
},
"peerDependencies": {
"ws": "^8.18.0",
"zod": "^3.23.8"
},
"peerDependenciesMeta": {
"ws": {
"optional": true
},
"zod": {
"optional": true
}
}
},
"node_modules/openai/node_modules/@types/node": {
"version": "18.19.130",
"resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.130.tgz",
"integrity": "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==",
"license": "MIT",
"dependencies": {
"undici-types": "~5.26.4"
}
},
"node_modules/openai/node_modules/undici-types": {
"version": "5.26.5",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz",
"integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==",
"license": "MIT"
},
"node_modules/parseurl": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
@@ -3085,6 +3375,12 @@
"node": ">=0.6"
}
},
"node_modules/tr46": {
"version": "0.0.3",
"resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
"integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==",
"license": "MIT"
},
"node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
@@ -3157,7 +3453,6 @@
"version": "7.19.2",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.19.2.tgz",
"integrity": "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==",
"dev": true,
"license": "MIT"
},
"node_modules/unpipe": {
@@ -3352,6 +3647,31 @@
}
}
},
"node_modules/web-streams-polyfill": {
"version": "4.0.0-beta.3",
"resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-4.0.0-beta.3.tgz",
"integrity": "sha512-QW95TCTaHmsYfHDybGMwO5IJIM93I/6vTRk+daHTWFPhwh+C8Cg7j7XyKrwrj8Ib6vYXe0ocYNrmzY4xAAN6ug==",
"license": "MIT",
"engines": {
"node": ">= 14"
}
},
"node_modules/webidl-conversions": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
"integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==",
"license": "BSD-2-Clause"
},
"node_modules/whatwg-url": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz",
"integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==",
"license": "MIT",
"dependencies": {
"tr46": "~0.0.3",
"webidl-conversions": "^3.0.0"
}
},
"node_modules/why-is-node-running": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
@@ -3374,6 +3694,15 @@
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
"license": "ISC"
},
"node_modules/zod": {
"version": "3.25.76",
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
}
}
}
+6 -1
View File
@@ -13,9 +13,14 @@
"bcrypt": "^6.0.0",
"better-sqlite3": "^11.9.1",
"cors": "^2.8.5",
"dotenv": "^16.4.7",
"express": "^5.1.0",
"express-rate-limit": "^7.5.0",
"helmet": "^8.1.0",
"jsonwebtoken": "^9.0.2",
"nodemailer": "^7.0.3"
"nodemailer": "^7.0.3",
"openai": "^4.95.0",
"zod": "^3.24.2"
},
"devDependencies": {
"@types/bcrypt": "^5.0.2",
+62
View File
@@ -0,0 +1,62 @@
import { describe, it, expect } from 'vitest'
describe('env helpers', () => {
it('uses default value when env var is missing', () => {
delete process.env.TEST_VAR_UNIQUE
const env = (key: string, defaultValue?: string): string => {
const value = process.env[key]
if (value === undefined || value === '') {
if (defaultValue !== undefined) return defaultValue
throw new Error(`Missing ${key}`)
}
return value
}
expect(env('TEST_VAR_UNIQUE', 'fallback')).toBe('fallback')
})
it('returns env value when present', () => {
process.env.TEST_VAR_UNIQUE = 'value'
const env = (key: string, defaultValue?: string): string => {
const value = process.env[key]
if (value === undefined || value === '') {
if (defaultValue !== undefined) return defaultValue
throw new Error(`Missing ${key}`)
}
return value
}
expect(env('TEST_VAR_UNIQUE')).toBe('value')
delete process.env.TEST_VAR_UNIQUE
})
it('envInt parses integer', () => {
process.env.TEST_INT = '42'
const envInt = (key: string, defaultValue?: number): number => {
const raw = process.env[key]
if (raw === undefined || raw === '') {
if (defaultValue !== undefined) return defaultValue
throw new Error(`Missing ${key}`)
}
const parsed = parseInt(raw, 10)
if (Number.isNaN(parsed)) throw new Error(`Invalid int ${key}`)
return parsed
}
expect(envInt('TEST_INT')).toBe(42)
delete process.env.TEST_INT
})
it('envInt throws on NaN', () => {
process.env.TEST_INT = 'abc'
const envInt = (key: string, defaultValue?: number): number => {
const raw = process.env[key]
if (raw === undefined || raw === '') {
if (defaultValue !== undefined) return defaultValue
throw new Error(`Missing ${key}`)
}
const parsed = parseInt(raw, 10)
if (Number.isNaN(parsed)) throw new Error(`Invalid int ${key}`)
return parsed
}
expect(() => envInt('TEST_INT')).toThrow('Invalid int TEST_INT')
delete process.env.TEST_INT
})
})
+49
View File
@@ -0,0 +1,49 @@
import { AppError } from './middleware/error.js'
function env(key: string, defaultValue?: string): string {
const value = process.env[key]
if (value === undefined || value === '') {
if (defaultValue !== undefined) return defaultValue
throw new AppError(500, `Missing required environment variable: ${key}`)
}
return value
}
function envInt(key: string, defaultValue?: number): number {
const raw = process.env[key]
if (raw === undefined || raw === '') {
if (defaultValue !== undefined) return defaultValue
throw new AppError(500, `Missing required environment variable: ${key}`)
}
const parsed = parseInt(raw, 10)
if (Number.isNaN(parsed)) {
throw new AppError(500, `Environment variable ${key} must be a valid integer, got: ${raw}`)
}
return parsed
}
export const config = {
env: env('NODE_ENV', 'development'),
port: envInt('PORT', 3001),
jwtSecret: env('JWT_SECRET'),
jwtExpiresIn: env('JWT_EXPIRES_IN', '7d'),
dbPath: env('DB_PATH', './db/brainwave.db'),
corsOrigin: env('CORS_ORIGIN', 'http://localhost:5173'),
bodyLimit: env('BODY_LIMIT', '100kb'),
email: {
smtpHost: env('SMTP_HOST', ''),
smtpPort: envInt('SMTP_PORT', 587),
smtpUser: env('SMTP_USER', ''),
smtpPass: env('SMTP_PASS', ''),
fromAddress: env('EMAIL_FROM', 'BrainWave <noreply@brainwave.local>'),
secure: env('SMTP_SECURE', 'auto'),
},
appUrl: env('APP_URL', 'http://localhost:5173'),
rateLimiting: {
authWindowMs: 15 * 60 * 1000, // 15 minutes
authMaxRequests: 10,
},
} as const
+13 -4
View File
@@ -1,17 +1,26 @@
import Database from 'better-sqlite3'
import fs from 'fs'
import path from 'path'
import { fileURLToPath } from 'url'
import { config } from '../config.js'
import { runMigrations } from './migrations.js'
const __dirname = path.dirname(fileURLToPath(import.meta.url))
const DB_PATH = path.join(__dirname, '..', '..', 'brainwave.db')
const DB_PATH = config.dbPath === ':memory:'
? ':memory:'
: path.isAbsolute(config.dbPath)
? config.dbPath
: path.join(process.cwd(), config.dbPath)
let db: Database.Database | null = null
export function getDb(): Database.Database {
if (!db) {
if (DB_PATH !== ':memory:') {
fs.mkdirSync(path.dirname(DB_PATH), { recursive: true })
}
db = new Database(DB_PATH)
db.pragma('journal_mode = WAL')
if (DB_PATH !== ':memory:') {
db.pragma('journal_mode = WAL')
}
db.pragma('foreign_keys = ON')
runMigrations(db)
}
+85 -9
View File
@@ -49,8 +49,31 @@ export function runMigrations(db: Database.Database): void {
updated_at INTEGER DEFAULT (unixepoch())
);
CREATE TABLE IF NOT EXISTS ai_usage_daily (
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
period_date TEXT NOT NULL,
tokens_used INTEGER NOT NULL DEFAULT 0,
requests_used INTEGER NOT NULL DEFAULT 0,
updated_at INTEGER DEFAULT (unixepoch()),
PRIMARY KEY (user_id, period_date)
);
CREATE INDEX IF NOT EXISTS idx_saved_exams_user ON saved_exams(user_id);
CREATE INDEX IF NOT EXISTS idx_exam_results_user ON exam_results(user_id);
CREATE INDEX IF NOT EXISTS idx_ai_usage_daily_user ON ai_usage_daily(user_id);
`);
// ── Add share_token and is_public columns if missing ──
const examCols = db.prepare(`PRAGMA table_info(saved_exams)`).all() as { name: string }[];
if (!examCols.some((c) => c.name === 'share_token')) {
db.exec(`ALTER TABLE saved_exams ADD COLUMN share_token TEXT`);
}
if (!examCols.some((c) => c.name === 'is_public')) {
db.exec(`ALTER TABLE saved_exams ADD COLUMN is_public INTEGER DEFAULT 0`);
}
db.exec(`
CREATE UNIQUE INDEX IF NOT EXISTS idx_saved_exams_share ON saved_exams(share_token);
`);
// ── Add role column if missing (existing databases) ──
@@ -61,15 +84,58 @@ export function runMigrations(db: Database.Database): void {
db.exec(`ALTER TABLE users ADD COLUMN role TEXT NOT NULL DEFAULT 'user'`);
}
// ── Promote first user to admin if no admins exist ──
const adminCount = db
.prepare(`SELECT COUNT(*) AS cnt FROM users WHERE role = 'admin'`)
.get() as { cnt: number };
if (adminCount.cnt === 0) {
db.prepare(
`UPDATE users SET role = 'admin' WHERE id = (SELECT MIN(id) FROM users)`,
).run();
}
// ── Add storage_quota_bytes if missing ──
if (!cols.some((c) => c.name === "storage_quota_bytes")) {
db.exec(`ALTER TABLE users ADD COLUMN storage_quota_bytes INTEGER NOT NULL DEFAULT 1073741824`);
}
// ── Add plan if missing ──
if (!cols.some((c) => c.name === "plan")) {
db.exec(`ALTER TABLE users ADD COLUMN plan TEXT NOT NULL DEFAULT 'free'`);
}
// ── Add token_version if missing (JWT revocation on password change/reset) ──
if (!cols.some((c) => c.name === "token_version")) {
db.exec(`ALTER TABLE users ADD COLUMN token_version INTEGER NOT NULL DEFAULT 0`);
}
// ── Add is_favorite to saved_exams if missing ──
const examSavedCols = db.prepare(`PRAGMA table_info(saved_exams)`).all() as { name: string }[];
if (!examSavedCols.some((c) => c.name === 'is_favorite')) {
db.exec(`ALTER TABLE saved_exams ADD COLUMN is_favorite INTEGER DEFAULT 0`);
}
// ── Add tags to saved_exams if missing ──
if (!examSavedCols.some((c) => c.name === 'tags')) {
db.exec(`ALTER TABLE saved_exams ADD COLUMN tags TEXT DEFAULT '[]'`);
}
// ── Add collection_id to saved_exams if missing ──
if (!examSavedCols.some((c) => c.name === 'collection_id')) {
db.exec(`ALTER TABLE saved_exams ADD COLUMN collection_id INTEGER REFERENCES collections(id) ON DELETE SET NULL`);
}
// ── Add share_mode to saved_exams if missing ──
if (!examSavedCols.some((c) => c.name === 'share_mode')) {
db.exec(`ALTER TABLE saved_exams ADD COLUMN share_mode TEXT DEFAULT 'clone'`);
}
// ── Add cloned_from_share_token to saved_exams if missing ──
if (!examSavedCols.some((c) => c.name === 'cloned_from_share_token')) {
db.exec(`ALTER TABLE saved_exams ADD COLUMN cloned_from_share_token TEXT`);
}
// ── Collections table ──
db.exec(`
CREATE TABLE IF NOT EXISTS collections (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
name TEXT NOT NULL,
created_at INTEGER DEFAULT (unixepoch()),
updated_at INTEGER DEFAULT (unixepoch())
);
CREATE INDEX IF NOT EXISTS idx_collections_user ON collections(user_id);
`);
// ── Seed default settings ──
const defaults: Record<string, string> = {
@@ -88,6 +154,16 @@ export function runMigrations(db: Database.Database): void {
smtp_from: "BrainWave <noreply@brainwave.local>",
smtp_secure: "auto",
app_url: "http://localhost:5173",
ai_provider: "",
ai_model: "gpt-4o-mini",
ai_api_key: "",
ai_base_url: "",
ai_model_hint: "",
ai_model_generate: "",
ai_model_grading: "",
ai_model_tutor: "",
ai_model_study_coach: "",
ai_daily_token_limit: "0",
};
const upsert = db.prepare(`
INSERT INTO site_settings (key, value) VALUES (?, ?)
+67 -5
View File
@@ -1,10 +1,20 @@
import 'dotenv/config'
import express from 'express'
import cors from 'cors'
import helmet from 'helmet'
import rateLimit from 'express-rate-limit'
import { getDb, closeDb } from './db/database.js'
import authRoutes from './routes/auth.js'
import examRoutes from './routes/exams.js'
import resultRoutes from './routes/results.js'
import adminRoutes from './routes/admin.js'
import setupRoutes from './routes/setup.js'
import gradeRoutes from './routes/grade.js'
import shareRoutes from './routes/share.js'
import collectionRoutes from './routes/collections.js'
import aiRoutes from './routes/ai.js'
import { errorHandler } from './middleware/error.js'
import { config } from './config.js'
import path from 'path'
import fs from 'fs'
import { fileURLToPath } from 'url'
@@ -12,28 +22,80 @@ import { fileURLToPath } from 'url'
const __filename = fileURLToPath(import.meta.url)
const __dirname = path.dirname(__filename)
const PORT = Number(process.env['PORT'] ?? '3001')
const PORT = config.port
const app = express()
app.use(cors({ origin: process.env['CORS_ORIGIN'] ?? 'http://localhost:5173' }))
app.use(express.json({ limit: '10mb' }))
// Security headers
app.use(helmet())
// CORS
app.use(cors({ origin: config.corsOrigin }))
// Body parsing with reasonable limit
app.use(express.json({ limit: config.bodyLimit }))
// Global API rate limiting
const globalLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 200,
message: { error: 'Too many requests, please try again later' },
standardHeaders: true,
legacyHeaders: false,
})
app.use('/api/', globalLimiter)
// Request logging
app.use((req, res, next) => {
const start = Date.now()
res.on('finish', () => {
const duration = Date.now() - start
console.log(`${req.method} ${req.path} ${res.statusCode} - ${duration}ms`)
})
next()
})
// Initialize database on startup
getDb()
console.log('✓ Database initialized')
// Routes
app.use('/api/setup', setupRoutes)
app.use('/api/auth', authRoutes)
app.use('/api/exams', examRoutes)
app.use('/api/results', resultRoutes)
app.use('/api/grade', gradeRoutes)
app.use('/api/ai', aiRoutes)
app.use('/api/admin', adminRoutes)
app.use('/api/share', shareRoutes)
app.use('/api/collections', collectionRoutes)
// Health check
// Health check with subsystem status
app.get('/api/health', (_req, res) => {
res.json({ status: 'ok', timestamp: Date.now() })
let dbStatus: 'connected' | 'error' = 'connected'
try {
getDb().prepare('SELECT 1').get()
} catch {
dbStatus = 'error'
}
const checks = {
database: dbStatus,
email: config.email.smtpHost ? 'configured' : 'not_configured',
}
const overall = dbStatus === 'connected' ? 'healthy' : 'unhealthy'
res.status(overall === 'healthy' ? 200 : 503).json({
status: overall,
timestamp: Date.now(),
checks,
})
})
// Error handler (must be after routes)
app.use(errorHandler)
// Serve static frontend files if they exist
const frontendDistPath = path.join(__dirname, '../../frontend/dist')
if (fs.existsSync(frontendDistPath)) {
+127
View File
@@ -0,0 +1,127 @@
import { describe, it, expect, vi } from 'vitest'
import type { Request, Response, NextFunction } from 'express'
import * as userRepo from '../repositories/userRepo.js'
import { signToken } from '../utils/tokens.js'
import { requireAuth } from './auth.js'
function mockRes(): Response {
const res = {} as Response
res.status = vi.fn(() => res) as unknown as Response['status']
res.json = vi.fn(() => res) as unknown as Response['json']
return res
}
function makeReq(token?: string): Request {
const headers: Record<string, string> = {}
if (token) headers.authorization = `Bearer ${token}`
return { headers } as unknown as Request
}
function createUser(role: 'user' | 'admin' = 'user'): { id: number; email: string; name: string } {
const email = `user-${Math.random().toString(36).slice(2)}@example.com`
const { id } = userRepo.createUser({
email,
passwordHash: 'x',
displayName: 'Test User',
role,
emailVerified: 1,
})
return { id, email, name: 'Test User' }
}
describe('requireAuth', () => {
it('accepts a valid token and loads fresh user data from the DB', () => {
const u = createUser()
const token = signToken({ userId: u.id, email: u.email, displayName: u.name, role: 'user', tv: 0 })
const req = makeReq(token)
const res = mockRes()
const next = vi.fn() as NextFunction
requireAuth(req, res, next)
expect(next).toHaveBeenCalledOnce()
expect(res.status).not.toHaveBeenCalled()
expect(req.user?.userId).toBe(u.id)
expect(req.user?.role).toBe('user')
expect(req.user?.tv).toBe(0)
})
it('serves the current DB role, not the role claimed by an old JWT', () => {
const u = createUser()
// Token signed while the user was still an admin
const staleAdminToken = signToken({ userId: u.id, email: u.email, displayName: u.name, role: 'admin', tv: 0 })
userRepo.updateUser(u.id, { role: 'user' })
const req = makeReq(staleAdminToken)
const res = mockRes()
const next = vi.fn() as NextFunction
requireAuth(req, res, next)
expect(next).toHaveBeenCalledOnce()
expect(req.user?.role).toBe('user')
})
it('rejects tokens for deleted accounts', () => {
const u = createUser()
const token = signToken({ userId: u.id, email: u.email, displayName: u.name, role: 'user', tv: 0 })
userRepo.deleteUser(u.id)
const req = makeReq(token)
const res = mockRes()
const next = vi.fn() as NextFunction
requireAuth(req, res, next)
expect(next).not.toHaveBeenCalled()
expect(res.status).toHaveBeenCalledWith(401)
})
it('rejects tokens whose token version is stale (e.g. after password change)', () => {
const u = createUser()
const oldToken = signToken({ userId: u.id, email: u.email, displayName: u.name, role: 'user', tv: 0 })
userRepo.updateUser(u.id, { tokenVersion: 1 })
const req = makeReq(oldToken)
const res = mockRes()
const next = vi.fn() as NextFunction
requireAuth(req, res, next)
expect(next).not.toHaveBeenCalled()
expect(res.status).toHaveBeenCalledWith(401)
})
it('accepts a token signed with the current token version after rotation', () => {
const u = createUser()
userRepo.updateUser(u.id, { tokenVersion: 2 })
const freshToken = signToken({ userId: u.id, email: u.email, displayName: u.name, role: 'user', tv: 2 })
const req = makeReq(freshToken)
const res = mockRes()
const next = vi.fn() as NextFunction
requireAuth(req, res, next)
expect(next).toHaveBeenCalledOnce()
expect(req.user?.tv).toBe(2)
})
it('rejects requests without a Bearer header', () => {
const req = makeReq()
const res = mockRes()
const next = vi.fn() as NextFunction
requireAuth(req, res, next)
expect(next).not.toHaveBeenCalled()
expect(res.status).toHaveBeenCalledWith(401)
})
it('rejects garbage tokens', () => {
const req = makeReq('not.a.jwt')
const res = mockRes()
const next = vi.fn() as NextFunction
requireAuth(req, res, next)
expect(next).not.toHaveBeenCalled()
expect(res.status).toHaveBeenCalledWith(401)
})
})
+35 -2
View File
@@ -1,5 +1,7 @@
import type { Request, Response, NextFunction } from 'express'
import { verifyToken, type JwtPayload } from '../utils/tokens.js'
import { verifyToken, hashToken, type JwtPayload } from '../utils/tokens.js'
import { isTokenBlacklisted } from '../utils/tokenBlacklist.js'
import { getUserById } from '../repositories/userRepo.js'
declare global {
namespace Express {
@@ -9,6 +11,10 @@ declare global {
}
}
export interface AuthenticatedRequest extends Request {
user: JwtPayload
}
export function requireAuth(req: Request, res: Response, next: NextFunction): void {
const header = req.headers.authorization
if (!header?.startsWith('Bearer ')) {
@@ -17,7 +23,34 @@ export function requireAuth(req: Request, res: Response, next: NextFunction): vo
}
try {
const token = header.slice(7)
req.user = verifyToken(token)
const payload = verifyToken(token)
// Check if the token has been blacklisted (e.g., after logout)
if (isTokenBlacklisted(hashToken(token))) {
res.status(401).json({ error: 'Token has been revoked' })
return
}
// Revalidate against the database: deleted accounts must not keep working,
// and role/token-version must come from the DB rather than an old JWT claim
// (revocation after demotion or password change/reset).
const user = getUserById(payload.userId)
if (!user) {
res.status(401).json({ error: 'Account no longer exists' })
return
}
if ((user.token_version ?? 0) !== payload.tv) {
res.status(401).json({ error: 'Session expired, please sign in again' })
return
}
req.user = {
userId: user.id,
email: user.email,
displayName: user.display_name,
role: user.role,
tv: payload.tv,
}
next()
} catch {
res.status(401).json({ error: 'Invalid or expired token' })
+21
View File
@@ -0,0 +1,21 @@
import { describe, it, expect } from 'vitest'
import { AppError } from './error'
describe('AppError', () => {
it('is instance of Error and AppError', () => {
const err = new AppError(404, 'Not found')
expect(err).toBeInstanceOf(AppError)
expect(err).toBeInstanceOf(Error)
})
it('sets statusCode and message', () => {
const err = new AppError(500, 'Server error')
expect(err.statusCode).toBe(500)
expect(err.message).toBe('Server error')
})
it('sets name to AppError', () => {
const err = new AppError(400, 'Bad request')
expect(err.name).toBe('AppError')
})
})
+37
View File
@@ -0,0 +1,37 @@
export class AppError extends Error {
constructor(
public statusCode: number,
message: string,
public isOperational = true
) {
super(message);
this.name = 'AppError';
Object.setPrototypeOf(this, AppError.prototype);
}
}
export function errorHandler(
err: Error | AppError,
_req: any,
res: any,
_next: any
) {
const statusCode = err instanceof AppError ? err.statusCode : 500;
const isOperational = err instanceof AppError && err.isOperational;
// Log full details server-side
console.error(`[Error] ${statusCode} - ${err.message}`);
if (!isOperational) {
console.error(err);
}
// Never leak internal error details to the client for unexpected 500s
const clientMessage = statusCode === 500 && !isOperational
? 'Internal Server Error'
: (err.message || 'Internal Server Error');
res.status(statusCode).json({
status: 'error',
message: clientMessage,
});
}
+43
View File
@@ -0,0 +1,43 @@
import { z } from 'zod'
import type { Request, Response, NextFunction } from 'express'
function getFirstIssueMessage(error: z.ZodError): string {
const issue = error.issues[0]
return issue?.message ?? 'Validation failed'
}
export function validateBody<T extends z.ZodTypeAny>(schema: T) {
return (req: Request, res: Response, next: NextFunction): void => {
const result = schema.safeParse(req.body)
if (!result.success) {
res.status(400).json({ error: getFirstIssueMessage(result.error) })
return
}
req.body = result.data as z.infer<T>
next()
}
}
export function validateQuery<T extends z.ZodTypeAny>(schema: T) {
return (req: Request, res: Response, next: NextFunction): void => {
const result = schema.safeParse(req.query)
if (!result.success) {
res.status(400).json({ error: getFirstIssueMessage(result.error) })
return
}
;(req as unknown as Record<string, unknown>).parsedQuery = result.data
next()
}
}
export function validateParams<T extends z.ZodTypeAny>(schema: T) {
return (req: Request, res: Response, next: NextFunction): void => {
const result = schema.safeParse(req.params)
if (!result.success) {
res.status(400).json({ error: getFirstIssueMessage(result.error) })
return
}
Object.assign(req.params, result.data)
next()
}
}
+46
View File
@@ -0,0 +1,46 @@
import { getDb } from '../db/database.js'
export interface AiDailyUsage {
tokensUsed: number
requestsUsed: number
}
function normalizeCount(value: number): number {
if (!Number.isFinite(value) || value <= 0) return 0
return Math.floor(value)
}
export function getDailyUsage(userId: number, periodDate: string): AiDailyUsage {
const db = getDb()
const row = db.prepare(`
SELECT tokens_used, requests_used
FROM ai_usage_daily
WHERE user_id = ? AND period_date = ?
`).get(userId, periodDate) as { tokens_used: number; requests_used: number } | undefined
return {
tokensUsed: row?.tokens_used ?? 0,
requestsUsed: row?.requests_used ?? 0,
}
}
export function incrementDailyUsage(
userId: number,
periodDate: string,
tokensUsed: number,
requestsUsed = 1,
): void {
const db = getDb()
const tokens = normalizeCount(tokensUsed)
const requests = normalizeCount(requestsUsed)
if (tokens === 0 && requests === 0) return
db.prepare(`
INSERT INTO ai_usage_daily (user_id, period_date, tokens_used, requests_used)
VALUES (?, ?, ?, ?)
ON CONFLICT(user_id, period_date) DO UPDATE SET
tokens_used = tokens_used + excluded.tokens_used,
requests_used = requests_used + excluded.requests_used,
updated_at = unixepoch()
`).run(userId, periodDate, tokens, requests)
}
@@ -0,0 +1,55 @@
import { getDb } from '../db/database.js'
export interface DbCollection {
id: number
user_id: number
name: string
created_at: number
updated_at: number
}
export interface CollectionWithCount extends DbCollection {
exam_count: number
}
export function createCollection(userId: number, name: string): { id: number } {
const db = getDb()
const result = db.prepare(`
INSERT INTO collections (user_id, name)
VALUES (?, ?)
`).run(userId, name)
return { id: result.lastInsertRowid as number }
}
export function getCollectionById(id: number, userId: number): DbCollection | undefined {
const db = getDb()
return db.prepare('SELECT * FROM collections WHERE id = ? AND user_id = ?').get(id, userId) as DbCollection | undefined
}
export function listCollectionsByUser(userId: number): CollectionWithCount[] {
const db = getDb()
return db.prepare(`
SELECT c.*, COUNT(se.id) AS exam_count
FROM collections c
LEFT JOIN saved_exams se ON se.collection_id = c.id
WHERE c.user_id = ?
GROUP BY c.id
ORDER BY c.updated_at DESC
`).all(userId) as CollectionWithCount[]
}
export function updateCollection(id: number, userId: number, name: string): boolean {
const db = getDb()
const result = db.prepare(`
UPDATE collections
SET name = ?, updated_at = unixepoch()
WHERE id = ? AND user_id = ?
`).run(name, id, userId)
return result.changes > 0
}
export function deleteCollection(id: number, userId: number): boolean {
const db = getDb()
const result = db.prepare('DELETE FROM collections WHERE id = ? AND user_id = ?').run(id, userId)
return result.changes > 0
}
+188
View File
@@ -0,0 +1,188 @@
import { getDb } from '../db/database.js'
export interface DbExam {
id: number
user_id: number
title: string
subtitle: string | null
exam_json: string
created_at: number
updated_at: number
share_token: string | null
is_public: number
is_favorite: number
tags: string | null
share_mode: string | null
cloned_from_share_token: string | null
}
export interface ExamListItem {
id: number
title: string
subtitle: string | null
created_at: number
updated_at: number
question_count: number
share_token: string | null
is_favorite: number
tags: string
collection_id: number | null
}
export function createExam(userId: number, title: string, subtitle: string | null, examJson: string, tagsJson: string = '[]'): { id: number } {
const db = getDb()
const result = db.prepare(`
INSERT INTO saved_exams (user_id, title, subtitle, exam_json, tags)
VALUES (?, ?, ?, ?, ?)
`).run(userId, title, subtitle, examJson, tagsJson)
return { id: result.lastInsertRowid as number }
}
export function getExamById(id: number, userId: number): DbExam | undefined {
const db = getDb()
return db.prepare('SELECT * FROM saved_exams WHERE id = ? AND user_id = ?').get(id, userId) as DbExam | undefined
}
export function listExamsByUser(userId: number): ExamListItem[] {
const db = getDb()
return db.prepare(`
SELECT id, title, subtitle, created_at, updated_at, share_token, is_favorite, tags, collection_id,
json_array_length(json_extract(exam_json, '$.questions')) AS question_count
FROM saved_exams
WHERE user_id = ?
ORDER BY is_favorite DESC, updated_at DESC
`).all(userId) as ExamListItem[]
}
export function updateExam(id: number, userId: number, title: string, subtitle: string | null, examJson: string, tagsJson: string | null = null): boolean {
const db = getDb()
if (tagsJson !== null) {
const result = db.prepare(`
UPDATE saved_exams
SET title = ?, subtitle = ?, exam_json = ?, tags = ?, updated_at = unixepoch()
WHERE id = ? AND user_id = ?
`).run(title, subtitle, examJson, tagsJson, id, userId)
return result.changes > 0
}
const result = db.prepare(`
UPDATE saved_exams
SET title = ?, subtitle = ?, exam_json = ?, updated_at = unixepoch()
WHERE id = ? AND user_id = ?
`).run(title, subtitle, examJson, id, userId)
return result.changes > 0
}
export function deleteExam(id: number, userId: number): boolean {
const db = getDb()
const result = db.prepare('DELETE FROM saved_exams WHERE id = ? AND user_id = ?').run(id, userId)
return result.changes > 0
}
export function countExams(): number {
const db = getDb()
const row = db.prepare('SELECT COUNT(*) AS cnt FROM saved_exams').get() as { cnt: number }
return row.cnt
}
export function setShareToken(id: number, userId: number, token: string, mode: string = 'clone'): boolean {
const db = getDb()
const result = db.prepare(`
UPDATE saved_exams
SET share_token = ?, is_public = 1, share_mode = ?, updated_at = unixepoch()
WHERE id = ? AND user_id = ?
`).run(token, mode, id, userId)
return result.changes > 0
}
export function clearShareToken(id: number, userId: number): boolean {
const db = getDb()
const result = db.prepare(`
UPDATE saved_exams
SET share_token = NULL, is_public = 0, share_mode = 'clone', updated_at = unixepoch()
WHERE id = ? AND user_id = ?
`).run(id, userId)
return result.changes > 0
}
export function getExamByShareToken(token: string): DbExam | undefined {
const db = getDb()
return db.prepare('SELECT * FROM saved_exams WHERE share_token = ? AND is_public = 1').get(token) as DbExam | undefined
}
export function setShareMode(id: number, userId: number, mode: string): boolean {
const db = getDb()
const result = db.prepare(`
UPDATE saved_exams
SET share_mode = ?, updated_at = unixepoch()
WHERE id = ? AND user_id = ?
`).run(mode, id, userId)
return result.changes > 0
}
export function cloneExam(userId: number, sourceExam: DbExam, shareToken: string): { id: number } {
const db = getDb()
const parsed = JSON.parse(sourceExam.exam_json)
parsed.id = undefined
const result = db.prepare(`
INSERT INTO saved_exams (user_id, title, subtitle, exam_json, cloned_from_share_token)
VALUES (?, ?, ?, ?, ?)
`).run(userId, sourceExam.title + ' (copy)', sourceExam.subtitle, JSON.stringify(parsed), shareToken)
return { id: result.lastInsertRowid as number }
}
export interface ClonedExamItem {
id: number
title: string
subtitle: string | null
created_at: number
updated_at: number
question_count: number
cloned_from_share_token: string
}
export function listClonedExams(userId: number): ClonedExamItem[] {
const db = getDb()
return db.prepare(`
SELECT id, title, subtitle, created_at, updated_at, cloned_from_share_token,
json_array_length(json_extract(exam_json, '$.questions')) AS question_count
FROM saved_exams
WHERE user_id = ? AND cloned_from_share_token IS NOT NULL
ORDER BY updated_at DESC
`).all(userId) as ClonedExamItem[]
}
export function setFavorite(id: number, userId: number, isFavorite: boolean): boolean {
const db = getDb()
const result = db.prepare(`
UPDATE saved_exams
SET is_favorite = ?, updated_at = unixepoch()
WHERE id = ? AND user_id = ?
`).run(isFavorite ? 1 : 0, id, userId)
return result.changes > 0
}
export function getExamRawJson(id: number, userId: number): { title: string; exam_json: string } | undefined {
const db = getDb()
return db.prepare('SELECT title, exam_json FROM saved_exams WHERE id = ? AND user_id = ?').get(id, userId) as { title: string; exam_json: string } | undefined
}
export function duplicateOwnExam(userId: number, sourceExam: DbExam): { id: number } {
const db = getDb()
const parsed = JSON.parse(sourceExam.exam_json)
parsed.id = undefined
const result = db.prepare(`
INSERT INTO saved_exams (user_id, title, subtitle, exam_json, tags)
VALUES (?, ?, ?, ?, ?)
`).run(userId, sourceExam.title + ' (copy)', sourceExam.subtitle, JSON.stringify(parsed), sourceExam.tags ?? '[]')
return { id: result.lastInsertRowid as number }
}
export function setExamCollection(id: number, userId: number, collectionId: number | null): boolean {
const db = getDb()
const result = db.prepare(`
UPDATE saved_exams
SET collection_id = ?, updated_at = unixepoch()
WHERE id = ? AND user_id = ?
`).run(collectionId, id, userId)
return result.changes > 0
}
+171
View File
@@ -0,0 +1,171 @@
import { getDb } from '../db/database.js'
export interface DbResult {
id: number
user_id: number
exam_id: number | null
title: string
score_points: number
total_points: number
correct_count: number
graded_count: number
passing_score: number
elapsed_ms: number
state_json: string
exam_json: string
completed_at: number
}
export interface ResultListItem {
id: number
title: string
score_points: number
total_points: number
correct_count: number
graded_count: number
passing_score: number
elapsed_ms: number
completed_at: number
exam_id: number | null
}
export function createResult(data: Omit<DbResult, 'id'>): { id: number } {
const db = getDb()
const result = db.prepare(`
INSERT INTO exam_results (user_id, exam_id, title, score_points, total_points, correct_count, graded_count, passing_score, elapsed_ms, state_json, exam_json)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(
data.user_id,
data.exam_id ?? null,
data.title,
data.score_points,
data.total_points,
data.correct_count,
data.graded_count,
data.passing_score ?? 60,
data.elapsed_ms ?? 0,
data.state_json,
data.exam_json
)
return { id: result.lastInsertRowid as number }
}
export function getResultById(id: number, userId: number): DbResult | undefined {
const db = getDb()
return db.prepare('SELECT * FROM exam_results WHERE id = ? AND user_id = ?').get(id, userId) as DbResult | undefined
}
export function listResultsByUser(userId: number, options: { limit?: number; offset?: number } = {}): { results: ResultListItem[]; total: number } {
const db = getDb()
const limit = Math.min(options.limit ?? 30, 100)
const offset = options.offset ?? 0
const results = db.prepare(`
SELECT id, title, score_points, total_points, correct_count, graded_count,
passing_score, elapsed_ms, completed_at, exam_id
FROM exam_results
WHERE user_id = ?
ORDER BY completed_at DESC
LIMIT ? OFFSET ?
`).all(userId, limit, offset) as ResultListItem[]
const countRow = db.prepare('SELECT COUNT(*) AS total FROM exam_results WHERE user_id = ?').get(userId) as { total: number }
return { results, total: countRow.total }
}
export function deleteResult(id: number, userId: number): boolean {
const db = getDb()
const result = db.prepare('DELETE FROM exam_results WHERE id = ? AND user_id = ?').run(id, userId)
return result.changes > 0
}
export function countResults(): number {
const db = getDb()
const row = db.prepare('SELECT COUNT(*) AS cnt FROM exam_results').get() as { cnt: number }
return row.cnt
}
export function getAverageScore(): number {
const db = getDb()
const row = db.prepare(
'SELECT COALESCE(AVG(score_points * 100.0 / NULLIF(total_points, 0)), 0) AS avg FROM exam_results'
).get() as { avg: number }
return Math.round(row.avg * 10) / 10
}
export function getRecentResults(limit: number): Array<DbResult & { display_name: string; email: string }> {
const db = getDb()
return db.prepare(`
SELECT er.*, u.display_name, u.email
FROM exam_results er
JOIN users u ON u.id = er.user_id
ORDER BY er.completed_at DESC
LIMIT ?
`).all(limit) as Array<DbResult & { display_name: string; email: string }>
}
export function countResultsByUser(userId: number): number {
const db = getDb()
const row = db.prepare('SELECT COUNT(*) AS cnt FROM exam_results WHERE user_id = ?').get(userId) as { cnt: number }
return row.cnt
}
export function getResultsWithExamJson(userId: number, limit = 100): Array<{
title: string
score_points: number
total_points: number
correct_count: number
graded_count: number
completed_at: number
exam_json: string
exam_id: number | null
}> {
const db = getDb()
return db.prepare(`
SELECT title, score_points, total_points, correct_count, graded_count, completed_at, exam_json, exam_id
FROM exam_results
WHERE user_id = ?
ORDER BY completed_at DESC
LIMIT ?
`).all(userId, limit) as Array<{
title: string
score_points: number
total_points: number
correct_count: number
graded_count: number
completed_at: number
exam_json: string
exam_id: number | null
}>
}
export interface ScoreTrend {
completed_at: number
score_pct: number
title: string
exam_id: number | null
}
export function getScoreTrends(userId: number, limit = 30): Array<{
completed_at: number
score_points: number
total_points: number
title: string
exam_id: number | null
}> {
const db = getDb()
return db.prepare(`
SELECT completed_at, score_points, total_points, title, exam_id
FROM exam_results
WHERE user_id = ? AND total_points > 0
ORDER BY completed_at ASC
LIMIT ?
`).all(userId, limit) as Array<{
completed_at: number
score_points: number
total_points: number
title: string
exam_id: number | null
}>
}
+56
View File
@@ -0,0 +1,56 @@
import { getDb } from '../db/database.js'
export interface Setting {
key: string
value: string
updated_at: number
}
export function getSetting(key: string): string | undefined {
const db = getDb()
const row = db.prepare('SELECT value FROM site_settings WHERE key = ?').get(key) as { value: string } | undefined
return row?.value
}
export function setSetting(key: string, value: string): void {
const db = getDb()
db.prepare(`
INSERT INTO site_settings (key, value) VALUES (?, ?)
ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = unixepoch()
`).run(key, value)
}
export function setSettingsBatch(entries: Array<[string, string]>): void {
const db = getDb()
const stmt = db.prepare(`
INSERT INTO site_settings (key, value) VALUES (?, ?)
ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = unixepoch()
`)
const tx = db.transaction((items: Array<[string, string]>) => {
for (const [key, value] of items) {
stmt.run(key, value)
}
})
tx(entries)
}
export function getAllSettings(): Record<string, string> {
const db = getDb()
const rows = db.prepare('SELECT key, value FROM site_settings ORDER BY key').all() as { key: string; value: string }[]
const settings: Record<string, string> = {}
for (const row of rows) {
settings[row.key] = row.value
}
return settings
}
export function seedDefaultSettings(defaults: Record<string, string>): void {
const db = getDb()
const stmt = db.prepare(`
INSERT INTO site_settings (key, value) VALUES (?, ?)
ON CONFLICT(key) DO NOTHING
`)
for (const [key, value] of Object.entries(defaults)) {
stmt.run(key, value)
}
}
@@ -0,0 +1,157 @@
import { describe, it, expect } from 'vitest'
// Set env vars before any module that reads them at import time
process.env.JWT_SECRET = process.env.JWT_SECRET || 'test-secret-for-unit-tests-only'
process.env.DB_PATH = ':memory:'
// Dynamic imports ensure env vars are set before module initialisation
const { getDb, closeDb } = await import('../db/database.js')
const userRepo = await import('./userRepo.js')
const storageRepo = await import('./storageRepo.js')
describe('storageRepo', () => {
it('returns zero usage for a new user', () => {
const { id: userId } = userRepo.createUser({
email: 'storage-test@example.com',
passwordHash: 'hash',
displayName: 'Storage Test',
emailVerified: 1,
})
const usage = storageRepo.getUserStorageUsage(userId)
expect(usage.usedBytes).toBe(0)
expect(usage.examBytes).toBe(0)
expect(usage.resultBytes).toBe(0)
})
it('calculates exam JSON length correctly', () => {
const { id: userId } = userRepo.createUser({
email: 'storage-exam@example.com',
passwordHash: 'hash',
displayName: 'Exam Storage',
emailVerified: 1,
})
const db = getDb()
const examJson = JSON.stringify({ title: 'Test Exam', questions: [] })
db.prepare(`
INSERT INTO saved_exams (user_id, title, exam_json)
VALUES (?, ?, ?)
`).run(userId, 'Test Exam', examJson)
const usage = storageRepo.getUserStorageUsage(userId)
expect(usage.examBytes).toBe(examJson.length)
expect(usage.usedBytes).toBe(examJson.length)
})
it('calculates result JSON length correctly', () => {
const { id: userId } = userRepo.createUser({
email: 'storage-result@example.com',
passwordHash: 'hash',
displayName: 'Result Storage',
emailVerified: 1,
})
const db = getDb()
const stateJson = JSON.stringify({ answers: [1, 2, 3] })
const examJson = JSON.stringify({ title: 'Result Exam' })
db.prepare(`
INSERT INTO exam_results
(user_id, title, score_points, total_points, correct_count, graded_count, elapsed_ms, state_json, exam_json)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(userId, 'Result Exam', 10, 20, 5, 10, 60000, stateJson, examJson)
const usage = storageRepo.getUserStorageUsage(userId)
expect(usage.resultBytes).toBe(stateJson.length + examJson.length)
expect(usage.usedBytes).toBe(stateJson.length + examJson.length)
})
it('aggregates multiple exams and results', () => {
const { id: userId } = userRepo.createUser({
email: 'storage-multi@example.com',
passwordHash: 'hash',
displayName: 'Multi Storage',
emailVerified: 1,
})
const db = getDb()
const examJson1 = JSON.stringify({ title: 'Exam 1' })
const examJson2 = JSON.stringify({ title: 'Exam 2', extra: 'data' })
db.prepare(`INSERT INTO saved_exams (user_id, title, exam_json) VALUES (?, ?, ?)`)
.run(userId, 'Exam 1', examJson1)
db.prepare(`INSERT INTO saved_exams (user_id, title, exam_json) VALUES (?, ?, ?)`)
.run(userId, 'Exam 2', examJson2)
const stateJson = JSON.stringify({ answers: [] })
db.prepare(`
INSERT INTO exam_results
(user_id, title, score_points, total_points, correct_count, graded_count, elapsed_ms, state_json, exam_json)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(userId, 'Result', 5, 10, 3, 5, 30000, stateJson, examJson1)
const usage = storageRepo.getUserStorageUsage(userId)
expect(usage.examBytes).toBe(examJson1.length + examJson2.length)
expect(usage.resultBytes).toBe(stateJson.length + examJson1.length)
expect(usage.usedBytes).toBe(examJson1.length + examJson2.length + stateJson.length + examJson1.length)
})
it('lists storage items sorted by size desc', () => {
const { id: userId } = userRepo.createUser({
email: 'storage-items@example.com',
passwordHash: 'hash',
displayName: 'Items Storage',
emailVerified: 1,
})
const db = getDb()
const smallExam = JSON.stringify({ title: 'Small' })
const largeExam = JSON.stringify({ title: 'Large Exam', content: 'x'.repeat(100) })
const stateJson = JSON.stringify({ a: 'y'.repeat(50) })
db.prepare(`INSERT INTO saved_exams (user_id, title, exam_json) VALUES (?, ?, ?)`)
.run(userId, 'Small Exam', smallExam)
db.prepare(`INSERT INTO saved_exams (user_id, title, exam_json) VALUES (?, ?, ?)`)
.run(userId, 'Large Exam', largeExam)
db.prepare(`
INSERT INTO exam_results
(user_id, title, score_points, total_points, correct_count, graded_count, elapsed_ms, state_json, exam_json)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(userId, 'Result', 5, 10, 3, 5, 30000, stateJson, smallExam)
const items = storageRepo.listStorageItems(userId)
expect(items.length).toBe(3)
expect(items[0].title).toBe('Large Exam')
expect(items[0].type).toBe('exam')
expect(items[1].title).toBe('Result')
expect(items[1].type).toBe('result')
expect(items[2].title).toBe('Small Exam')
expect(items[2].type).toBe('exam')
})
it('does not include other users data', () => {
const { id: userA } = userRepo.createUser({
email: 'user-a@example.com',
passwordHash: 'hash',
displayName: 'User A',
emailVerified: 1,
})
const { id: userB } = userRepo.createUser({
email: 'user-b@example.com',
passwordHash: 'hash',
displayName: 'User B',
emailVerified: 1,
})
const db = getDb()
const examJson = JSON.stringify({ title: 'A Exam' })
db.prepare(`INSERT INTO saved_exams (user_id, title, exam_json) VALUES (?, ?, ?)`)
.run(userA, 'A Exam', examJson)
const usageA = storageRepo.getUserStorageUsage(userA)
const usageB = storageRepo.getUserStorageUsage(userB)
expect(usageA.usedBytes).toBe(examJson.length)
expect(usageB.usedBytes).toBe(0)
})
})
+61
View File
@@ -0,0 +1,61 @@
import { getDb } from '../db/database.js'
export interface StorageUsage {
usedBytes: number
examBytes: number
resultBytes: number
}
export interface StorageItem {
id: number
title: string
type: 'exam' | 'result'
sizeBytes: number
createdAt: number
}
export function getUserStorageUsage(userId: number): StorageUsage {
const db = getDb()
const examRow = db.prepare(`
SELECT COALESCE(SUM(LENGTH(exam_json)), 0) AS total
FROM saved_exams
WHERE user_id = ?
`).get(userId) as { total: number }
const resultRow = db.prepare(`
SELECT COALESCE(SUM(LENGTH(state_json) + LENGTH(exam_json)), 0) AS total
FROM exam_results
WHERE user_id = ?
`).get(userId) as { total: number }
return {
usedBytes: examRow.total + resultRow.total,
examBytes: examRow.total,
resultBytes: resultRow.total,
}
}
export function listStorageItems(userId: number): StorageItem[] {
const db = getDb()
const exams = db.prepare(`
SELECT id, title, LENGTH(exam_json) AS size_bytes, created_at
FROM saved_exams
WHERE user_id = ?
ORDER BY size_bytes DESC
`).all(userId) as { id: number; title: string; size_bytes: number; created_at: number }[]
const results = db.prepare(`
SELECT id, title, LENGTH(state_json) + LENGTH(exam_json) AS size_bytes, completed_at AS created_at
FROM exam_results
WHERE user_id = ?
ORDER BY size_bytes DESC
`).all(userId) as { id: number; title: string; size_bytes: number; created_at: number }[]
const items: StorageItem[] = [
...exams.map((e) => ({ id: e.id, title: e.title, type: 'exam' as const, sizeBytes: e.size_bytes, createdAt: e.created_at })),
...results.map((r) => ({ id: r.id, title: r.title, type: 'result' as const, sizeBytes: r.size_bytes, createdAt: r.created_at })),
]
items.sort((a, b) => b.sizeBytes - a.sizeBytes)
return items
}
+47
View File
@@ -0,0 +1,47 @@
import { describe, it, expect } from 'vitest'
import * as userRepo from './userRepo.js'
function uniqueEmail(): string {
return `safe-${Math.random().toString(36).slice(2)}@example.com`
}
describe('userRepo.listUsers', () => {
it('never returns credential or token columns to callers', async () => {
const email = uniqueEmail()
userRepo.createUser({
email,
passwordHash: await import('bcrypt').then((b) => b.hashSync('SecretPassword1!', 10)),
displayName: 'Safe Listing',
role: 'user',
emailVerified: 0,
verifyToken: 'verify-token-hash',
resetToken: 'reset-token-hash',
})
const { users, total } = userRepo.listUsers({ search: email })
expect(total).toBe(1)
const user = users[0]!
expect(user.email).toBe(email)
expect(user.display_name).toBe('Safe Listing')
expect(user.role).toBe('user')
const keys = Object.keys(user)
expect(keys).not.toContain('password_hash')
expect(keys).not.toContain('verify_token')
expect(keys).not.toContain('verify_expires')
expect(keys).not.toContain('reset_token')
expect(keys).not.toContain('reset_expires')
})
it('still returns full rows from getUserById for internal auth use', () => {
const email = uniqueEmail()
const { id } = userRepo.createUser({
email,
passwordHash: 'internal-only',
displayName: 'Internal',
emailVerified: 1,
})
expect(userRepo.getUserById(id)?.password_hash).toBe('internal-only')
})
})
+205
View File
@@ -0,0 +1,205 @@
import { getDb } from '../db/database.js'
export interface DbUser {
id: number
email: string
password_hash: string
display_name: string
role: 'user' | 'admin'
email_verified: number
verify_token: string | null
verify_expires: number | null
reset_token: string | null
reset_expires: number | null
storage_quota_bytes: number
plan: string
token_version: number
created_at: number
updated_at: number
}
export interface CreateUserData {
email: string
passwordHash: string
displayName: string
role?: 'user' | 'admin'
emailVerified?: number
verifyToken?: string | null
verifyExpires?: number | null
storageQuotaBytes?: number
plan?: string
}
export interface UpdateUserData {
displayName?: string
passwordHash?: string
role?: 'user' | 'admin'
emailVerified?: number
verifyToken?: string | null
verifyExpires?: number | null
resetToken?: string | null
resetExpires?: number | null
storageQuotaBytes?: number
plan?: string
tokenVersion?: number
}
export function createUser(data: CreateUserData): { id: number } {
const db = getDb()
const result = db.prepare(`
INSERT INTO users (email, password_hash, display_name, role, email_verified, verify_token, verify_expires, storage_quota_bytes, plan)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(
data.email.toLowerCase().trim(),
data.passwordHash,
data.displayName.trim(),
data.role ?? 'user',
data.emailVerified ?? 1,
data.verifyToken ?? null,
data.verifyExpires ?? null,
data.storageQuotaBytes ?? 1073741824,
data.plan ?? 'free'
)
return { id: result.lastInsertRowid as number }
}
export function getUserByEmail(email: string): DbUser | undefined {
const db = getDb()
return db.prepare('SELECT * FROM users WHERE email = ?').get(email.toLowerCase().trim()) as DbUser | undefined
}
export function getUserById(id: number): DbUser | undefined {
const db = getDb()
return db.prepare('SELECT * FROM users WHERE id = ?').get(id) as DbUser | undefined
}
export function getUserByVerifyTokenHash(tokenHash: string): DbUser | undefined {
const db = getDb()
return db.prepare('SELECT * FROM users WHERE verify_token = ?').get(tokenHash) as DbUser | undefined
}
export function getUserByResetTokenHash(tokenHash: string): DbUser | undefined {
const db = getDb()
return db.prepare('SELECT * FROM users WHERE reset_token = ?').get(tokenHash) as DbUser | undefined
}
export function updateUser(id: number, data: UpdateUserData): boolean {
const db = getDb()
const fields: string[] = []
const values: unknown[] = []
if (data.displayName !== undefined) {
fields.push('display_name = ?')
values.push(data.displayName.trim())
}
if (data.passwordHash !== undefined) {
fields.push('password_hash = ?')
values.push(data.passwordHash)
}
if (data.role !== undefined) {
fields.push('role = ?')
values.push(data.role)
}
if (data.emailVerified !== undefined) {
fields.push('email_verified = ?')
values.push(data.emailVerified)
}
if (data.verifyToken !== undefined) {
fields.push('verify_token = ?')
values.push(data.verifyToken)
}
if (data.verifyExpires !== undefined) {
fields.push('verify_expires = ?')
values.push(data.verifyExpires)
}
if (data.resetToken !== undefined) {
fields.push('reset_token = ?')
values.push(data.resetToken)
}
if (data.resetExpires !== undefined) {
fields.push('reset_expires = ?')
values.push(data.resetExpires)
}
if (data.storageQuotaBytes !== undefined) {
fields.push('storage_quota_bytes = ?')
values.push(data.storageQuotaBytes)
}
if (data.plan !== undefined) {
fields.push('plan = ?')
values.push(data.plan)
}
if (data.tokenVersion !== undefined) {
fields.push('token_version = ?')
values.push(data.tokenVersion)
}
if (fields.length === 0) return false
fields.push('updated_at = unixepoch()')
values.push(id)
const result = db.prepare(`UPDATE users SET ${fields.join(', ')} WHERE id = ?`).run(...values)
return result.changes > 0
}
export function deleteUser(id: number): boolean {
const db = getDb()
const result = db.prepare('DELETE FROM users WHERE id = ?').run(id)
return result.changes > 0
}
export function countUsers(): number {
const db = getDb()
const row = db.prepare('SELECT COUNT(*) AS cnt FROM users').get() as { cnt: number }
return row.cnt
}
export function countAdmins(): number {
const db = getDb()
const row = db.prepare("SELECT COUNT(*) AS cnt FROM users WHERE role = 'admin'").get() as { cnt: number }
return row.cnt
}
/** User row safe for API exposure — no password/token material. */
export interface SafeUser {
id: number
email: string
display_name: string
role: 'user' | 'admin'
email_verified: number
plan: string
storage_quota_bytes: number
created_at: number
}
const SAFE_USER_COLUMNS =
'id, email, display_name, role, email_verified, plan, storage_quota_bytes, created_at'
export function listUsers(options: { limit?: number; offset?: number; search?: string } = {}): { users: SafeUser[]; total: number } {
const db = getDb()
const limit = Math.min(options.limit ?? 50, 200)
const offset = options.offset ?? 0
const search = options.search ?? ''
// Explicit column list so credential/token material can never leak into
// API responses, even if new sensitive columns are added later.
let query = `SELECT ${SAFE_USER_COLUMNS} FROM users`
let countQuery = 'SELECT COUNT(*) AS total FROM users'
const params: unknown[] = []
const countParams: unknown[] = []
if (search) {
query += ' WHERE email LIKE ? OR display_name LIKE ?'
countQuery += ' WHERE email LIKE ? OR display_name LIKE ?'
params.push(`%${search}%`, `%${search}%`)
countParams.push(`%${search}%`, `%${search}%`)
}
query += ' ORDER BY created_at DESC LIMIT ? OFFSET ?'
params.push(limit, offset)
const users = db.prepare(query).all(...params) as SafeUser[]
const countRow = db.prepare(countQuery).get(...countParams) as { total: number }
return { users, total: countRow.total }
}
+143 -242
View File
@@ -1,268 +1,169 @@
import { Router } from "express";
import { getDb } from "../db/database.js";
import { requireAuth, requireAdmin } from "../middleware/auth.js";
import { sendTestEmail } from "../utils/email.js";
import type { SmtpConfig } from "../utils/email.js";
import { Router } from 'express'
import { z } from 'zod'
import { validateBody, validateQuery, validateParams } from '../middleware/validate.js'
import { asyncHandler } from '../utils/asyncHandler.js'
import { requireAuth, requireAdmin } from '../middleware/auth.js'
import { AppError } from '../middleware/error.js'
import * as userRepo from '../repositories/userRepo.js'
import * as resultRepo from '../repositories/resultRepo.js'
import * as settingsRepo from '../repositories/settingsRepo.js'
import { updateUserRoleSchema, updateSettingSchema, updateSettingsBatchSchema, listUsersQuerySchema, userIdParamSchema } from '../validators/adminSchemas.js'
import { gradeQuestionWithAI } from '../services/aiGrading.js'
import type { Request } from 'express'
const router = Router();
const router = Router()
// All admin routes require authentication + admin role
router.use(requireAuth, requireAdmin);
router.use(requireAuth, requireAdmin)
// ─── Dashboard Stats ─────────────────────────────────────
router.get("/stats", (_req, res) => {
try {
const db = getDb();
const users = db.prepare("SELECT COUNT(*) AS cnt FROM users").get() as {
cnt: number;
};
const exams = db
.prepare("SELECT COUNT(*) AS cnt FROM saved_exams")
.get() as { cnt: number };
const results = db
.prepare("SELECT COUNT(*) AS cnt FROM exam_results")
.get() as { cnt: number };
const avgScore = db
.prepare(
"SELECT COALESCE(AVG(score_points * 100.0 / NULLIF(total_points, 0)), 0) AS avg FROM exam_results",
)
.get() as { avg: number };
const recentResults = db
.prepare(
`
SELECT er.id, er.title, er.score_points, er.total_points, er.completed_at,
u.display_name, u.email
FROM exam_results er
JOIN users u ON u.id = er.user_id
ORDER BY er.completed_at DESC
LIMIT 10
`,
)
.all();
// GET /api/admin/users
router.get(
'/users',
validateQuery(listUsersQuerySchema),
asyncHandler(async (req: Request, res) => {
const parsed = (req as unknown as Record<string, unknown>).parsedQuery as { page: number; search?: string }
const page = parsed.page ?? 1
const limit = 20
const offset = (page - 1) * limit
const { users, total } = userRepo.listUsers({
limit,
offset,
search: parsed.search,
})
const totalPages = Math.ceil(total / limit)
res.json({
totalUsers: users.cnt,
totalExams: exams.cnt,
totalResults: results.cnt,
averageScore: Math.round(avgScore.avg * 10) / 10,
recentResults,
});
} catch (err) {
console.error("Admin stats error:", err);
res.status(500).json({ error: "Internal server error" });
}
});
users,
total,
page,
totalPages,
limit,
})
})
)
// ─── Get All Settings ────────────────────────────────────
router.get("/settings", (_req, res) => {
try {
const db = getDb();
const rows = db
.prepare("SELECT key, value FROM site_settings ORDER BY key")
.all() as { key: string; value: string }[];
const settings: Record<string, string> = {};
for (const row of rows) {
settings[row.key] = row.value;
}
res.json({ settings });
} catch (err) {
console.error("Admin get settings error:", err);
res.status(500).json({ error: "Internal server error" });
}
});
// PUT /api/admin/users/:id/role
router.put(
'/users/:id/role',
validateParams(userIdParamSchema),
validateBody(updateUserRoleSchema),
asyncHandler(async (req: Request, res) => {
const id = Number(req.params.id)
const { role } = req.body
// ─── Update Settings ─────────────────────────────────────
router.put("/settings", (req, res) => {
try {
const { settings } = req.body as { settings: Record<string, string> };
if (!settings || typeof settings !== "object") {
res.status(400).json({ error: "Settings object is required" });
return;
const user = userRepo.getUserById(id)
if (!user) {
throw new AppError(404, 'User not found')
}
const validKeys = [
"site_name",
"max_upload_mb",
"allow_guest_mode",
"registration_enabled",
"default_theme",
"maintenance_mode",
"maintenance_message",
"smtp_host",
"smtp_port",
"smtp_user",
"smtp_pass",
"smtp_from",
"smtp_secure",
"app_url",
];
if (user.id === req.user!.userId) {
throw new AppError(400, 'Cannot change your own role')
}
const db = getDb();
const update = db.prepare(`
UPDATE site_settings SET value = ?, updated_at = unixepoch() WHERE key = ?
`);
const updateMany = db.transaction((entries: [string, string][]) => {
for (const [key, value] of entries) {
if (!validKeys.includes(key)) continue;
update.run(String(value), key);
if (role === 'user' && user.role === 'admin') {
const adminCount = userRepo.countAdmins()
if (adminCount <= 1) {
throw new AppError(400, 'Cannot remove the last admin')
}
});
updateMany(Object.entries(settings));
res.json({ message: "Settings updated successfully" });
} catch (err) {
console.error("Admin update settings error:", err);
res.status(500).json({ error: "Internal server error" });
}
});
// ─── List All Users ──────────────────────────────────────
router.get("/users", (req, res) => {
try {
const limit = Math.min(Number(req.query["limit"]) || 50, 200);
const offset = Number(req.query["offset"]) || 0;
const search = (req.query["search"] as string) || "";
const db = getDb();
let query = `
SELECT u.id, u.email, u.display_name, u.role, u.email_verified, u.created_at,
(SELECT COUNT(*) FROM exam_results WHERE user_id = u.id) AS results_count,
(SELECT COUNT(*) FROM saved_exams WHERE user_id = u.id) AS exams_count
FROM users u
`;
let countQuery = "SELECT COUNT(*) AS total FROM users";
const params: unknown[] = [];
const countParams: unknown[] = [];
if (search) {
const where = ` WHERE u.email LIKE ? OR u.display_name LIKE ?`;
query += where;
countQuery += where.replace("u.", "");
params.push(`%${search}%`, `%${search}%`);
countParams.push(`%${search}%`, `%${search}%`);
}
query += " ORDER BY u.created_at DESC LIMIT ? OFFSET ?";
params.push(limit, offset);
userRepo.updateUser(id, { role })
res.json({ success: true })
})
)
const users = db.prepare(query).all(...params);
const countRow = db.prepare(countQuery).get(...countParams) as {
total: number;
};
res.json({ users, total: countRow.total });
} catch (err) {
console.error("Admin list users error:", err);
res.status(500).json({ error: "Internal server error" });
}
});
// ─── Change User Role ────────────────────────────────────
router.patch("/users/:id/role", (req, res) => {
try {
const userId = Number(req.params["id"]);
const { role } = req.body as { role: string };
if (!["user", "admin"].includes(role)) {
res.status(400).json({ error: 'Role must be "user" or "admin"' });
return;
// DELETE /api/admin/users/:id
router.delete(
'/users/:id',
validateParams(userIdParamSchema),
asyncHandler(async (req: Request, res) => {
const id = Number(req.params.id)
if (id === req.user!.userId) {
throw new AppError(400, 'Cannot delete your own account')
}
// Prevent self-demotion
if (userId === req.user!.userId && role !== "admin") {
res.status(400).json({ error: "You cannot remove your own admin role" });
return;
const user = userRepo.getUserById(id)
if (!user) {
throw new AppError(404, 'User not found')
}
const db = getDb();
const result = db
.prepare(
"UPDATE users SET role = ?, updated_at = unixepoch() WHERE id = ?",
)
.run(role, userId);
if (result.changes === 0) {
res.status(404).json({ error: "User not found" });
return;
if (user.role === 'admin') {
const adminCount = userRepo.countAdmins()
if (adminCount <= 1) {
throw new AppError(400, 'Cannot delete the last admin')
}
}
res.json({ message: `User role updated to ${role}` });
} catch (err) {
console.error("Admin change role error:", err);
res.status(500).json({ error: "Internal server error" });
}
});
userRepo.deleteUser(id)
res.json({ success: true })
})
)
// ─── Delete User ─────────────────────────────────────────
router.delete("/users/:id", (req, res) => {
try {
const userId = Number(req.params["id"]);
// GET /api/admin/stats
router.get(
'/stats',
asyncHandler(async (_req, res) => {
res.json({
totalUsers: userRepo.countUsers(),
totalExams: resultRepo.countResults(),
totalResults: resultRepo.countResults(),
averageScore: resultRepo.getAverageScore(),
})
})
)
// Prevent self-deletion
if (userId === req.user!.userId) {
res.status(400).json({ error: "You cannot delete your own account" });
return;
}
// GET /api/admin/recent-results
router.get(
'/recent-results',
asyncHandler(async (_req, res) => {
const results = resultRepo.getRecentResults(10)
res.json(results)
})
)
const db = getDb();
const result = db.prepare("DELETE FROM users WHERE id = ?").run(userId);
// GET /api/admin/settings
router.get(
'/settings',
asyncHandler(async (_req, res) => {
res.json({ settings: settingsRepo.getAllSettings() })
})
)
if (result.changes === 0) {
res.status(404).json({ error: "User not found" });
return;
}
// PUT /api/admin/settings
router.put(
'/settings',
validateBody(updateSettingsBatchSchema),
asyncHandler(async (req: Request, res) => {
const { settings } = req.body
const entries = Object.entries(settings) as Array<[string, string]>
settingsRepo.setSettingsBatch(entries)
res.json({ success: true })
})
)
res.json({ message: "User deleted" });
} catch (err) {
console.error("Admin delete user error:", err);
res.status(500).json({ error: "Internal server error" });
}
});
// POST /api/admin/test-ai
router.post(
'/test-ai',
validateBody(z.object({
provider: z.string().min(1),
apiKey: z.string().min(1),
model: z.string().min(1),
baseURL: z.string().optional(),
})),
asyncHandler(async (req: Request, res) => {
const { provider, apiKey, model, baseURL } = req.body
const result = await gradeQuestionWithAI(
{
type: 'short-answer',
question: { prompt: 'What is 2+2?', answer: '4' },
answer: '4',
language: 'en',
},
{ provider, apiKey, model, baseURL },
)
res.json(result.data)
}),
)
// ─── Test Email ───────────────────────────────────────────
router.post("/test-email", async (req, res) => {
try {
// The body may contain optional SMTP override settings (for testing before saving)
// and a `to` field (defaults to the requesting admin's email)
const { to, smtp } = req.body as {
to?: string;
smtp?: {
host: string;
port: string;
user: string;
pass: string;
from: string;
secure: string;
};
};
const recipient = to || req.user!.email;
let overrideConfig: SmtpConfig | undefined;
if (smtp?.host) {
const port = Number(smtp.port ?? "587");
overrideConfig = {
host: smtp.host,
port,
user: smtp.user ?? "",
pass: smtp.pass ?? "",
from: smtp.from ?? "BrainWave <noreply@brainwave.local>",
secure:
smtp.secure === "true" || (smtp.secure === "auto" && port === 465),
};
}
await sendTestEmail(recipient, overrideConfig);
res.json({ message: `Test email sent to ${recipient}` });
} catch (err) {
console.error("Test email error:", err);
res
.status(500)
.json({
error: err instanceof Error ? err.message : "Failed to send test email",
});
}
});
export default router;
export default router
+157
View File
@@ -0,0 +1,157 @@
import { Router } from 'express'
import { z } from 'zod'
import { validateBody } from '../middleware/validate.js'
import { asyncHandler } from '../utils/asyncHandler.js'
import { requireAuth } from '../middleware/auth.js'
import { generateQuestions, getHint, tutorChat } from '../services/aiAssist.js'
import { getResultById } from '../repositories/resultRepo.js'
import { enforceDailyAiQuota, recordDailyAiUsage, getUtcDateKey } from '../utils/aiQuota.js'
import { hintSchema } from '../validators/aiSchemas.js'
const router = Router()
router.use(requireAuth)
const generateSchema = z.object({
topic: z.string().min(1).max(2000),
type: z.enum([
'multiple-choice',
'multiple-select',
'short-answer',
'translation',
'math-input',
'essay',
'match',
'order',
'fill-in-the-blanks',
'true-false',
'code-submission',
'dropdown-completion',
'numeric-range',
'dictation',
'grammar-correction',
'flashcard',
]),
count: z.number().int().min(1).max(10),
language: z.string().min(1).max(20),
context: z.string().max(8000).optional(),
})
router.post(
'/generate-question',
validateBody(generateSchema),
asyncHandler(async (req, res) => {
const body = req.body as z.infer<typeof generateSchema>
const userId = req.user!.userId
enforceDailyAiQuota(userId)
const result = await generateQuestions(body)
recordDailyAiUsage(userId, getUtcDateKey(), result.usageTokens)
res.json({ questions: result.data })
}),
)
router.post(
'/hint',
validateBody(hintSchema),
asyncHandler(async (req, res) => {
const body = req.body
const userId = req.user!.userId
enforceDailyAiQuota(userId)
const result = await getHint({ question: body.question, language: body.language })
recordDailyAiUsage(userId, getUtcDateKey(), result.usageTokens)
res.json(result.data)
}),
)
const tutorSchema = z.object({
resultId: z.number().int().positive(),
messages: z
.array(
z.object({
role: z.enum(['user', 'assistant']),
content: z.string().min(1).max(4000),
}),
)
.min(1)
.max(40),
language: z.string().min(1).max(20),
})
interface TokenBucket {
tokens: number
lastRefill: number
}
const TUTOR_RATE_LIMIT = 30
const TUTOR_REFILL_MS = 60 * 60 * 1000
const tutorBuckets = new Map<number, TokenBucket>()
function consumeTutorToken(userId: number): boolean {
const now = Date.now()
const bucket = tutorBuckets.get(userId) ?? { tokens: TUTOR_RATE_LIMIT, lastRefill: now }
const elapsed = now - bucket.lastRefill
if (elapsed >= TUTOR_REFILL_MS) {
bucket.tokens = TUTOR_RATE_LIMIT
bucket.lastRefill = now
}
if (bucket.tokens <= 0) {
tutorBuckets.set(userId, bucket)
return false
}
bucket.tokens -= 1
tutorBuckets.set(userId, bucket)
return true
}
router.post(
'/tutor-chat',
validateBody(tutorSchema),
asyncHandler(async (req, res) => {
const body = req.body as z.infer<typeof tutorSchema>
const userId = req.user!.userId
if (!consumeTutorToken(userId)) {
res.status(429).json({ error: 'Tutor chat rate limit reached. Please try again later.' })
return
}
const result = getResultById(body.resultId, userId)
if (!result) {
res.status(404).json({ error: 'Result not found' })
return
}
let exam: Record<string, unknown> = {}
let state: Record<string, unknown> = {}
try {
exam = JSON.parse(result.exam_json)
} catch {
res.status(500).json({ error: 'Stored exam data is corrupted' })
return
}
try {
state = JSON.parse(result.state_json)
} catch {
state = {}
}
enforceDailyAiQuota(userId)
const reply = await tutorChat({
exam,
state,
scoreSummary: {
title: result.title,
scorePoints: result.score_points,
totalPoints: result.total_points,
correctCount: result.correct_count,
gradedCount: result.graded_count,
},
messages: body.messages,
language: body.language,
})
recordDailyAiUsage(userId, getUtcDateKey(), reply.usageTokens)
res.json(reply.data)
}),
)
export default router
+167 -236
View File
@@ -1,248 +1,179 @@
import { Router } from 'express'
import bcrypt from 'bcrypt'
import { getDb } from '../db/database.js'
import { signToken, generateVerificationToken } from '../utils/tokens.js'
import { sendVerificationEmail, sendPasswordResetEmail } from '../utils/email.js'
import rateLimit from 'express-rate-limit'
import { validateBody } from '../middleware/validate.js'
import { validateQuery } from '../middleware/validate.js'
import { requireAuth } from '../middleware/auth.js'
import { asyncHandler } from '../utils/asyncHandler.js'
import * as authService from '../services/authService.js'
import * as userRepo from '../repositories/userRepo.js'
import { hashToken, verifyToken } from '../utils/tokens.js'
import { blacklistToken } from '../utils/tokenBlacklist.js'
import { AppError } from '../middleware/error.js'
import {
registerSchema,
loginSchema,
updateProfileSchema,
changePasswordSchema,
verifyEmailSchema,
resendVerificationSchema,
resetPasswordSchema,
forgotPasswordSchema,
} from '../validators/authSchemas.js'
import type { Request } from 'express'
const router = Router()
const BCRYPT_ROUNDS = 12
const VERIFY_EXPIRY_MS = 24 * 60 * 60 * 1000 // 24 hours
const RESET_EXPIRY_MS = 60 * 60 * 1000 // 1 hour
// ─── Register ────────────────────────────────────────────
router.post('/register', async (req, res) => {
try {
const { email, password, displayName } = req.body
if (!email || !password || !displayName) {
res.status(400).json({ error: 'Email, password, and display name are required' })
return
}
if (typeof email !== 'string' || !email.includes('@') || email.length > 255) {
res.status(400).json({ error: 'Invalid email address' })
return
}
if (typeof password !== 'string' || password.length < 8) {
res.status(400).json({ error: 'Password must be at least 8 characters' })
return
}
if (typeof displayName !== 'string' || displayName.trim().length < 1 || displayName.length > 100) {
res.status(400).json({ error: 'Display name is required (max 100 characters)' })
return
}
const db = getDb()
const existing = db.prepare('SELECT id FROM users WHERE email = ?').get(email)
if (existing) {
res.status(409).json({ error: 'An account with this email already exists' })
return
}
const passwordHash = await bcrypt.hash(password, BCRYPT_ROUNDS)
const verifyToken = generateVerificationToken()
const verifyExpires = Date.now() + VERIFY_EXPIRY_MS
const result = db.prepare(`
INSERT INTO users (email, password_hash, display_name, verify_token, verify_expires)
VALUES (?, ?, ?, ?, ?)
`).run(email.toLowerCase().trim(), passwordHash, displayName.trim(), verifyToken, verifyExpires)
await sendVerificationEmail(email, verifyToken)
res.status(201).json({
message: 'Account created. Please check your email to verify your account.',
userId: result.lastInsertRowid,
})
} catch (err) {
console.error('Register error:', err)
res.status(500).json({ error: 'Internal server error' })
}
const authLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 10,
message: { error: 'Too many attempts, please try again later' },
standardHeaders: true,
skipSuccessfulRequests: false,
})
// ─── Login ───────────────────────────────────────────────
router.post('/login', async (req, res) => {
try {
const { email, password } = req.body
if (!email || !password) {
res.status(400).json({ error: 'Email and password are required' })
return
}
const db = getDb()
const user = db.prepare('SELECT * FROM users WHERE email = ?').get(
(email as string).toLowerCase().trim()
) as Record<string, unknown> | undefined
if (!user) {
res.status(401).json({ error: 'Invalid email or password' })
return
}
const valid = await bcrypt.compare(password as string, user['password_hash'] as string)
if (!valid) {
res.status(401).json({ error: 'Invalid email or password' })
return
}
if (!user['email_verified']) {
res.status(403).json({ error: 'Please verify your email before logging in' })
return
}
const token = signToken({
userId: user['id'] as number,
email: user['email'] as string,
displayName: user['display_name'] as string,
role: (user['role'] as 'user' | 'admin') ?? 'user',
})
res.json({
token,
user: {
id: user['id'],
email: user['email'],
displayName: user['display_name'],
role: user['role'] ?? 'user',
},
})
} catch (err) {
console.error('Login error:', err)
res.status(500).json({ error: 'Internal server error' })
}
const resendVerificationLimiter = rateLimit({
windowMs: 60 * 1000,
max: 1,
message: { error: 'Please wait a minute before requesting another verification email' },
standardHeaders: true,
skipSuccessfulRequests: false,
})
// ─── Verify Email ────────────────────────────────────────
router.get('/verify-email', (req, res) => {
try {
const { token } = req.query
if (!token || typeof token !== 'string') {
res.status(400).json({ error: 'Verification token is required' })
return
}
const db = getDb()
const user = db.prepare('SELECT * FROM users WHERE verify_token = ?').get(token) as Record<string, unknown> | undefined
if (!user) {
res.status(400).json({ error: 'Invalid verification token' })
return
}
if ((user['verify_expires'] as number) < Date.now()) {
res.status(400).json({ error: 'Verification token has expired. Please register again.' })
return
}
db.prepare(`
UPDATE users SET email_verified = 1, verify_token = NULL, verify_expires = NULL, updated_at = unixepoch()
WHERE id = ?
`).run(user['id'])
res.json({ message: 'Email verified successfully. You can now log in.' })
} catch (err) {
console.error('Verify error:', err)
res.status(500).json({ error: 'Internal server error' })
}
})
// ─── Forgot Password ────────────────────────────────────
router.post('/forgot-password', async (req, res) => {
try {
const { email } = req.body
// Always return success to prevent email enumeration
if (!email || typeof email !== 'string') {
res.json({ message: 'If an account exists with that email, a reset link has been sent.' })
return
}
const db = getDb()
const user = db.prepare('SELECT id, email_verified FROM users WHERE email = ?').get(
email.toLowerCase().trim()
) as Record<string, unknown> | undefined
if (user && user['email_verified']) {
const resetToken = generateVerificationToken()
const resetExpires = Date.now() + RESET_EXPIRY_MS
db.prepare(`
UPDATE users SET reset_token = ?, reset_expires = ?, updated_at = unixepoch()
WHERE id = ?
`).run(resetToken, resetExpires, user['id'])
await sendPasswordResetEmail(email, resetToken)
}
res.json({ message: 'If an account exists with that email, a reset link has been sent.' })
} catch (err) {
console.error('Forgot password error:', err)
res.status(500).json({ error: 'Internal server error' })
}
})
// ─── Reset Password ─────────────────────────────────────
router.post('/reset-password', async (req, res) => {
try {
const { token, newPassword } = req.body
if (!token || typeof token !== 'string') {
res.status(400).json({ error: 'Reset token is required' })
return
}
if (!newPassword || typeof newPassword !== 'string' || newPassword.length < 8) {
res.status(400).json({ error: 'New password must be at least 8 characters' })
return
}
const db = getDb()
const user = db.prepare('SELECT * FROM users WHERE reset_token = ?').get(token) as Record<string, unknown> | undefined
if (!user) {
res.status(400).json({ error: 'Invalid reset token' })
return
}
if ((user['reset_expires'] as number) < Date.now()) {
res.status(400).json({ error: 'Reset token has expired. Please request a new one.' })
return
}
const passwordHash = await bcrypt.hash(newPassword, BCRYPT_ROUNDS)
db.prepare(`
UPDATE users SET password_hash = ?, reset_token = NULL, reset_expires = NULL, updated_at = unixepoch()
WHERE id = ?
`).run(passwordHash, user['id'])
res.json({ message: 'Password updated successfully. You can now log in with your new password.' })
} catch (err) {
console.error('Reset password error:', err)
res.status(500).json({ error: 'Internal server error' })
}
})
// ─── Get Current User ────────────────────────────────────
router.get('/me', requireAuth, (req, res) => {
const db = getDb()
const user = db.prepare('SELECT id, email, display_name, role, created_at FROM users WHERE id = ?').get(
req.user!.userId
) as Record<string, unknown> | undefined
if (!user) {
res.status(404).json({ error: 'User not found' })
return
}
res.json({
id: user['id'],
email: user['email'],
displayName: user['display_name'],
role: user['role'] ?? 'user',
createdAt: user['created_at'],
// POST /api/auth/register
router.post(
'/register',
authLimiter,
validateBody(registerSchema),
asyncHandler(async (req, res) => {
const result = await authService.register(req.body)
res.json(result)
})
})
)
// POST /api/auth/login
router.post(
'/login',
authLimiter,
validateBody(loginSchema),
asyncHandler(async (req, res) => {
const result = await authService.login(req.body)
res.json(result)
})
)
// GET /api/auth/me
router.get(
'/me',
requireAuth,
asyncHandler(async (req: Request, res) => {
const user = authService.getMe(req.user!.userId)
res.json(user)
})
)
// PUT /api/auth/me
router.put(
'/me',
requireAuth,
validateBody(updateProfileSchema),
asyncHandler(async (req: Request, res) => {
const user = await authService.updateProfile(req.user!.userId, req.body)
res.json(user)
})
)
// POST /api/auth/change-password
router.post(
'/change-password',
requireAuth,
validateBody(changePasswordSchema),
asyncHandler(async (req: Request, res) => {
await authService.changePassword(req.user!.userId, req.body)
res.json({ success: true })
})
)
// GET /api/auth/verify
router.get(
'/verify',
validateQuery(verifyEmailSchema),
asyncHandler(async (req, res) => {
await authService.verifyEmail(req.query as { token: string })
res.json({ message: 'Email verified successfully' })
})
)
// POST /api/auth/resend-verification
router.post(
'/resend-verification',
resendVerificationLimiter,
validateBody(resendVerificationSchema),
asyncHandler(async (req, res) => {
await authService.resendVerification(req.body.email)
res.json({ message: 'If the account exists, a new verification link has been sent' })
})
)
// POST /api/auth/forgot-password
router.post(
'/forgot-password',
authLimiter,
validateBody(forgotPasswordSchema),
asyncHandler(async (req, res) => {
await authService.forgotPassword(req.body.email)
res.json({ message: 'If an account exists, a reset email has been sent' })
})
)
// POST /api/auth/reset-password
router.post(
'/reset-password',
authLimiter,
validateBody(resetPasswordSchema),
asyncHandler(async (req, res) => {
await authService.resetPassword(req.body)
res.json({ message: 'Password reset successfully' })
})
)
// POST /api/auth/logout
router.post(
'/logout',
requireAuth,
asyncHandler(async (req: Request, res) => {
const header = req.headers.authorization
if (header?.startsWith('Bearer ')) {
const token = header.slice(7)
const tokenHash = hashToken(token)
// Blacklist until the token's real expiry so it cannot be replayed even
// after the in-memory blacklist is reloaded from SQLite on restart
try {
const decoded = verifyToken(token)
const expiresAt =
typeof decoded.exp === 'number' && decoded.exp * 1000 > Date.now()
? decoded.exp * 1000
: Date.now() + 60 * 1000
blacklistToken(tokenHash, expiresAt)
} catch {
// Token already failed verification — nothing worth blacklisting
}
}
res.json({ success: true })
})
)
// DELETE /api/auth/me
router.delete(
'/me',
requireAuth,
asyncHandler(async (req: Request, res) => {
const user = userRepo.getUserById(req.user!.userId)
if (user?.role === 'admin' && userRepo.countAdmins() <= 1) {
throw new AppError(400, 'Cannot delete the last admin account')
}
authService.deleteUserAccount(req.user!.userId)
res.json({ success: true })
})
)
export default router
+97
View File
@@ -0,0 +1,97 @@
import { Router } from 'express'
import { validateBody, validateParams } from '../middleware/validate.js'
import { asyncHandler } from '../utils/asyncHandler.js'
import { requireAuth } from '../middleware/auth.js'
import { AppError } from '../middleware/error.js'
import * as collectionRepo from '../repositories/collectionRepo.js'
import * as examRepo from '../repositories/examRepo.js'
import {
createCollectionSchema,
updateCollectionSchema,
collectionIdParamSchema,
moveExamSchema,
} from '../validators/collectionSchemas.js'
import { examIdParamSchema } from '../validators/examSchemas.js'
import type { Request } from 'express'
const router = Router()
router.use(requireAuth)
// GET /api/collections
router.get(
'/',
asyncHandler(async (req: Request, res) => {
const collections = collectionRepo.listCollectionsByUser(req.user!.userId)
res.json(collections)
})
)
// POST /api/collections
router.post(
'/',
validateBody(createCollectionSchema),
asyncHandler(async (req: Request, res) => {
const { id } = collectionRepo.createCollection(req.user!.userId, req.body.name)
res.status(201).json({ id, name: req.body.name })
})
)
// PUT /api/collections/:id
router.put(
'/:id',
validateParams(collectionIdParamSchema),
validateBody(updateCollectionSchema),
asyncHandler(async (req: Request, res) => {
const id = Number(req.params.id)
const updated = collectionRepo.updateCollection(id, req.user!.userId, req.body.name)
if (!updated) {
throw new AppError(404, 'Collection not found')
}
res.json({ success: true })
})
)
// DELETE /api/collections/:id
router.delete(
'/:id',
validateParams(collectionIdParamSchema),
asyncHandler(async (req: Request, res) => {
const id = Number(req.params.id)
const deleted = collectionRepo.deleteCollection(id, req.user!.userId)
if (!deleted) {
throw new AppError(404, 'Collection not found')
}
res.json({ success: true })
})
)
// POST /api/collections/move/:examId
router.post(
'/move/:examId',
validateParams(examIdParamSchema),
validateBody(moveExamSchema),
asyncHandler(async (req: Request, res) => {
const examId = Number(req.params.examId)
const collectionId = req.body.collectionId ?? null
// Verify exam belongs to user
const exam = examRepo.getExamById(examId, req.user!.userId)
if (!exam) {
throw new AppError(404, 'Exam not found')
}
// If collectionId provided, verify it belongs to user
if (collectionId !== null) {
const collection = collectionRepo.getCollectionById(collectionId, req.user!.userId)
if (!collection) {
throw new AppError(404, 'Collection not found')
}
}
examRepo.setExamCollection(examId, req.user!.userId, collectionId)
res.json({ success: true })
})
)
export default router
+203 -82
View File
@@ -1,99 +1,220 @@
import { Router } from 'express'
import { getDb } from '../db/database.js'
import { validateBody, validateParams } from '../middleware/validate.js'
import { asyncHandler } from '../utils/asyncHandler.js'
import { requireAuth } from '../middleware/auth.js'
import { AppError } from '../middleware/error.js'
import * as examRepo from '../repositories/examRepo.js'
import { saveExamSchema, updateExamSchema, examIdParamSchema, favoriteSchema, shareModeSchema, batchDeleteSchema } from '../validators/examSchemas.js'
import { sanitizeExam } from '../utils/sanitizeExam.js'
import type { Request } from 'express'
const router = Router()
// ─── List User's Saved Exams ─────────────────────────────
router.get('/', requireAuth, (req, res) => {
try {
const db = getDb()
const exams = db.prepare(`
SELECT id, title, subtitle, created_at, updated_at,
json_array_length(json_extract(exam_json, '$.questions')) AS question_count
FROM saved_exams
WHERE user_id = ?
ORDER BY updated_at DESC
`).all(req.user!.userId)
// All exam routes require authentication
router.use(requireAuth)
// GET /api/exams
router.get(
'/',
asyncHandler(async (req: Request, res) => {
const exams = examRepo.listExamsByUser(req.user!.userId)
res.json(exams)
} catch (err) {
console.error('List exams error:', err)
res.status(500).json({ error: 'Internal server error' })
}
})
// ─── Get Full Exam ───────────────────────────────────────
router.get('/:id', requireAuth, (req, res) => {
try {
const db = getDb()
const exam = db.prepare(`
SELECT * FROM saved_exams WHERE id = ? AND user_id = ?
`).get(Number(req.params['id']), req.user!.userId) as Record<string, unknown> | undefined
})
)
// GET /api/exams/:id
router.get(
'/:id',
validateParams(examIdParamSchema),
asyncHandler(async (req: Request, res) => {
const id = Number(req.params.id)
const exam = examRepo.getExamById(id, req.user!.userId)
if (!exam) {
res.status(404).json({ error: 'Exam not found' })
return
throw new AppError(404, 'Exam not found')
}
res.json({
id: exam['id'],
title: exam['title'],
subtitle: exam['subtitle'],
exam: JSON.parse(exam['exam_json'] as string),
createdAt: exam['created_at'],
updatedAt: exam['updated_at'],
})
} catch (err) {
console.error('Get exam error:', err)
res.status(500).json({ error: 'Internal server error' })
}
})
// ─── Save Exam ───────────────────────────────────────────
router.post('/', requireAuth, (req, res) => {
try {
const { exam } = req.body
if (!exam || !exam.title || !exam.questions?.length) {
res.status(400).json({ error: 'Valid exam data with title and questions is required' })
return
const parsedExam = JSON.parse(exam.exam_json)
let tags: string[] = []
try {
tags = JSON.parse(exam.tags ?? '[]')
if (!Array.isArray(tags)) tags = []
} catch {
tags = []
}
res.json({ ...parsedExam, id: exam.id, tags })
})
)
const db = getDb()
const result = db.prepare(`
INSERT INTO saved_exams (user_id, title, subtitle, exam_json)
VALUES (?, ?, ?, ?)
`).run(req.user!.userId, exam.title, exam.subtitle ?? null, JSON.stringify(exam))
// POST /api/exams
router.post(
'/',
validateBody(saveExamSchema),
asyncHandler(async (req: Request, res) => {
const exam = sanitizeExam(req.body.exam) as typeof req.body.exam
const tags = Array.isArray(req.body.tags) ? req.body.tags : []
const { id } = examRepo.createExam(
req.user!.userId,
exam.title,
exam.subtitle ?? null,
JSON.stringify(exam),
JSON.stringify(tags)
)
res.status(201).json({ id, title: exam.title })
})
)
res.status(201).json({
id: result.lastInsertRowid,
message: 'Exam saved successfully',
})
} catch (err) {
console.error('Save exam error:', err)
res.status(500).json({ error: 'Internal server error' })
}
})
// ─── Delete Exam ─────────────────────────────────────────
router.delete('/:id', requireAuth, (req, res) => {
try {
const db = getDb()
const result = db.prepare(`
DELETE FROM saved_exams WHERE id = ? AND user_id = ?
`).run(Number(req.params['id']), req.user!.userId)
if (result.changes === 0) {
res.status(404).json({ error: 'Exam not found' })
return
// PUT /api/exams/:id
router.put(
'/:id',
validateParams(examIdParamSchema),
validateBody(updateExamSchema),
asyncHandler(async (req: Request, res) => {
const id = Number(req.params.id)
const exam = sanitizeExam(req.body.exam) as typeof req.body.exam
const tags = Array.isArray(req.body.tags) ? JSON.stringify(req.body.tags) : null
const updated = examRepo.updateExam(
id,
req.user!.userId,
exam.title,
exam.subtitle ?? null,
JSON.stringify(exam),
tags
)
if (!updated) {
throw new AppError(404, 'Exam not found')
}
res.json({ success: true })
})
)
res.json({ message: 'Exam deleted' })
} catch (err) {
console.error('Delete exam error:', err)
res.status(500).json({ error: 'Internal server error' })
}
})
// DELETE /api/exams/:id
router.delete(
'/:id',
validateParams(examIdParamSchema),
asyncHandler(async (req: Request, res) => {
const id = Number(req.params.id)
const deleted = examRepo.deleteExam(id, req.user!.userId)
if (!deleted) {
throw new AppError(404, 'Exam not found')
}
res.json({ success: true })
})
)
// POST /api/exams/:id/share
router.post(
'/:id/share',
validateParams(examIdParamSchema),
asyncHandler(async (req: Request, res) => {
const id = Number(req.params.id)
const exam = examRepo.getExamById(id, req.user!.userId)
if (!exam) {
throw new AppError(404, 'Exam not found')
}
const token = exam.share_token ?? crypto.randomUUID().replace(/-/g, '')
const mode = exam.share_mode ?? 'clone'
examRepo.setShareToken(id, req.user!.userId, token, mode)
res.json({ shareToken: token, mode })
})
)
// PUT /api/exams/:id/share-mode
router.put(
'/:id/share-mode',
validateParams(examIdParamSchema),
validateBody(shareModeSchema),
asyncHandler(async (req: Request, res) => {
const id = Number(req.params.id)
const updated = examRepo.setShareMode(id, req.user!.userId, req.body.mode)
if (!updated) {
throw new AppError(404, 'Exam not found')
}
res.json({ success: true })
})
)
// GET /api/exams/cloned
router.get(
'/cloned',
asyncHandler(async (req: Request, res) => {
const exams = examRepo.listClonedExams(req.user!.userId)
res.json(exams)
})
)
// POST /api/exams/batch-delete
router.post(
'/batch-delete',
validateBody(batchDeleteSchema),
asyncHandler(async (req: Request, res) => {
const ids: number[] = req.body.ids
let deleted = 0
for (const id of ids) {
if (examRepo.deleteExam(id, req.user!.userId)) {
deleted++
}
}
res.json({ deleted })
})
)
// POST /api/exams/:id/unshare
router.post(
'/:id/unshare',
validateParams(examIdParamSchema),
asyncHandler(async (req: Request, res) => {
const id = Number(req.params.id)
const cleared = examRepo.clearShareToken(id, req.user!.userId)
if (!cleared) {
throw new AppError(404, 'Exam not found')
}
res.json({ success: true })
})
)
// PATCH /api/exams/:id/favorite
router.patch(
'/:id/favorite',
validateParams(examIdParamSchema),
validateBody(favoriteSchema),
asyncHandler(async (req: Request, res) => {
const id = Number(req.params.id)
const updated = examRepo.setFavorite(id, req.user!.userId, req.body.isFavorite)
if (!updated) {
throw new AppError(404, 'Exam not found')
}
res.json({ success: true })
})
)
// GET /api/exams/:id/export
router.get(
'/:id/export',
validateParams(examIdParamSchema),
asyncHandler(async (req: Request, res) => {
const id = Number(req.params.id)
const raw = examRepo.getExamRawJson(id, req.user!.userId)
if (!raw) {
throw new AppError(404, 'Exam not found')
}
res.setHeader('Content-Type', 'application/json')
res.setHeader('Content-Disposition', `attachment; filename="${encodeURIComponent(raw.title)}.json"`)
res.send(raw.exam_json)
})
)
// POST /api/exams/:id/duplicate
router.post(
'/:id/duplicate',
validateParams(examIdParamSchema),
asyncHandler(async (req: Request, res) => {
const id = Number(req.params.id)
const exam = examRepo.getExamById(id, req.user!.userId)
if (!exam) {
throw new AppError(404, 'Exam not found')
}
const { id: newId } = examRepo.duplicateOwnExam(req.user!.userId, exam)
res.status(201).json({ id: newId, title: exam.title + ' (copy)' })
})
)
export default router
+26
View File
@@ -0,0 +1,26 @@
import { Router } from 'express'
import { validateBody } from '../middleware/validate.js'
import { asyncHandler } from '../utils/asyncHandler.js'
import { requireAuth } from '../middleware/auth.js'
import { gradeQuestionWithAI } from '../services/aiGrading.js'
import { enforceDailyAiQuota, recordDailyAiUsage, getUtcDateKey } from '../utils/aiQuota.js'
import { gradeRequestSchema, type GradeRequestBody } from '../validators/gradeSchemas.js'
const router = Router()
router.use(requireAuth)
router.post(
'/',
validateBody(gradeRequestSchema),
asyncHandler(async (req, res) => {
const { type, question, answer, language } = req.body as GradeRequestBody
const userId = req.user!.userId
enforceDailyAiQuota(userId)
const result = await gradeQuestionWithAI({ type, question, answer, language })
recordDailyAiUsage(userId, getUtcDateKey(), result.usageTokens)
res.json(result.data)
}),
)
export default router
+114 -120
View File
@@ -1,139 +1,133 @@
import { Router } from 'express'
import { getDb } from '../db/database.js'
import { validateBody, validateParams } from '../middleware/validate.js'
import { asyncHandler } from '../utils/asyncHandler.js'
import { requireAuth } from '../middleware/auth.js'
import { AppError } from '../middleware/error.js'
import * as resultRepo from '../repositories/resultRepo.js'
import * as examRepo from '../repositories/examRepo.js'
import { submitResultSchema, resultIdParamSchema } from '../validators/resultSchemas.js'
import { analyzeResults } from '../services/studyCoach.js'
import { enforceDailyAiQuota, recordDailyAiUsage, getUtcDateKey } from '../utils/aiQuota.js'
import type { Request } from 'express'
const router = Router()
// ─── List User's Results ─────────────────────────────────
router.get('/', requireAuth, (req, res) => {
try {
const limit = Math.min(Number(req.query['limit']) || 30, 100)
const offset = Number(req.query['offset']) || 0
router.use(requireAuth)
const db = getDb()
const results = db.prepare(`
SELECT id, title, score_points, total_points, correct_count, graded_count,
passing_score, elapsed_ms, completed_at, exam_id
FROM exam_results
WHERE user_id = ?
ORDER BY completed_at DESC
LIMIT ? OFFSET ?
`).all(req.user!.userId, limit, offset)
const countRow = db.prepare(
'SELECT COUNT(*) AS total FROM exam_results WHERE user_id = ?'
).get(req.user!.userId) as { total: number }
res.json({ results, total: countRow.total })
} catch (err) {
console.error('List results error:', err)
res.status(500).json({ error: 'Internal server error' })
}
})
// ─── Get Single Result ───────────────────────────────────
router.get('/:id', requireAuth, (req, res) => {
try {
const db = getDb()
const result = db.prepare(`
SELECT * FROM exam_results WHERE id = ? AND user_id = ?
`).get(Number(req.params['id']), req.user!.userId) as Record<string, unknown> | undefined
// GET /api/results
router.get(
'/',
asyncHandler(async (req: Request, res) => {
const { results, total } = resultRepo.listResultsByUser(req.user!.userId)
res.json({ results, total })
})
)
// GET /api/results/:id
router.get(
'/:id',
validateParams(resultIdParamSchema),
asyncHandler(async (req: Request, res) => {
const id = Number(req.params.id)
const result = resultRepo.getResultById(id, req.user!.userId)
if (!result) {
res.status(404).json({ error: 'Result not found' })
return
throw new AppError(404, 'Result not found')
}
res.json({
id: result['id'],
title: result['title'],
scorePoints: result['score_points'],
totalPoints: result['total_points'],
correctCount: result['correct_count'],
gradedCount: result['graded_count'],
passingScore: result['passing_score'],
elapsedMs: result['elapsed_ms'],
completedAt: result['completed_at'],
examId: result['exam_id'],
state: JSON.parse(result['state_json'] as string),
exam: JSON.parse(result['exam_json'] as string),
...result,
state: JSON.parse(result.state_json),
exam: JSON.parse(result.exam_json),
state_json: undefined,
exam_json: undefined,
})
} catch (err) {
console.error('Get result error:', err)
res.status(500).json({ error: 'Internal server error' })
}
})
})
)
// ─── Save Result ─────────────────────────────────────────
router.post('/', requireAuth, (req, res) => {
try {
const { title, scorePoints, totalPoints, correctCount, gradedCount, passingScore, elapsedMs, notes, state, exam, examId } = req.body
if (
!title || scorePoints == null || totalPoints == null ||
correctCount == null || gradedCount == null || !state || !exam
) {
res.status(400).json({ error: 'Missing required result fields' })
return
}
let noteSource = ''
if (typeof notes === 'string') {
noteSource = notes
} else if (typeof state.notes === 'string') {
noteSource = state.notes
}
const normalizedState = {
...state,
notes: noteSource.slice(0, 1000),
}
const db = getDb()
const result = db.prepare(`
INSERT INTO exam_results (user_id, exam_id, title, score_points, total_points, correct_count, graded_count, passing_score, elapsed_ms, state_json, exam_json)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(
req.user!.userId,
examId ?? null,
title,
scorePoints,
totalPoints,
correctCount,
gradedCount,
passingScore ?? 60,
elapsedMs ?? 0,
JSON.stringify(normalizedState),
JSON.stringify(exam),
)
res.status(201).json({
id: result.lastInsertRowid,
message: 'Result saved successfully',
// POST /api/results
router.post(
'/',
validateBody(submitResultSchema),
asyncHandler(async (req: Request, res) => {
const data = req.body
const { id } = resultRepo.createResult({
user_id: req.user!.userId,
exam_id: data.exam_id ?? null,
title: data.title,
score_points: data.score_points,
total_points: data.total_points,
correct_count: data.correct_count,
graded_count: data.graded_count,
passing_score: data.passing_score ?? 60,
elapsed_ms: data.elapsed_ms,
state_json: JSON.stringify(data.state),
exam_json: JSON.stringify(data.exam),
completed_at: Date.now(),
})
} catch (err) {
console.error('Save result error:', err)
res.status(500).json({ error: 'Internal server error' })
}
})
res.status(201).json({ id })
})
)
// ─── Delete Result ───────────────────────────────────────
router.delete('/:id', requireAuth, (req, res) => {
try {
const db = getDb()
const result = db.prepare(`
DELETE FROM exam_results WHERE id = ? AND user_id = ?
`).run(Number(req.params['id']), req.user!.userId)
// DELETE /api/results/:id
router.delete(
'/:id',
validateParams(resultIdParamSchema),
asyncHandler(async (req: Request, res) => {
const id = Number(req.params.id)
const deleted = resultRepo.deleteResult(id, req.user!.userId)
if (!deleted) {
throw new AppError(404, 'Result not found')
}
res.json({ success: true })
})
)
if (result.changes === 0) {
res.status(404).json({ error: 'Result not found' })
return
// GET /api/results/analyze
router.get(
'/analyze',
asyncHandler(async (req: Request, res) => {
const userId = req.user!.userId
const rawResults = resultRepo.getResultsWithExamJson(userId, 200)
const lang = typeof req.query.lang === 'string' ? req.query.lang : 'en'
enforceDailyAiQuota(userId)
const analysis = await analyzeResults(rawResults, lang)
recordDailyAiUsage(userId, getUtcDateKey(), analysis.usageTokens)
// Find weakest exam (lowest score that still exists in workspace)
let weakestExamId: number | null = null
if (rawResults.length > 0) {
const sorted = [...rawResults].sort((a, b) => {
const pa = a.total_points > 0 ? a.score_points / a.total_points : 0
const pb = b.total_points > 0 ? b.score_points / b.total_points : 0
return pa - pb
})
for (const r of sorted) {
if (r.exam_id) {
const exam = examRepo.getExamById(r.exam_id, req.user!.userId)
if (exam) {
weakestExamId = r.exam_id
break
}
}
}
}
res.json({ message: 'Result deleted' })
} catch (err) {
console.error('Delete result error:', err)
res.status(500).json({ error: 'Internal server error' })
}
})
res.json({ ...analysis.data, weakestExamId })
})
)
// GET /api/results/trends
router.get(
'/trends',
asyncHandler(async (req: Request, res) => {
const raw = resultRepo.getScoreTrends(req.user!.userId, 30)
const trends = raw.map((r) => ({
completed_at: r.completed_at,
score_pct: r.total_points > 0 ? Math.round((r.score_points / r.total_points) * 100) : 0,
title: r.title,
exam_id: r.exam_id,
}))
res.json(trends)
})
)
export default router
+117
View File
@@ -0,0 +1,117 @@
import { Router } from 'express'
import bcrypt from 'bcrypt'
import { validateBody } from '../middleware/validate.js'
import { asyncHandler } from '../utils/asyncHandler.js'
import { AppError } from '../middleware/error.js'
import { getDb } from '../db/database.js'
import * as userRepo from '../repositories/userRepo.js'
import * as settingsRepo from '../repositories/settingsRepo.js'
import { setupSchema } from '../validators/setupSchemas.js'
import { signToken } from '../utils/tokens.js'
const router = Router()
const SALT_ROUNDS = 10
function getSiteTitle(): string | null {
return settingsRepo.getSetting('site_name') ?? null
}
// GET /api/setup/status
router.get(
'/status',
asyncHandler(async (_req, res) => {
const title = getSiteTitle()
// Setup is complete once an admin account exists — not merely because
// default settings were seeded
const completed = userRepo.countAdmins() > 0
res.json({
completed,
siteTitle: title ?? undefined,
})
})
)
// POST /api/setup
router.post(
'/',
validateBody(setupSchema),
asyncHandler(async (req, res) => {
const db = getDb()
// Atomic transaction prevents race conditions between concurrent setup requests
const tx = db.transaction(() => {
// Gate on admins, not on users or settings: a fresh instance has default
// settings seeded, and an instance that lost its admin can re-bootstrap
if (userRepo.countAdmins() > 0) {
throw new AppError(400, 'Setup already completed')
}
const {
email,
password,
displayName,
siteTitle,
language,
smtpHost,
smtpPort,
smtpUser,
smtpPass,
smtpFrom,
smtpSecure,
appUrl,
} = req.body
const passwordHash = bcrypt.hashSync(password, SALT_ROUNDS)
const { id } = userRepo.createUser({
email,
passwordHash,
displayName,
role: 'admin',
emailVerified: 1,
})
const settings: Array<[string, string]> = [
['site_name', siteTitle?.trim() || 'BrainWave'],
['site_language', language || 'en'],
['maintenance_mode', 'false'],
]
if (smtpHost !== undefined) settings.push(['smtp_host', smtpHost])
if (smtpPort !== undefined) settings.push(['smtp_port', smtpPort])
if (smtpUser !== undefined) settings.push(['smtp_user', smtpUser])
if (smtpPass !== undefined) settings.push(['smtp_pass', smtpPass])
if (smtpFrom !== undefined) settings.push(['smtp_from', smtpFrom])
if (smtpSecure !== undefined) settings.push(['smtp_secure', smtpSecure])
if (appUrl !== undefined) settings.push(['app_url', appUrl])
settingsRepo.setSettingsBatch(settings)
return { id, email, displayName }
})
const { id, email, displayName } = tx()
const token = signToken({
userId: id,
email,
displayName,
role: 'admin',
tv: 0,
})
res.status(201).json({
message: 'Setup completed successfully',
token,
user: {
id,
email,
displayName,
role: 'admin',
},
})
})
)
export default router
+14
View File
@@ -0,0 +1,14 @@
import { describe, it, expect } from 'vitest'
import { canClone } from './share.js'
describe('canClone', () => {
it('rejects view-only shares', () => {
expect(canClone('view')).toBe(false)
})
it('allows clone-mode shares and legacy rows without a mode', () => {
expect(canClone('clone')).toBe(true)
expect(canClone(null)).toBe(true)
expect(canClone(undefined)).toBe(true)
})
})
+54
View File
@@ -0,0 +1,54 @@
import { Router } from 'express'
import { asyncHandler } from '../utils/asyncHandler.js'
import { AppError } from '../middleware/error.js'
import { requireAuth } from '../middleware/auth.js'
import * as examRepo from '../repositories/examRepo.js'
import type { Request } from 'express'
const router = Router()
/** Server-side enforcement of the share mode: 'view' shares must not be
* clonable (the frontend hides the button, but that is not a control). */
export function canClone(shareMode: string | null | undefined): boolean {
return shareMode !== 'view'
}
// GET /api/share/:token — public, no auth required
router.get(
'/:token',
asyncHandler(async (req: Request, res) => {
const token = String(req.params.token)
const exam = examRepo.getExamByShareToken(token)
if (!exam) {
throw new AppError(404, 'Shared exam not found')
}
const parsed = JSON.parse(exam.exam_json)
res.json({
title: exam.title,
subtitle: exam.subtitle,
questionCount: JSON.parse(exam.exam_json).questions?.length ?? 0,
exam: parsed,
shareMode: exam.share_mode ?? 'clone',
})
})
)
// POST /api/share/:token/clone — requires auth
router.post(
'/:token/clone',
requireAuth,
asyncHandler(async (req: Request, res) => {
const token = String(req.params.token)
const source = examRepo.getExamByShareToken(token)
if (!source) {
throw new AppError(404, 'Shared exam not found')
}
if (!canClone(source.share_mode)) {
throw new AppError(403, 'This exam is shared as view-only and cannot be saved')
}
const { id } = examRepo.cloneExam(req.user!.userId, source, token)
res.status(201).json({ id, title: source.title + ' (copy)' })
})
)
export default router
+340
View File
@@ -0,0 +1,340 @@
import { z } from 'zod'
import { getAIClient, getAIModelForTask } from './aiClient.js'
import { AiUsageResult, estimateTokensFromMessages, estimateTokensFromText, estimateTotalTokens, resolveUsageTokens } from '../utils/aiTokens.js'
import type { ChatCompletionMessageParam } from 'openai/resources/index.js'
export type QuestionType =
| 'multiple-choice'
| 'multiple-select'
| 'short-answer'
| 'translation'
| 'math-input'
| 'essay'
| 'match'
| 'order'
| 'fill-in-the-blanks'
| 'true-false'
| 'code-submission'
| 'dropdown-completion'
| 'numeric-range'
| 'dictation'
| 'grammar-correction'
| 'flashcard'
const choiceSchema = z.object({
id: z.string().min(1),
text: z.string(),
})
const matchItemSchema = z.object({
id: z.string().min(1),
text: z.string(),
})
const generatedQuestionSchema = z.object({
type: z.enum([
'multiple-choice',
'multiple-select',
'short-answer',
'translation',
'math-input',
'essay',
'match',
'order',
'fill-in-the-blanks',
'true-false',
'code-submission',
'dropdown-completion',
'numeric-range',
'dictation',
'grammar-correction',
'flashcard',
]),
prompt: z.string().min(1),
category: z.string().optional(),
points: z.number().int().positive().optional(),
explanation: z.string().optional(),
answer: z.union([z.string(), z.array(z.string()), z.record(z.string())]).optional(),
acceptedForms: z.array(z.string()).optional(),
caseSensitive: z.boolean().optional(),
sourceLanguage: z.string().optional(),
targetLanguage: z.string().optional(),
choices: z.array(choiceSchema).optional(),
minWords: z.number().int().nonnegative().optional(),
rubric: z.array(z.string()).optional(),
left: z.array(matchItemSchema).optional(),
right: z.array(matchItemSchema).optional(),
items: z.array(matchItemSchema).optional(),
textSegments: z.array(z.string()).optional(),
blanks: z.array(
z.object({
id: z.string().min(1),
acceptedAnswers: z.array(z.string()),
acceptedForms: z.array(z.string()).optional(),
caseSensitive: z.boolean().optional(),
ignorePunctuation: z.boolean().optional(),
ignoreAccents: z.boolean().optional(),
}),
).optional(),
dropdowns: z.array(
z.object({
id: z.string().min(1),
options: z.array(choiceSchema),
correctOptionId: z.string(),
}),
).optional(),
codeConfig: z.object({
language: z.string(),
starterCode: z.string().optional(),
}).optional(),
numericConfig: z.object({
answer: z.number(),
tolerance: z.number(),
min: z.number().optional(),
max: z.number().optional(),
}).optional(),
textToCorrect: z.string().optional(),
})
export type GeneratedQuestion = z.infer<typeof generatedQuestionSchema>
export interface GenerateQuestionsRequest {
topic: string
type: QuestionType
count: number
language: string
context?: string
}
function shapeHintForType(type: QuestionType): string {
switch (type) {
case 'multiple-choice':
return `Required fields: prompt (string), choices (array of {id, text}; ids should be short like "a","b","c","d"; provide 3-4 plausible distractors), answer (string id of the correct choice).`
case 'multiple-select':
return `Required fields: prompt (string), choices (array of {id, text}; ids should be short like "a","b","c","d"), answer (array of correct choice ids).`
case 'short-answer':
return `Required fields: prompt (string), answer (string), acceptedForms (array of acceptable alternate strings, may be empty), caseSensitive (boolean, usually false).`
case 'translation':
return `Required fields: prompt (the source text), sourceLanguage (e.g. "en"), targetLanguage (e.g. "es"), answer (the canonical translation), acceptedForms (other valid translations).`
case 'math-input':
return `Required fields: prompt (string, may include LaTeX), answer (LaTeX string), acceptedForms (array of equivalent LaTeX forms).`
case 'essay':
return `Required fields: prompt (string), minWords (integer, e.g. 100), rubric (array of 3-5 short rubric criteria strings).`
case 'match':
return `Required fields: prompt (string), left (array of {id, text} 3-5 items), right (array of {id, text} 3-5 items), answer (object mapping left.id -> right.id).`
case 'order':
return `Required fields: prompt (string), items (array of {id, text} 3-6 items in any order), answer (array of item ids in the correct order).`
case 'fill-in-the-blanks':
return `Required fields: prompt (string), textSegments (array of text strings to interleave with blanks), blanks (array of {id, acceptedAnswers} where acceptedAnswers is an array of correct strings for that blank). The number of textSegments must be blanks.length + 1.`
case 'true-false':
return `Required fields: prompt (string), answer (boolean, true or false).`
case 'code-submission':
return `Required fields: prompt (string), codeConfig ({language: string, starterCode?: string}), rubric (array of 3-5 short rubric criteria strings).`
case 'dropdown-completion':
return `Required fields: prompt (string), textSegments (array of text strings to interleave with dropdowns), dropdowns (array of {id, options: [{id, text}], correctOptionId}). The number of textSegments must be dropdowns.length + 1.`
case 'numeric-range':
return `Required fields: prompt (string), numericConfig ({answer: number, tolerance: number, min?: number, max?: number}), answer (same number as numericConfig.answer).`
case 'dictation':
return `Required fields: prompt (string), audio ({src: string, a URL to an audio file}), answer (the correct text), acceptedForms (array of acceptable alternate strings), caseSensitive (boolean, usually false).`
case 'grammar-correction':
return `Required fields: prompt (string), textToCorrect (the sentence with errors to fix), answer (the corrected sentence), acceptedForms (array of acceptable corrected forms), caseSensitive (boolean, usually false).`
case 'flashcard':
return `Required fields: prompt (the front of the card, e.g. a word or phrase), answer (the back of the card, e.g. translation or definition), explanation (optional, extra info to show after reveal).`
}
}
export async function generateQuestions(req: GenerateQuestionsRequest): Promise<AiUsageResult<GeneratedQuestion[]>> {
const client = getAIClient()
if (!client) {
throw new Error('AI is not configured. Please set provider and API key in admin settings.')
}
const count = Math.max(1, Math.min(10, Math.floor(req.count)))
const model = getAIModelForTask('question_generation')
const shape = shapeHintForType(req.type)
const prompt = `You are an exam author. Generate ${count} high-quality "${req.type}" exam question(s) about the following topic.
Topic: ${req.topic}
${req.context ? `\nAdditional context / source material:\n${req.context}\n` : ''}
Write all user-facing text (prompts, choices, rubric items, explanations) in ${req.language}.
${shape}
Respond ONLY with a JSON object of the form:
{ "questions": [ ... ${count} question object(s) ... ] }
Each question object must have "type": "${req.type}" and the fields listed above. Do not include "id" — that will be assigned later. Do not include any text outside the JSON object.`
const promptTokens = estimateTokensFromText(prompt)
const estimatedTokens = estimateTotalTokens(promptTokens, 1800)
const completion = await client.chat.completions.create({
model,
messages: [{ role: 'user', content: prompt }],
response_format: { type: 'json_object' },
temperature: 0.6,
max_completion_tokens: 1800,
})
const usageTokens = resolveUsageTokens(completion.usage, estimatedTokens)
const raw = completion.choices[0]?.message?.content
if (!raw) {
throw new Error('Empty response from AI provider')
}
let parsed: unknown
try {
parsed = JSON.parse(raw)
} catch {
throw new Error('Invalid JSON response from AI provider')
}
const obj = parsed as { questions?: unknown }
if (!Array.isArray(obj.questions)) {
throw new Error('AI response did not include a "questions" array')
}
const validated: GeneratedQuestion[] = []
for (const q of obj.questions) {
const result = generatedQuestionSchema.safeParse(q)
if (result.success && result.data.type === req.type) {
validated.push(result.data)
}
}
if (validated.length === 0) {
throw new Error('AI returned no valid questions for the requested type')
}
return { data: validated, usageTokens }
}
export interface HintRequest {
question: Record<string, unknown>
language: string
}
const HINT_STRIP_KEYS = ['answer', 'acceptedForms']
function stripAnswerFields(question: Record<string, unknown>): Record<string, unknown> {
const clone: Record<string, unknown> = {}
for (const [k, v] of Object.entries(question)) {
if (HINT_STRIP_KEYS.includes(k)) continue
clone[k] = v
}
return clone
}
export async function getHint(req: HintRequest): Promise<AiUsageResult<{ hint: string }>> {
const client = getAIClient()
if (!client) {
throw new Error('AI is not configured. Please set provider and API key in admin settings.')
}
const safeQuestion = stripAnswerFields(req.question)
const model = getAIModelForTask('hint')
const messages: ChatCompletionMessageParam[] = [
{
role: 'system',
content: `You are a tutor helping a student during an exam. Give exactly one short hint (1-2 sentences) in ${req.language} that nudges the student toward the right approach. Never reveal the answer, never restate the answer in different words, and never give the answer to a multiple-choice option. Focus on the concept or strategy. Respond ONLY with a JSON object: { "hint": string }.`,
},
{
role: 'user',
content: `Question:\n${JSON.stringify(safeQuestion)}`,
},
]
const promptTokens = estimateTokensFromMessages(messages as Array<{ content: string }>)
const estimatedTokens = estimateTotalTokens(promptTokens, 160)
const completion = await client.chat.completions.create({
model,
messages,
response_format: { type: 'json_object' },
temperature: 0.4,
max_completion_tokens: 160,
})
const usageTokens = resolveUsageTokens(completion.usage, estimatedTokens)
const raw = completion.choices[0]?.message?.content
if (!raw) {
throw new Error('Empty response from AI provider')
}
let parsed: unknown
try {
parsed = JSON.parse(raw)
} catch {
throw new Error('Invalid JSON response from AI provider')
}
const obj = parsed as Record<string, unknown>
const hint = typeof obj.hint === 'string' ? obj.hint.trim() : ''
if (!hint) {
throw new Error('AI did not return a hint')
}
return { data: { hint }, usageTokens }
}
export interface TutorChatMessage {
role: 'user' | 'assistant'
content: string
}
export interface TutorChatRequest {
exam: Record<string, unknown>
state: Record<string, unknown>
scoreSummary: {
title: string
scorePoints: number
totalPoints: number
correctCount: number
gradedCount: number
}
messages: TutorChatMessage[]
language: string
}
export async function tutorChat(req: TutorChatRequest): Promise<AiUsageResult<{ reply: string }>> {
const client = getAIClient()
if (!client) {
throw new Error('AI is not configured. Please set provider and API key in admin settings.')
}
const model = getAIModelForTask('tutor_chat')
const recent = req.messages.slice(-10)
const systemPrompt = `You are a friendly, concise tutor reviewing a completed exam attempt with a student. Reply in ${req.language}. Keep answers short (under 200 words). When the student references a question by number, use the exam's question order. You may explain mistakes, give similar practice problems, or clarify rubric criteria. You may reference the student's actual answers and the AI feedback already produced. Never invent score numbers — use only what's in the exam summary.
Exam summary:
- Title: ${req.scoreSummary.title}
- Score: ${req.scoreSummary.scorePoints}/${req.scoreSummary.totalPoints}
- Correct: ${req.scoreSummary.correctCount}/${req.scoreSummary.gradedCount}
Exam (JSON): ${JSON.stringify(req.exam).slice(0, 8000)}
Student's submission state (answers, flags, etc., JSON): ${JSON.stringify(req.state).slice(0, 4000)}`
const messages: ChatCompletionMessageParam[] = [
{ role: 'system', content: systemPrompt },
...recent.map((m) => ({ role: m.role, content: m.content })),
]
const promptTokens = estimateTokensFromMessages(messages as Array<{ content: string }>)
const estimatedTokens = estimateTotalTokens(promptTokens, 600)
const completion = await client.chat.completions.create({
model,
messages,
temperature: 0.5,
max_completion_tokens: 600,
})
const usageTokens = resolveUsageTokens(completion.usage, estimatedTokens)
const reply = completion.choices[0]?.message?.content?.trim()
if (!reply) {
throw new Error('Empty response from AI provider')
}
return { data: { reply }, usageTokens }
}
+45
View File
@@ -0,0 +1,45 @@
import OpenAI from 'openai'
import { getSetting } from '../repositories/settingsRepo.js'
export interface AIClientOverride {
provider?: string
apiKey?: string
baseURL?: string
}
export type AiTask = 'hint' | 'question_generation' | 'grading' | 'tutor_chat' | 'study_coach'
export function getAIClient(override?: AIClientOverride): OpenAI | null {
const provider = override?.provider ?? getSetting('ai_provider')
const apiKey = override?.apiKey ?? getSetting('ai_api_key')
if (!provider || !apiKey) return null
const customBase = override?.baseURL ?? getSetting('ai_base_url')
const baseURL = provider === 'openai' ? undefined : (customBase || undefined)
return new OpenAI({ apiKey, baseURL })
}
export function getAIModel(override?: string): string {
return override || getSetting('ai_model') || 'gpt-4o-mini'
}
const TASK_MODEL_KEYS: Record<AiTask, string> = {
hint: 'ai_model_hint',
question_generation: 'ai_model_generate',
grading: 'ai_model_grading',
tutor_chat: 'ai_model_tutor',
study_coach: 'ai_model_study_coach',
}
function normalizeModel(value: string | undefined): string | undefined {
const trimmed = value?.trim()
return trimmed ? trimmed : undefined
}
export function getAIModelForTask(task: AiTask, override?: string): string {
const explicit = normalizeModel(override)
if (explicit) return explicit
const taskModel = normalizeModel(getSetting(TASK_MODEL_KEYS[task]))
return taskModel || getAIModel()
}
+183
View File
@@ -0,0 +1,183 @@
import { getAIClient, getAIModelForTask } from './aiClient.js'
import { AiUsageResult, estimateTokensFromText, estimateTotalTokens, resolveUsageTokens } from '../utils/aiTokens.js'
export interface GradingResult {
suggestedGrade: number
feedback: string
detail?: unknown
}
interface GradeRequest {
type: 'essay' | 'short-answer' | 'translation' | 'math' | 'ordering' | 'match'
question: Record<string, unknown>
answer: unknown
language: string
}
function buildPrompt(req: GradeRequest): string {
const { type, question, answer, language } = req
const base = `You are an exam grading assistant. Grade the student's answer and respond ONLY with a JSON object containing:
- "suggestedGrade": number between 0.0 and 1.0
- "feedback": string with 1-3 sentences explaining the grade in ${language}
- "detail": type-specific breakdown object (see below)
Do not include any text outside the JSON object.`
switch (type) {
case 'essay': {
const rubric = (question.rubric as string[] | undefined) ?? []
return `${base}
Question: ${question.prompt}
Rubric criteria: ${rubric.join('; ') || 'None provided'}
Student answer: ${String(answer ?? '')}
Evaluate how well the essay covers the rubric criteria, structure, and quality. Provide "detail" as:
{ "rubricBreakdown": [{ "item": string, "met": boolean }] }
Each item should correspond to a rubric criterion.`
}
case 'short-answer': {
const accepted = [question.answer as string, ...(question.acceptedForms as string[] | undefined ?? [])].filter(Boolean)
return `${base}
Question: ${question.prompt}
Accepted answers: ${accepted.join(' | ')}
Student answer: ${String(answer ?? '')}
Determine if the student's answer is semantically equivalent to any accepted form. Provide "detail" as:
{ "matchedForm": string | null, "reason": string }`
}
case 'translation': {
const accepted = [question.answer as string, ...(question.acceptedForms as string[] | undefined ?? [])].filter(Boolean)
const source = question.sourceLanguage as string
const target = question.targetLanguage as string
return `${base}
Translate from ${source} to ${target}.
Question: ${question.prompt}
Accepted translations: ${accepted.join(' | ')}
Student answer: ${String(answer ?? '')}
Evaluate meaning accuracy and fluency, not literal word-for-word matching. Provide "detail" as:
{ "accuracy": number (0-1), "fluency": number (0-1), "notes": string }`
}
case 'math': {
const accepted = [question.answer as string, ...(question.acceptedForms as string[] | undefined ?? [])].filter(Boolean)
return `${base}
Question: ${question.prompt}
Accepted answers (LaTeX): ${accepted.join(' | ')}
Student answer (LaTeX): ${String(answer ?? '')}
Determine if the student's LaTeX expression is mathematically equivalent to the accepted answer. Provide "detail" as:
{ "equivalent": boolean, "explanation": string }`
}
case 'ordering': {
const correctOrder = question.answer as string[]
const items = question.items as Array<{ id: string; text: string }> | undefined
const itemMap = new Map(items?.map(i => [i.id, i.text]) ?? [])
const given = answer as string[]
const correctTexts = correctOrder.map(id => itemMap.get(id) ?? id)
const givenTexts = given?.map(id => itemMap.get(id) ?? id) ?? []
return `${base}
Question: ${question.prompt}
Correct order: ${correctTexts.join(' → ')}
Student order: ${givenTexts.join(' → ')}
Determine how many items are in the correct position. Provide "detail" as:
{ "correctPositions": number[], "totalItems": number }
correctPositions should be 1-based indices of items in the correct position.`
}
case 'match': {
const correctPairs = question.answer as Record<string, string>
const left = question.left as Array<{ id: string; text: string }> | undefined
const right = question.right as Array<{ id: string; text: string }> | undefined
const leftMap = new Map(left?.map(i => [i.id, i.text]) ?? [])
const rightMap = new Map(right?.map(i => [i.id, i.text]) ?? [])
const given = (answer as Record<string, string> | undefined) ?? {}
const correctPairTexts: string[] = []
const incorrectPairTexts: string[] = []
for (const [lId, rId] of Object.entries(correctPairs)) {
const pairText = `${leftMap.get(lId) ?? lId} → ${rightMap.get(rId) ?? rId}`
if (given[lId] === rId) {
correctPairTexts.push(pairText)
} else {
const actualR = given[lId] ? (rightMap.get(given[lId]) ?? given[lId]) : '(unanswered)'
incorrectPairTexts.push(`${leftMap.get(lId) ?? lId} → ${actualR} (should be ${rightMap.get(rId) ?? rId})`)
}
}
return `${base}
Question: ${question.prompt}
Correct pairings:
${Object.entries(correctPairs).map(([l, r]) => `${leftMap.get(l) ?? l} → ${rightMap.get(r) ?? r}`).join('\n')}
Student pairings:
${Object.entries(given).map(([l, r]) => `${leftMap.get(l) ?? l} → ${rightMap.get(r) ?? r}`).join('\n')}
Determine partial credit based on correct pairings. Provide "detail" as:
{ "correctPairs": string[], "incorrectPairs": string[] }
Use the human-readable text labels in the arrays.`
}
default:
return base
}
}
export async function gradeQuestionWithAI(
req: GradeRequest,
override?: { provider?: string; apiKey?: string; baseURL?: string; model?: string },
): Promise<AiUsageResult<GradingResult>> {
const client = getAIClient(override)
if (!client) {
throw new Error('AI grading is not configured. Please set provider and API key in admin settings.')
}
const model = getAIModelForTask('grading', override?.model)
const prompt = buildPrompt(req)
const promptTokens = estimateTokensFromText(prompt)
const estimatedTokens = estimateTotalTokens(promptTokens, 800)
const completion = await client.chat.completions.create({
model,
messages: [{ role: 'user', content: prompt }],
response_format: { type: 'json_object' },
temperature: 0.2,
max_completion_tokens: 800,
})
const usageTokens = resolveUsageTokens(completion.usage, estimatedTokens)
const raw = completion.choices[0]?.message?.content
if (!raw) {
throw new Error('Empty response from AI provider')
}
let parsed: unknown
try {
parsed = JSON.parse(raw)
} catch {
throw new Error('Invalid JSON response from AI provider')
}
const obj = parsed as Record<string, unknown>
const suggestedGrade = typeof obj.suggestedGrade === 'number'
? Math.max(0, Math.min(1, obj.suggestedGrade))
: 0
return {
data: {
suggestedGrade,
feedback: typeof obj.feedback === 'string' ? obj.feedback : '',
detail: obj.detail,
},
usageTokens,
}
}
+106
View File
@@ -0,0 +1,106 @@
import { describe, it, expect, vi } from 'vitest'
import bcrypt from 'bcrypt'
vi.mock('../utils/email.js', async (importOriginal) => ({
...(await importOriginal<object>()),
sendVerificationEmail: vi.fn(async () => {}),
sendPasswordResetEmail: vi.fn(async () => {}),
}))
import * as authService from './authService.js'
import { sendPasswordResetEmail } from '../utils/email.js'
import * as userRepo from '../repositories/userRepo.js'
async function seedUser(overrides: Partial<userRepo.CreateUserData> = {}): Promise<number> {
const email = overrides.email ?? `svc-${Math.random().toString(36).slice(2)}@example.com`
const { id } = userRepo.createUser({
email,
passwordHash: await bcrypt.hash('OldPassword1!', 10),
displayName: 'Svc Test',
emailVerified: 1,
...overrides,
})
return id
}
describe('authService.register', () => {
it('registers new accounts as plain users (admin bootstrap is the setup wizard)', async () => {
const email = `new-${Math.random().toString(36).slice(2)}@example.com`
const result = await authService.register({
email,
password: 'Sup3rSecure!pass',
displayName: 'New User',
})
if (!('token' in result)) throw new Error('expected an AuthResult with a token')
expect(result.token).toBeTruthy()
expect(result.user.role).toBe('user')
const dbUser = userRepo.getUserByEmail(email)
expect(dbUser?.role).toBe('user')
expect(dbUser?.email_verified).toBe(0)
})
it('responds to duplicate emails with a token-less success-shaped body (no enumeration oracle)', async () => {
const id = await seedUser()
const existing = userRepo.getUserById(id)!
const before = userRepo.countUsers()
const result = await authService.register({
email: existing.email.toUpperCase(),
password: 'Whatever!123',
displayName: 'Imposter',
})
expect('token' in result).toBe(false)
expect('message' in result && typeof result.message).toBe('string')
expect(userRepo.countUsers()).toBe(before)
// The original account must be untouched
const after = userRepo.getUserById(id)!
expect(after.display_name).toBe(existing.display_name)
})
})
describe('password rotation revokes sessions', () => {
it('changePassword bumps token_version', async () => {
const id = await seedUser()
const before = userRepo.getUserById(id)!
await authService.changePassword(id, { currentPassword: 'OldPassword1!', newPassword: 'N3wPassword!x' })
const after = userRepo.getUserById(id)!
expect(after.password_hash).not.toBe(before.password_hash)
expect(after.token_version).toBe((before.token_version ?? 0) + 1)
})
it('changePassword rejects a wrong current password', async () => {
const id = await seedUser()
const before = userRepo.getUserById(id)!
await expect(
authService.changePassword(id, { currentPassword: 'WrongPassword!', newPassword: 'N3wPassword!x' }),
).rejects.toThrow()
expect(userRepo.getUserById(id)!.token_version).toBe(before.token_version)
})
it('resetPassword bumps token_version and clears the reset token', async () => {
const id = await seedUser({ emailVerified: 0 })
const user = userRepo.getUserById(id)!
const email = user.email
await authService.forgotPassword(email)
const calls = vi.mocked(sendPasswordResetEmail).mock.calls
const rawToken = calls.at(-1)?.[1] as string | undefined
expect(rawToken).toBeTruthy()
await authService.resetPassword({ token: rawToken!, newPassword: 'BrandNew!123' })
const after = userRepo.getUserById(id)!
expect(after.token_version).toBe((user.token_version ?? 0) + 1)
expect(after.reset_token).toBeNull()
expect(after.reset_expires).toBeNull()
expect(bcrypt.compareSync('BrandNew!123', after.password_hash)).toBe(true)
})
})
+214
View File
@@ -0,0 +1,214 @@
import bcrypt from 'bcrypt'
import * as userRepo from '../repositories/userRepo.js'
import { signToken, generateVerificationToken, hashToken } from '../utils/tokens.js'
import { sendVerificationEmail, sendPasswordResetEmail } from '../utils/email.js'
import { AppError } from '../middleware/error.js'
import * as storageRepo from '../repositories/storageRepo.js'
import type { RegisterInput, LoginInput, UpdateProfileInput, ChangePasswordInput, VerifyEmailInput, ResetPasswordInput } from '../validators/authSchemas.js'
const SALT_ROUNDS = 10
const VERIFY_TOKEN_EXPIRY_MS = 24 * 60 * 60 * 1000
const RESET_TOKEN_EXPIRY_MS = 60 * 60 * 1000
export interface AuthResult {
token: string
user: { id: number; email: string; displayName: string; role: string }
}
/** Response for a registration attempt we did not act on (email already exists).
* Deliberately indistinguishable from success so the endpoint cannot be used
* to enumerate registered email addresses. */
export interface RegistrationPending {
message: string
}
function toAuthResult(user: userRepo.DbUser): AuthResult {
return {
token: signToken({
userId: user.id,
email: user.email,
displayName: user.display_name,
role: user.role as 'user' | 'admin',
tv: user.token_version ?? 0,
}),
user: {
id: user.id,
email: user.email,
displayName: user.display_name,
role: user.role,
},
}
}
export async function register(input: RegisterInput): Promise<AuthResult | RegistrationPending> {
// Hash before the duplicate check so response timing does not reveal whether
// the email is already registered (user enumeration)
const passwordHash = await bcrypt.hash(input.password, SALT_ROUNDS)
const existing = userRepo.getUserByEmail(input.email)
if (existing) {
return { message: 'Check your inbox to finish setting up your account.' }
}
const rawVerifyToken = generateVerificationToken()
const verifyTokenHash = hashToken(rawVerifyToken)
const verifyExpires = Date.now() + VERIFY_TOKEN_EXPIRY_MS
const { id } = userRepo.createUser({
email: input.email,
passwordHash,
displayName: input.displayName,
role: 'user',
emailVerified: 0,
verifyToken: verifyTokenHash,
verifyExpires,
})
const user = userRepo.getUserById(id)!
// Don't await — fire and forget so registration isn't blocked by email failures
sendVerificationEmail(user.email, rawVerifyToken).catch(() => {})
return toAuthResult(user)
}
export async function login(input: LoginInput): Promise<AuthResult> {
const user = userRepo.getUserByEmail(input.email)
if (!user) {
throw new AppError(401, 'Invalid email or password')
}
const valid = await bcrypt.compare(input.password, user.password_hash)
if (!valid) {
throw new AppError(401, 'Invalid email or password')
}
if (!user.email_verified) {
throw new AppError(403, 'Please verify your email before logging in')
}
return toAuthResult(user)
}
export function getMe(userId: number) {
const user = userRepo.getUserById(userId)
if (!user) {
throw new AppError(404, 'User not found')
}
const usage = storageRepo.getUserStorageUsage(userId)
const usedPercent = user.storage_quota_bytes > 0
? Math.round((usage.usedBytes / user.storage_quota_bytes) * 100)
: 0
return {
id: user.id,
email: user.email,
displayName: user.display_name,
role: user.role,
emailVerified: !!user.email_verified,
storageQuotaBytes: user.storage_quota_bytes,
usedBytes: usage.usedBytes,
usedPercent,
plan: user.plan,
}
}
export async function updateProfile(userId: number, input: UpdateProfileInput) {
userRepo.updateUser(userId, { displayName: input.displayName })
return getMe(userId)
}
export async function changePassword(userId: number, input: ChangePasswordInput) {
const user = userRepo.getUserById(userId)
if (!user) {
throw new AppError(404, 'User not found')
}
const valid = await bcrypt.compare(input.currentPassword, user.password_hash)
if (!valid) {
throw new AppError(400, 'Current password is incorrect')
}
const passwordHash = await bcrypt.hash(input.newPassword, SALT_ROUNDS)
// Bump token version so tokens issued before the change stop working
userRepo.updateUser(userId, {
passwordHash,
tokenVersion: (user.token_version ?? 0) + 1,
})
}
export async function verifyEmail(input: VerifyEmailInput) {
const tokenHash = hashToken(input.token)
const user = userRepo.getUserByVerifyTokenHash(tokenHash)
if (!user || !user.verify_expires || Date.now() > user.verify_expires) {
throw new AppError(400, 'Invalid or expired verification token')
}
userRepo.updateUser(user.id, {
emailVerified: 1,
verifyToken: null,
verifyExpires: null,
})
}
export async function resendVerification(email: string) {
const user = userRepo.getUserByEmail(email)
if (!user || user.email_verified) {
return
}
const rawVerifyToken = generateVerificationToken()
const verifyTokenHash = hashToken(rawVerifyToken)
const verifyExpires = Date.now() + VERIFY_TOKEN_EXPIRY_MS
userRepo.updateUser(user.id, {
verifyToken: verifyTokenHash,
verifyExpires,
})
sendVerificationEmail(user.email, rawVerifyToken).catch(() => {})
}
export async function forgotPassword(email?: string) {
if (!email) {
throw new AppError(400, 'Email is required')
}
const user = userRepo.getUserByEmail(email)
if (!user) {
// Don't reveal whether email exists
return
}
const rawResetToken = generateVerificationToken()
const resetTokenHash = hashToken(rawResetToken)
const resetExpires = Date.now() + RESET_TOKEN_EXPIRY_MS
userRepo.updateUser(user.id, { resetToken: resetTokenHash, resetExpires })
// Fire and forget
sendPasswordResetEmail(user.email, rawResetToken).catch(() => {})
}
export async function resetPassword(input: ResetPasswordInput) {
const tokenHash = hashToken(input.token)
const user = userRepo.getUserByResetTokenHash(tokenHash)
if (!user || !user.reset_expires || Date.now() > user.reset_expires) {
throw new AppError(400, 'Invalid or expired reset token')
}
const passwordHash = await bcrypt.hash(input.newPassword, SALT_ROUNDS)
// Bump token version so tokens from before the reset stop working
userRepo.updateUser(user.id, {
passwordHash,
resetToken: null,
resetExpires: null,
tokenVersion: (user.token_version ?? 0) + 1,
})
}
export function deleteUserAccount(userId: number) {
const success = userRepo.deleteUser(userId)
if (!success) {
throw new AppError(404, 'User not found')
}
}
+162
View File
@@ -0,0 +1,162 @@
import { getAIClient, getAIModelForTask } from './aiClient.js'
import { AiUsageResult, estimateTokensFromText, estimateTotalTokens, resolveUsageTokens } from '../utils/aiTokens.js'
export interface WeakArea {
category: string
avgScore: number
attempts: number
}
export interface StudyCoachResult {
weakAreas: WeakArea[]
studyTips: string[]
streakDays: number
}
interface ResultSummary {
title: string
scorePoints: number
totalPoints: number
correctCount: number
gradedCount: number
completedAt: number
categories: Record<string, { score: number; total: number }>
}
function calculateStreak(results: Array<{ completed_at: number }>): number {
if (results.length === 0) return 0
const days = new Set<string>()
for (const r of results) {
const d = new Date(r.completed_at * 1000)
days.add(`${d.getFullYear()}-${d.getMonth()}-${d.getDate()}`)
}
const sorted = Array.from(days).sort()
if (sorted.length === 0) return 0
const today = new Date()
const todayStr = `${today.getFullYear()}-${today.getMonth()}-${today.getDate()}`
let streak = 0
for (let i = sorted.length - 1; i >= 0; i--) {
const expected = new Date(today)
expected.setDate(today.getDate() - (sorted.length - 1 - i))
const expectedStr = `${expected.getFullYear()}-${expected.getMonth()}-${expected.getDate()}`
if (sorted[i] === expectedStr || (i === sorted.length - 1 && sorted[i] === todayStr)) {
streak++
} else if (i === sorted.length - 1) {
// Most recent is not today — check if it was yesterday
const yesterday = new Date(today)
yesterday.setDate(today.getDate() - 1)
const yestStr = `${yesterday.getFullYear()}-${yesterday.getMonth()}-${yesterday.getDate()}`
if (sorted[i] === yestStr) {
streak++
} else {
break
}
} else {
break
}
}
return streak
}
export async function analyzeResults(
results: Array<{
title: string
score_points: number
total_points: number
correct_count: number
graded_count: number
completed_at: number
exam_json: string
}>,
language = 'en',
options?: { allowAi?: boolean },
): Promise<AiUsageResult<StudyCoachResult>> {
const streakDays = calculateStreak(results)
// Build per-category stats from exam_json
const categoryStats: Record<string, { score: number; total: number; attempts: number }> = {}
for (const r of results) {
let exam: { questions?: Array<{ category?: string; points?: number }> } | undefined
try {
exam = JSON.parse(r.exam_json)
} catch {
continue
}
const questions = exam?.questions ?? []
// We don't have per-question results in the stored result, so we approximate
// using the overall score distributed across categories proportionally
const totalQuestionPoints = questions.reduce((sum, q) => sum + (q.points ?? 1), 0)
if (totalQuestionPoints === 0) continue
const overallRatio = r.total_points > 0 ? r.score_points / r.total_points : 0
for (const q of questions) {
const cat = q.category ?? 'Uncategorized'
const pts = q.points ?? 1
if (!categoryStats[cat]) {
categoryStats[cat] = { score: 0, total: 0, attempts: 0 }
}
categoryStats[cat].score += pts * overallRatio
categoryStats[cat].total += pts
}
// Mark attempt for each unique category in this exam
const catsInExam = new Set(questions.map((q) => q.category ?? 'Uncategorized'))
for (const cat of catsInExam) {
categoryStats[cat].attempts++
}
}
const weakAreas: WeakArea[] = Object.entries(categoryStats)
.map(([category, stats]) => ({
category,
avgScore: stats.total > 0 ? Math.round((stats.score / stats.total) * 100) : 0,
attempts: stats.attempts,
}))
.filter((w) => w.attempts >= 1)
.sort((a, b) => a.avgScore - b.avgScore)
.slice(0, 5)
const client = getAIClient()
if (!client || weakAreas.length === 0 || options?.allowAi === false) {
return { data: { weakAreas, studyTips: [], streakDays }, usageTokens: 0 }
}
const model = getAIModelForTask('study_coach')
const prompt = `You are a study coach. A student has taken several practice exams. Based on their performance per category, suggest 2-3 concise study tips to help them improve in their weakest areas. Respond ONLY with a JSON object containing:
- "studyTips": string[] (2-3 short tips in ${language})
Do not include any text outside the JSON object.
Weak areas (score %, attempts):
${weakAreas.map((w) => `- ${w.category}: ${w.avgScore}% over ${w.attempts} attempt(s)`).join('\n')}`
const promptTokens = estimateTokensFromText(prompt)
const estimatedTokens = estimateTotalTokens(promptTokens, 400)
try {
const completion = await client.chat.completions.create({
model,
messages: [{ role: 'user', content: prompt }],
response_format: { type: 'json_object' },
temperature: 0.4,
max_completion_tokens: 400,
})
const usageTokens = resolveUsageTokens(completion.usage, estimatedTokens)
const raw = completion.choices[0]?.message?.content
if (!raw) {
return { data: { weakAreas, studyTips: [], streakDays }, usageTokens: 0 }
}
const parsed = JSON.parse(raw) as Record<string, unknown>
const tips = Array.isArray(parsed.studyTips)
? parsed.studyTips.filter((t): t is string => typeof t === 'string')
: []
return { data: { weakAreas, studyTips: tips, streakDays }, usageTokens }
} catch {
return { data: { weakAreas, studyTips: [], streakDays }, usageTokens: 0 }
}
}
+54
View File
@@ -0,0 +1,54 @@
import { describe, it, expect } from 'vitest'
import * as settingsRepo from '../repositories/settingsRepo.js'
import * as aiUsageRepo from '../repositories/aiUsageRepo.js'
import * as userRepo from '../repositories/userRepo.js'
import { enforceDailyAiQuota, getDailyTokenLimit, getUtcDateKey } from './aiQuota.js'
import { AppError } from '../middleware/error.js'
function seedUser(): number {
const email = `quota-${Math.random().toString(36).slice(2)}@example.com`
return userRepo.createUser({
email,
passwordHash: 'x',
displayName: 'Quota Test',
emailVerified: 1,
}).id
}
describe('daily AI token quota', () => {
it('allows everything when no limit is configured', () => {
settingsRepo.setSetting('ai_daily_token_limit', '0')
expect(getDailyTokenLimit()).toBeNull()
expect(() => enforceDailyAiQuota(seedUser())).not.toThrow()
})
it('blocks with a 429 once used tokens reach the configured limit', () => {
settingsRepo.setSetting('ai_daily_token_limit', '1000')
const userId = seedUser()
aiUsageRepo.incrementDailyUsage(userId, getUtcDateKey(), 1000, 1)
expect(() => enforceDailyAiQuota(userId)).toThrowError(AppError)
try {
enforceDailyAiQuota(userId)
} catch (err) {
expect(err).toBeInstanceOf(AppError)
expect((err as AppError).statusCode).toBe(429)
}
})
it('allows users under the limit and isolates users from each other', () => {
settingsRepo.setSetting('ai_daily_token_limit', '1000')
const heavy = seedUser()
const fresh = seedUser()
aiUsageRepo.incrementDailyUsage(heavy, getUtcDateKey(), 999, 1)
expect(() => enforceDailyAiQuota(heavy)).not.toThrow()
expect(() => enforceDailyAiQuota(fresh)).not.toThrow()
})
it('only counts today’s usage', () => {
settingsRepo.setSetting('ai_daily_token_limit', '1000')
const userId = seedUser()
aiUsageRepo.incrementDailyUsage(userId, '2000-01-01', 999_999, 1)
expect(() => enforceDailyAiQuota(userId)).not.toThrow()
})
})
+51
View File
@@ -0,0 +1,51 @@
import { getSetting } from '../repositories/settingsRepo.js'
import { getDailyUsage, incrementDailyUsage } from '../repositories/aiUsageRepo.js'
import { AppError } from '../middleware/error.js'
export interface AiQuotaStatus {
allowed: boolean
limit: number | null
usedTokens: number
periodDate: string
}
export function getUtcDateKey(date = new Date()): string {
return date.toISOString().slice(0, 10)
}
export function getDailyTokenLimit(): number | null {
const raw = getSetting('ai_daily_token_limit') ?? ''
const parsed = Number(raw)
if (!Number.isFinite(parsed) || parsed <= 0) return null
return Math.floor(parsed)
}
export function checkDailyAiQuota(userId: number): AiQuotaStatus {
const periodDate = getUtcDateKey()
const limit = getDailyTokenLimit()
if (!limit) {
return { allowed: true, limit: null, usedTokens: 0, periodDate }
}
const usage = getDailyUsage(userId, periodDate)
return {
allowed: usage.tokensUsed < limit,
limit,
usedTokens: usage.tokensUsed,
periodDate,
}
}
export function recordDailyAiUsage(userId: number, periodDate: string, tokens: number): void {
incrementDailyUsage(userId, periodDate, tokens, 1)
}
/**
* Throws a 429 when the user has exhausted the admin-configured daily token
* limit. Call before every AI provider invocation; pair with recordDailyAiUsage.
*/
export function enforceDailyAiQuota(userId: number): void {
if (!checkDailyAiQuota(userId).allowed) {
throw new AppError(429, 'Daily AI usage limit reached. Please try again tomorrow.')
}
}
+29
View File
@@ -0,0 +1,29 @@
export interface AiUsageResult<T> {
data: T
usageTokens: number
}
export function estimateTokensFromText(text: string): number {
if (!text) return 0
return Math.max(1, Math.ceil(text.length / 4))
}
export function estimateTokensFromMessages(messages: Array<{ content: string }>): number {
return messages.reduce((sum, msg) => sum + estimateTokensFromText(msg.content), 0)
}
// Conservative estimate when providers omit usage details.
export function estimateTotalTokens(promptTokens: number, maxCompletionTokens: number): number {
const completionEstimate = Math.max(0, Math.round(maxCompletionTokens * 0.6))
return Math.max(1, promptTokens + completionEstimate)
}
export function resolveUsageTokens(
usage: { total_tokens?: number } | undefined,
estimate: number,
): number {
if (usage && typeof usage.total_tokens === 'number' && Number.isFinite(usage.total_tokens)) {
return Math.max(0, Math.round(usage.total_tokens))
}
return estimate
}
+37
View File
@@ -0,0 +1,37 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { asyncHandler } from './asyncHandler'
import type { Request, Response, NextFunction } from 'express'
const mockReq = {} as Request
const mockRes = {} as Response
let mockNext: NextFunction
beforeEach(() => {
mockNext = vi.fn() as unknown as NextFunction
})
describe('asyncHandler', () => {
it('calls next with error when async handler throws', async () => {
const error = new Error('boom')
const handler = asyncHandler(async () => {
throw error
})
await handler(mockReq, mockRes, mockNext)
expect(mockNext).toHaveBeenCalledWith(error)
})
it('does not call next when handler resolves', async () => {
const handler = asyncHandler(async () => {
// success
})
await handler(mockReq, mockRes, mockNext)
expect(mockNext).not.toHaveBeenCalled()
})
it('calls next with error when handler rejects', async () => {
const error = new Error('rejected')
const handler = asyncHandler(() => Promise.reject(error))
await handler(mockReq, mockRes, mockNext)
expect(mockNext).toHaveBeenCalledWith(error)
})
})
+7
View File
@@ -0,0 +1,7 @@
import type { Request, Response, NextFunction, RequestHandler } from 'express'
export function asyncHandler(fn: (req: Request, res: Response, next: NextFunction) => Promise<void>): RequestHandler {
return (req, res, next) => {
Promise.resolve(fn(req, res, next)).catch(next)
}
}
+7 -2
View File
@@ -1,5 +1,6 @@
import nodemailer from "nodemailer";
import { getDb } from "../db/database.js";
import { config } from "../config.js";
// ─── Types ────────────────────────────────────────────────────────────────────
@@ -21,10 +22,10 @@ export function getAppUrl(): string {
.prepare("SELECT value FROM site_settings WHERE key = ?")
.get("app_url") as { value: string } | undefined;
return (
(row?.value?.trim() || process.env["APP_URL"]) ?? "http://localhost:5173"
(row?.value?.trim() || config.appUrl) ?? "http://localhost:5173"
);
} catch {
return process.env["APP_URL"] ?? "http://localhost:5173";
return config.appUrl;
}
}
@@ -38,6 +39,10 @@ function getSetting(key: string): string {
return row?.value ?? "";
}
export function isEmailConfigured(): boolean {
return !!getSetting("smtp_host").trim();
}
function getSmtpConfig(): SmtpConfig | null {
const host = getSetting("smtp_host").trim();
if (!host) return null;
+30
View File
@@ -0,0 +1,30 @@
/**
* Server-side sanitization for user-provided exam content.
* Removes script tags and event handlers from string fields.
*/
function sanitizeString(value: unknown): unknown {
if (typeof value !== 'string') return value
return value
.replace(/<script[^>]*>[\s\S]*?<\/script>/gi, '')
.replace(/<script[^>]*\/>/gi, '')
.replace(/javascript:/gi, '')
.replace(/on\w+\s*=/gi, '')
}
function sanitizeObject(obj: unknown): unknown {
if (typeof obj === 'string') return sanitizeString(obj)
if (Array.isArray(obj)) return obj.map(sanitizeObject)
if (obj && typeof obj === 'object') {
const result: Record<string, unknown> = {}
for (const [key, val] of Object.entries(obj)) {
result[key] = sanitizeObject(val)
}
return result
}
return obj
}
export function sanitizeExam(exam: Record<string, unknown>): Record<string, unknown> {
return sanitizeObject(exam) as Record<string, unknown>
}
+47
View File
@@ -0,0 +1,47 @@
/**
* In-memory token blacklist with SQLite persistence.
* Tokens are blacklisted on logout and expire automatically based on JWT expiry.
*/
import { getDb } from '../db/database.js'
const memoryCache = new Set<string>()
let initialized = false
function initCache(): void {
if (initialized) return
const db = getDb()
db.exec(`
CREATE TABLE IF NOT EXISTS token_blacklist (
token_hash TEXT PRIMARY KEY,
expires_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_token_blacklist_expires ON token_blacklist(expires_at);
`)
// Purge rows that can never matter again, then load the rest into memory
db.prepare('DELETE FROM token_blacklist WHERE expires_at <= ?').run(Date.now())
const rows = db.prepare('SELECT token_hash FROM token_blacklist WHERE expires_at > ?')
.all(Date.now()) as { token_hash: string }[]
for (const row of rows) {
memoryCache.add(row.token_hash)
}
initialized = true
}
export function isTokenBlacklisted(tokenHash: string): boolean {
initCache()
return memoryCache.has(tokenHash)
}
export function blacklistToken(tokenHash: string, expiresAt: number): void {
initCache()
memoryCache.add(tokenHash)
const db = getDb()
db.prepare('INSERT OR IGNORE INTO token_blacklist (token_hash, expires_at) VALUES (?, ?)')
.run(tokenHash, expiresAt)
}
export function cleanupExpiredTokens(): void {
const db = getDb()
db.prepare('DELETE FROM token_blacklist WHERE expires_at <= ?').run(Date.now())
}
+45
View File
@@ -0,0 +1,45 @@
import { describe, it, expect } from 'vitest'
describe('tokens', () => {
it('round-trips JWT payload', async () => {
process.env.JWT_SECRET = 'test-secret-for-unit-tests-only'
const { signToken, verifyToken } = await import('./tokens')
const payload = { userId: 1, email: 'a@b.com', displayName: 'Test', role: 'user' as const, tv: 3 }
const token = signToken(payload)
const decoded = verifyToken(token)
expect(decoded.userId).toBe(1)
expect(decoded.email).toBe('a@b.com')
expect(decoded.displayName).toBe('Test')
expect(decoded.role).toBe('user')
expect(decoded.tv).toBe(3)
})
it('throws on invalid token', async () => {
process.env.JWT_SECRET = 'test-secret-for-unit-tests-only'
const { verifyToken } = await import('./tokens')
expect(() => verifyToken('invalid.token.here')).toThrow()
})
it('throws on tampered token', async () => {
process.env.JWT_SECRET = 'test-secret-for-unit-tests-only'
const { signToken, verifyToken } = await import('./tokens')
const payload = { userId: 1, email: 'a@b.com', displayName: 'Test', role: 'user' as const, tv: 0 }
const token = signToken(payload)
const tampered = token.slice(0, -5) + 'xxxxx'
expect(() => verifyToken(tampered)).toThrow()
})
it('generates hex verification token of expected length', async () => {
const { generateVerificationToken } = await import('./tokens')
const token = generateVerificationToken()
expect(token).toMatch(/^[a-f0-9]+$/)
expect(token.length).toBe(64)
})
it('generates unique verification tokens', async () => {
const { generateVerificationToken } = await import('./tokens')
const t1 = generateVerificationToken()
const t2 = generateVerificationToken()
expect(t1).not.toBe(t2)
})
})
+13 -5
View File
@@ -1,23 +1,31 @@
import jwt from 'jsonwebtoken'
import crypto from 'crypto'
const JWT_SECRET = process.env['JWT_SECRET'] ?? 'brainwave-dev-secret-change-in-production'
import { config } from '../config.js'
export interface JwtPayload {
userId: number
email: string
displayName: string
role: 'user' | 'admin'
/** Token version — must match the user's current token_version or the token is rejected */
tv: number
}
export function signToken(payload: JwtPayload): string {
return jwt.sign(payload, JWT_SECRET, { expiresIn: '24h' })
return jwt.sign(payload, config.jwtSecret, { expiresIn: config.jwtExpiresIn } as jwt.SignOptions)
}
export function verifyToken(token: string): JwtPayload {
return jwt.verify(token, JWT_SECRET) as JwtPayload
/** Verified JWT body: our claims plus the registered exp/iat timestamps. */
export type VerifiedToken = JwtPayload & { exp?: number; iat?: number }
export function verifyToken(token: string): VerifiedToken {
return jwt.verify(token, config.jwtSecret) as VerifiedToken
}
export function generateVerificationToken(): string {
return crypto.randomBytes(32).toString('hex')
}
export function hashToken(token: string): string {
return crypto.createHash('sha256').update(token).digest('hex')
}
+181
View File
@@ -0,0 +1,181 @@
import { describe, it, expect } from 'vitest'
import { updateUserRoleSchema, updateSettingSchema, updateSettingsBatchSchema, listUsersQuerySchema, userIdParamSchema } from './adminSchemas'
describe('updateUserRoleSchema', () => {
it('accepts valid role change to admin', () => {
const result = updateUserRoleSchema.safeParse({ user_id: 1, role: 'admin' })
expect(result.success).toBe(true)
})
it('accepts valid role change to user', () => {
const result = updateUserRoleSchema.safeParse({ user_id: 5, role: 'user' })
expect(result.success).toBe(true)
})
it('rejects invalid role', () => {
const result = updateUserRoleSchema.safeParse({ user_id: 1, role: 'superadmin' })
expect(result.success).toBe(false)
})
it('rejects zero user_id', () => {
const result = updateUserRoleSchema.safeParse({ user_id: 0, role: 'admin' })
expect(result.success).toBe(false)
})
it('rejects negative user_id', () => {
const result = updateUserRoleSchema.safeParse({ user_id: -1, role: 'admin' })
expect(result.success).toBe(false)
})
it('rejects non-numeric user_id', () => {
const result = updateUserRoleSchema.safeParse({ user_id: '1', role: 'admin' })
expect(result.success).toBe(false)
})
it('rejects missing fields', () => {
expect(updateUserRoleSchema.safeParse({ user_id: 1 }).success).toBe(false)
expect(updateUserRoleSchema.safeParse({ role: 'admin' }).success).toBe(false)
})
})
describe('updateSettingSchema', () => {
it('accepts valid setting', () => {
const result = updateSettingSchema.safeParse({ key: 'site_name', value: 'BrainWave' })
expect(result.success).toBe(true)
})
it('rejects unknown key', () => {
const result = updateSettingSchema.safeParse({ key: 'unknown_key', value: 'x' })
expect(result.success).toBe(false)
})
it('rejects empty key', () => {
const result = updateSettingSchema.safeParse({ key: '', value: 'BrainWave' })
expect(result.success).toBe(false)
})
it('accepts empty value (clearing a setting)', () => {
const result = updateSettingSchema.safeParse({ key: 'smtp_pass', value: '' })
expect(result.success).toBe(true)
})
it('rejects non-string value', () => {
const result = updateSettingSchema.safeParse({ key: 'site_name', value: 123 })
expect(result.success).toBe(false)
})
it('accepts each allowed key', () => {
const keys = [
'site_name', 'site_language', 'maintenance_mode', 'maintenance_message',
'allow_guest_mode', 'registration_enabled', 'default_theme',
'smtp_host', 'smtp_port', 'smtp_user', 'smtp_pass', 'smtp_from', 'smtp_secure',
'app_url', 'max_upload_mb', 'ai_provider', 'ai_model', 'ai_api_key', 'ai_base_url',
]
for (const key of keys) {
const result = updateSettingSchema.safeParse({ key, value: 'x' })
expect(result.success, `key ${key} should be accepted`).toBe(true)
}
})
})
describe('updateSettingsBatchSchema', () => {
it('accepts valid batch', () => {
const result = updateSettingsBatchSchema.safeParse({
settings: { site_name: 'BrainWave', maintenance_mode: 'false' },
})
expect(result.success).toBe(true)
})
it('accepts empty settings record', () => {
const result = updateSettingsBatchSchema.safeParse({ settings: {} })
expect(result.success).toBe(true)
})
it('accepts empty value in batch (clearing)', () => {
const result = updateSettingsBatchSchema.safeParse({
settings: { smtp_pass: '' },
})
expect(result.success).toBe(true)
})
it('rejects non-string value in batch', () => {
const result = updateSettingsBatchSchema.safeParse({
settings: { site_name: 42 },
})
expect(result.success).toBe(false)
})
it('rejects missing settings property', () => {
const result = updateSettingsBatchSchema.safeParse({})
expect(result.success).toBe(false)
})
})
describe('listUsersQuerySchema', () => {
it('accepts empty query and defaults page to 1', () => {
const result = listUsersQuerySchema.safeParse({})
expect(result.success).toBe(true)
expect((result as any).data.page).toBe(1)
})
it('parses page string into number', () => {
const result = listUsersQuerySchema.safeParse({ page: '3' })
expect(result.success).toBe(true)
expect((result as any).data.page).toBe(3)
})
it('rejects non-numeric page', () => {
const result = listUsersQuerySchema.safeParse({ page: 'abc' })
expect(result.success).toBe(false)
})
it('rejects negative page', () => {
const result = listUsersQuerySchema.safeParse({ page: '-1' })
expect(result.success).toBe(false)
})
it('rejects decimal page', () => {
const result = listUsersQuerySchema.safeParse({ page: '1.5' })
expect(result.success).toBe(false)
})
it('accepts search', () => {
const result = listUsersQuerySchema.safeParse({ search: 'john' })
expect(result.success).toBe(true)
expect((result as any).data.search).toBe('john')
})
it('accepts both page and search', () => {
const result = listUsersQuerySchema.safeParse({ page: '2', search: 'jane' })
expect(result.success).toBe(true)
expect((result as any).data).toEqual({ page: 2, search: 'jane' })
})
})
describe('userIdParamSchema', () => {
it('accepts numeric id', () => {
const result = userIdParamSchema.safeParse({ id: '7' })
expect(result.success).toBe(true)
})
it('accepts multi-digit id', () => {
const result = userIdParamSchema.safeParse({ id: '12345' })
expect(result.success).toBe(true)
})
it('rejects non-numeric id', () => {
const result = userIdParamSchema.safeParse({ id: 'seven' })
expect(result.success).toBe(false)
})
it('rejects empty id', () => {
const result = userIdParamSchema.safeParse({ id: '' })
expect(result.success).toBe(false)
})
it('rejects id with non-digit characters', () => {
expect(userIdParamSchema.safeParse({ id: '1a' }).success).toBe(false)
expect(userIdParamSchema.safeParse({ id: '-1' }).success).toBe(false)
expect(userIdParamSchema.safeParse({ id: '1.0' }).success).toBe(false)
})
})
+46
View File
@@ -0,0 +1,46 @@
import { z } from 'zod'
export const updateUserRoleSchema = z.object({
user_id: z.number().min(1),
role: z.enum(['user', 'admin']),
})
const ALLOWED_SETTING_KEYS = [
'site_name',
'site_language',
'maintenance_mode',
'maintenance_message',
'allow_guest_mode',
'registration_enabled',
'default_theme',
'smtp_host',
'smtp_port',
'smtp_user',
'smtp_pass',
'smtp_from',
'smtp_secure',
'app_url',
'max_upload_mb',
'ai_provider',
'ai_model',
'ai_api_key',
'ai_base_url',
] as const
export const updateSettingSchema = z.object({
key: z.enum(ALLOWED_SETTING_KEYS),
value: z.string(),
})
export const updateSettingsBatchSchema = z.object({
settings: z.record(z.string()),
})
export const listUsersQuerySchema = z.object({
page: z.string().regex(/^\d+$/).optional().transform((v: string | undefined) => (v ? Number(v) : 1)),
search: z.string().optional(),
})
export const userIdParamSchema = z.object({
id: z.string().regex(/^\d+$/, 'ID must be a number'),
})
+23
View File
@@ -0,0 +1,23 @@
import { describe, it, expect } from 'vitest'
import { hintSchema, MAX_HINT_QUESTION_CHARS } from './aiSchemas.js'
describe('hintSchema', () => {
it('accepts a normal hint request', () => {
const result = hintSchema.safeParse({
question: { type: 'short-answer', prompt: 'What is 2+2?' },
language: 'en',
})
expect(result.success).toBe(true)
})
it(`rejects questions whose JSON exceeds ${MAX_HINT_QUESTION_CHARS} chars`, () => {
const result = hintSchema.safeParse({
question: { prompt: 'x'.repeat(MAX_HINT_QUESTION_CHARS + 1) },
language: 'en',
})
expect(result.success).toBe(false)
if (!result.success) {
expect(JSON.stringify(result.error.issues)).toContain('too large')
}
})
})
+25
View File
@@ -0,0 +1,25 @@
import { z } from 'zod'
export const MAX_HINT_QUESTION_CHARS = 8000
function serializedSize(value: unknown): number {
if (value === undefined || value === null) return 0
return JSON.stringify(value).length
}
export const hintSchema = z
.object({
question: z.record(z.unknown()),
language: z.string().min(1).max(20),
})
.superRefine((val, ctx) => {
if (serializedSize(val.question) > MAX_HINT_QUESTION_CHARS) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'Question is too large for AI hints',
path: ['question'],
})
}
})
export type HintRequestBody = z.infer<typeof hintSchema>
+131
View File
@@ -0,0 +1,131 @@
import { describe, it, expect } from 'vitest'
import { registerSchema, loginSchema, verifyEmailSchema, resendVerificationSchema, resetPasswordSchema, updateProfileSchema, changePasswordSchema, forgotPasswordSchema } from './authSchemas'
describe('registerSchema', () => {
it('accepts valid registration', () => {
const result = registerSchema.safeParse({
email: 'test@example.com',
password: 'Password123!',
displayName: 'Test User',
})
expect(result.success).toBe(true)
})
it('accepts passwords with hash as special character', () => {
const result = registerSchema.safeParse({
email: 'test@example.com',
password: 'Password123#',
displayName: 'Test User',
})
expect(result.success).toBe(true)
})
it('rejects invalid email', () => {
const result = registerSchema.safeParse({
email: 'not-an-email',
password: 'Password123!',
displayName: 'Test User',
})
expect(result.success).toBe(false)
})
it('rejects short password', () => {
const result = registerSchema.safeParse({
email: 'test@example.com',
password: '123',
displayName: 'Test User',
})
expect(result.success).toBe(false)
})
it('rejects empty display name', () => {
const result = registerSchema.safeParse({
email: 'test@example.com',
password: 'Password123!',
displayName: '',
})
expect(result.success).toBe(false)
})
})
describe('loginSchema', () => {
it('accepts valid login', () => {
const result = loginSchema.safeParse({ email: 'test@example.com', password: 'pass' })
expect(result.success).toBe(true)
})
it('rejects missing email', () => {
const result = loginSchema.safeParse({ password: 'pass' })
expect(result.success).toBe(false)
})
})
describe('verifyEmailSchema', () => {
it('accepts valid token', () => {
const result = verifyEmailSchema.safeParse({ token: 'abc123' })
expect(result.success).toBe(true)
})
it('rejects empty token', () => {
const result = verifyEmailSchema.safeParse({ token: '' })
expect(result.success).toBe(false)
})
})
describe('resendVerificationSchema', () => {
it('accepts valid email', () => {
const result = resendVerificationSchema.safeParse({ email: 'test@example.com' })
expect(result.success).toBe(true)
})
})
describe('resetPasswordSchema', () => {
it('accepts valid reset', () => {
const result = resetPasswordSchema.safeParse({ token: 'abc', newPassword: 'NewPass123!' })
expect(result.success).toBe(true)
})
it('rejects short password', () => {
const result = resetPasswordSchema.safeParse({ token: 'abc', password: '123' })
expect(result.success).toBe(false)
})
})
describe('updateProfileSchema', () => {
it('accepts valid profile', () => {
const result = updateProfileSchema.safeParse({ displayName: 'New Name' })
expect(result.success).toBe(true)
})
it('rejects empty displayName', () => {
const result = updateProfileSchema.safeParse({ displayName: '' })
expect(result.success).toBe(false)
})
})
describe('changePasswordSchema', () => {
it('accepts valid change', () => {
const result = changePasswordSchema.safeParse({
currentPassword: 'oldpass',
newPassword: 'NewPass123!',
})
expect(result.success).toBe(true)
})
})
describe('forgotPasswordSchema', () => {
it('accepts valid email', () => {
const result = forgotPasswordSchema.safeParse({ email: 'test@example.com' })
expect(result.success).toBe(true)
})
it('accepts empty object', () => {
const result = forgotPasswordSchema.safeParse({})
expect(result.success).toBe(true)
})
it('rejects invalid email', () => {
const result = forgotPasswordSchema.safeParse({ email: 'bad' })
expect(result.success).toBe(false)
})
})
+54
View File
@@ -0,0 +1,54 @@
import { z } from 'zod'
const passwordRegex = /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&#])[A-Za-z\d@$!%*?&#]{8,}$/
export const registerSchema = z.object({
email: z.string().email().max(255),
password: z.string()
.min(8, 'Password must be at least 8 characters')
.regex(passwordRegex, 'Password must contain at least one uppercase letter, one lowercase letter, one number, and one special character (@$!%*?&#)'),
displayName: z.string().min(1, 'Display name is required').max(100),
})
export const loginSchema = z.object({
email: z.string().email(),
password: z.string(),
})
export const verifyEmailSchema = z.object({
token: z.string().min(1, 'Token is required'),
})
export const resendVerificationSchema = z.object({
email: z.string().email(),
})
export const resetPasswordSchema = z.object({
token: z.string().min(1, 'Token is required'),
newPassword: z.string()
.min(8, 'Password must be at least 8 characters')
.regex(passwordRegex, 'Password must contain at least one uppercase letter, one lowercase letter, one number, and one special character (@$!%*?&#)'),
})
export const updateProfileSchema = z.object({
displayName: z.string().min(1, 'Display name is required').max(100),
})
export const changePasswordSchema = z.object({
currentPassword: z.string(),
newPassword: z.string()
.min(8, 'Password must be at least 8 characters')
.regex(passwordRegex, 'Password must contain at least one uppercase letter, one lowercase letter, one number, and one special character (@$!%*?&#)'),
})
export const forgotPasswordSchema = z.object({
email: z.string().email().optional(),
})
export type RegisterInput = z.infer<typeof registerSchema>
export type LoginInput = z.infer<typeof loginSchema>
export type VerifyEmailInput = z.infer<typeof verifyEmailSchema>
export type ResendVerificationInput = z.infer<typeof resendVerificationSchema>
export type ResetPasswordInput = z.infer<typeof resetPasswordSchema>
export type UpdateProfileInput = z.infer<typeof updateProfileSchema>
export type ChangePasswordInput = z.infer<typeof changePasswordSchema>
@@ -0,0 +1,100 @@
import { describe, it, expect } from 'vitest'
import {
createCollectionSchema,
updateCollectionSchema,
collectionIdParamSchema,
moveExamSchema,
} from './collectionSchemas'
describe('createCollectionSchema', () => {
it('accepts valid name', () => {
const result = createCollectionSchema.safeParse({ name: 'Math 101' })
expect(result.success).toBe(true)
})
it('rejects empty name', () => {
const result = createCollectionSchema.safeParse({ name: '' })
expect(result.success).toBe(false)
})
it('rejects missing name', () => {
const result = createCollectionSchema.safeParse({})
expect(result.success).toBe(false)
})
it('rejects name longer than 100 chars', () => {
const result = createCollectionSchema.safeParse({ name: 'a'.repeat(101) })
expect(result.success).toBe(false)
})
it('accepts name at max length (100)', () => {
const result = createCollectionSchema.safeParse({ name: 'a'.repeat(100) })
expect(result.success).toBe(true)
})
it('rejects non-string name', () => {
const result = createCollectionSchema.safeParse({ name: 42 })
expect(result.success).toBe(false)
})
})
describe('updateCollectionSchema', () => {
it('accepts valid name', () => {
const result = updateCollectionSchema.safeParse({ name: 'Renamed' })
expect(result.success).toBe(true)
})
it('rejects empty name', () => {
const result = updateCollectionSchema.safeParse({ name: '' })
expect(result.success).toBe(false)
})
it('rejects name over 100 chars', () => {
const result = updateCollectionSchema.safeParse({ name: 'b'.repeat(101) })
expect(result.success).toBe(false)
})
})
describe('collectionIdParamSchema', () => {
it('accepts numeric id', () => {
const result = collectionIdParamSchema.safeParse({ id: '12' })
expect(result.success).toBe(true)
})
it('rejects non-numeric id', () => {
const result = collectionIdParamSchema.safeParse({ id: 'abc' })
expect(result.success).toBe(false)
})
it('rejects empty id', () => {
const result = collectionIdParamSchema.safeParse({ id: '' })
expect(result.success).toBe(false)
})
it('rejects negative id string', () => {
const result = collectionIdParamSchema.safeParse({ id: '-3' })
expect(result.success).toBe(false)
})
})
describe('moveExamSchema', () => {
it('accepts numeric collectionId', () => {
const result = moveExamSchema.safeParse({ collectionId: 4 })
expect(result.success).toBe(true)
})
it('accepts null collectionId (moving out of any collection)', () => {
const result = moveExamSchema.safeParse({ collectionId: null })
expect(result.success).toBe(true)
})
it('accepts empty payload (collectionId omitted)', () => {
const result = moveExamSchema.safeParse({})
expect(result.success).toBe(true)
})
it('rejects string collectionId', () => {
const result = moveExamSchema.safeParse({ collectionId: '4' })
expect(result.success).toBe(false)
})
})
@@ -0,0 +1,17 @@
import { z } from 'zod'
export const createCollectionSchema = z.object({
name: z.string().min(1, 'Name is required').max(100),
})
export const updateCollectionSchema = z.object({
name: z.string().min(1, 'Name is required').max(100),
})
export const collectionIdParamSchema = z.object({
id: z.string().regex(/^\d+$/, 'ID must be a number'),
})
export const moveExamSchema = z.object({
collectionId: z.number().nullable().optional(),
})
+246
View File
@@ -0,0 +1,246 @@
import { describe, it, expect } from 'vitest'
import {
examSchema,
saveExamSchema,
updateExamSchema,
examIdParamSchema,
favoriteSchema,
shareModeSchema,
batchDeleteSchema,
} from './examSchemas'
describe('examSchema', () => {
it('accepts minimal valid exam', () => {
const result = examSchema.safeParse({
title: 'My Exam',
questions: [{ id: '1', type: 'essay', prompt: 'Q' }],
})
expect(result.success).toBe(true)
})
it('rejects empty title', () => {
const result = examSchema.safeParse({
title: '',
questions: [{ id: '1', type: 'essay', prompt: 'Q' }],
})
expect(result.success).toBe(false)
})
it('rejects missing title', () => {
const result = examSchema.safeParse({
questions: [{ id: '1', type: 'essay', prompt: 'Q' }],
})
expect(result.success).toBe(false)
})
it('rejects empty questions array', () => {
const result = examSchema.safeParse({
title: 'My Exam',
questions: [],
})
expect(result.success).toBe(false)
})
it('rejects question missing id', () => {
const result = examSchema.safeParse({
title: 'My Exam',
questions: [{ type: 'essay', prompt: 'Q' }],
})
expect(result.success).toBe(false)
})
it('accepts optional id', () => {
const result = examSchema.safeParse({
id: 'abc',
title: 'My Exam',
questions: [{ id: '1', type: 'essay', prompt: 'Q' }],
})
expect(result.success).toBe(true)
})
it('accepts each supported question type', () => {
const types = [
'multiple-choice', 'multiple-select', 'short-answer', 'translation',
'math-input', 'essay', 'match', 'order',
'fill-in-the-blanks', 'true-false', 'code-submission',
'dropdown-completion', 'numeric-range',
'dictation', 'grammar-correction', 'flashcard',
]
for (const type of types) {
const result = examSchema.safeParse({
title: 'X',
questions: [{ id: '1', type, prompt: 'Q' }],
})
expect(result.success, `type ${type} should be accepted`).toBe(true)
}
})
it('rejects unsupported question type', () => {
const result = examSchema.safeParse({
title: 'X',
questions: [{ id: '1', type: 'file-upload', prompt: 'Q' }],
})
expect(result.success).toBe(false)
})
it('accepts answer as string, array, or record', () => {
const variants = ['answer', ['a', 'b'], { left1: 'right1' }]
for (const answer of variants) {
const result = examSchema.safeParse({
title: 'X',
questions: [{ id: '1', type: 'short-answer', prompt: 'Q', answer }],
})
expect(result.success).toBe(true)
}
})
it('rejects non-string/array/record/number/boolean answer', () => {
const result = examSchema.safeParse({
title: 'X',
questions: [{ id: '1', type: 'short-answer', prompt: 'Q', answer: null }],
})
expect(result.success).toBe(false)
})
it('accepts null timeLimitMinutes', () => {
const result = examSchema.safeParse({
title: 'X',
timeLimitMinutes: null,
questions: [{ id: '1', type: 'essay', prompt: 'Q' }],
})
expect(result.success).toBe(true)
})
it('accepts feedbackMode end and immediate', () => {
for (const feedbackMode of ['end', 'immediate'] as const) {
const result = examSchema.safeParse({
title: 'X',
feedbackMode,
questions: [{ id: '1', type: 'essay', prompt: 'Q' }],
})
expect(result.success).toBe(true)
}
})
it('rejects invalid feedbackMode', () => {
const result = examSchema.safeParse({
title: 'X',
feedbackMode: 'never',
questions: [{ id: '1', type: 'essay', prompt: 'Q' }],
})
expect(result.success).toBe(false)
})
it('accepts question with rich content (image, audio, code, table)', () => {
const result = examSchema.safeParse({
title: 'Rich',
questions: [{
id: '1',
type: 'multiple-choice',
prompt: 'Pick one',
image: { src: 'a.png', alt: 'desc', caption: 'cap' },
audio: { src: 'a.mp3' },
code: { language: 'js', content: 'x=1' },
table: { headers: ['a'], rows: [['b']] },
choices: [{ id: 'c1', text: 'one' }, { id: 'c2', text: 'two' }],
}],
})
expect(result.success).toBe(true)
})
})
describe('saveExamSchema', () => {
it('accepts valid save payload', () => {
const result = saveExamSchema.safeParse({
exam: {
title: 'My Exam',
questions: [{ id: '1', type: 'essay', prompt: 'Q' }],
},
})
expect(result.success).toBe(true)
})
it('rejects missing exam', () => {
const result = saveExamSchema.safeParse({})
expect(result.success).toBe(false)
})
})
describe('updateExamSchema', () => {
it('accepts valid update payload', () => {
const result = updateExamSchema.safeParse({
exam: {
title: 'Updated',
questions: [{ id: '1', type: 'essay', prompt: 'Q' }],
},
})
expect(result.success).toBe(true)
})
})
describe('examIdParamSchema', () => {
it('accepts numeric id string', () => {
const result = examIdParamSchema.safeParse({ id: '42' })
expect(result.success).toBe(true)
})
it('rejects non-numeric id', () => {
const result = examIdParamSchema.safeParse({ id: 'abc' })
expect(result.success).toBe(false)
})
it('rejects empty id', () => {
const result = examIdParamSchema.safeParse({ id: '' })
expect(result.success).toBe(false)
})
})
describe('favoriteSchema', () => {
it('accepts true', () => {
expect(favoriteSchema.safeParse({ isFavorite: true }).success).toBe(true)
})
it('accepts false', () => {
expect(favoriteSchema.safeParse({ isFavorite: false }).success).toBe(true)
})
it('rejects non-boolean', () => {
expect(favoriteSchema.safeParse({ isFavorite: 'true' }).success).toBe(false)
})
it('rejects missing field', () => {
expect(favoriteSchema.safeParse({}).success).toBe(false)
})
})
describe('shareModeSchema', () => {
it('accepts view', () => {
expect(shareModeSchema.safeParse({ mode: 'view' }).success).toBe(true)
})
it('accepts clone', () => {
expect(shareModeSchema.safeParse({ mode: 'clone' }).success).toBe(true)
})
it('rejects unknown mode', () => {
expect(shareModeSchema.safeParse({ mode: 'edit' }).success).toBe(false)
})
})
describe('batchDeleteSchema', () => {
it('accepts array of ids', () => {
expect(batchDeleteSchema.safeParse({ ids: [1, 2, 3] }).success).toBe(true)
})
it('accepts empty array', () => {
expect(batchDeleteSchema.safeParse({ ids: [] }).success).toBe(true)
})
it('rejects string ids', () => {
expect(batchDeleteSchema.safeParse({ ids: ['1', '2'] }).success).toBe(false)
})
it('rejects missing ids', () => {
expect(batchDeleteSchema.safeParse({}).success).toBe(false)
})
})
+148
View File
@@ -0,0 +1,148 @@
import { z } from 'zod'
const choiceSchema = z.object({
id: z.string(),
text: z.string(),
})
const matchItemSchema = z.object({
id: z.string(),
text: z.string(),
})
const questionImageSchema = z.object({
src: z.string(),
alt: z.string().optional(),
caption: z.string().optional(),
})
const questionAudioSchema = z.object({
src: z.string(),
})
const questionCodeSchema = z.object({
language: z.string(),
content: z.string(),
})
const questionTableSchema = z.object({
headers: z.array(z.string()),
rows: z.array(z.array(z.string())),
})
const blankSchema = z.object({
id: z.string(),
acceptedAnswers: z.array(z.string()),
acceptedForms: z.array(z.string()).optional(),
caseSensitive: z.boolean().optional(),
ignorePunctuation: z.boolean().optional(),
ignoreAccents: z.boolean().optional(),
})
const dropdownSchema = z.object({
id: z.string(),
options: z.array(choiceSchema),
correctOptionId: z.string(),
})
const codeConfigSchema = z.object({
language: z.string(),
starterCode: z.string().optional(),
})
const numericConfigSchema = z.object({
answer: z.number(),
tolerance: z.number(),
min: z.number().optional(),
max: z.number().optional(),
})
const questionSchema = z.object({
id: z.string(),
type: z.enum([
'multiple-choice',
'multiple-select',
'short-answer',
'translation',
'math-input',
'essay',
'match',
'order',
'fill-in-the-blanks',
'true-false',
'code-submission',
'dropdown-completion',
'numeric-range',
'dictation',
'grammar-correction',
'flashcard',
]),
category: z.string().optional(),
points: z.number().optional(),
prompt: z.string(),
explanation: z.string().optional(),
image: questionImageSchema.optional(),
audio: questionAudioSchema.optional(),
code: questionCodeSchema.optional(),
table: questionTableSchema.optional(),
svg: z.string().optional(),
answer: z.union([z.string(), z.array(z.string()), z.record(z.string(), z.string()), z.boolean(), z.number()]).optional(),
acceptedForms: z.array(z.string()).optional(),
caseSensitive: z.boolean().optional(),
ignorePunctuation: z.boolean().optional(),
ignoreAccents: z.boolean().optional(),
sourceLanguage: z.string().optional(),
targetLanguage: z.string().optional(),
choices: z.array(choiceSchema).optional(),
minWords: z.number().optional(),
rubric: z.array(z.string()).optional(),
left: z.array(matchItemSchema).optional(),
right: z.array(matchItemSchema).optional(),
items: z.array(matchItemSchema).optional(),
textSegments: z.array(z.string()).optional(),
blanks: z.array(blankSchema).optional(),
dropdowns: z.array(dropdownSchema).optional(),
codeConfig: codeConfigSchema.optional(),
numericConfig: numericConfigSchema.optional(),
textToCorrect: z.string().optional(),
})
export const examSchema = z.object({
id: z.string().optional(),
title: z.string().min(1, 'Title is required'),
subtitle: z.string().optional(),
author: z.string().optional(),
timeLimitMinutes: z.number().nullable().optional(),
feedbackMode: z.enum(['end', 'immediate']).optional(),
passingScore: z.number().optional(),
categories: z.array(z.string()).optional(),
questions: z.array(questionSchema).min(1, 'At least one question is required'),
})
export const saveExamSchema = z.object({
exam: examSchema,
tags: z.array(z.string()).optional(),
})
export const updateExamSchema = z.object({
exam: examSchema,
tags: z.array(z.string()).optional(),
})
export const examIdParamSchema = z.object({
id: z.string().regex(/^\d+$/, 'ID must be a number'),
})
export const favoriteSchema = z.object({
isFavorite: z.boolean(),
})
export const shareModeSchema = z.object({
mode: z.enum(['view', 'clone']),
})
export const batchDeleteSchema = z.object({
ids: z.array(z.number()),
})
export type ExamInput = z.infer<typeof examSchema>
@@ -0,0 +1,65 @@
import { describe, it, expect } from 'vitest'
import {
gradeRequestSchema,
MAX_GRADE_QUESTION_CHARS,
MAX_GRADE_ANSWER_CHARS,
} from './gradeSchemas.js'
const validBase = {
question: { prompt: 'Explain photosynthesis' },
answer: 'Plants convert light into chemical energy',
language: 'en',
}
describe('gradeRequestSchema', () => {
it('accepts a well-formed grading request', () => {
const result = gradeRequestSchema.safeParse({ type: 'essay', ...validBase })
expect(result.success).toBe(true)
})
it(`rejects questions whose JSON exceeds ${MAX_GRADE_QUESTION_CHARS} chars`, () => {
const result = gradeRequestSchema.safeParse({
type: 'essay',
question: { prompt: 'x'.repeat(MAX_GRADE_QUESTION_CHARS + 1) },
answer: 'irrelevant',
language: 'en',
})
expect(result.success).toBe(false)
if (!result.success) {
expect(JSON.stringify(result.error.issues)).toContain('too large')
}
})
it(`rejects answers whose JSON exceeds ${MAX_GRADE_ANSWER_CHARS} chars`, () => {
const result = gradeRequestSchema.safeParse({
type: 'essay',
question: { prompt: 'small' },
answer: 'y'.repeat(MAX_GRADE_ANSWER_CHARS + 1),
language: 'en',
})
expect(result.success).toBe(false)
if (!result.success) {
expect(JSON.stringify(result.error.issues)).toContain('too large')
}
})
it('rejects unknown question types', () => {
const result = gradeRequestSchema.safeParse({
type: 'flashcard',
question: { prompt: 'hi' },
answer: 'yo',
language: 'en',
})
expect(result.success).toBe(false)
})
it('bounds the language field', () => {
const result = gradeRequestSchema.safeParse({
type: 'essay',
question: { prompt: 'hi' },
answer: 'yo',
language: 'l'.repeat(500),
})
expect(result.success).toBe(false)
})
})
+37
View File
@@ -0,0 +1,37 @@
import { z } from 'zod'
/** Serialized size caps: the grading prompt is built from client-supplied
* question/answer objects, so these bound what can be billed per request. */
export const MAX_GRADE_QUESTION_CHARS = 8000
export const MAX_GRADE_ANSWER_CHARS = 4000
function serializedSize(value: unknown): number {
if (value === undefined || value === null) return 0
return JSON.stringify(value).length
}
const gradeRequestBase = z.object({
type: z.enum(['essay', 'short-answer', 'translation', 'math', 'ordering', 'match']),
question: z.record(z.unknown()),
answer: z.unknown(),
language: z.string().min(1).max(30),
})
export const gradeRequestSchema = gradeRequestBase.superRefine((val, ctx) => {
if (serializedSize(val.question) > MAX_GRADE_QUESTION_CHARS) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'Question is too large for AI grading',
path: ['question'],
})
}
if (serializedSize(val.answer) > MAX_GRADE_ANSWER_CHARS) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'Answer is too large for AI grading',
path: ['answer'],
})
}
})
export type GradeRequestBody = z.infer<typeof gradeRequestSchema>
@@ -0,0 +1,144 @@
import { describe, it, expect } from 'vitest'
import { submitResultSchema, resultIdParamSchema } from './resultSchemas'
describe('submitResultSchema', () => {
it('accepts valid result', () => {
const result = submitResultSchema.safeParse({
title: 'My Exam',
score_points: 8,
total_points: 10,
correct_count: 8,
graded_count: 10,
elapsed_ms: 120000,
state: {
currentIdx: 0,
answers: { q1: 'a' },
flags: { q1: false },
notes: '',
startedAt: Date.now(),
feedbackMode: 'end',
},
exam: {
title: 'My Exam',
questions: [],
},
})
expect(result.success).toBe(true)
})
it('rejects missing title', () => {
const result = submitResultSchema.safeParse({
score_points: 8,
total_points: 10,
correct_count: 8,
graded_count: 10,
elapsed_ms: 120000,
state: {
currentIdx: 0,
answers: {},
flags: {},
notes: '',
startedAt: Date.now(),
feedbackMode: 'end',
},
exam: { title: 'My Exam', questions: [] },
})
expect(result.success).toBe(false)
})
it('accepts optional exam_id', () => {
const result = submitResultSchema.safeParse({
exam_id: 1,
title: 'My Exam',
score_points: 8,
total_points: 10,
correct_count: 8,
graded_count: 10,
elapsed_ms: 120000,
state: {
currentIdx: 0,
answers: {},
flags: {},
notes: '',
startedAt: Date.now(),
feedbackMode: 'end',
},
exam: { title: 'My Exam', questions: [] },
})
expect(result.success).toBe(true)
})
it('rejects negative score_points (client-supplied scores are bounded)', () => {
const result = submitResultSchema.safeParse({
title: 'My Exam',
score_points: -1,
total_points: 10,
correct_count: 8,
graded_count: 10,
elapsed_ms: 120000,
state: {
currentIdx: 0,
answers: {},
flags: {},
notes: '',
startedAt: Date.now(),
feedbackMode: 'end',
},
exam: { title: 'My Exam', questions: [] },
})
expect(result.success).toBe(false)
})
it('rejects non-integer counts and absurd magnitudes', () => {
const base = {
title: 'My Exam',
score_points: 8,
total_points: 10,
correct_count: 8,
graded_count: 10,
elapsed_ms: 120000,
state: { currentIdx: 0, answers: {}, flags: {}, notes: '', startedAt: Date.now(), feedbackMode: 'end' },
exam: { title: 'My Exam', questions: [] },
}
expect(submitResultSchema.safeParse({ ...base, correct_count: 1.5 }).success).toBe(false)
expect(submitResultSchema.safeParse({ ...base, score_points: 1e12 }).success).toBe(false)
expect(submitResultSchema.safeParse({ ...base, elapsed_ms: -5 }).success).toBe(false)
expect(submitResultSchema.safeParse({ ...base, exam_id: -3 }).success).toBe(false)
expect(submitResultSchema.safeParse({ ...base, title: 't'.repeat(301) }).success).toBe(false)
})
it('accepts immediate feedback mode', () => {
const result = submitResultSchema.safeParse({
title: 'My Exam',
score_points: 8,
total_points: 10,
correct_count: 8,
graded_count: 10,
elapsed_ms: 120000,
state: {
currentIdx: 2,
answers: { q1: 'a', q2: 'b' },
flags: { q1: true },
notes: 'some notes',
revealed: { q1: true },
startedAt: Date.now(),
completedAt: Date.now(),
feedbackMode: 'immediate',
},
exam: { title: 'My Exam', questions: [] },
})
expect(result.success).toBe(true)
})
})
describe('resultIdParamSchema', () => {
it('accepts numeric id string', () => {
const result = resultIdParamSchema.safeParse({ id: '42' })
expect(result.success).toBe(true)
})
it('rejects non-numeric id', () => {
const result = resultIdParamSchema.safeParse({ id: 'abc' })
expect(result.success).toBe(false)
})
})
+20
View File
@@ -0,0 +1,20 @@
import { z } from 'zod'
// Scores/counts arrive from the client, so they are bounded to keep forged
// garbage out of analytics while accepting every value honest clients send.
export const submitResultSchema = z.object({
exam_id: z.number().int().positive().nullable().optional(),
title: z.string().min(1).max(300),
score_points: z.number().finite().min(0).max(1e9),
total_points: z.number().finite().min(0).max(1e9),
correct_count: z.number().int().min(0).max(1e6),
graded_count: z.number().int().min(0).max(1e6),
passing_score: z.number().finite().min(0).max(1e9).optional(),
elapsed_ms: z.number().int().min(0).max(2_147_483_647),
state: z.record(z.unknown()),
exam: z.record(z.unknown()),
})
export const resultIdParamSchema = z.object({
id: z.string().regex(/^\d+$/, 'ID must be a number'),
})
+118
View File
@@ -0,0 +1,118 @@
import { describe, it, expect } from 'vitest'
import { setupSchema, setupStatusSchema } from './setupSchemas'
describe('setupSchema', () => {
const baseValid = {
email: 'admin@example.com',
password: 'AdminPass123!',
displayName: 'Admin',
}
it('accepts minimal valid setup', () => {
const result = setupSchema.safeParse(baseValid)
expect(result.success).toBe(true)
})
it('accepts passwords with hash as special character', () => {
const result = setupSchema.safeParse({ ...baseValid, password: 'AdminPass123#' })
expect(result.success).toBe(true)
})
it('accepts full setup including SMTP fields', () => {
const result = setupSchema.safeParse({
...baseValid,
siteTitle: 'BrainWave',
language: 'nl',
smtpHost: 'smtp.example.com',
smtpPort: '587',
smtpUser: 'user',
smtpPass: 'pass',
smtpFrom: 'noreply@example.com',
smtpSecure: 'auto',
appUrl: 'https://example.com',
})
expect(result.success).toBe(true)
})
it('rejects invalid email', () => {
const result = setupSchema.safeParse({ ...baseValid, email: 'bad' })
expect(result.success).toBe(false)
})
it('rejects short password', () => {
const result = setupSchema.safeParse({ ...baseValid, password: '123' })
expect(result.success).toBe(false)
})
it('rejects password missing complexity (no special char)', () => {
const result = setupSchema.safeParse({ ...baseValid, password: 'AdminPass123' })
expect(result.success).toBe(false)
})
it('rejects password missing uppercase', () => {
const result = setupSchema.safeParse({ ...baseValid, password: 'adminpass123!' })
expect(result.success).toBe(false)
})
it('rejects password missing digit', () => {
const result = setupSchema.safeParse({ ...baseValid, password: 'AdminPass!' })
expect(result.success).toBe(false)
})
it('rejects empty displayName', () => {
const result = setupSchema.safeParse({ ...baseValid, displayName: '' })
expect(result.success).toBe(false)
})
it('rejects displayName over 100 chars', () => {
const result = setupSchema.safeParse({ ...baseValid, displayName: 'a'.repeat(101) })
expect(result.success).toBe(false)
})
it('rejects email longer than 255 chars', () => {
const longLocal = 'a'.repeat(250)
const result = setupSchema.safeParse({ ...baseValid, email: `${longLocal}@x.com` })
expect(result.success).toBe(false)
})
it('rejects invalid language', () => {
const result = setupSchema.safeParse({ ...baseValid, language: 'fr' })
expect(result.success).toBe(false)
})
it('accepts each language enum value', () => {
for (const language of ['en', 'nl'] as const) {
const result = setupSchema.safeParse({ ...baseValid, language })
expect(result.success).toBe(true)
}
})
it('rejects invalid smtpSecure value', () => {
const result = setupSchema.safeParse({ ...baseValid, smtpSecure: 'maybe' })
expect(result.success).toBe(false)
})
it('accepts each smtpSecure enum value', () => {
for (const smtpSecure of ['auto', 'true', 'false'] as const) {
const result = setupSchema.safeParse({ ...baseValid, smtpSecure })
expect(result.success).toBe(true)
}
})
})
describe('setupStatusSchema', () => {
it('accepts empty object', () => {
const result = setupStatusSchema.safeParse({})
expect(result.success).toBe(true)
})
it('accepts optional email', () => {
const result = setupStatusSchema.safeParse({ email: 'admin@example.com' })
expect(result.success).toBe(true)
})
it('rejects invalid optional email', () => {
const result = setupStatusSchema.safeParse({ email: 'bad' })
expect(result.success).toBe(false)
})
})
+26
View File
@@ -0,0 +1,26 @@
import { z } from 'zod'
const passwordRegex = /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&#])[A-Za-z\d@$!%*?&#]{8,}$/
export const setupSchema = z.object({
email: z.string().email().max(255),
password: z.string()
.min(8, 'Password must be at least 8 characters')
.regex(passwordRegex, 'Password must contain at least one uppercase letter, one lowercase letter, one number, and one special character (@$!%*?&#)'),
displayName: z.string().min(1, 'Display name is required').max(100),
siteTitle: z.string().min(1, 'Site title is required').max(255).optional(),
language: z.enum(['en', 'nl']).optional(),
smtpHost: z.string().max(255).optional(),
smtpPort: z.string().max(10).optional(),
smtpUser: z.string().max(255).optional(),
smtpPass: z.string().max(255).optional(),
smtpFrom: z.string().max(255).optional(),
smtpSecure: z.enum(['auto', 'true', 'false']).optional(),
appUrl: z.string().max(255).optional(),
})
export const setupStatusSchema = z.object({
email: z.string().email().max(255).optional(),
password: z.string().min(8).optional(),
})
+1 -1
View File
@@ -16,5 +16,5 @@
"isolatedModules": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
"exclude": ["node_modules", "dist", "src/**/*.test.ts", "src/**/*.spec.ts"]
}
+9
View File
@@ -0,0 +1,9 @@
import { defineConfig } from 'vitest/config'
export default defineConfig({
test: {
setupFiles: ['./vitest.setup.ts'],
include: ['src/**/*.{test,spec}.{ts,tsx}'],
exclude: ['node_modules', 'dist'],
},
})
+7
View File
@@ -0,0 +1,7 @@
import { vi } from 'vitest'
vi.stubEnv('JWT_SECRET', 'test-secret-for-unit-tests-only')
vi.stubEnv('NODE_ENV', 'test')
vi.stubEnv('DB_PATH', ':memory:')
vi.stubEnv('CORS_ORIGIN', 'http://localhost:5173')
vi.stubEnv('APP_URL', 'http://localhost:5173')
+446
View File
@@ -0,0 +1,446 @@
# BrainWave Implementation Plan — Future Expansion Readiness
> **Status**: Phase 1 & Phase 2 Complete (Foundation + Consolidation). All TypeScript compilation verified.
> **Date**: 2026-04-26
---
## Executive Summary
This plan documents all changes made and remaining work needed to make BrainWave ready for future expansion. The core principle is **layered architecture**: separating data access, business logic, API routes, and frontend concerns into independent, testable layers.
---
## ✅ Completed (Phase 1 — Foundation + Phase 2 — Consolidation)
### 1. Backend: Repository Layer
**Problem**: Routes had direct SQL/DB access mixed with business logic.
**Solution**: Created pure data access modules:
| Repository | File | Responsibility |
| -------------- | ------------------------------------------ | ------------------------------- |
| `userRepo` | `backend/src/repositories/userRepo.ts` | Users CRUD, counting, searching |
| `examRepo` | `backend/src/repositories/examRepo.ts` | Exams CRUD, listing by user |
| `resultRepo` | `backend/src/repositories/resultRepo.ts` | Results CRUD, statistics |
| `settingsRepo` | `backend/src/repositories/settingsRepo.ts` | Key-value settings |
**Why**: Lets you swap SQLite for PostgreSQL later by only touching 4 files. No route or service changes needed.
### 2. Backend: Zod Validation + Middleware
**Problem**: No input validation. Manual type checking in every route.
**Solution**: Created schema-per-domain:
- `authSchemas.ts` — register, login, profile, password, email verification, reset
- `examSchemas.ts` — save, update, id param
- `resultSchemas.ts` — submit, id param
- `adminSchemas.ts` — role update, settings, user list queries
- `setupSchemas.ts` — wizard setup
**Middleware**: `validateBody()`, `validateParams()`, `validateQuery()` — generic wrappers that parse with Zod and attach `req.parsedBody`/`req.parsedQuery`.
**Why**: Defensive coding. Prevents injection, malformed data, and gives typed request bodies.
### 3. Backend: Centralized Error Handling
**Problem**: try/catch in every async route handler, inconsistent error responses.
**Solution**:
- `AppError` class with status codes
- `asyncHandler()` wrapper — catches async errors, forwards to error handler
- `errorHandler` middleware — consistent JSON error format
**Why**: No more try/catch boilerplate. All errors flow through one path.
### 4. Backend: Service Layer (Auth)
**Problem**: Auth business logic (registration, login, email flow) was inline in routes.
**Solution**: `backend/src/services/authService.ts` — pure business logic, no HTTP concerns.
**Why**: Business rules can be unit tested without spinning up Express. Enables sharing logic between REST and future GraphQL/WebSocket APIs.
### 5. Backend: Route Refactoring
All routes now follow the same pattern:
```
middleware → validateInput → asyncHandler(serviceCall) → errorHandler
```
| Route | Status |
| ------------ | -------------------------------------------------------- |
| `auth.ts` | ✅ Full refactor (services + validation + rate limiting) |
| `exams.ts` | ✅ Refactored (repo + validation + asyncHandler) |
| `results.ts` | ✅ Refactored (repo + validation + asyncHandler) |
| `admin.ts` | ✅ Refactored (repo + validation + asyncHandler) |
| `setup.ts` | ✅ Refactored (repo + validation + asyncHandler) |
### 6. Backend: Security Hardening
- **Rate limiting** on auth endpoints (10 req/15min window)
- **JWT secret validation** — app exits with clear error if `JWT_SECRET` missing
- **Request logging** — `METHOD PATH STATUS - DURATIONms`
### 7. Frontend: API Abstraction Layer
**Problem**: Raw `apiFetch()` calls scattered across components. Hard to change endpoints or add caching.
**Solution**: Domain-specific API modules:
| Module | File | Covers |
| ----------- | ------------------------------- | ---------------------------------- |
| `authApi` | `frontend/src/api/authApi.ts` | Login, register, profile, password |
| `examApi` | `frontend/src/api/examApi.ts` | CRUD for exams |
| `resultApi` | `frontend/src/api/resultApi.ts` | List, get, submit, delete results |
| `adminApi` | `frontend/src/api/adminApi.ts` | Users, stats, settings |
| `setupApi` | `frontend/src/api/setupApi.ts` | Setup wizard |
**Updated components**: ALL components now import from API modules:
- `AuthContext` — uses `authApi` + `setupApi`
- `MyExams` — uses `examApi`
- `MyResults` — uses `resultApi`
- `ExamEditor` — uses `examApi`
- `Settings` — uses `authApi`
- `AdminDashboard` — uses `adminApi`
- `SetupWizard` — uses `setupApi`
- `ExamRunner` — uses `resultApi`
- `Workspace` — uses `authApi`
Zero `apiFetch()` calls remain in any `.tsx` file.
**Why**: One place to add request deduplication, caching, or optimistic updates. Also makes mocking for tests trivial.
### 8. Frontend: React Error Boundaries
**Problem**: Any rendering error crashes the entire app (white screen).
**Solution**: `frontend/src/components/ErrorBoundary.tsx` — class component with fallback UI showing error message and reload button.
**Integration**: Wrapped the entire `<Routes>` tree in `ErrorBoundary` inside `App.tsx`.
**Why**: Isolates crashes to the component that failed, not the whole SPA.
---
## ✅ Completed (Phase 2 — Consolidation)
### Backend
#### A. Database Migration System
**Priority**: High (blocks schema evolution)
**Current**: Schema is created inline in `database.ts` on startup. No version tracking.
**Needed**:
```
backend/src/db/migrations/
001_initial.sql
002_add_exam_tags.sql
003_add_user_preferences.sql
```
With a `schema_migrations` table tracking applied versions. Run on startup, ordered by filename.
**Why**: Required before any schema change. Without it, adding columns = production downtime or manual ALTER TABLE.
#### B. Database Connection Pooling / Migration to PostgreSQL
**Priority**: Medium
**Current**: SQLite with `better-sqlite3`. Single connection, file-based.
**Why SQLite is fine for now**: Single-node deployment, no concurrent writes expected.
**When to migrate**:
- Multi-instance deployments
- Concurrent writes (multiple admins)
- Need for backups, replication, or managed hosting
**Path**: Abstract `getDb()` behind an interface. Swap implementation.
#### C. Email Delivery Reliability
**Priority**: Medium
**Current**: Nodemailer with SMTP. No queue, no retry.
**Needed**:
- Abstract email behind an interface (`IEmailService`)
- Implementations: `SmtpEmailService`, `ConsoleEmailService` (dev), `SendGridEmailService`
- Add retry with exponential backoff
- Consider a job queue (Bull + Redis) for high volume
#### D. Comprehensive API Testing
**Priority**: High
**Current**: Vitest installed but no tests.
**Needed**:
```
backend/src/tests/
auth.test.ts — register, login, me, change password
exams.test.ts — CRUD
results.test.ts — submit, list, delete
admin.test.ts — user management, stats
```
Use `supertest` for HTTP-level tests, in-memory SQLite for isolation.
#### E. Environment Configuration Management ✅
**Created**: `backend/src/config.ts`
Centralized environment variable management with:
- `env()` helper for optional vars with defaults
- `env.required()` helper for mandatory vars (throws with clear error)
- Type-safe exports: `JWT_SECRET`, `PORT`, `DB_PATH`, `SMTP_HOST`, `SMTP_PORT`, `EMAIL_FROM`, `FRONTEND_URL`, `NODE_ENV`
- `tokens.ts` updated to use `config.JWT_SECRET` instead of `process.env`
This provides fail-fast validation at startup and a single source of truth for all environment configuration.
### Frontend
#### F. Remaining Component API Migration ✅
**Priority**: High
All components migrated to API modules. Zero raw `apiFetch()` calls remain in `.tsx` files:
| Component | API Module Used |
| -------------------- | --------------- |
| `Settings.tsx` | `authApi` |
| `AdminDashboard.tsx` | `adminApi` |
| `SetupWizard.tsx` | `setupApi` |
| `ExamRunner.tsx` | `resultApi` |
| `Workspace.tsx` | `authApi` |
#### G. Extract Exam/Result State from App.tsx
**Priority**: High — NOT completed in Phase 2
**Current**: `App.tsx` holds `exam` and `history` in local state, passed via props and `navigate(state)`.
**Needed**: Dedicated contexts:
```
frontend/src/contexts/
ExamContext.tsx — current exam, load, unload
HistoryContext.tsx — result history, review data
```
This decouples state from routing and enables features like:
- Resume interrupted exam
- Background auto-save
- Offline mode (PWA)
#### H. React Error Boundary Integration ✅
**Priority**: Medium
**Created**: `frontend/src/components/ErrorBoundary.tsx`
**Integrated**: Wrapped the entire `<Routes>` tree in `ErrorBoundary` inside `App.tsx`.
Consider also wrapping specific high-risk sections (exam editor, admin dashboard) individually for finer-grained isolation.
#### I. Frontend Testing
**Priority**: Medium — NOT completed
**Needed**:
```
frontend/src/tests/
api/ — mock API tests
components/ — React Testing Library
utils/ — unit tests for validation, slug, etc.
```
Start with API module tests (mock fetch) and utility function tests.
### DevOps / Infrastructure
#### J. Health Check Endpoint Enhancement
**Priority**: Medium — NOT completed
**Current**: `/api/health` returns `{ status: 'ok' }`
**Needed**:
```json
{
"status": "healthy",
"timestamp": 1234567890,
"version": "1.2.3",
"checks": {
"database": "connected",
"email": "configured",
"disk": "ok"
}
}
```
#### K. Docker Production Optimization
**Priority**: Medium — NOT completed
**Current**: Single Dockerfile, no multi-stage build.
**Needed**: Multi-stage build:
1. `builder` — install deps, build frontend, compile backend
2. `production` — copy only `dist/`, `node_modules` (prod), runtime files
This reduces image size from ~500MB to ~150MB.
#### L. Backup Strategy
**Priority**: High for production — NOT completed
**Current**: SQLite file on disk. No automated backups.
**Needed**:
- Scheduled `sqlite3 .backup` or `VACUUM INTO`
- Upload to S3-compatible storage
- Retention policy (7 daily, 4 weekly, 12 monthly)
- Documented restore procedure
---
## Architecture Decisions
### Why Repository Pattern?
| Without | With |
| ---------------------------- | -------------------------------- |
| SQL in routes | Pure JS functions |
| Can't test routes without DB | Mock repos for unit tests |
| Swapping DB = rewrite routes | Swap 4 files, zero route changes |
### Why Zod over manual validation?
| Manual | Zod |
| --------------------------- | ---------------------------------- |
| `if (!email.includes('@'))` | `email: z.string().email()` |
| Type narrowing manually | `infer<typeof schema>` gives types |
| Easy to miss edge cases | Battle-tested validators |
| No documentation | Schema IS documentation |
### Why API modules on frontend?
| Raw fetch | API modules |
| ------------------------ | -------------------------------- |
| URL strings everywhere | Single source of truth |
| Type `any` for responses | Typed interfaces |
| Can't mock easily | Export functions, mock in tests |
| No caching layer | One place to add SWR/React Query |
---
## Migration Path for New Features
When adding a new feature (e.g., "Exam Tags"):
1. **Schema**: Add `tags` table migration (`003_add_tags.sql`)
2. **Repository**: Add `tagRepo.ts` with `createTag()`, `listTags()`, `attachTagToExam()`
3. **Validation**: Add `tagSchema` in `validators/tagSchemas.ts`
4. **Routes**: Add `routes/tags.ts` using repo + validation + asyncHandler
5. **API Module**: Add `frontend/src/api/tagApi.ts`
6. **UI**: Build components, import from `tagApi`
Each layer is independently testable and replaceable.
---
## Files Created/Modified in Phase 1
### New Files (Backend)
```
backend/src/repositories/userRepo.ts
backend/src/repositories/examRepo.ts
backend/src/repositories/resultRepo.ts
backend/src/repositories/settingsRepo.ts
backend/src/validators/authSchemas.ts
backend/src/validators/examSchemas.ts
backend/src/validators/resultSchemas.ts
backend/src/validators/adminSchemas.ts
backend/src/validators/setupSchemas.ts
backend/src/middleware/validate.ts
backend/src/middleware/error.ts
backend/src/utils/asyncHandler.ts
backend/src/services/authService.ts
```
### Modified Files (Backend)
```
backend/src/index.ts — env validation, request logging, routes
backend/src/routes/auth.ts — services + validation + rate limiting
backend/src/routes/exams.ts — repo + validation + asyncHandler + AppError fixes
backend/src/routes/results.ts — repo + validation + asyncHandler + AppError fixes
backend/src/routes/admin.ts — repo + validation + asyncHandler + AppError fixes
backend/src/routes/setup.ts — repo + validation + asyncHandler + AppError fixes
backend/src/middleware/auth.ts — AuthenticatedRequest interface
backend/src/utils/tokens.ts — uses config.JWT_SECRET
backend/src/services/authService.ts — AppError argument order fixed
```
### New Files (Frontend)
```
frontend/src/api/index.ts
frontend/src/api/authApi.ts
frontend/src/api/examApi.ts
frontend/src/api/resultApi.ts
frontend/src/api/adminApi.ts
frontend/src/api/setupApi.ts
frontend/src/components/ErrorBoundary.tsx
```
### Modified Files (Frontend)
```
frontend/src/contexts/AuthContext.tsx — uses authApi + setupApi
frontend/src/components/MyExams.tsx — uses examApi
frontend/src/components/MyResults.tsx — uses resultApi
frontend/src/components/ExamEditor.tsx — uses examApi
frontend/src/components/Settings.tsx — uses authApi
frontend/src/components/AdminDashboard.tsx — uses adminApi
frontend/src/components/SetupWizard.tsx — uses setupApi
frontend/src/components/ExamRunner.tsx — uses resultApi
frontend/src/components/Workspace.tsx — uses authApi
frontend/src/App.tsx — integrates ErrorBoundary around Routes
```
---
## Conclusion
Phase 1 + Phase 2 establishes a solid, production-ready foundation:
- ✅ Clean separation of concerns
- ✅ Type-safe validation at boundaries
- ✅ Consistent error handling
- ✅ Frontend API abstraction (100% migrated)
- ✅ Error boundaries for resilience
- ✅ Environment configuration management
- ✅ Both frontend and backend compile with zero TypeScript errors
The remaining items (A–D, G, I–L) are well-scoped, incremental improvements that can be tackled one at a time without blocking each other. The architecture now supports horizontal scaling, database migration, and team parallelization.
@@ -0,0 +1,104 @@
# Security Audit Fixes Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Fix all vulnerabilities confirmed by the 2026-08-21 full-codebase security audit (5 confirmed ≥8/10 confidence + 6 sub-threshold verified findings).
**Architecture:** Three clusters. (A) Session security: JWTs gain a `tv` (token version) claim; `requireAuth` revalidates the user against SQLite on every request (deleted accounts, demoted roles, password-rotation revocation); logout blacklists until real `exp`. (B) AI cost control: wire the existing-but-dead quota helpers into every provider call path and cap attacker-controlled prompt sizes. (C) Bootstrap & hygiene: make the setup wizard the only admin bootstrap, stop register-time auto-admin, sanitize admin user listings, bound result schemas, enforce view-only share mode server-side, pin SSH host keys in CI.
**Tech Stack:** Express 5, better-sqlite3 (sync), jsonwebtoken 9, zod 3, vitest 4, Gitea Actions.
**Spec:** Security audit report delivered in this session (2026-08-21). Findings referenced below as Vuln N / sub-threshold N.
## Global Constraints
- Do not alter lines containing redacted numeric literals in `userRepo.ts` (`storageQuotaBytes ?? …`) and `migrations.ts` (`storage_quota_bytes … DEFAULT`) — edit around them.
- Keep HTTP response shapes of AI endpoints unchanged (`{ data, usageTokens }` payloads stay) so the frontend needs no changes.
- Tests run with `cd backend && npx vitest run`; vitest.setup stubs `JWT_SECRET`, `DB_PATH=:memory:`; `getDb()` runs full migrations on first use.
- No git commits — user has not requested them; leave changes in working tree.
- Existing deployments upgrade transparently; all users are logged out once by the new `tv` claim (accepted, noted in final report).
---
### Task 1: Token versioning + DB revalidation (Vuln 3, Vuln 4, sub-threshold 1)
**Files:**
- Modify: `backend/src/db/migrations.ts` (add column)
- Modify: `backend/src/utils/tokens.ts` (JwtPayload.tv)
- Modify: `backend/src/repositories/userRepo.ts` (token_version plumbing)
- Modify: `backend/src/middleware/auth.ts` (revalidation)
- Modify: `backend/src/services/authService.ts` (sign with tv, bump on password events)
- Modify: `backend/src/routes/setup.ts` (sign with tv)
- Modify: `backend/src/routes/auth.ts` (logout uses decoded exp)
- Modify: `backend/src/utils/tokenBlacklist.ts` (purge expired rows at init)
- Test: `backend/src/utils/tokens.test.ts` (update payload fixtures), `backend/src/middleware/auth.test.ts` (new)
**Interfaces:**
- Produces: `JwtPayload { userId, email, displayName, role, tv }`; `DbUser.token_version: number`; `UpdateUserData.tokenVersion?: number`.
- [ ] **Step 1: Write failing tests** (`auth.test.ts`): valid token passes and `req.user.role` comes from DB; deleted user → 401; tv mismatch → 401. Update `tokens.test.ts` payload fixtures to include `tv: 0` (type error until implemented = failing).
- [ ] **Step 2: Run** `npx vitest run src/middleware/auth.test.ts src/utils/tokens.test.ts` → FAIL (missing tv / column).
- [ ] **Step 3: Implement** per code in execution notes: migration guard block; `token_version INTEGER NOT NULL DEFAULT 0`; JwtPayload gains required `tv`; requireAuth loads user via `getUserById`, 401 on missing user, 401 on `user.token_version !== payload.tv`, rebuilds `req.user` from the DB row; changePassword/resetPassword bump tokenVersion; logout blacklists `(decoded.exp ?? now+86400)*1000`; initCache purges expired rows before loading.
- [ ] **Step 4: Run** → PASS.
- [ ] **Step 5: Full suite** `npx vitest run` → PASS.
### Task 2: Setup-gated bootstrap, no register auto-admin, enumeration-safe register, last-admin guard (sub-threshold 2, 3, 4)
**Files:**
- Modify: `backend/src/routes/setup.ts` (status + POST gate on countAdmins)
- Modify: `backend/src/routes/auth.ts` (drop isFirstUser; last-admin self-delete guard)
- Modify: `backend/src/services/authService.ts` (register signature, role always 'user', duplicate → generic success after timing-equalized hash)
- Modify: `backend/src/db/migrations.ts` (remove auto-promote block)
- Modify: `backend/src/repositories/userRepo.ts` (remove dead promoteFirstUserToAdmin)
- Test: `backend/src/services/authService.test.ts` (new)
- [ ] **Step 1: Failing tests**: duplicate-email register resolves `{ message }` with no token; fresh register returns token with role 'user'; both take ~equal time (hash-before-check asserted structurally, not by timing).
- [ ] **Step 2: Run** → FAIL.
- [ ] **Step 3: Implement**: setup status/gate switch to `countAdmins() > 0`; register drops isFirstUser param, hashes password before duplicate check, returns `{ message: 'Check your inbox…' }` on duplicates; `DELETE /api/auth/me` refuses when `role==='admin' && countAdmins()<=1`; migrations/userRepo promotion code deleted.
- [ ] **Step 4: Run** → PASS. **Step 5: Full suite** → PASS.
### Task 3: AI quota enforcement + prompt size caps (Vuln 1, Vuln 2)
**Files:**
- Modify: `backend/src/utils/aiQuota.ts` (add enforceDailyAiQuota throwing AppError 429)
- Create: `backend/src/validators/gradeSchemas.ts` (schema with serialized-size superRefine caps), `backend/src/validators/aiSchemas.ts` (hintSchema moved + cap)
- Modify: `backend/src/routes/ai.ts`, `backend/src/routes/grade.ts`, `backend/src/routes/results.ts` (enforce before provider call; recordDailyAiUsage after, using returned usageTokens)
- Test: `backend/src/utils/aiQuota.test.ts`, `backend/src/validators/gradeSchemas.test.ts`
**Interfaces:**
- Consumes: `recordDailyAiUsage(userId, getUtcDateKey(), tokens)`; services' existing `AiUsageResult.usageTokens`.
- Produces: `enforceDailyAiQuota(userId): void` (throws `AppError(429)`).
- [ ] **Step 1: Failing tests**: quota throws 429 when used ≥ limit; passes under limit; passes when limit unset ("0"/absent → null → unlimited). Grade schema rejects question JSON > 8000 chars / answer > 4000 chars.
- [ ] **Step 2: Run** → FAIL. **Step 3: Implement. Step 4: Run → PASS. Step 5: Full suite → PASS.**
### Task 4: Admin user DTO + result schema bounds (sub-threshold 5, 6)
**Files:**
- Modify: `backend/src/repositories/userRepo.ts` (`SafeUser`, explicit SELECT columns in listUsers)
- Modify: `backend/src/validators/resultSchemas.ts` (finite/int/min/max bounds, title ≤300)
- Test: `backend/src/validators/resultSchemas.test.ts`, repo test for sanitized listing
- [ ] **Step 1: Failing tests**: listUsers never includes password_hash/verify_token/reset_token/reset_expires keys; schema rejects negative counts, non-integers, huge floats, oversized titles.
- [ ] **Step 2–5:** implement → PASS → full suite PASS.
### Task 5: View-only share clone block (Vuln 5)
**Files:**
- Modify: `backend/src/routes/share.ts`
- Test: `backend/src/routes/share.test.ts`
- [ ] Export pure `canClone(shareMode?: string | null): boolean` ('view' → false; 'clone'/null/undefined → true). Failing test first, then guard `if (!canClone(source.share_mode)) throw new AppError(403, 'This exam is shared as view-only and cannot be saved')` before `cloneExam`. PASS + full suite.
### Task 6: CI SSH host-key pinning (sub-threshold 7)
**Files:** Modify `.gitea/workflows/deploy.yml`
- [ ] Replace keyscan step: write `${{ vars.SSH_HOST_PUBLIC_KEY }}` via env-indirection into known_hosts; hard-fail with setup instructions when unset. Validate YAML parses.
### Task 7: Verification
- [ ] `cd backend && npx tsc --noEmit && npx vitest run` — all green.
- [ ] `cd frontend && npm run build` (or tsc -b) — typecheck green (no frontend changes expected).
- [ ] Ultracode adversarial review workflow over the full diff; fix anything CONFIRMED.
- [ ] Final report mapping each finding → fix → evidence.
+22 -9
View File
@@ -1,14 +1,14 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
import { defineConfig, globalIgnores } from 'eslint/config'
import js from "@eslint/js";
import globals from "globals";
import reactHooks from "eslint-plugin-react-hooks";
import reactRefresh from "eslint-plugin-react-refresh";
import tseslint from "typescript-eslint";
import { defineConfig, globalIgnores } from "eslint/config";
export default defineConfig([
globalIgnores(['dist']),
globalIgnores(["dist"]),
{
files: ['**/*.{ts,tsx}'],
files: ["**/*.{ts,tsx}"],
extends: [
js.configs.recommended,
tseslint.configs.recommended,
@@ -19,5 +19,18 @@ export default defineConfig([
ecmaVersion: 2020,
globals: globals.browser,
},
rules: {
"react-hooks/rules-of-hooks": "warn",
"react-hooks/exhaustive-deps": "warn",
"react-hooks/set-state-in-effect": "warn",
"react-hooks/purity": "warn",
"react-refresh/only-export-components": "warn",
"no-control-regex": "off",
"@typescript-eslint/no-unused-vars": [
"warn",
{ argsIgnorePattern: "^_" },
],
"prefer-const": "warn",
},
},
])
]);
+101
View File
@@ -9,9 +9,13 @@
"version": "0.0.0",
"dependencies": {
"dompurify": "^3.4.1",
"i18next": "^23.16.8",
"i18next-browser-languagedetector": "^8.0.4",
"katex": "^0.16.45",
"marked": "^18.0.3",
"react": "^19.2.5",
"react-dom": "^19.2.5",
"react-i18next": "^15.4.1",
"react-router-dom": "^7.14.2"
},
"devDependencies": {
@@ -228,6 +232,15 @@
"node": ">=6.0.0"
}
},
"node_modules/@babel/runtime": {
"version": "7.29.2",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz",
"integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==",
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/template": {
"version": "7.28.6",
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz",
@@ -2287,6 +2300,47 @@
"dev": true,
"license": "MIT"
},
"node_modules/html-parse-stringify": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz",
"integrity": "sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==",
"license": "MIT",
"dependencies": {
"void-elements": "3.1.0"
}
},
"node_modules/i18next": {
"version": "23.16.8",
"resolved": "https://registry.npmjs.org/i18next/-/i18next-23.16.8.tgz",
"integrity": "sha512-06r/TitrM88Mg5FdUXAKL96dJMzgqLE5dv3ryBAra4KCwD9mJ4ndOTS95ZuymIGoE+2hzfdaMak2X11/es7ZWg==",
"funding": [
{
"type": "individual",
"url": "https://locize.com"
},
{
"type": "individual",
"url": "https://locize.com/i18next.html"
},
{
"type": "individual",
"url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project"
}
],
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.23.2"
}
},
"node_modules/i18next-browser-languagedetector": {
"version": "8.2.1",
"resolved": "https://registry.npmjs.org/i18next-browser-languagedetector/-/i18next-browser-languagedetector-8.2.1.tgz",
"integrity": "sha512-bZg8+4bdmaOiApD7N7BPT9W8MLZG+nPTOFlLiJiT8uzKXFjhxw4v2ierCXOwB5sFDMtuA5G4kgYZ0AznZxQ/cw==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.23.2"
}
},
"node_modules/ignore": {
"version": "5.3.2",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
@@ -2845,6 +2899,18 @@
"node": ">=10"
}
},
"node_modules/marked": {
"version": "18.0.3",
"resolved": "https://registry.npmjs.org/marked/-/marked-18.0.3.tgz",
"integrity": "sha512-7VT90JOkDeaRWpfjOReRGPEKn0ecdARBkDGL+tT1wZY0efPPqkUxLUSmzy/C7TIylQYJC9STISEsCHrqb/7VIA==",
"license": "MIT",
"bin": {
"marked": "bin/marked.js"
},
"engines": {
"node": ">= 20"
}
},
"node_modules/minimatch": {
"version": "3.1.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
@@ -3089,6 +3155,32 @@
"react": "^19.2.5"
}
},
"node_modules/react-i18next": {
"version": "15.7.4",
"resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-15.7.4.tgz",
"integrity": "sha512-nyU8iKNrI5uDJch0z9+Y5XEr34b0wkyYj3Rp+tfbahxtlswxSCjcUL9H0nqXo9IR3/t5Y5PKIA3fx3MfUyR9Xw==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.27.6",
"html-parse-stringify": "^3.0.1"
},
"peerDependencies": {
"i18next": ">= 23.4.0",
"react": ">= 16.8.0",
"typescript": "^5"
},
"peerDependenciesMeta": {
"react-dom": {
"optional": true
},
"react-native": {
"optional": true
},
"typescript": {
"optional": true
}
}
},
"node_modules/react-router": {
"version": "7.14.2",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.14.2.tgz",
@@ -3612,6 +3704,15 @@
}
}
},
"node_modules/void-elements": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz",
"integrity": "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/which": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
+4
View File
@@ -13,9 +13,13 @@
},
"dependencies": {
"dompurify": "^3.4.1",
"i18next": "^23.16.8",
"i18next-browser-languagedetector": "^8.0.4",
"katex": "^0.16.45",
"marked": "^18.0.3",
"react": "^19.2.5",
"react-dom": "^19.2.5",
"react-i18next": "^15.4.1",
"react-router-dom": "^7.14.2"
},
"devDependencies": {
+4 -1
View File
@@ -1 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="46" fill="none" viewBox="0 0 48 46"><path fill="#863bff" d="M25.946 44.938c-.664.845-2.021.375-2.021-.698V33.937a2.26 2.26 0 0 0-2.262-2.262H10.287c-.92 0-1.456-1.04-.92-1.788l7.48-10.471c1.07-1.497 0-3.578-1.842-3.578H1.237c-.92 0-1.456-1.04-.92-1.788L10.013.474c.214-.297.556-.474.92-.474h28.894c.92 0 1.456 1.04.92 1.788l-7.48 10.471c-1.07 1.498 0 3.579 1.842 3.579h11.377c.943 0 1.473 1.088.89 1.83L25.947 44.94z" style="fill:#863bff;fill:color(display-p3 .5252 .23 1);fill-opacity:1"/><mask id="a" width="48" height="46" x="0" y="0" maskUnits="userSpaceOnUse" style="mask-type:alpha"><path fill="#000" d="M25.842 44.938c-.664.844-2.021.375-2.021-.698V33.937a2.26 2.26 0 0 0-2.262-2.262H10.183c-.92 0-1.456-1.04-.92-1.788l7.48-10.471c1.07-1.498 0-3.579-1.842-3.579H1.133c-.92 0-1.456-1.04-.92-1.787L9.91.473c.214-.297.556-.474.92-.474h28.894c.92 0 1.456 1.04.92 1.788l-7.48 10.471c-1.07 1.498 0 3.578 1.842 3.578h11.377c.943 0 1.473 1.088.89 1.832L25.843 44.94z" style="fill:#000;fill-opacity:1"/></mask><g mask="url(#a)"><g filter="url(#b)"><ellipse cx="5.508" cy="14.704" fill="#ede6ff" rx="5.508" ry="14.704" style="fill:#ede6ff;fill:color(display-p3 .9275 .9033 1);fill-opacity:1" transform="matrix(.00324 1 1 -.00324 -4.47 31.516)"/></g><g filter="url(#c)"><ellipse cx="10.399" cy="29.851" fill="#ede6ff" rx="10.399" ry="29.851" style="fill:#ede6ff;fill:color(display-p3 .9275 .9033 1);fill-opacity:1" transform="matrix(.00324 1 1 -.00324 -39.328 7.883)"/></g><g filter="url(#d)"><ellipse cx="5.508" cy="30.487" fill="#7e14ff" rx="5.508" ry="30.487" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(89.814 -25.913 -14.639)scale(1 -1)"/></g><g filter="url(#e)"><ellipse cx="5.508" cy="30.599" fill="#7e14ff" rx="5.508" ry="30.599" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(89.814 -32.644 -3.334)scale(1 -1)"/></g><g filter="url(#f)"><ellipse cx="5.508" cy="30.599" fill="#7e14ff" rx="5.508" ry="30.599" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="matrix(.00324 1 1 -.00324 -34.34 30.47)"/></g><g filter="url(#g)"><ellipse cx="14.072" cy="22.078" fill="#ede6ff" rx="14.072" ry="22.078" style="fill:#ede6ff;fill:color(display-p3 .9275 .9033 1);fill-opacity:1" transform="rotate(93.35 24.506 48.493)scale(-1 1)"/></g><g filter="url(#h)"><ellipse cx="3.47" cy="21.501" fill="#7e14ff" rx="3.47" ry="21.501" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(89.009 28.708 47.59)scale(-1 1)"/></g><g filter="url(#i)"><ellipse cx="3.47" cy="21.501" fill="#7e14ff" rx="3.47" ry="21.501" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(89.009 28.708 47.59)scale(-1 1)"/></g><g filter="url(#j)"><ellipse cx=".387" cy="8.972" fill="#7e14ff" rx="4.407" ry="29.108" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(39.51 .387 8.972)"/></g><g filter="url(#k)"><ellipse cx="47.523" cy="-6.092" fill="#7e14ff" rx="4.407" ry="29.108" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(37.892 47.523 -6.092)"/></g><g filter="url(#l)"><ellipse cx="41.412" cy="6.333" fill="#47bfff" rx="5.971" ry="9.665" style="fill:#47bfff;fill:color(display-p3 .2799 .748 1);fill-opacity:1" transform="rotate(37.892 41.412 6.333)"/></g><g filter="url(#m)"><ellipse cx="-1.879" cy="38.332" fill="#7e14ff" rx="4.407" ry="29.108" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(37.892 -1.88 38.332)"/></g><g filter="url(#n)"><ellipse cx="-1.879" cy="38.332" fill="#7e14ff" rx="4.407" ry="29.108" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(37.892 -1.88 38.332)"/></g><g filter="url(#o)"><ellipse cx="35.651" cy="29.907" fill="#7e14ff" rx="4.407" ry="29.108" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(37.892 35.651 29.907)"/></g><g filter="url(#p)"><ellipse cx="38.418" cy="32.4" fill="#47bfff" rx="5.971" ry="15.297" style="fill:#47bfff;fill:color(display-p3 .2799 .748 1);fill-opacity:1" transform="rotate(37.892 38.418 32.4)"/></g></g><defs><filter id="b" width="60.045" height="41.654" x="-19.77" y="16.149" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="7.659"/></filter><filter id="c" width="90.34" height="51.437" x="-54.613" y="-7.533" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="7.659"/></filtLine truncated
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" role="img" aria-label="BrainWave">
<rect x="2" y="2" width="20" height="20" rx="5" fill="none" stroke="#1f1f24" stroke-width="2" />
<rect x="6" y="6" width="10" height="12" rx="1.5" fill="#1f1f24" />
</svg>

Before

Width:  |  Height:  |  Size: 9.3 KiB

After

Width:  |  Height:  |  Size: 271 B

+215 -126
View File
@@ -1,5 +1,6 @@
import { useState, useEffect, useCallback } from "react";
import { useState, useEffect, useCallback, useRef } from "react";
import { flushSync } from "react-dom";
import { useTranslation } from "react-i18next";
import {
useNavigate,
useLocation,
@@ -7,7 +8,7 @@ import {
Route,
Navigate,
} from "react-router-dom";
import type { Exam, ExamState, HistoryEntry, Tweaks } from "./types";
import type { Exam, ExamState, HistoryEntry } from "./types";
import { gradeQuestion } from "./utils/grading";
import {
loadState,
@@ -17,23 +18,24 @@ import {
appendHistory,
clearHistory,
} from "./utils/storage";
import { apiFetch, isAuthenticated } from "./utils/api";
import { slugify } from "./utils/slug";
import { isAuthenticated } from "./utils/api";
import * as resultApi from "./api/resultApi";
import * as examApi from "./api/examApi";
import { getExamUrlId } from "./utils/slug";
import { AuthProvider, useAuth } from "./contexts/AuthContext";
import { TweaksProvider, useTweaks } from "./contexts/TweaksContext";
import { ErrorBoundary } from "./components/ErrorBoundary";
import Landing from "./components/Landing";
import ExamRoute from "./components/ExamRoute";
import Results from "./components/Results";
import TweaksPanel from "./components/TweaksPanel";
import AuthModal from "./components/AuthModal";
import EmailVerify from "./components/EmailVerify";
import ResetPassword from "./components/ResetPassword";
import AdminDashboard from "./components/AdminDashboard";
const DEFAULT_TWEAKS: Tweaks = {
theme: "light",
fontSize: "medium",
feedbackMode: "auto",
};
import SetupWizard from "./components/SetupWizard";
import Workspace from "./components/Workspace";
import ExamEditor from "./components/ExamEditor";
import ShareView from "./components/ShareView";
const withNotes = (state: ExamState): ExamState => ({
...state,
@@ -41,38 +43,27 @@ const withNotes = (state: ExamState): ExamState => ({
});
function AppInner() {
const { user, loading: authLoading, logout } = useAuth();
const { t } = useTranslation();
const { user, loading: authLoading, needsSetup, refreshUser, logout } = useAuth();
const { tweaks } = useTweaks();
const navigate = useNavigate();
const { pathname } = useLocation();
const [exam, setExam] = useState<Exam | null>(null);
const [examState, setExamState] = useState<ExamState | null>(null);
const [reviewResultId, setReviewResultId] = useState<number | null>(null);
const [history, setHistory] = useState<HistoryEntry[]>(() => loadHistory());
const [tweaks, setTweaks] = useState<Tweaks>(DEFAULT_TWEAKS);
const [tweaksOpen, setTweaksOpen] = useState(false);
const [authModalOpen, setAuthModalOpen] = useState(false);
const [userMenuOpen, setUserMenuOpen] = useState(false);
const savedExamIdRef = useRef<number | null>(null);
useEffect(() => {
if (!authLoading && user && isAuthenticated()) {
apiFetch<{
results: Array<{
id: number;
title: string;
score_points: number;
total_points: number;
correct_count: number;
graded_count: number;
passing_score: number;
elapsed_ms: number;
completed_at: number;
exam_id: number | null;
}>;
}>("/api/results")
resultApi.listResults()
.then((data) => {
const apiHistory: HistoryEntry[] = data.results.map((r) => ({
id: String(r.id),
title: r.title,
completedAt: r.completed_at * 1000,
completedAt: r.completed_at,
elapsedMs: r.elapsed_ms,
gradedCount: r.graded_count,
correctCount: r.correct_count,
@@ -98,41 +89,67 @@ function AppInner() {
}
}, [user, authLoading]);
useEffect(() => {
document.documentElement.dataset["theme"] = tweaks.theme;
const sizeMap: Record<string, string> = {
small: "14px",
medium: "15px",
large: "17px",
};
document.body.style.fontSize = sizeMap[tweaks.fontSize] ?? "15px";
}, [tweaks]);
useEffect(() => {
if (pathname.startsWith("/exam/") && exam && examState)
saveState({ exam, state: examState });
}, [exam, examState, pathname]);
const updateTweak = <K extends keyof Tweaks>(key: K, value: Tweaks[K]) =>
setTweaks((t) => ({ ...t, [key]: value }));
// Bridge: MyResults stores review data in localStorage; load it on /review/:slug
useEffect(() => {
if (pathname.startsWith("/review/")) {
const reviewExam = localStorage.getItem('brainwave.review.exam')
const reviewState = localStorage.getItem('brainwave.review.state')
const reviewResultIdRaw = localStorage.getItem('brainwave.review.resultId')
if (reviewExam && reviewState) {
try {
const parsedExam = JSON.parse(reviewExam) as Exam
const parsedState = JSON.parse(reviewState) as ExamState
setExam(parsedExam)
setExamState(withNotes(parsedState))
if (reviewResultIdRaw) {
const id = Number(reviewResultIdRaw)
if (Number.isFinite(id) && id > 0) setReviewResultId(id)
}
localStorage.removeItem('brainwave.review.exam')
localStorage.removeItem('brainwave.review.state')
localStorage.removeItem('brainwave.review.resultId')
} catch {
// ignore parse errors
}
}
}
}, [pathname]);
const setExamStateStrict: React.Dispatch<React.SetStateAction<ExamState>> = (
nextState,
) => {
setExamState((prev) => {
if (!prev) return prev;
return typeof nextState === "function"
? (nextState as (prevState: ExamState) => ExamState)(prev)
: nextState;
});
};
const setExamStateStrict = useCallback<React.Dispatch<React.SetStateAction<ExamState>>>(
(nextState) => {
setExamState((prev) => {
if (!prev) return prev;
return typeof nextState === "function"
? (nextState as (prevState: ExamState) => ExamState)(prev)
: nextState;
});
},
[],
);
const activateExam = (data: Exam, state: ExamState) => {
setExam(data);
setExamState(withNotes(state));
};
const loadExam = (data: Exam) => {
const loadExam = (data: Exam, saveToWorkspace = false, examId?: number | null, isRedo = false) => {
if (saveToWorkspace && user && isAuthenticated()) {
examApi.createExam(data)
.then((res) => {
savedExamIdRef.current = res.id;
})
.catch(() => {
// silent fail — exam not saved but user can still take it
});
}
if (examId != null) {
savedExamIdRef.current = examId;
}
const fm =
tweaks.feedbackMode === "auto"
? (data.feedbackMode ?? "end")
@@ -147,9 +164,10 @@ function AppInner() {
revealed: {},
startedAt: Date.now(),
feedbackMode: fm,
isRedo,
});
});
navigate(`/exam/${slugify(data.title)}/q1`);
navigate(`/exam/${getExamUrlId(data)}/q1`);
};
const resume = () => {
@@ -160,7 +178,7 @@ function AppInner() {
setExamState(withNotes(saved.state));
});
navigate(
`/exam/${slugify(saved.exam.title)}/q${saved.state.currentIdx + 1}`,
`/exam/${getExamUrlId(saved.exam)}/q${saved.state.currentIdx + 1}`,
);
}
};
@@ -186,38 +204,66 @@ function AppInner() {
state: { ...examState, completedAt },
};
if (user && isAuthenticated()) {
void apiFetch("/api/results", {
method: "POST",
body: JSON.stringify({
// Skip saving to history / API when this is a redo session
if (!examState.isRedo) {
if (user && isAuthenticated()) {
void resultApi.submitResult({
exam_id: savedExamIdRef.current,
title: entry.title,
scorePoints: entry.scorePoints,
totalPoints: entry.totalPoints,
correctCount: entry.correctCount,
gradedCount: entry.gradedCount,
passingScore: entry.passingScore,
elapsedMs: entry.elapsedMs,
notes: entry.state.notes,
state: entry.state,
exam: entry.exam,
}),
}).catch(() => {
score_points: entry.scorePoints,
total_points: entry.totalPoints,
correct_count: entry.correctCount,
graded_count: entry.gradedCount,
passing_score: entry.passingScore,
elapsed_ms: entry.elapsedMs,
state: entry.state as unknown,
exam: entry.exam as unknown,
}).then((data) => {
sessionStorage.setItem('brainwave.pendingResultId', String(data.id));
}).catch(() => {
appendHistory(entry);
});
} else {
appendHistory(entry);
});
} else {
appendHistory(entry);
}
setHistory((prev) => [entry, ...prev]);
}
setHistory((prev) => [entry, ...prev]);
clearState();
setExamState((s) => (s ? { ...s, completedAt } : s));
navigate("/results");
}, [exam, examState, user, navigate]);
const redoFailed = useCallback(() => {
if (!exam || !examState) return;
const graded = exam.questions.map((q) =>
gradeQuestion(q, examState.answers[q.id]),
);
const failedQuestions = exam.questions
.map((q, i) => ({ q, result: graded[i] }))
.filter(({ result }) => !result.correct)
.map(({ q }) => q);
if (failedQuestions.length === 0) return;
// Shuffle failed questions using Fisher-Yates
const shuffled = [...failedQuestions];
for (let i = shuffled.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]];
}
const redoExam: Exam = { ...exam, questions: shuffled };
loadExam(redoExam, false, savedExamIdRef.current, true);
}, [exam, examState]);
const exit = () => {
setExam(null);
setExamState(null);
navigate("/");
savedExamIdRef.current = null;
sessionStorage.removeItem('brainwave.pendingResultId');
navigate(user && isAuthenticated() ? "/workspace" : "/");
};
const restart = () => {
@@ -227,53 +273,86 @@ function AppInner() {
const reviewAttempt = (entry: HistoryEntry) => {
const remoteId = (entry as HistoryEntry & { _remoteId?: number })._remoteId;
if (remoteId && user && isAuthenticated()) {
void apiFetch<{
exam: Exam;
state: ExamState;
title: string;
scorePoints: number;
totalPoints: number;
correctCount: number;
gradedCount: number;
passingScore: number;
elapsedMs: number;
completedAt: number;
}>(`/api/results/${remoteId}`)
void resultApi.getResult(remoteId)
.then((data) => {
flushSync(() => {
setExam(data.exam);
setExamState(withNotes(data.state));
setExam(data.exam as unknown as Exam);
setExamState(withNotes(data.state as unknown as ExamState));
});
navigate(`/review/${slugify(entry.title)}`);
navigate(`/review/${getExamUrlId(entry.exam)}`);
})
.catch(() => {
flushSync(() => {
setExam(entry.exam);
setExamState(withNotes(entry.state));
});
navigate(`/review/${slugify(entry.title)}`);
navigate(`/review/${getExamUrlId(entry.exam)}`);
});
} else {
flushSync(() => {
setExam(entry.exam);
setExamState(withNotes(entry.state));
});
navigate(`/review/${slugify(entry.title)}`);
navigate(`/review/${getExamUrlId(entry.exam)}`);
}
};
const retakeAttempt = (entry: HistoryEntry) => {
const remoteId = (entry as HistoryEntry & { _remoteId?: number })._remoteId;
if (remoteId && user && isAuthenticated()) {
void resultApi.getResult(remoteId)
.then((data) => {
loadExam(data.exam as unknown as Exam, false, null, false);
})
.catch(() => {
loadExam(entry.exam, false, null, false);
});
} else {
loadExam(entry.exam, false, null, false);
}
};
const clearAllHistory = () => {
if (confirm("Clear all past attempts?")) {
if (confirm(t('landing.clearHistoryConfirm'))) {
clearHistory();
setHistory([]);
}
};
const [pendingResultId, setPendingResultId] = useState<number | null>(null);
useEffect(() => {
if (pathname.startsWith('/results') && !exam && !examState && user && isAuthenticated()) {
const id = sessionStorage.getItem('brainwave.pendingResultId');
if (!id) {
navigate('/', { replace: true });
return;
}
if (Number(id) !== pendingResultId) {
setPendingResultId(Number(id));
resultApi.getResult(Number(id))
.then((data) => {
setExam(data.exam as unknown as Exam);
setExamState(withNotes(data.state as unknown as ExamState));
})
.catch(() => {
navigate('/', { replace: true });
});
}
}
}, [pathname, exam, examState, user, navigate, pendingResultId]);
const handleAuthFlowDone = () => {
navigate("/");
navigate(user && isAuthenticated() ? "/workspace" : "/");
setAuthModalOpen(true);
};
if (needsSetup) {
return (
<SetupWizard onComplete={() => void refreshUser()} />
);
}
if (pathname === "/verify") {
return (
<div className="shell">
@@ -310,7 +389,7 @@ function AppInner() {
<button
className="brand-name"
onClick={exit}
aria-label="Home"
aria-label={t('common.backToHome')}
style={{
background: "none",
border: "none",
@@ -324,7 +403,7 @@ function AppInner() {
BrainWave
</button>
<span className="faint mono" style={{ fontSize: 11, marginLeft: 4 }}>
/ practice
/ {t('common.appTagline')}
</span>
</div>
<div className="topbar-meta">
@@ -334,25 +413,16 @@ function AppInner() {
{exam.title}
</span>
<button className="btn btn-ghost btn-sm" onClick={exit}>
Exit
{t('exam.exit')}
</button>
</>
)}
<button
className="btn btn-ghost btn-sm"
onClick={() => setTweaksOpen((o) => !o)}
style={{ marginLeft: 8 }}
aria-label="Tweaks"
>
⚙
</button>
{!authLoading && !user && (
<button
className="btn btn-primary btn-sm"
onClick={() => setAuthModalOpen(true)}
>
Sign In
{t('auth.signIn')}
</button>
)}
{!authLoading && user && (
@@ -360,7 +430,7 @@ function AppInner() {
<button
className="user-avatar"
onClick={() => setUserMenuOpen((o) => !o)}
aria-label="User menu"
aria-label={t('auth.userMenu')}
>
{user.displayName.charAt(0).toUpperCase()}
</button>
@@ -371,6 +441,15 @@ function AppInner() {
<div className="user-menu-email">{user.email}</div>
</div>
<div className="user-menu-divider" />
<button
className="user-menu-item"
onClick={() => {
setUserMenuOpen(false);
navigate("/workspace");
}}
>
{t('auth.myWorkspace')}
</button>
{user.role === "admin" && (
<button
className="user-menu-item"
@@ -379,7 +458,7 @@ function AppInner() {
navigate("/admin");
}}
>
Admin Dashboard
{t('auth.adminDashboard')}
</button>
)}
<button
@@ -389,7 +468,7 @@ function AppInner() {
logout();
}}
>
Sign Out
{t('auth.signOut')}
</button>
</div>
)}
@@ -402,13 +481,18 @@ function AppInner() {
<Route
path="/"
element={
<Landing
onLoadExam={loadExam}
history={history}
onResume={resume}
onClearHistory={clearAllHistory}
onReviewAttempt={reviewAttempt}
/>
user && isAuthenticated() ? (
<Navigate to="/workspace" replace />
) : (
<Landing
onLoadExam={loadExam}
history={history}
onResume={resume}
onClearHistory={clearAllHistory}
onReviewAttempt={reviewAttempt}
onRetakeAttempt={retakeAttempt}
/>
)
}
/>
<Route
@@ -433,8 +517,11 @@ function AppInner() {
state={examState}
onRestart={restart}
onExit={exit}
onRedoFailed={redoFailed}
reviewOnly={false}
/>
) : user && isAuthenticated() ? (
<div className="workspace-empty">{t('common.loading')}</div>
) : (
<Navigate to="/" replace />
)
@@ -450,6 +537,7 @@ function AppInner() {
onRestart={restart}
onExit={exit}
reviewOnly={true}
resultId={reviewResultId}
/>
) : (
<Navigate to="/" replace />
@@ -466,16 +554,13 @@ function AppInner() {
)
}
/>
<Route path="/workspace" element={<Navigate to="/workspace/overview" replace />} />
<Route path="/workspace/:tab" element={<Workspace onLoadExam={loadExam} />} />
<Route path="/exam-editor/:id" element={<ExamEditor />} />
<Route path="/share/:token" element={<ShareView onLoadExam={loadExam} />} />
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
{tweaksOpen && (
<TweaksPanel
tweaks={tweaks}
update={updateTweak}
onClose={() => setTweaksOpen(false)}
/>
)}
{authModalOpen && (
<AuthModal
onClose={() => setAuthModalOpen(false)}
@@ -488,8 +573,12 @@ function AppInner() {
export default function App() {
return (
<AuthProvider>
<AppInner />
</AuthProvider>
<ErrorBoundary>
<AuthProvider>
<TweaksProvider>
<AppInner />
</TweaksProvider>
</AuthProvider>
</ErrorBoundary>
);
}
+111
View File
@@ -0,0 +1,111 @@
import { apiFetch } from '../utils/api.js'
import type { GradingResult } from './gradeApi.js'
export interface AdminUser {
id: number
email: string
display_name: string
role: 'user' | 'admin'
email_verified: number
created_at: number
results_count: number
exams_count: number
}
export interface DashboardStats {
totalUsers: number
totalExams: number
totalResults: number
averageScore: number
recentResults: {
id: number
title: string
score_points: number
total_points: number
completed_at: number
display_name: string
email: string
}[]
}
export interface SiteSettings {
site_name: string
max_upload_mb: string
allow_guest_mode: string
registration_enabled: string
default_theme: string
maintenance_mode: string
maintenance_message: string
smtp_host: string
smtp_port: string
smtp_user: string
smtp_pass: string
smtp_from: string
smtp_secure: string
app_url: string
ai_provider: string
ai_model: string
ai_api_key: string
ai_base_url: string
}
export function getStats(): Promise<DashboardStats> {
return apiFetch('/api/admin/stats')
}
export function getSettings(): Promise<{ settings: SiteSettings }> {
return apiFetch('/api/admin/settings')
}
export function updateSettings(settings: SiteSettings): Promise<void> {
return apiFetch('/api/admin/settings', {
method: 'PUT',
body: JSON.stringify({ settings }),
})
}
export function listUsers(search?: string): Promise<{ users: AdminUser[]; total: number }> {
const q = search ? `?search=${encodeURIComponent(search)}` : ''
return apiFetch(`/api/admin/users${q}`)
}
export function changeRole(userId: number, role: 'user' | 'admin'): Promise<void> {
return apiFetch(`/api/admin/users/${userId}/role`, {
method: 'PATCH',
body: JSON.stringify({ role }),
})
}
export function deleteUser(userId: number): Promise<void> {
return apiFetch(`/api/admin/users/${userId}`, { method: 'DELETE' })
}
export interface SmtpConfig {
host: string
port: string
user: string
pass: string
from: string
secure: string
}
export function sendTestEmail(to: string | undefined, smtp: SmtpConfig): Promise<{ message: string }> {
return apiFetch('/api/admin/test-email', {
method: 'POST',
body: JSON.stringify({ to, smtp }),
})
}
export interface AiTestPayload {
provider: string
apiKey: string
model: string
baseURL?: string
}
export function testAiConnection(payload: AiTestPayload): Promise<GradingResult> {
return apiFetch('/api/admin/test-ai', {
method: 'POST',
body: JSON.stringify(payload),
})
}
+63
View File
@@ -0,0 +1,63 @@
import { apiFetch } from '../utils/api.js'
import type { Question, QuestionType } from '../types'
export interface GenerateQuestionsRequest {
topic: string
type: QuestionType
count: number
language: string
context?: string
}
export interface GeneratedQuestion extends Omit<Question, 'id'> {
id?: string
}
export interface GenerateQuestionsResponse {
questions: GeneratedQuestion[]
}
export function generateQuestions(req: GenerateQuestionsRequest): Promise<GenerateQuestionsResponse> {
return apiFetch('/api/ai/generate-question', {
method: 'POST',
body: JSON.stringify(req),
})
}
export interface HintRequest {
question: Record<string, unknown>
language: string
}
export interface HintResponse {
hint: string
}
export function getHint(req: HintRequest): Promise<HintResponse> {
return apiFetch('/api/ai/hint', {
method: 'POST',
body: JSON.stringify(req),
})
}
export interface TutorChatMessage {
role: 'user' | 'assistant'
content: string
}
export interface TutorChatRequest {
resultId: number
messages: TutorChatMessage[]
language: string
}
export interface TutorChatResponse {
reply: string
}
export function tutorChat(req: TutorChatRequest): Promise<TutorChatResponse> {
return apiFetch('/api/ai/tutor-chat', {
method: 'POST',
body: JSON.stringify(req),
})
}
+62
View File
@@ -0,0 +1,62 @@
import { apiFetch } from '../utils/api.js'
export interface User {
id: number
email: string
displayName: string
role: string
emailVerified?: boolean
storageQuotaBytes?: number
usedBytes?: number
usedPercent?: number
plan?: string
}
export interface AuthResponse {
token: string
user: User
}
export function register(data: { email: string; password: string; displayName: string }): Promise<AuthResponse> {
return apiFetch('/api/auth/register', { method: 'POST', body: JSON.stringify(data) })
}
export function login(data: { email: string; password: string }): Promise<AuthResponse> {
return apiFetch('/api/auth/login', { method: 'POST', body: JSON.stringify(data) })
}
export function getMe(): Promise<User> {
return apiFetch('/api/auth/me')
}
export function updateProfile(data: { displayName: string }): Promise<User> {
return apiFetch('/api/auth/me', { method: 'PUT', body: JSON.stringify(data) })
}
export function changePassword(data: { currentPassword: string; newPassword: string }): Promise<{ success: boolean }> {
return apiFetch('/api/auth/change-password', { method: 'POST', body: JSON.stringify(data) })
}
export function verifyEmailToken(token: string): Promise<{ message: string }> {
return apiFetch(`/api/auth/verify?token=${encodeURIComponent(token)}`)
}
export function resendVerification(email: string): Promise<{ message: string }> {
return apiFetch('/api/auth/resend-verification', { method: 'POST', body: JSON.stringify({ email }) })
}
export function forgotPassword(email?: string): Promise<{ message: string }> {
return apiFetch('/api/auth/forgot-password', { method: 'POST', body: JSON.stringify({ email }) })
}
export function resetPassword(data: { token: string; newPassword: string }): Promise<{ message: string }> {
return apiFetch('/api/auth/reset-password', { method: 'POST', body: JSON.stringify(data) })
}
export function deleteAccount(): Promise<{ success: boolean }> {
return apiFetch('/api/auth/me', { method: 'DELETE' })
}
export function logout(): Promise<void> {
return apiFetch('/api/auth/logout', { method: 'POST' })
}
+29
View File
@@ -0,0 +1,29 @@
import { apiFetch } from '../utils/api.js'
export interface Collection {
id: number
name: string
created_at: number
updated_at: number
exam_count: number
}
export function listCollections(): Promise<Collection[]> {
return apiFetch('/api/collections')
}
export function createCollection(name: string): Promise<{ id: number; name: string }> {
return apiFetch('/api/collections', { method: 'POST', body: JSON.stringify({ name }) })
}
export function updateCollection(id: number, name: string): Promise<{ success: boolean }> {
return apiFetch(`/api/collections/${id}`, { method: 'PUT', body: JSON.stringify({ name }) })
}
export function deleteCollection(id: number): Promise<{ success: boolean }> {
return apiFetch(`/api/collections/${id}`, { method: 'DELETE' })
}
export function moveExamToCollection(examId: number, collectionId: number | null): Promise<{ success: boolean }> {
return apiFetch(`/api/collections/move/${examId}`, { method: 'POST', body: JSON.stringify({ collectionId }) })
}
+92
View File
@@ -0,0 +1,92 @@
import { apiFetch } from '../utils/api.js'
import type { Exam } from '../types'
export interface ExamListItem {
id: number
title: string
subtitle: string | null
created_at: number
updated_at: number
question_count: number
share_token: string | null
is_favorite: number
tags: string
collection_id: number | null
}
export function listExams(): Promise<ExamListItem[]> {
return apiFetch('/api/exams')
}
export function getExam(id: number): Promise<Exam & { tags?: string[] }> {
return apiFetch(`/api/exams/${id}`)
}
export function createExam(exam: Exam, tags?: string[]): Promise<{ id: number; title: string }> {
return apiFetch('/api/exams', { method: 'POST', body: JSON.stringify({ exam, tags }) })
}
export function updateExam(id: number, exam: Exam, tags?: string[]): Promise<{ success: boolean }> {
return apiFetch(`/api/exams/${id}`, { method: 'PUT', body: JSON.stringify({ exam, tags }) })
}
export function deleteExam(id: number): Promise<{ success: boolean }> {
return apiFetch(`/api/exams/${id}`, { method: 'DELETE' })
}
export function shareExam(id: number): Promise<{ shareToken: string }> {
return apiFetch(`/api/exams/${id}/share`, { method: 'POST' })
}
export function unshareExam(id: number): Promise<{ success: boolean }> {
return apiFetch(`/api/exams/${id}/unshare`, { method: 'POST' })
}
export function getSharedExam(token: string): Promise<{ title: string; subtitle: string | null; questionCount: number; exam: unknown; shareMode: string }> {
return apiFetch(`/api/share/${token}`)
}
export function cloneSharedExam(token: string): Promise<{ id: number; title: string }> {
return apiFetch(`/api/share/${token}/clone`, { method: 'POST' })
}
export function setFavorite(id: number, isFavorite: boolean): Promise<{ success: boolean }> {
return apiFetch(`/api/exams/${id}/favorite`, { method: 'PATCH', body: JSON.stringify({ isFavorite }) })
}
export function exportExam(id: number): Promise<Blob> {
return fetch(`/api/exams/${id}/export`, {
headers: {
'Authorization': `Bearer ${localStorage.getItem('brainwave.auth.token') || ''}`,
},
}).then((res) => {
if (!res.ok) throw new Error('Export failed')
return res.blob()
})
}
export function duplicateExam(id: number): Promise<{ id: number; title: string }> {
return apiFetch(`/api/exams/${id}/duplicate`, { method: 'POST' })
}
export interface ClonedExamItem {
id: number
title: string
subtitle: string | null
created_at: number
updated_at: number
question_count: number
cloned_from_share_token: string
}
export function listClonedExams(): Promise<ClonedExamItem[]> {
return apiFetch('/api/exams/cloned')
}
export function setShareMode(id: number, mode: 'view' | 'clone'): Promise<{ success: boolean }> {
return apiFetch(`/api/exams/${id}/share-mode`, { method: 'PUT', body: JSON.stringify({ mode }) })
}
export function batchDelete(ids: number[]): Promise<{ deleted: number }> {
return apiFetch('/api/exams/batch-delete', { method: 'POST', body: JSON.stringify({ ids }) })
}
+21
View File
@@ -0,0 +1,21 @@
import { apiFetch } from '../utils/api.js'
export interface GradingResult {
suggestedGrade: number
feedback: string
detail?: unknown
}
export interface GradeRequest {
type: 'essay' | 'short-answer' | 'translation' | 'math' | 'ordering' | 'match'
question: Record<string, unknown>
answer: unknown
language: string
}
export function gradeQuestion(req: GradeRequest): Promise<GradingResult> {
return apiFetch('/api/grade', {
method: 'POST',
body: JSON.stringify(req),
})
}
+2
View File
@@ -0,0 +1,2 @@
// Re-export shared utilities
export { apiFetch, setToken, clearToken, isAuthenticated, ApiError } from '../utils/api.js'
+71
View File
@@ -0,0 +1,71 @@
import { apiFetch } from '../utils/api.js'
export interface ResultListItem {
id: number
title: string
score_points: number
total_points: number
correct_count: number
graded_count: number
passing_score: number
elapsed_ms: number
completed_at: number
exam_id: number | null
}
export interface SubmitResultData {
exam_id?: number | null
title: string
score_points: number
total_points: number
correct_count: number
graded_count: number
passing_score?: number
elapsed_ms: number
state: unknown
exam: unknown
}
export function listResults(): Promise<{ results: ResultListItem[]; total: number }> {
return apiFetch('/api/results')
}
export function getResult(id: number): Promise<ResultListItem & { state: unknown; exam: unknown }> {
return apiFetch(`/api/results/${id}`)
}
export function submitResult(data: SubmitResultData): Promise<{ id: number }> {
return apiFetch('/api/results', { method: 'POST', body: JSON.stringify(data) })
}
export function deleteResult(id: number): Promise<{ success: boolean }> {
return apiFetch(`/api/results/${id}`, { method: 'DELETE' })
}
export interface WeakArea {
category: string
avgScore: number
attempts: number
}
export interface StudyCoachResult {
weakAreas: WeakArea[]
studyTips: string[]
streakDays: number
weakestExamId: number | null
}
export interface ScoreTrend {
completed_at: number
score_pct: number
title: string
exam_id: number | null
}
export function analyzeResults(): Promise<StudyCoachResult> {
return apiFetch('/api/results/analyze')
}
export function getScoreTrends(): Promise<ScoreTrend[]> {
return apiFetch('/api/results/trends')
}
+38
View File
@@ -0,0 +1,38 @@
import { apiFetch } from '../utils/api.js'
export interface SetupStatus {
completed: boolean
siteTitle?: string
}
export function getSetupStatus(): Promise<SetupStatus> {
return apiFetch('/api/setup/status')
}
export interface SetupPayload {
email: string
password: string
displayName: string
smtpHost?: string
smtpPort?: string
smtpUser?: string
smtpPass?: string
smtpFrom?: string
smtpSecure?: string
appUrl?: string
}
export interface SetupResponse {
message: string
token: string
user: {
id: number
email: string
displayName: string
role: 'user' | 'admin'
}
}
export function completeSetup(data: SetupPayload): Promise<SetupResponse> {
return apiFetch('/api/setup', { method: 'POST', body: JSON.stringify(data) })
}
+214 -151
View File
@@ -1,49 +1,7 @@
import { useState, useEffect, useCallback } from "react";
import { apiFetch } from "../utils/api";
interface SiteSettings {
site_name: string;
max_upload_mb: string;
allow_guest_mode: string;
registration_enabled: string;
default_theme: string;
maintenance_mode: string;
maintenance_message: string;
smtp_host: string;
smtp_port: string;
smtp_user: string;
smtp_pass: string;
smtp_from: string;
smtp_secure: string;
app_url: string;
}
interface DashboardStats {
totalUsers: number;
totalExams: number;
totalResults: number;
averageScore: number;
recentResults: {
id: number;
title: string;
score_points: number;
total_points: number;
completed_at: number;
display_name: string;
email: string;
}[];
}
interface AdminUser {
id: number;
email: string;
display_name: string;
role: "user" | "admin";
email_verified: number;
created_at: number;
results_count: number;
exams_count: number;
}
import { useTranslation } from "react-i18next";
import * as adminApi from "../api/adminApi";
import type { DashboardStats, SiteSettings, AdminUser } from "../api/adminApi";
type AdminTab = "overview" | "settings" | "users";
@@ -52,6 +10,7 @@ interface Props {
}
export default function AdminDashboard({ onExit }: Readonly<Props>) {
const { t } = useTranslation();
const [tab, setTab] = useState<AdminTab>("overview");
const [stats, setStats] = useState<DashboardStats | null>(null);
const [settings, setSettings] = useState<SiteSettings | null>(null);
@@ -69,10 +28,15 @@ export default function AdminDashboard({ onExit }: Readonly<Props>) {
text: string;
} | null>(null);
const [testEmailSending, setTestEmailSending] = useState(false);
const [testAiMsg, setTestAiMsg] = useState<{
ok: boolean;
text: string;
} | null>(null);
const [testAiSending, setTestAiSending] = useState(false);
const loadStats = useCallback(async () => {
try {
const data = await apiFetch<DashboardStats>("/api/admin/stats");
const data = await adminApi.getStats();
setStats(data);
} catch (e) {
setError((e as Error).message);
@@ -81,9 +45,7 @@ export default function AdminDashboard({ onExit }: Readonly<Props>) {
const loadSettings = useCallback(async () => {
try {
const data = await apiFetch<{ settings: SiteSettings }>(
"/api/admin/settings",
);
const data = await adminApi.getSettings();
setSettings(data.settings);
setSettingsDraft(data.settings);
} catch (e) {
@@ -93,10 +55,7 @@ export default function AdminDashboard({ onExit }: Readonly<Props>) {
const loadUsers = useCallback(async (search = "") => {
try {
const q = search ? `?search=${encodeURIComponent(search)}` : "";
const data = await apiFetch<{ users: AdminUser[]; total: number }>(
`/api/admin/users${q}`,
);
const data = await adminApi.listUsers(search);
setUsers(data.users);
setUsersTotal(data.total);
} catch (e) {
@@ -115,12 +74,9 @@ export default function AdminDashboard({ onExit }: Readonly<Props>) {
setSaving(true);
setSaveMsg(null);
try {
await apiFetch("/api/admin/settings", {
method: "PUT",
body: JSON.stringify({ settings: settingsDraft }),
});
await adminApi.updateSettings(settingsDraft);
setSettings(settingsDraft);
setSaveMsg("Settings saved successfully");
setSaveMsg(t("admin.settingsSaved"));
setTimeout(() => setSaveMsg(null), 3000);
} catch (e) {
setError((e as Error).message);
@@ -129,21 +85,18 @@ export default function AdminDashboard({ onExit }: Readonly<Props>) {
}
};
const changeRole = async (userId: number, newRole: "user" | "admin") => {
const handleChangeRole = async (userId: number, newRole: "user" | "admin") => {
try {
await apiFetch(`/api/admin/users/${userId}/role`, {
method: "PATCH",
body: JSON.stringify({ role: newRole }),
});
await adminApi.changeRole(userId, newRole);
await loadUsers(userSearch);
} catch (e) {
setError((e as Error).message);
}
};
const deleteUser = async (userId: number) => {
const handleDeleteUser = async (userId: number) => {
try {
await apiFetch(`/api/admin/users/${userId}`, { method: "DELETE" });
await adminApi.deleteUser(userId);
setDeleteConfirm(null);
await loadUsers(userSearch);
await loadStats();
@@ -157,21 +110,17 @@ export default function AdminDashboard({ onExit }: Readonly<Props>) {
setTestEmailSending(true);
setTestEmailMsg(null);
try {
const smtp = {
await adminApi.sendTestEmail(testEmailTo || undefined, {
host: settingsDraft.smtp_host,
port: settingsDraft.smtp_port,
user: settingsDraft.smtp_user,
pass: settingsDraft.smtp_pass,
from: settingsDraft.smtp_from,
secure: settingsDraft.smtp_secure,
};
await apiFetch<{ message: string }>("/api/admin/test-email", {
method: "POST",
body: JSON.stringify({ to: testEmailTo || undefined, smtp }),
});
setTestEmailMsg({
ok: true,
text: "Test email sent successfully! Check your inbox.",
text: t("admin.testEmailSent"),
});
} catch (e) {
setTestEmailMsg({ ok: false, text: (e as Error).message });
@@ -181,6 +130,26 @@ export default function AdminDashboard({ onExit }: Readonly<Props>) {
}
};
const sendTestAi = async () => {
if (!settingsDraft) return;
setTestAiSending(true);
setTestAiMsg(null);
try {
await adminApi.testAiConnection({
provider: settingsDraft.ai_provider,
apiKey: settingsDraft.ai_api_key,
model: settingsDraft.ai_model || 'gpt-4o-mini',
baseURL: settingsDraft.ai_base_url || undefined,
});
setTestAiMsg({ ok: true, text: t("admin.aiGrading.testSuccess") });
} catch (e) {
setTestAiMsg({ ok: false, text: (e as Error).message });
} finally {
setTestAiSending(false);
setTimeout(() => setTestAiMsg(null), 6000);
}
};
const handleUserSearch = (e: React.FormEvent) => {
e.preventDefault();
void loadUsers(userSearch);
@@ -197,22 +166,33 @@ export default function AdminDashboard({ onExit }: Readonly<Props>) {
const formatDate = (ts: number) => {
const d = new Date(ts * 1000);
return d.toLocaleDateString("en-US", {
return d.toLocaleDateString(undefined, {
month: "short",
day: "numeric",
year: "numeric",
});
};
const tabLabel = (tName: AdminTab) => {
switch (tName) {
case "overview":
return t("admin.overview");
case "settings":
return t("admin.settings");
case "users":
return t("admin.users");
}
};
return (
<div className="admin">
<div className="admin-header">
<div className="admin-header-left">
<div className="admin-eyebrow">Administration</div>
<h1 className="admin-title">Dashboard</h1>
<div className="admin-eyebrow">{t("admin.administration")}</div>
<h1 className="admin-title">{t("admin.dashboard")}</h1>
</div>
<button className="btn btn-ghost btn-sm" onClick={onExit}>
← Back to site
{t("admin.backToSite")}
</button>
</div>
@@ -226,16 +206,16 @@ export default function AdminDashboard({ onExit }: Readonly<Props>) {
)}
<div className="admin-tabs">
{(["overview", "settings", "users"] as AdminTab[]).map((t) => (
{(["overview", "settings", "users"] as AdminTab[]).map((tName) => (
<button
key={t}
className={`admin-tab${tab === t ? " active" : ""}`}
onClick={() => setTab(t)}
key={tName}
className={`admin-tab${tab === tName ? " active" : ""}`}
onClick={() => setTab(tName)}
>
<span className="admin-tab-icon">
{t === "overview" ? "◈" : t === "settings" ? "⚙" : "⊕"}
{tName === "overview" ? "◈" : tName === "settings" ? "⚙" : "⊕"}
</span>
{t.charAt(0).toUpperCase() + t.slice(1)}
{tabLabel(tName)}
</button>
))}
</div>
@@ -250,7 +230,7 @@ export default function AdminDashboard({ onExit }: Readonly<Props>) {
<div className="admin-stat-value">
{stats?.totalUsers ?? "—"}
</div>
<div className="admin-stat-label">Total Users</div>
<div className="admin-stat-label">{t("admin.totalUsers")}</div>
</div>
</div>
<div className="admin-stat-card">
@@ -259,7 +239,7 @@ export default function AdminDashboard({ onExit }: Readonly<Props>) {
<div className="admin-stat-value">
{stats?.totalExams ?? "—"}
</div>
<div className="admin-stat-label">Saved Exams</div>
<div className="admin-stat-label">{t("admin.savedExams")}</div>
</div>
</div>
<div className="admin-stat-card">
@@ -268,7 +248,7 @@ export default function AdminDashboard({ onExit }: Readonly<Props>) {
<div className="admin-stat-value">
{stats?.totalResults ?? "—"}
</div>
<div className="admin-stat-label">Exams Taken</div>
<div className="admin-stat-label">{t("admin.examsTaken")}</div>
</div>
</div>
<div className="admin-stat-card">
@@ -277,18 +257,18 @@ export default function AdminDashboard({ onExit }: Readonly<Props>) {
<div className="admin-stat-value">
{stats ? `${stats.averageScore}%` : "—"}
</div>
<div className="admin-stat-label">Avg. Score</div>
<div className="admin-stat-label">{t("admin.avgScore")}</div>
</div>
</div>
</div>
<div className="admin-panel">
<div className="admin-panel-header">
<h3>Recent Activity</h3>
<h3>{t("admin.recentActivity")}</h3>
</div>
<div className="admin-panel-body">
{!stats?.recentResults?.length && (
<div className="admin-empty">No recent activity yet</div>
<div className="admin-empty">{t("admin.noRecentActivity")}</div>
)}
{stats?.recentResults?.map((r) => {
const pct = r.total_points
@@ -333,7 +313,7 @@ export default function AdminDashboard({ onExit }: Readonly<Props>) {
<div className="admin-panel">
<div className="admin-panel-header">
<h3>General</h3>
<h3>{t("admin.general")}</h3>
</div>
<div className="admin-panel-body admin-settings-form">
<div className="admin-setting-row">
@@ -341,9 +321,9 @@ export default function AdminDashboard({ onExit }: Readonly<Props>) {
className="admin-setting-label"
htmlFor="setting-site-name"
>
<span className="admin-setting-title">Site Name</span>
<span className="admin-setting-title">{t("admin.siteName")}</span>
<span className="admin-setting-desc">
The name displayed throughout the application
{t("admin.siteNameDesc")}
</span>
</label>
<input
@@ -359,10 +339,10 @@ export default function AdminDashboard({ onExit }: Readonly<Props>) {
htmlFor="setting-max-upload"
>
<span className="admin-setting-title">
Max Upload Size (MB)
{t("admin.maxUpload")}
</span>
<span className="admin-setting-desc">
Maximum file size for exam JSON uploads
{t("admin.maxUploadDesc")}
</span>
</label>
<input
@@ -380,9 +360,9 @@ export default function AdminDashboard({ onExit }: Readonly<Props>) {
className="admin-setting-label"
htmlFor="setting-default-theme"
>
<span className="admin-setting-title">Default Theme</span>
<span className="admin-setting-title">{t("admin.defaultTheme")}</span>
<span className="admin-setting-desc">
Default theme for new visitors
{t("admin.defaultThemeDesc")}
</span>
</label>
<div className="admin-setting-toggle-group">
@@ -390,13 +370,13 @@ export default function AdminDashboard({ onExit }: Readonly<Props>) {
className={`admin-toggle-btn${settingsDraft.default_theme === "light" ? " active" : ""}`}
onClick={() => updateDraft("default_theme", "light")}
>
☀ Light
☀ {t("tweaks.light")}
</button>
<button
className={`admin-toggle-btn${settingsDraft.default_theme === "dark" ? " active" : ""}`}
onClick={() => updateDraft("default_theme", "dark")}
>
☾ Dark
☾ {t("tweaks.dark")}
</button>
</div>
</div>
@@ -405,14 +385,14 @@ export default function AdminDashboard({ onExit }: Readonly<Props>) {
<div className="admin-panel">
<div className="admin-panel-header">
<h3>Access Control</h3>
<h3>{t("admin.accessControl")}</h3>
</div>
<div className="admin-panel-body admin-settings-form">
<div className="admin-setting-row">
<label className="admin-setting-label">
<span className="admin-setting-title">Allow Guest Mode</span>
<span className="admin-setting-title">{t("admin.allowGuestMode")}</span>
<span className="admin-setting-desc">
Let unauthenticated users take exams without signing in
{t("admin.allowGuestModeDesc")}
</span>
</label>
<button
@@ -434,10 +414,10 @@ export default function AdminDashboard({ onExit }: Readonly<Props>) {
<div className="admin-setting-row">
<label className="admin-setting-label">
<span className="admin-setting-title">
Registration Enabled
{t("admin.registrationEnabled")}
</span>
<span className="admin-setting-desc">
Allow new users to create accounts
{t("admin.registrationEnabledDesc")}
</span>
</label>
<button
@@ -461,7 +441,7 @@ export default function AdminDashboard({ onExit }: Readonly<Props>) {
<div className="admin-panel">
<div className="admin-panel-header">
<h3>Email / SMTP</h3>
<h3>{t("admin.emailSmtp")}</h3>
</div>
<div className="admin-panel-body admin-settings-form">
<div className="admin-setting-row">
@@ -469,10 +449,9 @@ export default function AdminDashboard({ onExit }: Readonly<Props>) {
className="admin-setting-label"
htmlFor="setting-app-url"
>
<span className="admin-setting-title">App URL</span>
<span className="admin-setting-title">{t("admin.appUrl")}</span>
<span className="admin-setting-desc">
Base URL used in email links (e.g.
https://brainwave.example.com)
{t("admin.appUrlDesc")}
</span>
</label>
<input
@@ -487,9 +466,9 @@ export default function AdminDashboard({ onExit }: Readonly<Props>) {
className="admin-setting-label"
htmlFor="setting-smtp-host"
>
<span className="admin-setting-title">SMTP Host</span>
<span className="admin-setting-title">{t("admin.smtpHost")}</span>
<span className="admin-setting-desc">
Hostname of your SMTP server
{t("admin.smtpHostDesc")}
</span>
</label>
<input
@@ -505,9 +484,9 @@ export default function AdminDashboard({ onExit }: Readonly<Props>) {
className="admin-setting-label"
htmlFor="setting-smtp-port"
>
<span className="admin-setting-title">SMTP Port</span>
<span className="admin-setting-title">{t("admin.smtpPort")}</span>
<span className="admin-setting-desc">
Common ports: 587 (STARTTLS), 465 (SSL), 25 (plain)
{t("admin.smtpPortDesc")}
</span>
</label>
<input
@@ -525,9 +504,9 @@ export default function AdminDashboard({ onExit }: Readonly<Props>) {
className="admin-setting-label"
htmlFor="setting-smtp-user"
>
<span className="admin-setting-title">Username</span>
<span className="admin-setting-title">{t("admin.username")}</span>
<span className="admin-setting-desc">
SMTP authentication username
{t("admin.usernameDesc")}
</span>
</label>
<input
@@ -543,9 +522,9 @@ export default function AdminDashboard({ onExit }: Readonly<Props>) {
className="admin-setting-label"
htmlFor="setting-smtp-pass"
>
<span className="admin-setting-title">Password</span>
<span className="admin-setting-title">{t("admin.password")}</span>
<span className="admin-setting-desc">
SMTP authentication password
{t("admin.passwordDesc")}
</span>
</label>
<input
@@ -562,9 +541,9 @@ export default function AdminDashboard({ onExit }: Readonly<Props>) {
className="admin-setting-label"
htmlFor="setting-smtp-from"
>
<span className="admin-setting-title">From Address</span>
<span className="admin-setting-title">{t("admin.fromAddress")}</span>
<span className="admin-setting-desc">
The sender name and address shown in emails
{t("admin.fromAddressDesc")}
</span>
</label>
<input
@@ -577,10 +556,9 @@ export default function AdminDashboard({ onExit }: Readonly<Props>) {
</div>
<div className="admin-setting-row">
<label className="admin-setting-label">
<span className="admin-setting-title">TLS Mode</span>
<span className="admin-setting-title">{t("admin.tlsMode")}</span>
<span className="admin-setting-desc">
Auto detects from port (465→SSL). Use explicit override if
needed.
{t("admin.tlsModeDesc")}
</span>
</label>
<div className="admin-setting-toggle-group">
@@ -588,34 +566,33 @@ export default function AdminDashboard({ onExit }: Readonly<Props>) {
className={`admin-toggle-btn${settingsDraft.smtp_secure === "auto" ? " active" : ""}`}
onClick={() => updateDraft("smtp_secure", "auto")}
>
Auto
{t("admin.auto")}
</button>
<button
className={`admin-toggle-btn${settingsDraft.smtp_secure === "true" ? " active" : ""}`}
onClick={() => updateDraft("smtp_secure", "true")}
>
Force TLS
{t("admin.forceTls")}
</button>
<button
className={`admin-toggle-btn${settingsDraft.smtp_secure === "false" ? " active" : ""}`}
onClick={() => updateDraft("smtp_secure", "false")}
>
Off
{t("admin.off")}
</button>
</div>
</div>
<div className="admin-setting-row admin-setting-row-stacked">
<div className="admin-setting-label">
<span className="admin-setting-title">Send Test Email</span>
<span className="admin-setting-title">{t("admin.sendTestEmail")}</span>
<span className="admin-setting-desc">
Sends a test email using the settings above (uses draft
values, no need to save first)
{t("admin.sendTestEmailDesc")}
</span>
</div>
<div className="admin-test-email-row">
<input
className="input admin-setting-input"
placeholder="Recipient address (defaults to your account email)"
placeholder={t("admin.sendTestEmailDesc")}
value={testEmailTo}
onChange={(e) => setTestEmailTo(e.target.value)}
type="email"
@@ -625,7 +602,7 @@ export default function AdminDashboard({ onExit }: Readonly<Props>) {
onClick={() => void sendTestEmail()}
disabled={testEmailSending || !settingsDraft?.smtp_host}
>
{testEmailSending ? "Sending…" : "Send Test"}
{testEmailSending ? t("admin.sending") : t("admin.sendTest")}
</button>
</div>
{testEmailMsg && (
@@ -641,14 +618,100 @@ export default function AdminDashboard({ onExit }: Readonly<Props>) {
<div className="admin-panel">
<div className="admin-panel-header">
<h3>Maintenance</h3>
<h3>{t("admin.aiGrading.title")}</h3>
</div>
<div className="admin-panel-body admin-settings-form">
<div className="admin-setting-row">
<label className="admin-setting-label" htmlFor="setting-ai-provider">
<span className="admin-setting-title">{t("admin.aiGrading.provider")}</span>
</label>
<select
id="setting-ai-provider"
className="input admin-setting-input"
value={settingsDraft.ai_provider || ''}
onChange={(e) => updateDraft("ai_provider", e.target.value)}
>
<option value="">—</option>
<option value="openai">OpenAI</option>
<option value="openai-compatible">OpenAI-compatible</option>
</select>
</div>
{settingsDraft.ai_provider === 'openai-compatible' && (
<div className="admin-setting-row">
<label className="admin-setting-label" htmlFor="setting-ai-base-url">
<span className="admin-setting-title">{t("admin.aiGrading.baseUrl")}</span>
<span className="admin-setting-desc">
{t("admin.aiGrading.baseUrlDesc")}
</span>
</label>
<input
id="setting-ai-base-url"
className="input admin-setting-input"
placeholder="https://api.example.com/v1"
value={settingsDraft.ai_base_url || ''}
onChange={(e) => updateDraft("ai_base_url", e.target.value)}
/>
</div>
)}
<div className="admin-setting-row">
<label className="admin-setting-label" htmlFor="setting-ai-model">
<span className="admin-setting-title">{t("admin.aiGrading.model")}</span>
</label>
<input
id="setting-ai-model"
className="input admin-setting-input"
placeholder="gpt-4o-mini"
value={settingsDraft.ai_model || ''}
onChange={(e) => updateDraft("ai_model", e.target.value)}
/>
</div>
<div className="admin-setting-row">
<label className="admin-setting-label" htmlFor="setting-ai-key">
<span className="admin-setting-title">{t("admin.aiGrading.apiKey")}</span>
</label>
<input
id="setting-ai-key"
className="input admin-setting-input"
type="password"
autoComplete="off"
value={settingsDraft.ai_api_key || ''}
onChange={(e) => updateDraft("ai_api_key", e.target.value)}
/>
</div>
<div className="admin-setting-row admin-setting-row-stacked">
<div className="admin-setting-label">
<span className="admin-setting-title">{t("admin.aiGrading.testButton")}</span>
</div>
<div className="admin-test-email-row">
<button
className="btn btn-sm"
onClick={() => void sendTestAi()}
disabled={testAiSending || !settingsDraft?.ai_provider || !settingsDraft?.ai_api_key || (settingsDraft?.ai_provider === 'openai-compatible' && !settingsDraft?.ai_base_url)}
>
{testAiSending ? t("admin.sending") : t("admin.aiGrading.testButton")}
</button>
</div>
{testAiMsg && (
<div
className={`admin-alert ${testAiMsg.ok ? "admin-alert-success" : "admin-alert-error"} admin-alert-inline`}
>
{testAiMsg.text}
</div>
)}
</div>
</div>
</div>
<div className="admin-panel">
<div className="admin-panel-header">
<h3>{t("admin.maintenance")}</h3>
</div>
<div className="admin-panel-body admin-settings-form">
<div className="admin-setting-row">
<label className="admin-setting-label">
<span className="admin-setting-title">Maintenance Mode</span>
<span className="admin-setting-title">{t("admin.maintenanceMode")}</span>
<span className="admin-setting-desc">
Take the site offline for non-admin users
{t("admin.maintenanceModeDesc")}
</span>
</label>
<button
@@ -674,10 +737,10 @@ export default function AdminDashboard({ onExit }: Readonly<Props>) {
htmlFor="setting-maint-msg"
>
<span className="admin-setting-title">
Maintenance Message
{t("admin.maintenanceMessage")}
</span>
<span className="admin-setting-desc">
Message displayed to visitors during maintenance
{t("admin.maintenanceMessageDesc")}
</span>
</label>
<textarea
@@ -700,14 +763,14 @@ export default function AdminDashboard({ onExit }: Readonly<Props>) {
onClick={() => setSettingsDraft(settings)}
disabled={!hasChanges}
>
Discard Changes
{t("admin.discardChanges")}
</button>
<button
className="btn btn-primary"
onClick={() => void saveSettings()}
disabled={saving || !hasChanges}
>
{saving ? "Saving…" : "Save Settings"}
{saving ? t("admin.sending") : t("admin.saveSettings")}
</button>
</div>
</div>
@@ -720,16 +783,16 @@ export default function AdminDashboard({ onExit }: Readonly<Props>) {
<form onSubmit={handleUserSearch} className="admin-search-form">
<input
className="input admin-search-input"
placeholder="Search by name or email…"
placeholder={t("admin.searchByNameOrEmail")}
value={userSearch}
onChange={(e) => setUserSearch(e.target.value)}
/>
<button className="btn btn-sm" type="submit">
Search
{t("admin.search")}
</button>
</form>
<div className="admin-users-count mono">
{usersTotal} user{usersTotal !== 1 ? "s" : ""}
{usersTotal} {usersTotal !== 1 ? t("admin.users") : t("admin.user")}
</div>
</div>
@@ -738,13 +801,13 @@ export default function AdminDashboard({ onExit }: Readonly<Props>) {
<table className="admin-table">
<thead>
<tr>
<th>User</th>
<th>Role</th>
<th>Verified</th>
<th>Exams</th>
<th>Results</th>
<th>Joined</th>
<th>Actions</th>
<th>{t("admin.user")}</th>
<th>{t("admin.role")}</th>
<th>{t("admin.verified")}</th>
<th>{t("admin.exams")}</th>
<th>{t("results.points")}</th>
<th>{t("admin.joined")}</th>
<th>{t("admin.actions")}</th>
</tr>
</thead>
<tbody>
@@ -784,35 +847,35 @@ export default function AdminDashboard({ onExit }: Readonly<Props>) {
className="admin-role-select"
value={u.role}
onChange={(e) =>
void changeRole(
void handleChangeRole(
u.id,
e.target.value as "user" | "admin",
)
}
>
<option value="user">User</option>
<option value="admin">Admin</option>
<option value="user">{t("admin.user")}</option>
<option value="admin">{t("admin.role")}</option>
</select>
{deleteConfirm === u.id ? (
<div className="admin-delete-confirm">
<button
className="btn btn-sm admin-btn-danger"
onClick={() => void deleteUser(u.id)}
onClick={() => void handleDeleteUser(u.id)}
>
Confirm
{t("common.confirm")}
</button>
<button
className="btn btn-ghost btn-sm"
onClick={() => setDeleteConfirm(null)}
>
Cancel
{t("common.cancel")}
</button>
</div>
) : (
<button
className="btn btn-ghost btn-sm admin-btn-danger-ghost"
onClick={() => setDeleteConfirm(u.id)}
title="Delete user"
title={t("common.delete")}
>
✕
</button>
@@ -824,7 +887,7 @@ export default function AdminDashboard({ onExit }: Readonly<Props>) {
{users.length === 0 && (
<tr>
<td colSpan={7} className="admin-empty">
No users found
{t("admin.noUsersFound")}
</td>
</tr>
)}
+91 -19
View File
@@ -1,6 +1,8 @@
import { useState } from 'react'
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useAuth } from '../contexts/AuthContext'
import { ApiError } from '../utils/api'
import * as authApi from '../api/authApi'
interface Props {
onClose: () => void
@@ -9,7 +11,11 @@ interface Props {
type Tab = 'login' | 'register'
const VERIFICATION_RESEND_DELAY_MS = 60_000
const VERIFICATION_REQUIRED_MESSAGE = 'Please verify your email before logging in'
export default function AuthModal({ onClose, onForgotPassword }: Readonly<Props>) {
const { t } = useTranslation()
const { login, register } = useAuth()
const [tab, setTab] = useState<Tab>('login')
const [email, setEmail] = useState('')
@@ -18,23 +24,51 @@ export default function AuthModal({ onClose, onForgotPassword }: Readonly<Props>
const [displayName, setDisplayName] = useState('')
const [error, setError] = useState<string | null>(null)
const [success, setSuccess] = useState<string | null>(null)
const [notice, setNotice] = useState<string | null>(null)
const [submitting, setSubmitting] = useState(false)
const [resendingVerification, setResendingVerification] = useState(false)
const [verificationEmail, setVerificationEmail] = useState('')
const [verificationResendAvailableAt, setVerificationResendAvailableAt] = useState<number | null>(null)
const [now, setNow] = useState(() => Date.now())
useEffect(() => {
if (!verificationResendAvailableAt) return undefined
const timer = window.setInterval(() => {
setNow(Date.now())
}, 1000)
return () => window.clearInterval(timer)
}, [verificationResendAvailableAt])
const verificationResendSeconds = verificationResendAvailableAt
? Math.max(0, Math.ceil((verificationResendAvailableAt - now) / 1000))
: 0
const canResendVerification = verificationEmail.length > 0 && verificationResendSeconds === 0 && !resendingVerification
const switchTab = (t: Tab) => {
setTab(t)
setError(null)
setSuccess(null)
setNotice(null)
setVerificationEmail('')
setVerificationResendAvailableAt(null)
}
const handleLogin = async (e: React.FormEvent) => {
e.preventDefault()
setError(null)
setNotice(null)
setSubmitting(true)
try {
await login(email, password)
onClose()
} catch (err) {
setError(err instanceof ApiError ? err.message : 'Login failed')
if (err instanceof ApiError && err.status === 403 && err.message === VERIFICATION_REQUIRED_MESSAGE) {
setVerificationEmail(email)
setVerificationResendAvailableAt(Date.now() + VERIFICATION_RESEND_DELAY_MS)
}
setError(err instanceof ApiError ? err.message : t('auth.loginFailed'))
} finally {
setSubmitting(false)
}
@@ -43,8 +77,9 @@ export default function AuthModal({ onClose, onForgotPassword }: Readonly<Props>
const handleRegister = async (e: React.FormEvent) => {
e.preventDefault()
setError(null)
setNotice(null)
if (password !== confirmPassword) {
setError('Passwords do not match')
setError(t('auth.passwordsDoNotMatch'))
return
}
setSubmitting(true)
@@ -52,26 +87,43 @@ export default function AuthModal({ onClose, onForgotPassword }: Readonly<Props>
const message = await register(email, password, displayName)
setSuccess(message)
} catch (err) {
setError(err instanceof ApiError ? err.message : 'Registration failed')
setError(err instanceof ApiError ? err.message : t('auth.registrationFailed'))
} finally {
setSubmitting(false)
}
}
const handleResendVerification = async () => {
if (!canResendVerification) return
setResendingVerification(true)
setNotice(null)
setError(null)
try {
const response = await authApi.resendVerification(verificationEmail)
setNotice(response.message)
setVerificationResendAvailableAt(Date.now() + VERIFICATION_RESEND_DELAY_MS)
} catch (err) {
setError(err instanceof ApiError ? err.message : t('auth.requestFailed'))
} finally {
setResendingVerification(false)
}
}
return (
<div className="auth-overlay" onClick={onClose}>
<div className="auth-modal" onClick={(e) => e.stopPropagation()}>
<button className="auth-close" onClick={onClose} aria-label="Close">×</button>
<button className="auth-close" onClick={onClose} aria-label={t('common.close')}>×</button>
<div className="auth-header">
<div className="brand-mark" />
<h2 className="auth-title">
{tab === 'login' ? 'Welcome back' : 'Create account'}
{tab === 'login' ? t('auth.welcomeBack') : t('auth.createAccount')}
</h2>
<p className="auth-subtitle">
{tab === 'login'
? 'Sign in to access your saved exams and results'
: 'Create an account to save your progress'}
? t('auth.signInSubtitle')
: t('auth.registerSubtitle')}
</p>
</div>
@@ -80,23 +132,24 @@ export default function AuthModal({ onClose, onForgotPassword }: Readonly<Props>
className={`auth-tab ${tab === 'login' ? 'active' : ''}`}
onClick={() => switchTab('login')}
>
Sign In
{t('auth.signIn')}
</button>
<button
className={`auth-tab ${tab === 'register' ? 'active' : ''}`}
onClick={() => switchTab('register')}
>
Register
{t('auth.register')}
</button>
</div>
{error && <div className="auth-error">{error}</div>}
{success && <div className="auth-success">{success}</div>}
{notice && <div className="auth-success">{notice}</div>}
{!success && tab === 'login' && (
<form onSubmit={(e) => void handleLogin(e)} className="auth-form">
<div className="auth-field">
<label className="auth-label" htmlFor="login-email">Email</label>
<label className="auth-label" htmlFor="login-email">{t('auth.email')}</label>
<input
id="login-email"
className="input"
@@ -109,7 +162,7 @@ export default function AuthModal({ onClose, onForgotPassword }: Readonly<Props>
/>
</div>
<div className="auth-field">
<label className="auth-label" htmlFor="login-password">Password</label>
<label className="auth-label" htmlFor="login-password">{t('auth.password')}</label>
<input
id="login-password"
className="input"
@@ -122,22 +175,41 @@ export default function AuthModal({ onClose, onForgotPassword }: Readonly<Props>
/>
</div>
<button className="btn btn-primary auth-submit" type="submit" disabled={submitting}>
{submitting ? 'Signing in…' : 'Sign In'}
{submitting ? t('auth.signingIn') : t('auth.signIn')}
</button>
<button
type="button"
className="auth-forgot"
onClick={() => { onClose(); onForgotPassword() }}
>
Forgot your password?
{t('auth.forgotPassword')}
</button>
{verificationEmail && error === VERIFICATION_REQUIRED_MESSAGE && (
<div className="auth-field" style={{ marginTop: 8 }}>
<p className="auth-subtitle" style={{ marginBottom: 8 }}>
{t('auth.verificationResendHint')}
</p>
<button
type="button"
className="btn"
onClick={() => void handleResendVerification()}
disabled={!canResendVerification}
>
{resendingVerification
? t('auth.sending')
: verificationResendSeconds > 0
? t('auth.resendVerificationLinkIn', { seconds: verificationResendSeconds })
: t('auth.resendVerificationLink')}
</button>
</div>
)}
</form>
)}
{!success && tab === 'register' && (
<form onSubmit={(e) => void handleRegister(e)} className="auth-form">
<div className="auth-field">
<label className="auth-label" htmlFor="reg-name">Display Name</label>
<label className="auth-label" htmlFor="reg-name">{t('auth.displayName')}</label>
<input
id="reg-name"
className="input"
@@ -150,7 +222,7 @@ export default function AuthModal({ onClose, onForgotPassword }: Readonly<Props>
/>
</div>
<div className="auth-field">
<label className="auth-label" htmlFor="reg-email">Email</label>
<label className="auth-label" htmlFor="reg-email">{t('auth.email')}</label>
<input
id="reg-email"
className="input"
@@ -163,7 +235,7 @@ export default function AuthModal({ onClose, onForgotPassword }: Readonly<Props>
/>
</div>
<div className="auth-field">
<label className="auth-label" htmlFor="reg-password">Password</label>
<label className="auth-label" htmlFor="reg-password">{t('auth.password')}</label>
<input
id="reg-password"
className="input"
@@ -177,7 +249,7 @@ export default function AuthModal({ onClose, onForgotPassword }: Readonly<Props>
/>
</div>
<div className="auth-field">
<label className="auth-label" htmlFor="reg-confirm">Confirm Password</label>
<label className="auth-label" htmlFor="reg-confirm">{t('auth.confirmPassword')}</label>
<input
id="reg-confirm"
className="input"
@@ -191,7 +263,7 @@ export default function AuthModal({ onClose, onForgotPassword }: Readonly<Props>
/>
</div>
<button className="btn btn-primary auth-submit" type="submit" disabled={submitting}>
{submitting ? 'Creating account…' : 'Create Account'}
{submitting ? t('auth.creatingAccount') : t('auth.register')}
</button>
</form>
)}
+14 -11
View File
@@ -1,11 +1,14 @@
import { useState, useEffect, useRef } from 'react'
import { apiFetch, ApiError } from '../utils/api'
import { useTranslation } from 'react-i18next'
import { ApiError } from '../utils/api'
import * as authApi from '../api/authApi'
interface Props {
onDone: () => void
}
export default function EmailVerify({ onDone }: Readonly<Props>) {
const { t } = useTranslation()
const [status, setStatus] = useState<'loading' | 'success' | 'error'>('loading')
const [message, setMessage] = useState('')
const called = useRef(false)
@@ -19,11 +22,11 @@ export default function EmailVerify({ onDone }: Readonly<Props>) {
if (!token) {
setStatus('error')
setMessage('No verification token found.')
setMessage(t('verify.noToken'))
return
}
apiFetch<{ message: string }>(`/api/auth/verify-email?token=${encodeURIComponent(token)}`)
authApi.verifyEmailToken(token)
.then((data) => {
setStatus('success')
setMessage(data.message)
@@ -32,9 +35,9 @@ export default function EmailVerify({ onDone }: Readonly<Props>) {
})
.catch((err) => {
setStatus('error')
setMessage(err instanceof ApiError ? err.message : 'Verification failed')
setMessage(err instanceof ApiError ? err.message : t('verify.verificationFailed'))
})
}, [])
}, [t])
return (
<div className="verify-page">
@@ -42,27 +45,27 @@ export default function EmailVerify({ onDone }: Readonly<Props>) {
{status === 'loading' && (
<>
<div className="verify-icon">⏳</div>
<h2 className="verify-title">Verifying your email…</h2>
<p className="verify-message">Please wait while we confirm your email address.</p>
<h2 className="verify-title">{t('verify.verifying')}</h2>
<p className="verify-message">{t('verify.pleaseWait')}</p>
</>
)}
{status === 'success' && (
<>
<div className="verify-icon success">✓</div>
<h2 className="verify-title">Email Verified</h2>
<h2 className="verify-title">{t('verify.emailVerified')}</h2>
<p className="verify-message">{message}</p>
<button className="btn btn-primary" onClick={onDone} style={{ marginTop: 24 }}>
Sign In →
{t('verify.signIn')}
</button>
</>
)}
{status === 'error' && (
<>
<div className="verify-icon error">✗</div>
<h2 className="verify-title">Verification Failed</h2>
<h2 className="verify-title">{t('verify.verificationFailed')}</h2>
<p className="verify-message">{message}</p>
<button className="btn" onClick={onDone} style={{ marginTop: 24 }}>
Back to Home
{t('verify.backToHome')}
</button>
</>
)}
+56
View File
@@ -0,0 +1,56 @@
import { Component, type ReactNode, type ErrorInfo } from 'react'
interface Props {
children: ReactNode
fallback?: ReactNode
}
interface State {
hasError: boolean
error?: Error
}
export class ErrorBoundary extends Component<Props, State> {
constructor(props: Props) {
super(props)
this.state = { hasError: false }
}
static getDerivedStateFromError(error: Error): State {
return { hasError: true, error }
}
override componentDidCatch(error: Error, info: ErrorInfo) {
console.error('ErrorBoundary caught error:', error, info)
}
override render() {
if (this.state.hasError) {
return (
this.props.fallback ?? (
<div style={{ padding: 40, textAlign: 'center' }}>
<h2>Something went wrong</h2>
<p style={{ color: '#666' }}>
{this.state.error?.message ?? 'An unexpected error occurred'}
</p>
<button
onClick={() => window.location.reload()}
style={{
marginTop: 16,
padding: '10px 20px',
background: '#1a1a2e',
color: '#fff',
border: 'none',
borderRadius: 8,
cursor: 'pointer',
}}
>
Reload page
</button>
</div>
)
)
}
return this.props.children
}
}
File diff suppressed because it is too large. Load diff
+5 -2
View File
@@ -1,9 +1,10 @@
"use no memo";
import { useParams, useNavigate } from 'react-router-dom'
import { useEffect } from 'react'
import type { Dispatch, SetStateAction } from 'react'
import ExamRunner from './ExamRunner'
import { loadState } from '../utils/storage'
import { slugify } from '../utils/slug'
import { getExamUrlId } from '../utils/slug'
import type { Exam, ExamState } from '../types'
interface Props {
@@ -25,7 +26,7 @@ export default function ExamRoute({ exam, examState, onActivate, setExamState, o
useEffect(() => {
if (!exam) {
const saved = loadState()
if (saved?.exam && slugify(saved.exam.title) === slug) {
if (saved?.exam && getExamUrlId(saved.exam) === slug) {
onActivate(saved.exam, { ...saved.state, currentIdx: questionIdx })
} else {
navigate('/', { replace: true })
@@ -44,12 +45,14 @@ export default function ExamRoute({ exam, examState, onActivate, setExamState, o
return (
<ExamRunner
key={n}
exam={exam}
state={examState}
setState={setExamState}
onNavigate={(i) => navigate(`/exam/${slug}/q${i + 1}`)}
onSubmit={onSubmit}
onExit={onExit}
n={n}
/>
)
}
+122 -30
View File
@@ -1,7 +1,10 @@
"use no memo";
import { useState, useEffect, useCallback } from "react";
import { useTranslation } from 'react-i18next'
import type { Exam, ExamState, Question } from "../types";
import { gradeQuestion } from "../utils/grading";
import { formatTime } from "../utils/formatting";
import * as aiApi from "../api/aiApi";
import Tex from "./Tex";
import QuestionMedia from "./QuestionMedia";
import QuestionRenderer from "./questions/QuestionRenderer";
@@ -13,6 +16,7 @@ interface Props {
onSubmit: () => void;
onExit: () => void;
onNavigate?: (i: number) => void;
n?: string;
}
export default function ExamRunner({
@@ -22,8 +26,12 @@ export default function ExamRunner({
onSubmit,
onExit,
onNavigate,
n,
}: Readonly<Props>) {
const { currentIdx, answers, flags, startedAt, feedbackMode } = state;
const { t, i18n } = useTranslation()
const urlIdx = Math.max(0, (Number((n ?? "").replace(/^q/, "")) || 1) - 1);
const { answers, flags, startedAt, feedbackMode } = state;
const currentIdx = urlIdx;
const q = exam.questions[currentIdx];
const total = exam.questions.length;
const notes = state.notes ?? "";
@@ -33,6 +41,35 @@ export default function ExamRunner({
setRevealed(!!state.revealed?.[q.id]);
}, [currentIdx, q.id, state.revealed]);
const [hintCache, setHintCache] = useState<Record<string, string>>({});
const [hintLoading, setHintLoading] = useState(false);
const [hintError, setHintError] = useState<string | null>(null);
const currentHint = hintCache[q.id];
useEffect(() => {
setHintError(null);
}, [q.id]);
const requestHint = async () => {
if (currentHint || hintLoading) return;
setHintLoading(true);
setHintError(null);
try {
const safeQ: Record<string, unknown> = { ...(q as unknown as Record<string, unknown>) };
delete safeQ.answer;
delete safeQ.acceptedForms;
const res = await aiApi.getHint({
question: safeQ,
language: i18n.language || 'en',
});
setHintCache((prev) => ({ ...prev, [q.id]: res.hint }));
} catch (e) {
setHintError((e as Error).message);
} finally {
setHintLoading(false);
}
};
const setAnswer = (val: unknown) =>
setState((s) => ({ ...s, answers: { ...s.answers, [q.id]: val } }));
@@ -146,7 +183,7 @@ export default function ExamRunner({
<h2 className="sidebar-title">{exam.title}</h2>
{exam.subtitle && <p className="sidebar-sub">{exam.subtitle}</p>}
<div className="sidebar-section-label">Questions</div>
<div className="sidebar-section-label">{t('exam.questionsLabel')}</div>
<div className="nav-list">
{exam.questions.map((qq, i) => {
const st = navStatus(qq);
@@ -169,9 +206,9 @@ export default function ExamRunner({
})}
</div>
<div className="sidebar-section-label">Progress</div>
<div className="sidebar-section-label">{t('exam.progressLabel')}</div>
<div className="mono" style={{ fontSize: 13, color: "var(--ink-2)" }}>
{answeredCount} of {total} answered
{t('exam.progressText', { answered: answeredCount, total })}
</div>
<div className="progress-bar">
<div style={{ width: `${(answeredCount / total) * 100}%` }} />
@@ -186,10 +223,10 @@ export default function ExamRunner({
}}
>
<button className="btn btn-primary" onClick={onSubmit}>
Submit exam
{t('exam.submitExam')}
</button>
<button className="btn btn-ghost btn-sm" onClick={onExit}>
Exit to home
{t('exam.exitToHome')}
</button>
</div>
</aside>
@@ -197,16 +234,15 @@ export default function ExamRunner({
<main className="main">
<header className="question-header">
<span className="q-index">
Question {String(currentIdx + 1).padStart(2, "0")} /{" "}
{String(total).padStart(2, "0")}
{t('exam.questionHeader', { current: String(currentIdx + 1).padStart(2, "0"), total: String(total).padStart(2, "0") })}
</span>
{q.category && <span className="q-category">{q.category}</span>}
<span className="q-points">
{q.points ?? 1} {(q.points ?? 1) === 1 ? "pt" : "pts"}
{q.points ?? 1} {(q.points ?? 1) === 1 ? t('common.pt') : t('common.pts')}
</span>
</header>
<Tex tag="div" className="q-prompt">
<Tex tag="div" className="q-prompt" markdown>
{q.prompt}
</Tex>
<QuestionMedia q={q} />
@@ -219,10 +255,30 @@ export default function ExamRunner({
graded={graded}
/>
{(currentHint || hintError) && (
<div
style={{
marginTop: 12,
padding: 12,
borderLeft: '3px solid var(--accent)',
background: 'var(--paper)',
fontSize: 13,
color: 'var(--ink-2)',
borderRadius: 4,
}}
>
<div style={{ fontSize: 11, color: 'var(--ink-3)', marginBottom: 4 }}>
{t('exam.hintLabel')}
</div>
{currentHint && <Tex>{currentHint}</Tex>}
{hintError && <span style={{ color: 'var(--wrong)' }}>{hintError}</span>}
</div>
)}
{graded && result && q.type !== "essay" && (
<div className={`feedback ${result.correct ? "correct" : "wrong"}`}>
<div className="feedback-label">
{result.correct ? "Correct" : "Not quite"}
{result.correct ? t('exam.correct') : t('exam.notQuite')}
</div>
<div className="feedback-body">
{!result.correct &&
@@ -230,7 +286,7 @@ export default function ExamRunner({
q.type !== "match" &&
q.type !== "order" && (
<p>
<strong>Answer:</strong> <Tex>{answerText}</Tex>
<strong>{t('exam.answerLabel')}</strong> <Tex>{answerText}</Tex>
</p>
)}
{q.explanation && (
@@ -248,18 +304,32 @@ export default function ExamRunner({
onClick={() => goTo(currentIdx - 1)}
disabled={currentIdx === 0}
>
← Previous
{t('exam.previous')}
</button>
<button className="btn btn-ghost" onClick={toggleFlag}>
{flags[q.id] ? "⚑ Flagged" : "⚐ Flag for review"}
{flags[q.id] ? t('exam.flagged') : t('exam.flagForReview')}
</button>
{!revealed && (
<button
className="btn btn-ghost"
onClick={() => void requestHint()}
disabled={hintLoading || !!currentHint}
title={t('exam.hintTitle')}
>
{hintLoading
? t('exam.hintLoading')
: currentHint
? t('exam.hintShown')
: t('exam.needHint')}
</button>
)}
<span className="spacer" />
{feedbackMode === "immediate" &&
!revealed &&
q.type !== "essay" &&
answered && (
<button className="btn btn-primary" onClick={checkAnswer}>
Check answer
{t('exam.checkAnswer')}
</button>
)}
{currentIdx < total - 1 ? (
@@ -267,11 +337,11 @@ export default function ExamRunner({
className="btn btn-primary"
onClick={() => goTo(currentIdx + 1)}
>
Next →
{t('exam.next')}
</button>
) : (
<button className="btn btn-primary" onClick={onSubmit}>
Submit exam
{t('exam.submitExam')}
</button>
)}
</footer>
@@ -288,15 +358,15 @@ export default function ExamRunner({
>
<span>
<span className="kbd">←</span> <span className="kbd">→</span>{" "}
navigate
{t('exam.navigateHint')}
</span>
<span>
<span className="kbd">F</span> flag
<span className="kbd">F</span> {t('exam.flagHint')}
</span>
{q.type === "multiple-choice" && q.choices && (
<span>
<span className="kbd">1</span>–
<span className="kbd">{q.choices.length}</span> select
<span className="kbd">{q.choices.length}</span> {t('exam.selectHint')}
</span>
)}
</div>
@@ -304,12 +374,12 @@ export default function ExamRunner({
<aside className="meta-rail">
<div className="meta-block">
<div className="meta-label">Elapsed</div>
<div className="meta-label">{t('exam.elapsed')}</div>
<div className="meta-value mono">{formatTime(elapsedMs)}</div>
</div>
{remainingMs != null && (
<div className="meta-block">
<div className="meta-label">Remaining</div>
<div className="meta-label">{t('exam.remaining')}</div>
<div
className={`meta-value mono${timeUrgent ? " timer-urgent" : ""}`}
>
@@ -318,35 +388,57 @@ export default function ExamRunner({
</div>
)}
<div className="meta-block">
<div className="meta-label">Points possible</div>
<div className="meta-label">{t('exam.pointsPossible')}</div>
<div className="meta-value mono">{totalPossible}</div>
</div>
<div className="meta-block">
<div className="meta-label">Mode</div>
<div className="meta-label">{t('exam.mode')}</div>
<div className="meta-value mono" style={{ fontSize: 13 }}>
{feedbackMode === "immediate"
? "Immediate feedback"
: "Grade at end"}
? t('exam.immediateFeedback')
: t('exam.gradeAtEnd')}
</div>
</div>
<div className="meta-block notes-block">
<div className="meta-label">Notes</div>
<div className="meta-label">{t('exam.notes')}</div>
<textarea
className="textarea notes-pad"
value={notes}
onChange={(e) => updateNotes(e.target.value)}
placeholder="Add short notes while you work..."
placeholder={t('exam.notesPlaceholder')}
maxLength={1000}
aria-label="Exam notes"
aria-label={t('exam.notes')}
/>
<div className="notes-count mono">{notes.length}/1000</div>
</div>
{exam.author && (
<div className="meta-block">
<div className="meta-label">Author</div>
<div className="meta-label">{t('exam.author')}</div>
<div style={{ fontSize: 14 }}>{exam.author}</div>
</div>
)}
<div className="meta-block" style={{ marginTop: 'auto' }}>
<div className="meta-label">{t('tweaks.feedback')}</div>
<div className="tweak-control">
<button
className={`tweak-seg${feedbackMode === 'immediate' ? ' active' : ''}`}
onClick={() =>
setState((s) => ({ ...s, feedbackMode: 'immediate' }))
}
>
{t('tweaks.immediate')}
</button>
<button
className={`tweak-seg${feedbackMode === 'end' ? ' active' : ''}`}
onClick={() =>
setState((s) => ({ ...s, feedbackMode: 'end' }))
}
>
{t('tweaks.end')}
</button>
</div>
</div>
</aside>
</div>
);
+34 -344
View File
@@ -1,15 +1,18 @@
import { useState, useRef } from 'react'
import { useTranslation } from 'react-i18next'
import type { Exam, HistoryEntry } from '../types'
import { validateExam, parseExam } from '../utils/validation'
import { loadState } from '../utils/storage'
import { formatDate, formatTime } from '../utils/formatting'
import { downloadInstructions } from '../utils/instructions'
interface Props {
onLoadExam: (exam: Exam) => void
onLoadExam: (exam: Exam, saveToWorkspace?: boolean) => void
history: HistoryEntry[]
onResume: () => void
onClearHistory: () => void
onReviewAttempt: (entry: HistoryEntry) => void
onRetakeAttempt?: (entry: HistoryEntry) => void
}
const SAMPLES = [
@@ -18,7 +21,8 @@ const SAMPLES = [
{ path: '/samples/mixed-demo.json', title: 'Mixed Demo — Every Question Type', meta: '4 questions · Untimed · Media', mark: '◇' },
]
export default function Landing({ onLoadExam, history, onResume, onClearHistory, onReviewAttempt }: Readonly<Props>) {
export default function Landing({ onLoadExam, history, onResume, onClearHistory, onReviewAttempt, onRetakeAttempt }: Readonly<Props>) {
const { t } = useTranslation()
const [err, setErr] = useState<string | null>(null)
const [dragOver, setDragOver] = useState(false)
const fileRef = useRef<HTMLInputElement>(null)
@@ -30,9 +34,9 @@ export default function Landing({ onLoadExam, history, onResume, onClearHistory,
const data = JSON.parse(text) as unknown
const msg = validateExam(data)
if (msg) throw new Error(msg)
onLoadExam(parseExam(data))
onLoadExam(parseExam(data), true)
} catch (e) {
setErr(`Could not load: ${(e as Error).message}`)
setErr(t('landing.loadError', { message: (e as Error).message }))
}
}
@@ -46,339 +50,18 @@ export default function Landing({ onLoadExam, history, onResume, onClearHistory,
if (msg) throw new Error(msg)
onLoadExam(parseExam(data))
} catch (e) {
setErr(`Could not load sample: ${(e as Error).message}`)
setErr(t('landing.loadSampleError', { message: (e as Error).message }))
}
}
const downloadInstructions = () => {
const content = `BRAINWAVE EXAM JSON — FORMAT GUIDE
====================================
Give this document to your preferred LLM (ChatGPT, Claude, Gemini, etc.) along with your exam content,
and ask it to produce a valid JSON file you can upload to BrainWave.
────────────────────────────────────
TOP-LEVEL STRUCTURE
────────────────────────────────────
{
"title": "string (REQUIRED) — The name of the exam",
"subtitle": "string (optional) — A short description shown under the title",
"author": "string (optional) — Who created the exam",
"timeLimitMinutes": number | null (optional) — Time limit in minutes, or null/omit for untimed,
"feedbackMode": "end" | "immediate" (optional, default: "end")
- "end": students see all feedback after submitting the full exam
- "immediate": feedback is shown after each question is answered,
"passingScore": number (optional) — Passing percentage, e.g. 70 means 70%,
"categories": ["string", ...] (optional) — Category labels for organizing questions,
"questions": [ ... ] (REQUIRED) — Array of question objects (see below)
}
────────────────────────────────────
QUESTION OBJECT (common fields)
────────────────────────────────────
Every question MUST have these fields:
"id": "string" — Unique identifier, e.g. "q1", "q2", etc.
"type": "string" — One of the supported types listed below
"prompt": "string" — The question text (supports LaTeX with $...$ or $$...$$)
Optional fields available on ALL question types:
"category": "string" — Which category this question belongs to
"points": number — Point value (default: 1)
"explanation": "string" — Explanation shown after grading (supports LaTeX)
"image": { "src": "URL", "alt": "string", "caption": "string" } — Attach an image
"audio": { "src": "URL" } — Attach an audio clip
"code": { "language": "string", "content": "string" } — Show a code block
"table": { "headers": ["Col1", "Col2"], "rows": [["a","b"], ["c","d"]] } — Show a data table
"svg": "string" — Raw SVG markup to display
────────────────────────────────────
QUESTION TYPES
────────────────────────────────────
1. MULTIPLE CHOICE ("multiple-choice")
Student picks ONE correct answer from a list.
Required fields:
"choices": [{ "id": "a", "text": "Option A" }, { "id": "b", "text": "Option B" }, ...]
"answer": "a" ← the id of the correct choice
Example:
{
"id": "q1",
"type": "multiple-choice",
"prompt": "What is the capital of France?",
"choices": [
{ "id": "a", "text": "London" },
{ "id": "b", "text": "Paris" },
{ "id": "c", "text": "Berlin" }
],
"answer": "b"
}
2. MULTIPLE SELECT ("multiple-select")
Student selects ALL correct answers (checkboxes).
Required fields:
"choices": [{ "id": "a", "text": "Option A" }, ...]
"answer": ["a", "c"] ← array of correct choice ids
Example:
{
"id": "q2",
"type": "multiple-select",
"prompt": "Which are prime numbers?",
"choices": [
{ "id": "a", "text": "2" },
{ "id": "b", "text": "4" },
{ "id": "c", "text": "7" },
{ "id": "d", "text": "9" }
],
"answer": ["a", "c"]
}
3. SHORT ANSWER ("short-answer")
Student types a short text answer.
Required fields:
"answer": "string" — The correct answer
Optional fields:
"acceptedForms": ["alt1", "alt2"] — Other accepted answers
"caseSensitive": true|false (default: false)
"ignorePunctuation": true|false (default: false)
"ignoreAccents": true|false (default: false)
Example:
{
"id": "q3",
"type": "short-answer",
"prompt": "What element has the chemical symbol 'O'?",
"answer": "Oxygen",
"caseSensitive": false
}
4. TRANSLATION ("translation")
Student translates text between languages.
Required fields:
"answer": "string" — The correct translation
"sourceLanguage": "string" — e.g. "English"
"targetLanguage": "string" — e.g. "Spanish"
Optional fields:
"acceptedForms": ["alt translation 1", "alt translation 2"]
"caseSensitive": true|false
"ignorePunctuation": true|false
"ignoreAccents": true|false
Example:
{
"id": "q4",
"type": "translation",
"sourceLanguage": "English",
"targetLanguage": "Spanish",
"prompt": "Translate: \\"Good morning, how are you?\\"",
"answer": "Buenos días, ¿cómo estás?",
"acceptedForms": ["Buenos dias, como estas"],
"caseSensitive": false,
"ignorePunctuation": true,
"ignoreAccents": true
}
5. MATH INPUT ("math-input")
Student enters a LaTeX math expression.
Required fields:
"answer": "string" — Correct LaTeX expression
Optional fields:
"acceptedForms": ["alt LaTeX 1", "alt LaTeX 2"]
Example:
{
"id": "q5",
"type": "math-input",
"prompt": "Compute the derivative of $x^3$.",
"answer": "3x^2",
"acceptedForms": ["3x^{2}", "3 x^2"]
}
6. ESSAY ("essay")
Student writes a long-form response. Graded manually (always marked "ungraded").
Optional fields:
"minWords": number — Minimum word count required
"rubric": ["criterion 1", "criterion 2"] — Grading rubric shown during review
Example:
{
"id": "q6",
"type": "essay",
"prompt": "Discuss the causes of World War I.",
"points": 10,
"minWords": 100,
"rubric": [
"Mentions alliance systems",
"Discusses assassination of Archduke Franz Ferdinand",
"Addresses nationalism and imperialism",
"Well-structured argument"
]
}
7. MATCH ("match")
Student matches items from a left column to items in a right column.
Required fields:
"left": [{ "id": "l1", "text": "Item A" }, { "id": "l2", "text": "Item B" }, ...]
"right": [{ "id": "r1", "text": "Match 1" }, { "id": "r2", "text": "Match 2" }, ...]
"answer": { "l1": "r2", "l2": "r1" } ← maps each left id to its correct right id
Example:
{
"id": "q7",
"type": "match",
"prompt": "Match the country to its capital.",
"left": [
{ "id": "l1", "text": "France" },
{ "id": "l2", "text": "Japan" },
{ "id": "l3", "text": "Brazil" }
],
"right": [
{ "id": "r1", "text": "Brasília" },
{ "id": "r2", "text": "Tokyo" },
{ "id": "r3", "text": "Paris" }
],
"answer": { "l1": "r3", "l2": "r2", "l3": "r1" }
}
8. ORDER ("order")
Student arranges items in the correct sequence (drag & drop).
Required fields:
"items": [{ "id": "s1", "text": "Step 1" }, { "id": "s2", "text": "Step 2" }, ...]
"answer": ["s1", "s2", ...] ← array of item ids in the correct order
Example:
{
"id": "q8",
"type": "order",
"prompt": "Put these historical events in chronological order.",
"items": [
{ "id": "e1", "text": "Moon landing (1969)" },
{ "id": "e2", "text": "Fall of the Berlin Wall (1989)" },
{ "id": "e3", "text": "World Wide Web invented (1991)" },
{ "id": "e4", "text": "First iPhone released (2007)" }
],
"answer": ["e1", "e2", "e3", "e4"]
}
────────────────────────────────────
LATEX SUPPORT
────────────────────────────────────
Use LaTeX math in prompts, choices, and explanations:
- Inline math: $x^2 + y^2 = z^2$
- Display math: $$\\\\int_0^1 x^2 \\\\, dx = \\\\frac{1}{3}$$
Remember to double-escape backslashes in JSON strings:
"prompt": "Evaluate $\\\\frac{d}{dx} x^2$"
────────────────────────────────────
MEDIA ATTACHMENTS
────────────────────────────────────
You can attach images, audio, code blocks, tables, and SVGs to any question.
Use publicly accessible URLs for image and audio sources.
Image: "image": { "src": "https://example.com/photo.jpg", "alt": "Description", "caption": "Optional caption" }
Audio: "audio": { "src": "https://example.com/clip.mp3" }
Code: "code": { "language": "python", "content": "def hello():\\n print('hi')" }
Table: "table": { "headers": ["Name", "Value"], "rows": [["A", "1"], ["B", "2"]] }
────────────────────────────────────
COMPLETE MINI EXAMPLE
────────────────────────────────────
{
"title": "Biology 101 — Cell Structure Quiz",
"subtitle": "Chapters 3-4 review",
"author": "Prof. Smith",
"timeLimitMinutes": 15,
"feedbackMode": "end",
"passingScore": 70,
"categories": ["Cells", "Organelles"],
"questions": [
{
"id": "q1",
"type": "multiple-choice",
"category": "Cells",
"points": 2,
"prompt": "Which organelle is known as the 'powerhouse of the cell'?",
"choices": [
{ "id": "a", "text": "Nucleus" },
{ "id": "b", "text": "Mitochondria" },
{ "id": "c", "text": "Ribosome" },
{ "id": "d", "text": "Golgi apparatus" }
],
"answer": "b",
"explanation": "Mitochondria produce ATP through cellular respiration."
},
{
"id": "q2",
"type": "short-answer",
"category": "Organelles",
"points": 1,
"prompt": "What is the gel-like fluid inside a cell called?",
"answer": "cytoplasm",
"acceptedForms": ["Cytoplasm", "cytosol"],
"caseSensitive": false
},
{
"id": "q3",
"type": "order",
"category": "Cells",
"points": 3,
"prompt": "Order these from smallest to largest:",
"items": [
{ "id": "a", "text": "Atom" },
{ "id": "b", "text": "Molecule" },
{ "id": "c", "text": "Organelle" },
{ "id": "d", "text": "Cell" }
],
"answer": ["a", "b", "c", "d"]
}
]
}
────────────────────────────────────
TIPS FOR LLM PROMPTING
────────────────────────────────────
When asking your LLM to create an exam, include:
1. This document (paste it in full)
2. The subject matter or source material
3. How many questions you want
4. Which question types to use
5. Desired difficulty level
6. Whether to include a time limit
Example prompt: "Using the attached format guide, create a 10-question exam on
World War II. Use a mix of multiple-choice, short-answer, and ordering questions.
Set a 20-minute time limit. Include explanations for each answer."
`
const blob = new Blob([content], { type: 'text/plain' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = 'brainwave-exam-format-guide.txt'
a.click()
URL.revokeObjectURL(url)
}
const resumable = loadState()
return (
<div className="landing">
<div className="landing-eyebrow">BrainWave · practice exam platform</div>
<h1 className="landing-title">Upload an exam. <em>Practice anything.</em></h1>
<div className="landing-eyebrow">{t('landing.eyebrow')}</div>
<h1 className="landing-title">{t('landing.title')}<em>{t('landing.titleEmphasis')}</em></h1>
<p className="landing-sub">
Drop in a JSON file to run a practice exam — math with full LaTeX, language translation, multiple choice, matching, ordering, essays, and more. Progress saves automatically.
{t('landing.subtitle')}
</p>
<div className="landing-grid">
@@ -395,9 +78,13 @@ Set a 20-minute time limit. Include explanations for each answer."
if (f) void handleFile(f)
}}
>
<div className="dropzone-icon" />
<h3>Drop your exam JSON here</h3>
<p>or click to browse · max 5 MB</p>
<div className="dropzone-icon">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M12 3v12M7 8l5-5 5 5M5 21h14" />
</svg>
</div>
<h3>{t('landing.dropzoneTitle')}</h3>
<p>{t('landing.dropzoneSub')}</p>
<input
ref={fileRef}
type="file"
@@ -413,8 +100,8 @@ Set a 20-minute time limit. Include explanations for each answer."
onClick={downloadInstructions}
id="download-instructions"
>
<span className="download-instructions-icon">↓</span>
Download Instructions — How to create your own exam JSON
<span className="download-instructions-icon">{t('landing.downloadInstructionsIcon')}</span>
{t('landing.downloadInstructions')}
</button>
{err && (
@@ -426,23 +113,23 @@ Set a 20-minute time limit. Include explanations for each answer."
{resumable && (
<div style={{ marginTop: 20, padding: 16, border: '1px solid var(--line-2)', borderRadius: 10, background: 'var(--paper-2)', display: 'flex', alignItems: 'center', gap: 16 }}>
<div style={{ flex: 1 }}>
<div style={{ fontWeight: 500, fontSize: 14 }}>Resume in-progress exam</div>
<div style={{ fontWeight: 500, fontSize: 14 }}>{t('landing.resumeTitle')}</div>
<div className="muted" style={{ fontSize: 12.5, marginTop: 2 }}>
{resumable.exam.title} · {Object.keys(resumable.state.answers ?? {}).length}/{resumable.exam.questions.length} answered
{resumable.exam.title} · {t('landing.resumeProgress', { answered: Object.keys(resumable.state.answers ?? {}).length, total: resumable.exam.questions.length })}
</div>
</div>
<button className="btn btn-primary btn-sm" onClick={onResume}>Resume →</button>
<button className="btn btn-primary btn-sm" onClick={onResume}>{t('landing.resumeButton')}</button>
</div>
)}
<div style={{ marginTop: 20, fontSize: 12.5, color: 'var(--ink-3)', lineHeight: 1.7 }}>
<div className="mono" style={{ textTransform: 'uppercase', letterSpacing: '0.12em', fontSize: 10.5, marginBottom: 6 }}>Supported types</div>
multiple-choice · multiple-select · short-answer · translation · math-input (LaTeX) · essay · match · order
<div className="mono" style={{ textTransform: 'uppercase', letterSpacing: '0.12em', fontSize: 10.5, marginBottom: 6 }}>{t('landing.supportedTypes')}</div>
{t('landing.supportedTypesList')}
</div>
</div>
<div className="samples">
<h4>Try a sample</h4>
<h4>{t('landing.trySample')}</h4>
{SAMPLES.map((s) => (
<button key={s.path} type="button" className="sample" onClick={() => void loadSample(s.path)}>
<div className="sample-mark serif" style={{ fontSize: 18 }}>{s.mark}</div>
@@ -460,9 +147,9 @@ Set a 20-minute time limit. Include explanations for each answer."
<div className="history">
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', marginBottom: 16 }}>
<h4 style={{ fontSize: 11, fontFamily: 'var(--font-mono)', textTransform: 'uppercase', letterSpacing: '0.12em', color: 'var(--ink-3)', margin: 0, fontWeight: 500 }}>
Past attempts
{t('landing.pastAttempts')}
</h4>
<button className="btn btn-ghost btn-sm" onClick={onClearHistory}>Clear history</button>
<button className="btn btn-ghost btn-sm" onClick={onClearHistory}>{t('landing.clearHistory')}</button>
</div>
{history.map((h) => {
const pct = h.totalPoints ? Math.round((h.scorePoints / h.totalPoints) * 100) : 0
@@ -472,12 +159,15 @@ Set a 20-minute time limit. Include explanations for each answer."
<div>
<div className="history-title">{h.title}</div>
<div className="muted" style={{ fontSize: 12 }}>
{formatDate(h.completedAt)} · {h.correctCount}/{h.gradedCount} correct
{t('landing.dateTimeFormat', { date: formatDate(h.completedAt), correct: h.correctCount, graded: h.gradedCount })}
</div>
</div>
<div className="mono muted" style={{ fontSize: 12 }}>{formatTime(h.elapsedMs)}</div>
<div className={`score-pill ${passed ? 'pass' : 'fail'}`}>{pct}%</div>
<button className="btn btn-ghost btn-sm" onClick={() => onReviewAttempt(h)}>Review</button>
{onRetakeAttempt && (
<button className="btn btn-ghost btn-sm" onClick={() => onRetakeAttempt(h)}>{t('landing.retake')}</button>
)}
<button className="btn btn-ghost btn-sm" onClick={() => onReviewAttempt(h)}>{t('landing.review')}</button>
</div>
)
})}
+826
View File
@@ -0,0 +1,826 @@
import { useState, useEffect, useCallback } from 'react'
import { useNavigate } from 'react-router-dom'
import { useTranslation } from 'react-i18next'
import * as examApi from '../api/examApi'
import * as collectionApi from '../api/collectionApi'
import type { Exam } from '../types'
interface SavedExam {
id: number
title: string
subtitle: string | null
question_count: number
created_at: number
updated_at: number
share_token: string | null
is_favorite: number
tags: string
collection_id: number | null
}
interface Props {
onLoadExam: (exam: Exam, saveToWorkspace?: boolean, examId?: number | null) => void
}
type CollectionFilter = number | null | 'all' | 'uncategorized'
export default function MyExams({ onLoadExam }: Readonly<Props>) {
const { t } = useTranslation()
const navigate = useNavigate()
const [exams, setExams] = useState<SavedExam[]>([])
const [collections, setCollections] = useState<collectionApi.Collection[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [deleteConfirm, setDeleteConfirm] = useState<number | null>(null)
const [startingId, setStartingId] = useState<number | null>(null)
const [sharingId, setSharingId] = useState<number | null>(null)
const [duplicatingId, setDuplicatingId] = useState<number | null>(null)
const [exportingId, setExportingId] = useState<number | null>(null)
const [searchQuery, setSearchQuery] = useState('')
const [activeFilter, setActiveFilter] = useState<CollectionFilter>('all')
const [showNewCollection, setShowNewCollection] = useState(false)
const [newCollectionName, setNewCollectionName] = useState('')
const [creatingCollection, setCreatingCollection] = useState(false)
const [movingExamId, setMovingExamId] = useState<number | null>(null)
const [menuOpenId, setMenuOpenId] = useState<number | null>(null)
const [menuSection, setMenuSection] = useState<'main' | 'move' | 'share'>('main')
const closeMenu = () => { setMenuOpenId(null); setMenuSection('main') }
const openMenu = (id: number) => { setMenuOpenId(id); setMenuSection('main') }
const [editingCollectionId, setEditingCollectionId] = useState<number | null>(null)
const [editingCollectionName, setEditingCollectionName] = useState('')
const [deleteCollectionId, setDeleteCollectionId] = useState<number | null>(null)
const [selectedIds, setSelectedIds] = useState<Set<number>>(new Set())
const [batchMoveOpen, setBatchMoveOpen] = useState(false)
const [batchDeleting, setBatchDeleting] = useState(false)
const [batchExporting, setBatchExporting] = useState(false)
const [batchMoving, setBatchMoving] = useState(false)
const loadData = useCallback(async () => {
setLoading(true)
setError(null)
try {
const [examData, collectionData] = await Promise.all([
examApi.listExams(),
collectionApi.listCollections(),
])
setExams(examData)
setCollections(collectionData)
} catch (e) {
setError((e as Error).message)
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
void loadData()
}, [loadData])
const deleteExam = async (id: number) => {
try {
await examApi.deleteExam(id)
setDeleteConfirm(null)
void loadData()
} catch (e) {
setError((e as Error).message)
}
}
const createNewExam = () => {
navigate('/exam-editor/new')
}
const startExam = async (id: number) => {
setStartingId(id)
try {
const exam = await examApi.getExam(id)
onLoadExam(exam, false, id)
} catch (e) {
setError((e as Error).message)
setStartingId(null)
}
}
const handleShare = async (id: number) => {
setSharingId(id)
try {
const res = await examApi.shareExam(id)
const url = `${window.location.origin}/share/${res.shareToken}`
await navigator.clipboard.writeText(url)
closeMenu()
setExams((prev) => prev.map((e) => (e.id === id ? { ...e, share_token: res.shareToken } : e)))
} catch (e) {
setError((e as Error).message)
} finally {
setSharingId(null)
}
}
const handleUnshare = async (id: number) => {
try {
await examApi.unshareExam(id)
closeMenu()
setExams((prev) => prev.map((e) => (e.id === id ? { ...e, share_token: null } : e)))
} catch (e) {
setError((e as Error).message)
}
}
const handleFavorite = async (id: number, isFavorite: boolean) => {
try {
await examApi.setFavorite(id, isFavorite)
setExams((prev) => prev.map((e) => {
if (e.id !== id) return e
const updated = { ...e, is_favorite: isFavorite ? 1 : 0 }
return updated
}).sort((a, b) => (b.is_favorite - a.is_favorite) || (b.updated_at - a.updated_at)))
} catch (e) {
setError((e as Error).message)
}
}
const handleExport = async (id: number, title: string) => {
setExportingId(id)
try {
const blob = await examApi.exportExam(id)
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `${title.replace(/[^a-z0-9]/gi, '_').toLowerCase()}.json`
a.click()
URL.revokeObjectURL(url)
} catch (e) {
setError((e as Error).message)
} finally {
setExportingId(null)
}
}
const handleDuplicate = async (id: number) => {
setDuplicatingId(id)
try {
await examApi.duplicateExam(id)
void loadData()
} catch (e) {
setError((e as Error).message)
} finally {
setDuplicatingId(null)
}
}
const handleCreateCollection = async () => {
const name = newCollectionName.trim()
if (!name) return
setCreatingCollection(true)
try {
await collectionApi.createCollection(name)
setNewCollectionName('')
setShowNewCollection(false)
void loadData()
} catch (e) {
setError((e as Error).message)
} finally {
setCreatingCollection(false)
}
}
const handleMoveExam = async (examId: number, collectionId: number | null) => {
setMovingExamId(examId)
try {
await collectionApi.moveExamToCollection(examId, collectionId)
closeMenu()
setExams((prev) => prev.map((e) => e.id === examId ? { ...e, collection_id: collectionId } : e))
setCollections((prev) => prev.map((c) => {
if (collectionId !== null && c.id === collectionId) return { ...c, exam_count: c.exam_count + 1 }
const oldExam = exams.find((e) => e.id === examId)
if (oldExam?.collection_id !== null && c.id === oldExam?.collection_id) return { ...c, exam_count: Math.max(0, c.exam_count - 1) }
return c
}))
} catch (e) {
setError((e as Error).message)
} finally {
setMovingExamId(null)
}
}
const handleRenameCollection = async (id: number) => {
const name = editingCollectionName.trim()
if (!name) {
setEditingCollectionId(null)
return
}
try {
await collectionApi.updateCollection(id, name)
setCollections((prev) => prev.map((c) => c.id === id ? { ...c, name } : c))
setEditingCollectionId(null)
} catch (e) {
setError((e as Error).message)
}
}
const handleDeleteCollection = async (id: number) => {
try {
await collectionApi.deleteCollection(id)
setDeleteCollectionId(null)
setExams((prev) => prev.map((e) => e.collection_id === id ? { ...e, collection_id: null } : e))
setCollections((prev) => prev.filter((c) => c.id !== id))
} catch (e) {
setError((e as Error).message)
}
}
const toggleSelect = (id: number) => {
setSelectedIds((prev) => {
const next = new Set(prev)
if (next.has(id)) next.delete(id)
else next.add(id)
return next
})
}
const selectAllFiltered = () => {
setSelectedIds(new Set(filteredExams.map((e) => e.id)))
}
const clearSelection = () => {
setSelectedIds(new Set())
}
const handleBatchDelete = async () => {
if (selectedIds.size === 0) return
if (!window.confirm(t('workspace.batchDeleteConfirm'))) return
setBatchDeleting(true)
try {
await examApi.batchDelete(Array.from(selectedIds))
setSelectedIds(new Set())
void loadData()
} catch (e) {
setError((e as Error).message)
} finally {
setBatchDeleting(false)
}
}
const handleBatchExport = async () => {
if (selectedIds.size === 0) return
setBatchExporting(true)
try {
for (const id of selectedIds) {
const exam = exams.find((e) => e.id === id)
if (exam) {
const blob = await examApi.exportExam(id)
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `${exam.title.replace(/[^a-z0-9]/gi, '_').toLowerCase()}.json`
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(url)
}
}
} catch (e) {
setError((e as Error).message)
} finally {
setBatchExporting(false)
}
}
const handleBatchMove = async (collectionId: number | null) => {
if (selectedIds.size === 0) return
setBatchMoving(true)
try {
for (const id of selectedIds) {
await collectionApi.moveExamToCollection(id, collectionId)
}
setBatchMoveOpen(false)
setSelectedIds(new Set())
void loadData()
} catch (e) {
setError((e as Error).message)
} finally {
setBatchMoving(false)
}
}
const formatDate = (ts: number) => {
const d = new Date(ts * 1000)
return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })
}
const parseTags = (tagsJson: string): string[] => {
try {
const parsed = JSON.parse(tagsJson)
return Array.isArray(parsed) ? parsed : []
} catch {
return []
}
}
const filteredExams = exams.filter((exam) => {
if (!searchQuery.trim()) return true
const q = searchQuery.toLowerCase()
const tags = parseTags(exam.tags)
return (
exam.title.toLowerCase().includes(q) ||
(exam.subtitle?.toLowerCase().includes(q) ?? false) ||
tags.some((t) => t.toLowerCase().includes(q))
)
}).filter((exam) => {
if (activeFilter === 'all') return true
if (activeFilter === 'uncategorized') return exam.collection_id === null
return exam.collection_id === activeFilter
})
const uncategorizedCount = exams.filter((e) => e.collection_id === null).length
return (
<div className="workspace-section fade-in" style={{ display: 'flex', gap: 20 }}>
{/* Collections Sidebar */}
<div style={{ width: 220, flexShrink: 0 }}>
<div style={{ fontSize: 11, fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.08em', color: 'var(--ink-3)', marginBottom: 10, display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
{t('workspace.collections')}
<button
className="btn btn-ghost btn-sm"
style={{ fontSize: 16, padding: '2px 6px', lineHeight: 1 }}
onClick={() => setShowNewCollection(true)}
title={t('workspace.newCollection')}
>
+
</button>
</div>
{showNewCollection && (
<div style={{ marginBottom: 8, display: 'flex', gap: 4 }}>
<input
type="text"
className="input"
placeholder={t('workspace.collectionNamePlaceholder')}
value={newCollectionName}
onChange={(e) => setNewCollectionName(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') void handleCreateCollection() }}
style={{ fontSize: 12, padding: '4px 8px', flex: 1 }}
autoFocus
/>
<button className="btn btn-sm btn-primary" onClick={() => void handleCreateCollection()} disabled={creatingCollection} style={{ fontSize: 11, padding: '4px 8px' }}>
{t('workspace.create')}
</button>
<button className="btn btn-sm btn-ghost" onClick={() => { setShowNewCollection(false); setNewCollectionName('') }} style={{ fontSize: 11, padding: '4px 8px' }}>
{t('common.cancel')}
</button>
</div>
)}
<div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<button
className={`btn btn-ghost btn-sm${activeFilter === 'all' ? ' active' : ''}`}
onClick={() => setActiveFilter('all')}
style={{ justifyContent: 'space-between', textAlign: 'left', fontWeight: activeFilter === 'all' ? 600 : 400 }}
>
<span>{t('workspace.allExams')}</span>
<span className="mono" style={{ fontSize: 11, color: 'var(--ink-3)' }}>{exams.length}</span>
</button>
<button
className={`btn btn-ghost btn-sm${activeFilter === 'uncategorized' ? ' active' : ''}`}
onClick={() => setActiveFilter('uncategorized')}
style={{ justifyContent: 'space-between', textAlign: 'left', fontWeight: activeFilter === 'uncategorized' ? 600 : 400 }}
>
<span>{t('workspace.uncategorized')}</span>
<span className="mono" style={{ fontSize: 11, color: 'var(--ink-3)' }}>{uncategorizedCount}</span>
</button>
{collections.map((col) => (
<div key={col.id} style={{ position: 'relative' }}>
{editingCollectionId === col.id ? (
<div style={{ display: 'flex', gap: 4, padding: '2px 0' }}>
<input
type="text"
className="input"
value={editingCollectionName}
onChange={(e) => setEditingCollectionName(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') void handleRenameCollection(col.id)
if (e.key === 'Escape') setEditingCollectionId(null)
}}
onBlur={() => void handleRenameCollection(col.id)}
style={{ fontSize: 12, padding: '3px 6px', flex: 1 }}
autoFocus
/>
</div>
) : (
<button
className={`btn btn-ghost btn-sm${activeFilter === col.id ? ' active' : ''}`}
onClick={() => setActiveFilter(col.id)}
onContextMenu={(e) => {
e.preventDefault()
setEditingCollectionName(col.name)
setEditingCollectionId(col.id)
}}
style={{ justifyContent: 'space-between', textAlign: 'left', fontWeight: activeFilter === col.id ? 600 : 400 }}
>
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{col.name}</span>
<span className="mono" style={{ fontSize: 11, color: 'var(--ink-3)', flexShrink: 0 }}>{col.exam_count}</span>
</button>
)}
{activeFilter === col.id && editingCollectionId !== col.id && (
<div style={{ display: 'flex', gap: 4, padding: '0 8px 4px', marginTop: -2 }}>
<button className="btn btn-ghost btn-sm" style={{ fontSize: 10, padding: '2px 6px' }} onClick={() => { setEditingCollectionName(col.name); setEditingCollectionId(col.id) }}>
{t('workspace.rename')}
</button>
<button className="btn btn-ghost btn-sm admin-btn-danger-ghost" style={{ fontSize: 10, padding: '2px 6px' }} onClick={() => setDeleteCollectionId(col.id)}>
{t('common.delete')}
</button>
</div>
)}
{deleteCollectionId === col.id && (
<div style={{ padding: '4px 8px', fontSize: 11, background: 'var(--wrong-weak)', borderRadius: 6, margin: '2px 0' }}>
<div style={{ marginBottom: 4 }}>{t('workspace.deleteCollectionConfirm')}</div>
<div style={{ display: 'flex', gap: 4 }}>
<button className="btn btn-sm admin-btn-danger" style={{ fontSize: 10, padding: '2px 8px' }} onClick={() => void handleDeleteCollection(col.id)}>
{t('common.confirm')}
</button>
<button className="btn btn-sm btn-ghost" style={{ fontSize: 10, padding: '2px 8px' }} onClick={() => setDeleteCollectionId(null)}>
{t('common.cancel')}
</button>
</div>
</div>
)}
</div>
))}
</div>
</div>
{/* Exams List */}
<div style={{ flex: 1, minWidth: 0 }}>
<div className="workspace-section-header">
<div>
<div className="workspace-section-eyebrow">{t('workspace.myWorkspace')}</div>
<h2 className="workspace-section-title">{t('workspace.myExams')}</h2>
</div>
<button className="btn btn-primary btn-sm" onClick={createNewExam}>
+ {t('workspace.newExam')}
</button>
</div>
{/* Search */}
<div style={{ marginBottom: 16 }}>
<input
type="text"
className="input"
placeholder={t('workspace.searchExams')}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
style={{ maxWidth: 400 }}
/>
</div>
{/* Batch toolbar */}
{!loading && filteredExams.length > 0 && (
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 12, flexWrap: 'wrap' }}>
<label style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 13, cursor: 'pointer', userSelect: 'none' }}>
<input
type="checkbox"
checked={selectedIds.size > 0 && selectedIds.size === filteredExams.length}
onChange={() => {
if (selectedIds.size === filteredExams.length) clearSelection()
else selectAllFiltered()
}}
ref={(el) => {
if (el) el.indeterminate = selectedIds.size > 0 && selectedIds.size < filteredExams.length
}}
/>
{selectedIds.size === 0 ? t('workspace.batchSelectAll') : t('workspace.batchSelected', { count: selectedIds.size })}
</label>
{selectedIds.size > 0 && (
<>
<div style={{ width: 1, height: 16, background: 'var(--line-2)' }} />
<button
className="btn btn-sm admin-btn-danger-ghost"
onClick={() => void handleBatchDelete()}
disabled={batchDeleting}
style={{ fontSize: 12 }}
>
{batchDeleting ? t('common.loading') : `${t('common.delete')} (${selectedIds.size})`}
</button>
<button
className="btn btn-sm btn-ghost"
onClick={() => void handleBatchExport()}
disabled={batchExporting}
style={{ fontSize: 12 }}
>
{batchExporting ? t('common.loading') : `${t('workspace.export')} (${selectedIds.size})`}
</button>
<div style={{ position: 'relative' }}>
<button
className="btn btn-sm btn-ghost"
onClick={() => setBatchMoveOpen((v) => !v)}
disabled={batchMoving}
style={{ fontSize: 12 }}
>
{batchMoving ? t('common.loading') : `${t('workspace.moveTo')} (${selectedIds.size})`}
</button>
{batchMoveOpen && (
<div
style={{
position: 'absolute',
left: 0,
top: 'calc(100% + 4px)',
zIndex: 10,
minWidth: 180,
background: 'var(--paper)',
border: '1px solid var(--line-2)',
borderRadius: 8,
boxShadow: 'var(--shadow-md)',
padding: '6px 0',
}}
>
<button
className="user-menu-item"
style={{ fontSize: 13 }}
onClick={() => void handleBatchMove(null)}
>
{t('workspace.uncategorized')}
</button>
{collections.map((col) => (
<button
key={col.id}
className="user-menu-item"
style={{ fontSize: 13 }}
onClick={() => void handleBatchMove(col.id)}
>
{col.name}
</button>
))}
</div>
)}
</div>
</>
)}
</div>
)}
{error && (
<div className="workspace-alert workspace-alert-error">
<span>{error}</span>
<button onClick={() => setError(null)} className="workspace-alert-close">×</button>
</div>
)}
{loading ? (
<div className="workspace-empty">{t('common.loading')}</div>
) : filteredExams.length === 0 ? (
<div className="workspace-empty">
<div className="workspace-empty-icon">◇</div>
<div className="workspace-empty-title">
{searchQuery || activeFilter !== 'all' ? t('workspace.noSearchResults') : t('workspace.noSavedExams')}
</div>
<div className="workspace-empty-desc">
{searchQuery || activeFilter !== 'all' ? t('workspace.noSearchResultsDesc') : t('workspace.noSavedExamsDesc')}
</div>
{!searchQuery && activeFilter === 'all' && (
<button className="btn btn-primary" onClick={createNewExam} style={{ marginTop: 16 }}>
{t('workspace.createFirstExam')}
</button>
)}
</div>
) : (
<div className="workspace-list">
{filteredExams.map((exam) => {
const tags = parseTags(exam.tags)
return (
<div key={exam.id} className={`workspace-card${exam.is_favorite ? ' workspace-card-favorite' : ''}`}>
<div className="workspace-card-body">
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<input
type="checkbox"
checked={selectedIds.has(exam.id)}
onChange={() => toggleSelect(exam.id)}
style={{ cursor: 'pointer' }}
/>
<button
className="btn btn-ghost btn-sm"
onClick={() => void handleFavorite(exam.id, !exam.is_favorite)}
title={exam.is_favorite ? t('workspace.unpin') : t('workspace.pin')}
style={{ fontSize: 16, padding: '2px 6px', lineHeight: 1 }}
>
{exam.is_favorite ? '★' : '☆'}
</button>
<div className="workspace-card-title">{exam.title}</div>
</div>
{exam.subtitle && (
<div className="workspace-card-sub">{exam.subtitle}</div>
)}
<div className="workspace-card-meta">
<span>{exam.question_count} {exam.question_count === 1 ? t('common.question') : t('common.questions')}</span>
<span>·</span>
<span>{t('workspace.updated')} {formatDate(exam.updated_at)}</span>
</div>
{tags.length > 0 && (
<div style={{ display: 'flex', gap: 6, marginTop: 6, flexWrap: 'wrap' }}>
{tags.map((tag) => (
<span
key={tag}
style={{
fontSize: 11,
padding: '2px 8px',
borderRadius: 4,
background: 'var(--paper)',
color: 'var(--ink-3)',
border: '1px solid var(--line-2)',
}}
>
{tag}
</span>
))}
</div>
)}
</div>
<div className="workspace-card-actions">
{deleteConfirm === exam.id ? (
<div className="workspace-delete-confirm">
<span style={{ fontSize: 13, color: 'var(--ink-3)', marginRight: 4 }}>
{t('workspace.batchDeleteConfirm')}
</span>
<button className="btn btn-sm admin-btn-danger" onClick={() => void deleteExam(exam.id)}>
{t('common.confirm')}
</button>
<button className="btn btn-ghost btn-sm" onClick={() => setDeleteConfirm(null)}>
{t('common.cancel')}
</button>
</div>
) : (
<>
<button
className="btn btn-primary btn-sm"
onClick={() => void startExam(exam.id)}
disabled={startingId === exam.id}
>
{startingId === exam.id ? t('common.loading') : t('workspace.takeExam')}
</button>
<button
className="btn btn-sm"
onClick={() => navigate(`/exam-editor/${exam.id}`)}
disabled={startingId === exam.id}
>
{t('common.edit')}
</button>
<div style={{ position: 'relative' }}>
<button
className="btn btn-ghost btn-sm workspace-menu-trigger"
onClick={() => menuOpenId === exam.id ? closeMenu() : openMenu(exam.id)}
disabled={startingId === exam.id}
title="More actions"
aria-label="More actions"
aria-haspopup="menu"
aria-expanded={menuOpenId === exam.id}
>
⋯
</button>
{menuOpenId === exam.id && (
<>
<button
className="workspace-menu-backdrop"
onClick={closeMenu}
aria-label="Close menu"
tabIndex={-1}
/>
<div className="workspace-menu" role="menu">
{menuSection === 'main' && (
<>
<button
className="user-menu-item workspace-menu-item"
onClick={() => { closeMenu(); void handleDuplicate(exam.id) }}
disabled={duplicatingId === exam.id}
>
<span className="workspace-menu-icon">⧉</span>
<span>{duplicatingId === exam.id ? t('common.loading') : t('workspace.duplicate')}</span>
</button>
<button
className="user-menu-item workspace-menu-item"
onClick={() => { closeMenu(); void handleExport(exam.id, exam.title) }}
disabled={exportingId === exam.id}
>
<span className="workspace-menu-icon">↓</span>
<span>{exportingId === exam.id ? t('common.loading') : t('workspace.export')}</span>
</button>
<button
className="user-menu-item workspace-menu-item"
onClick={() => setMenuSection('move')}
disabled={movingExamId === exam.id}
>
<span className="workspace-menu-icon">▤</span>
<span>{t('workspace.moveTo')}</span>
<span className="workspace-menu-chevron">›</span>
</button>
<button
className="user-menu-item workspace-menu-item"
onClick={() => setMenuSection('share')}
disabled={sharingId === exam.id}
>
<span className="workspace-menu-icon">↗</span>
<span>
{sharingId === exam.id
? t('common.loading')
: (exam.share_token ? t('share.shared') : t('share.share'))}
</span>
<span className="workspace-menu-chevron">›</span>
</button>
<div className="workspace-menu-divider" />
<button
className="user-menu-item workspace-menu-item workspace-menu-item-danger"
onClick={() => { closeMenu(); setDeleteConfirm(exam.id) }}
>
<span className="workspace-menu-icon">✕</span>
<span>{t('common.delete')}</span>
</button>
</>
)}
{menuSection === 'move' && (
<>
<button
className="user-menu-item workspace-menu-back"
onClick={() => setMenuSection('main')}
>
‹ {t('common.back')}
</button>
<div className="workspace-menu-divider" />
{exam.collection_id !== null && (
<button
className="user-menu-item workspace-menu-item"
onClick={() => void handleMoveExam(exam.id, null)}
>
{t('workspace.removeFromCollection')}
</button>
)}
{collections.map((col) => (
col.id !== exam.collection_id && (
<button
key={col.id}
className="user-menu-item workspace-menu-item"
onClick={() => void handleMoveExam(exam.id, col.id)}
>
{col.name}
</button>
)
))}
{collections.length === 0 && exam.collection_id === null && (
<div className="user-menu-item workspace-menu-item" style={{ color: 'var(--ink-3)', cursor: 'default' }}>
{t('workspace.collections')}: 0
</div>
)}
</>
)}
{menuSection === 'share' && (
<>
<button
className="user-menu-item workspace-menu-back"
onClick={() => setMenuSection('main')}
>
‹ {t('common.back')}
</button>
<div className="workspace-menu-divider" />
{exam.share_token ? (
<>
<button
className="user-menu-item workspace-menu-item"
onClick={() => {
const url = `${window.location.origin}/share/${exam.share_token}`
void navigator.clipboard.writeText(url)
closeMenu()
}}
>
{t('share.copyLink')}
</button>
<button
className="user-menu-item workspace-menu-item workspace-menu-item-danger"
onClick={() => void handleUnshare(exam.id)}
>
{t('share.revokeLink')}
</button>
</>
) : (
<button
className="user-menu-item workspace-menu-item"
onClick={() => void handleShare(exam.id)}
>
{sharingId === exam.id ? t('common.loading') : t('share.generateLink')}
</button>
)}
</>
)}
</div>
</>
)}
</div>
</>
)}
</div>
</div>
)
})}
</div>
)}
</div>
</div>
)
}
+189
View File
@@ -0,0 +1,189 @@
import { useState, useEffect, useCallback } from 'react'
import { useNavigate } from 'react-router-dom'
import { useTranslation } from 'react-i18next'
import * as resultApi from '../api/resultApi'
import * as examApi from '../api/examApi'
import { getExamUrlId } from '../utils/slug'
import type { Exam } from '../types'
interface ResultItem {
id: number
title: string
score_points: number
total_points: number
correct_count: number
graded_count: number
passing_score: number
elapsed_ms: number
completed_at: number
exam_id: number | null
}
interface Props {
onLoadExam: (exam: Exam, saveToWorkspace?: boolean, examId?: number | null) => void
}
export default function MyResults({ onLoadExam }: Props) {
const { t } = useTranslation()
const navigate = useNavigate()
const [results, setResults] = useState<ResultItem[]>([])
const [total, setTotal] = useState(0)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [deleteConfirm, setDeleteConfirm] = useState<number | null>(null)
const [retakingId, setRetakingId] = useState<number | null>(null)
const loadResults = useCallback(async () => {
setLoading(true)
setError(null)
try {
const data = await resultApi.listResults()
setResults(data.results)
setTotal(data.total)
} catch (e) {
setError((e as Error).message)
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
void loadResults()
}, [loadResults])
const deleteResult = async (id: number) => {
try {
await resultApi.deleteResult(id)
setDeleteConfirm(null)
void loadResults()
} catch (e) {
setError((e as Error).message)
}
}
const retakeExam = async (item: ResultItem) => {
if (!item.exam_id) return
setRetakingId(item.id)
try {
const exam = await examApi.getExam(item.exam_id)
onLoadExam(exam, false, item.exam_id)
} catch (e) {
setError((e as Error).message)
setRetakingId(null)
}
}
const reviewResult = async (item: ResultItem) => {
try {
const data = await resultApi.getResult(item.id)
// Store in session for review - we'll navigate to review route
// Use localStorage as a bridge since review expects exam/examState in App state
localStorage.setItem('brainwave.review.exam', JSON.stringify(data.exam))
const completedAt = (data as unknown as Record<string, unknown>).completed_at
const state = data.state as Record<string, unknown> | undefined
localStorage.setItem('brainwave.review.state', JSON.stringify({
...(state || {}),
completedAt: typeof completedAt === 'number' ? completedAt * 1000 : Date.now(),
}))
localStorage.setItem('brainwave.review.resultId', String(item.id))
navigate(`/review/${getExamUrlId(data.exam as unknown as { title: string; id?: string })}`)
} catch (e) {
setError((e as Error).message)
}
}
const formatDate = (ts: number) => {
const d = new Date(ts * 1000)
return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })
}
const formatTime = (ms: number) => {
const m = Math.floor(ms / 60000)
const s = Math.floor((ms % 60000) / 1000)
return `${m}m ${s}s`
}
return (
<div className="workspace-section fade-in">
<div className="workspace-section-header">
<div>
<div className="workspace-section-eyebrow">{t('workspace.myWorkspace')}</div>
<h2 className="workspace-section-title">{t('workspace.results')}</h2>
</div>
<div className="mono muted" style={{ fontSize: 13 }}>{total} {total === 1 ? t('workspace.attempts') : t('workspace.attemptsPlural')}</div>
</div>
{error && (
<div className="workspace-alert workspace-alert-error">
<span>{error}</span>
<button onClick={() => setError(null)} className="workspace-alert-close">×</button>
</div>
)}
{loading ? (
<div className="workspace-empty">{t('common.loading')}</div>
) : results.length === 0 ? (
<div className="workspace-empty">
<div className="workspace-empty-icon">✦</div>
<div className="workspace-empty-title">{t('workspace.noAttempts')}</div>
<div className="workspace-empty-desc">
{t('workspace.noAttemptsDesc')}
</div>
</div>
) : (
<div className="workspace-list">
{results.map((r) => {
const pct = r.total_points ? Math.round((r.score_points / r.total_points) * 100) : 0
const passed = pct >= (r.passing_score ?? 60)
return (
<div key={r.id} className="workspace-card">
<div className="workspace-card-body">
<div className="workspace-card-title">{r.title}</div>
<div className="workspace-card-meta">
<span>{formatDate(r.completed_at)}</span>
<span>·</span>
<span>{formatTime(r.elapsed_ms)}</span>
<span>·</span>
<span>{r.correct_count}/{r.graded_count} {t('results.correct')}</span>
</div>
</div>
<div className="workspace-card-actions" style={{ gap: 8 }}>
<div className={`score-pill ${passed ? 'pass' : 'fail'}`}>{pct}%</div>
<button
className="btn btn-primary btn-sm"
onClick={() => void retakeExam(r)}
disabled={retakingId === r.id || !r.exam_id}
title={r.exam_id ? undefined : t('results.cantRetake')}
>
{retakingId === r.id ? t('common.loading') : t('results.retakeExam')}
</button>
<button className="btn btn-sm" onClick={() => void reviewResult(r)}>
{t('results.review')}
</button>
{deleteConfirm === r.id ? (
<div className="workspace-delete-confirm">
<button className="btn btn-sm admin-btn-danger" onClick={() => void deleteResult(r.id)}>
{t('common.confirm')}
</button>
<button className="btn btn-ghost btn-sm" onClick={() => setDeleteConfirm(null)}>
{t('common.cancel')}
</button>
</div>
) : (
<button
className="btn btn-ghost btn-sm admin-btn-danger-ghost"
onClick={() => setDeleteConfirm(r.id)}
title={t('common.delete')}
>
✕
</button>
)}
</div>
</div>
)
})}
</div>
)}
</div>
)
}
+289
View File
@@ -0,0 +1,289 @@
import { useState, useRef, useEffect } from 'react'
import { useNavigate } from 'react-router-dom'
import { useTranslation } from 'react-i18next'
import { useAuth } from '../contexts/AuthContext'
import * as resultApi from '../api/resultApi'
import * as examApi from '../api/examApi'
import { loadState } from '../utils/storage'
import { validateExam, parseExam } from '../utils/validation'
import type { Exam } from '../types'
import StudyCoach from './StudyCoach'
interface Props {
examCount: number
resultCount: number
recentResults: resultApi.ResultListItem[]
onLoadExam: (exam: Exam, saveToWorkspace?: boolean, examId?: number | null) => void
}
export default function Overview({ examCount, resultCount, recentResults, onLoadExam }: Props) {
const { t } = useTranslation()
const navigate = useNavigate()
const { user } = useAuth()
const [dragOver, setDragOver] = useState(false)
const [uploadErr, setUploadErr] = useState<string | null>(null)
const fileRef = useRef<HTMLInputElement>(null)
const [trends, setTrends] = useState<resultApi.ScoreTrend[]>([])
const [trendsLoading, setTrendsLoading] = useState(true)
useEffect(() => {
resultApi.getScoreTrends()
.then((data) => setTrends(data))
.catch(() => {/* ignore */ })
.finally(() => setTrendsLoading(false))
}, [])
const handleFile = async (file: File) => {
setUploadErr(null)
try {
const text = await file.text()
const data = JSON.parse(text) as unknown
const msg = validateExam(data)
if (msg) throw new Error(msg)
onLoadExam(parseExam(data), true)
} catch (e) {
setUploadErr(t('landing.loadError', { message: (e as Error).message }))
}
}
const handleRetakeWeakest = async (examId: number) => {
try {
const exam = await examApi.getExam(examId)
onLoadExam(exam, false, examId)
} catch {
setUploadErr(t('landing.loadError', { message: 'Could not load exam' }))
}
}
const resumable = loadState()
const totalQuestions = recentResults.reduce((sum, r) => sum + (r.graded_count || 0), 0)
const avgScore = recentResults.length > 0
? Math.round(recentResults.reduce((sum, r) => sum + (r.total_points ? (r.score_points / r.total_points) * 100 : 0), 0) / recentResults.length)
: 0
// SVG chart dimensions
const margin = { top: 10, right: 20, bottom: 30, left: 40 }
const viewW = 520
const viewH = 200
const cw = viewW - margin.left - margin.right
const ch = viewH - margin.top - margin.bottom
const chartData = trends.slice(-20)
const hasChartData = chartData.length >= 2
const xMin = chartData.length ? chartData[0].completed_at : 0
const xMax = chartData.length ? chartData[chartData.length - 1].completed_at : 0
const xRange = xMax - xMin || 1
const xScale = (v: number) => margin.left + ((v - xMin) / xRange) * cw
const yScale = (v: number) => margin.top + ch - (v / 100) * ch
const linePoints = chartData.map((d, i) => {
const x = xScale(d.completed_at)
const y = yScale(d.score_pct)
return `${i === 0 ? 'M' : 'L'} ${x} ${y}`
}).join(' ')
const gridLines = [0, 25, 50, 75, 100].map((pct) => ({
y: yScale(pct),
label: `${pct}%`,
}))
const xLabelIndices = [...new Set([0, Math.floor(chartData.length / 2), chartData.length - 1])]
return (
<div className="workspace-section fade-in">
{/* Dashboard Stats */}
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(140px, 1fr))', gap: 12, marginBottom: 24 }}>
<div className="workspace-card" style={{ padding: 16, textAlign: 'center' }}>
<div className="mono" style={{ fontSize: 24, fontWeight: 700 }}>{examCount}</div>
<div style={{ fontSize: 12, color: 'var(--ink-3)', marginTop: 4 }}>{t('workspace.myExams')}</div>
</div>
<div className="workspace-card" style={{ padding: 16, textAlign: 'center' }}>
<div className="mono" style={{ fontSize: 24, fontWeight: 700 }}>{resultCount}</div>
<div style={{ fontSize: 12, color: 'var(--ink-3)', marginTop: 4 }}>{t('workspace.totalAttempts')}</div>
</div>
<div className="workspace-card" style={{ padding: 16, textAlign: 'center' }}>
<div className="mono" style={{ fontSize: 24, fontWeight: 700 }}>{avgScore}%</div>
<div style={{ fontSize: 12, color: 'var(--ink-3)', marginTop: 4 }}>{t('workspace.avgScore')}</div>
</div>
<div className="workspace-card" style={{ padding: 16, textAlign: 'center' }}>
<div className="mono" style={{ fontSize: 24, fontWeight: 700 }}>{totalQuestions}</div>
<div style={{ fontSize: 12, color: 'var(--ink-3)', marginTop: 4 }}>{t('workspace.questionsAnswered')}</div>
</div>
</div>
{/* Quick Actions */}
<div style={{ display: 'flex', gap: 12, marginBottom: 24, flexWrap: 'wrap' }}>
<button
type="button"
className={`dropzone${dragOver ? ' drag-over' : ''}`}
style={{ flex: 1, minWidth: 220, padding: 16 }}
onClick={() => fileRef.current?.click()}
onDragOver={(e) => { e.preventDefault(); setDragOver(true) }}
onDragLeave={() => setDragOver(false)}
onDrop={(e) => {
e.preventDefault(); setDragOver(false)
const f = e.dataTransfer.files?.[0]
if (f) void handleFile(f)
}}
>
<div className="dropzone-icon" style={{ marginBottom: 6 }}>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M12 3v12M7 8l5-5 5 5M5 21h14" />
</svg>
</div>
<div style={{ fontSize: 13, fontWeight: 500 }}>{t('workspace.uploadExam')}</div>
<div style={{ fontSize: 11, color: 'var(--ink-3)', marginTop: 2 }}>{t('workspace.uploadDropzone')}</div>
<input
ref={fileRef}
type="file"
accept="application/json,.json"
style={{ display: 'none' }}
onChange={(e) => { const f = e.target.files?.[0]; if (f) void handleFile(f) }}
/>
</button>
<button
className="workspace-card"
style={{ flex: 1, minWidth: 220, padding: 16, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', cursor: 'pointer', background: 'var(--paper-2)' }}
onClick={() => navigate('/exam-editor/new')}
>
<div style={{ fontSize: 20, marginBottom: 6 }}>+</div>
<div style={{ fontSize: 13, fontWeight: 500 }}>{t('workspace.newExam')}</div>
<div style={{ fontSize: 11, color: 'var(--ink-3)', marginTop: 2 }}>{t('workspace.createFirstExam')}</div>
</button>
{resumable && (
<button
className="workspace-card"
style={{ flex: 1, minWidth: 220, padding: 16, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', cursor: 'pointer', background: 'var(--paper-2)', borderColor: 'var(--accent)' }}
onClick={() => onLoadExam(resumable.exam, false, null)}
>
<div style={{ fontSize: 20, marginBottom: 6 }}>▶</div>
<div style={{ fontSize: 13, fontWeight: 500 }}>{t('landing.resumeTitle')}</div>
<div style={{ fontSize: 11, color: 'var(--ink-3)', marginTop: 2 }}>
{resumable.exam.title}
</div>
</button>
)}
</div>
{uploadErr && (
<div style={{ marginBottom: 16, padding: '10px 14px', border: '1px solid var(--wrong)', borderRadius: 8, background: 'var(--wrong-weak)', color: 'var(--wrong)', fontSize: 13 }}>
{uploadErr}
</div>
)}
{/* Study Coach */}
<StudyCoach onRetakeWeakest={handleRetakeWeakest} />
{/* Performance Trends */}
{hasChartData && !trendsLoading && (
<div className="workspace-card" style={{ marginBottom: 24 }}>
<div style={{ padding: '16px 20px 4px' }}>
<h3 style={{ margin: '0 0 12px', fontSize: 15, fontWeight: 600 }}>{t('workspace.performanceTrends')}</h3>
<svg viewBox={`0 0 ${viewW} ${viewH}`} style={{ width: '100%', height: 'auto', overflow: 'visible' }}>
{/* Grid lines */}
{gridLines.map((g) => (
<g key={g.label}>
<line
x1={margin.left}
x2={margin.left + cw}
y1={g.y}
y2={g.y}
stroke="var(--line-2)"
strokeDasharray="2,2"
strokeWidth={1}
/>
<text x={margin.left - 6} y={g.y + 3} textAnchor="end" fontSize={10} fill="var(--ink-3)">
{g.label}
</text>
</g>
))}
{/* Trend line */}
<path
d={linePoints}
fill="none"
stroke="var(--accent)"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
/>
{/* Data points */}
{chartData.map((d, i) => {
const x = xScale(d.completed_at)
const y = yScale(d.score_pct)
const date = new Date(d.completed_at * 1000).toLocaleDateString(undefined, { month: 'short', day: 'numeric' })
return (
<g key={i}>
<circle
cx={x}
cy={y}
r={4}
fill="var(--accent)"
stroke="var(--paper-2)"
strokeWidth={2}
>
<title>{`${d.title} — ${d.score_pct}% (${date})`}</title>
</circle>
</g>
)
})}
{/* X-axis labels */}
{xLabelIndices.map((idx) => {
const d = chartData[idx]
if (!d) return null
const x = xScale(d.completed_at)
const date = new Date(d.completed_at * 1000).toLocaleDateString(undefined, { month: 'short', day: 'numeric' })
return (
<text key={idx} x={x} y={viewH - 6} textAnchor="middle" fontSize={10} fill="var(--ink-3)">
{date}
</text>
)
})}
</svg>
</div>
</div>
)}
{/* Recent Results */}
{recentResults.length > 0 && (
<div style={{ marginBottom: 24 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', marginBottom: 12 }}>
<h3 style={{ margin: 0, fontSize: 15, fontWeight: 600 }}>{t('workspace.recentResults')}</h3>
<button className="btn btn-ghost btn-sm" onClick={() => navigate('/workspace/results')}>
{t('workspace.viewAll')} →
</button>
</div>
<div className="workspace-list">
{recentResults.map((r) => {
const pct = r.total_points ? Math.round((r.score_points / r.total_points) * 100) : 0
const passed = pct >= (r.passing_score ?? 60)
const d = new Date(r.completed_at * 1000)
const dateStr = d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' })
return (
<div key={r.id} className="workspace-card" style={{ display: 'flex', alignItems: 'center', gap: 12, padding: 12 }}>
<div style={{ flex: 1 }}>
<div style={{ fontSize: 13, fontWeight: 500 }}>{r.title}</div>
<div className="muted" style={{ fontSize: 11 }}>{dateStr}</div>
</div>
<div className={`score-pill ${passed ? 'pass' : 'fail'}`} style={{ fontSize: 11 }}>{pct}%</div>
</div>
)
})}
</div>
</div>
)}
{!user && (
<div className="workspace-card" style={{ padding: 20, textAlign: 'center' }}>
<div style={{ fontSize: 14, marginBottom: 8 }}>{t('workspace.signInPrompt')}</div>
<button className="btn btn-primary" onClick={() => { /* handled by parent */ }}>
{t('auth.signIn')}
</button>
</div>
)}
</div>
)
}
Loaded 100 of 143 files, more files were not shown because too many files have changed in this diff. Show more