diff --git a/CLAUDE_UPDATED.md b/CLAUDE_UPDATED.md new file mode 100644 index 00000000..c354d01d --- /dev/null +++ b/CLAUDE_UPDATED.md @@ -0,0 +1,87 @@ +# CLAUDE.md (Updated) + +Guidance for Claude Code when working in this repository. + +## What this is + +FLN Assessment & Personalized Worksheet Platform — an AI-driven system that assesses each child's foundational **Mathematics** numeracy level (Classes 2–4), generates level-personalized printable worksheets, ingests scanned answers, evaluates them, and rolls data up a 7-role national hierarchy. See [SRS.md](SRS.md) (authoritative spec), [PRD.md](PRD.md) (product framing), [AUDIT.md](AUDIT.md) (code health), and [MIGRATION_PLAN.md](MIGRATION_PLAN.md) (target structure). + +**Stack:** MERN Monorepo — React 19 + Vite + Tailwind CSS + TanStack React Query + Axios (frontend), Node/Express + TypeScript + MongoDB/Mongoose (backend API), Puppeteer worksheet renderer microservice (`backend/fln-backend`), Python (AI evaluation pipeline), Google Gemini / Groq (LLM). The repo is an **npm-workspaces monorepo**: `frontend/`, `backend/`, `ai-services/`. + +## Architecture Overview + +- **Backend API (`backend/`)**: Modular Express + TypeScript server running on port `3001` (or `PORT` env), connected to MongoDB (`MONGODB_URI`). Implements JWT authentication (`/api/auth`), role guard middleware, domain routes (`states`, `districts`, `blocks`, `schools`, `teachers`, `classes`, `students`), and database seeding (`npm run seed`). +- **Worksheet Generator Microservice (`backend/fln-backend`)**: Express + Puppeteer service running on port `4000` dedicated to batch worksheet rendering, answer key generation, and ZIP packaging. +- **Frontend (`frontend/`)**: React 19 + Vite SPA running on port `5173`. Connects to the backend via Axios (`frontend/src/services/api.ts`) and TanStack React Query. In dev mode, Vite proxies `/api`, `/output`, and `/worksheets` requests to the backend on `http://localhost:3001` (configurable via `VITE_API_TARGET`). +- **AI Services (`ai-services/`)**: Python evaluation pipeline for scanned OMR worksheets using Gemini / Groq models. + +## Layout + +``` +fln/ # npm-workspaces monorepo root +├── frontend/ # @fln/frontend — React + Vite app +│ ├── index.html vite.config.ts tsconfig.json package.json +│ ├── public/worksheets/ # worksheet HTML templates — read by backend / Puppeteer +│ └── src/ +│ ├── main.tsx # QueryClientProvider + BrowserRouter entrypoint +│ ├── App.tsx # Router configuration & main application routing +│ ├── pages/ # Page-level route components +│ ├── components/ # UI components & role dashboards +│ ├── services/ # api.ts (Axios instance with JWT Bearer token header) +│ ├── hooks/ # Custom React hooks & data queries +│ ├── types.ts # Frontend TypeScript interfaces +│ └── constants.ts # UI labels and configuration +├── backend/ # @fln/backend — Node/Express + MongoDB API +│ ├── package.json tsconfig.json .env.example +│ ├── fln-backend/ # Worksheet microservice (Express + Puppeteer, port 4000) +│ ├── src/ +│ │ ├── server.ts # Server bootstrap (connects MongoDB and starts Express server) +│ │ ├── app.ts # Express application setup & middleware/route registration +│ │ ├── config/ # Database connection (Mongoose) & environment variables +│ │ ├── controllers/ # Request controllers (auth, students, classes, schools, geo) +│ │ ├── models/ # Mongoose models (User, Student, Teacher, School, State, etc.) +│ │ ├── routes/ # Express router modules +│ │ ├── services/ # Domain business logic & data repositories +│ │ ├── middlewares/ # JWT auth guard, input validation & error handler +│ │ ├── seed.ts # MongoDB database seed script +│ │ ├── levelGenerator.ts # Worksheet level math question generator +│ │ └── paperGenerator.ts # HTML / PDF worksheet generation logic +├── ai-services/ # REAL Python evaluation pipeline (run_pipeline.py, prompts/, syllabus/) +├── scripts/ # Development helper scripts (dev-backend.js) +└── docs/ # Documentation & workflow specs +``` + +## Commands + +Run from the repo root (npm workspaces). One install covers all packages: + +```bash +npm install # Install dependencies across all workspaces +npm run dev:frontend # Vite dev server on :5173 (proxies /api to main backend on :3001) +npm run dev:backend # Launches dev-backend.js script — starts main API (:3001) and levels backend (:4000) +npm run dev # Runs backend workspace dev script +npm run build # Builds frontend (vite) and backend (esbuild) +npm run lint # tsc --noEmit across workspaces (type-check only) +npm run seed --workspace=@fln/backend # Seeds MongoDB database with initial sample data +``` + +## Environment Variables + +Copy `.env.example` to `.env` in the repository root: + +- `MONGODB_URI` / `MONGO_URI` — Connection URI for MongoDB (default: `mongodb://localhost:27017/fln`). +- `JWT_SECRET` — Secret key for signing auth tokens. +- `JWT_EXPIRES_IN` — Expiration duration for JWT tokens (default: `7d`). +- `PORT` — Port for the main backend API server (default: `3001`). +- `VITE_API_TARGET` — Backend API target URL for Vite dev proxy (default: `http://localhost:3001`). +- `LEVELS_BACKEND_URL` — Service URL for the worksheet generation microservice (default: `http://localhost:4000`). +- `GEMINI_API_KEY` & `GROQ_API_KEY` — API keys for AI worksheet evaluation pipeline. + +## Conventions & Gotchas + +- **Authentication**: Real JWT authentication is active (`/api/auth/login`, `/api/auth/register`). Tokens are stored in `localStorage` under key `fln_token` and sent via `Authorization: Bearer ` through `frontend/src/services/api.ts`. +- **Database**: MongoDB with Mongoose ODM handles persistence. Run `npm run seed --workspace=@fln/backend` to seed sample state, district, block, school, teacher, class, and student records into MongoDB. +- **Microservices**: Main API handles REST endpoints on port `3001`, while `backend/fln-backend` on port `4000` handles heavy Puppeteer HTML-to-PDF rendering and ZIP generation. +- **Frontend State & Data Fetching**: TanStack React Query (`@tanstack/react-query`) manages server state on the frontend; client mock interceptors have been completely removed. +- **Type Safety**: `npm run lint` runs `tsc --noEmit` across all workspaces to verify TypeScript compilation. +- **Vite Config & HMR**: Avoid changing `frontend/vite.config.ts` HMR/watch settings directly if working in automated agent environments. diff --git a/backend/src/config/environment.ts b/backend/src/config/environment.ts index cb238b91..366d4a78 100644 --- a/backend/src/config/environment.ts +++ b/backend/src/config/environment.ts @@ -5,10 +5,10 @@ import { fileURLToPath } from 'url'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); -dotenv.config({ path: path.resolve(__dirname, '../../.env') }); +dotenv.config({ path: path.resolve(__dirname, '../../../.env') }); export const env = { - port: parseInt(process.env.PORT || '3000', 10), + port: parseInt(process.env.PORT || '3001', 10), nodeEnv: process.env.NODE_ENV || 'development', mongodbUri: process.env.MONGODB_URI || 'mongodb://localhost:27017/fln', jwtSecret: process.env.JWT_SECRET || 'fallback_secret_change_in_prod', diff --git a/backend/src/controllers/auth.controller.ts b/backend/src/controllers/auth.controller.ts index c67b1716..23aa61c6 100644 --- a/backend/src/controllers/auth.controller.ts +++ b/backend/src/controllers/auth.controller.ts @@ -1,17 +1,29 @@ import { Request, Response, NextFunction } from 'express'; -import { TeacherService } from '../services/teacher.service'; +import { AuthService } from '../services/auth.service'; import { sendSuccess } from '../utils/response'; -const teacherService = new TeacherService(); +const authService = new AuthService(); export class AuthController { async login(req: Request, res: Response, next: NextFunction) { try { const { email, password } = req.body; - const result = await teacherService.login(email, password); + const result = await authService.login(email, password); sendSuccess(res, 'Login successful', result); } catch (error) { next(error); } } + async me(req: Request, res: Response, next: NextFunction) { + try { + const payload = (req as any).user; + if (!payload || !payload.email) { + throw new Error('Invalid token payload'); + } + const fullUser = await authService.getMe(payload.email, payload.role); + sendSuccess(res, 'Session restored', { user: fullUser }); + } catch (error) { + next(error); + } + } } diff --git a/backend/src/index.ts b/backend/src/index.ts index a1e8dafa..9149c763 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -1,4 +1,4 @@ -import 'dotenv/config'; +import dotenv from 'dotenv'; import express from 'express'; import path from 'path'; import { fileURLToPath } from 'url'; @@ -14,6 +14,7 @@ import bcrypt from 'bcrypt'; import jwt from 'jsonwebtoken'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); +dotenv.config({ path: path.resolve(__dirname, '../../.env') }); const ROOT_DIR = path.resolve(__dirname, '..'); const PORT = process.env.PORT ? parseInt(process.env.PORT, 10) : 3000; diff --git a/backend/src/interfaces/user.interface.ts b/backend/src/interfaces/user.interface.ts new file mode 100644 index 00000000..dd52b730 --- /dev/null +++ b/backend/src/interfaces/user.interface.ts @@ -0,0 +1,30 @@ +import { Document } from 'mongoose'; + +export enum UserRole { + SUPERADMIN = 'superadmin', + ADMIN = 'admin', + DISTRICT_ADMIN = 'district_admin', + BLOCK_ADMIN = 'block_admin', + SCHOOL = 'school', + VOLUNTEER = 'volunteer' +} + +export interface IUser { + userId: string; + email: string; + password?: string; + name: string; + role: UserRole; + stateCode?: string; + districtCode?: string; + blockCode?: string; + schoolId?: string; + assignedSchools?: string[]; + delayedAttemptsCount?: number; + isActive?: boolean; + isBanned?: boolean; +} + +export interface IUserDocument extends IUser, Document { + comparePassword(candidatePassword: string): Promise; +} diff --git a/backend/src/models/teacher.model.ts b/backend/src/models/teacher.model.ts index 03d92cb9..762afa7c 100644 --- a/backend/src/models/teacher.model.ts +++ b/backend/src/models/teacher.model.ts @@ -79,6 +79,7 @@ const teacherSchema = new Schema( toJSON: { transform(_doc, ret) { ret.id = ret._id; + ret.name = `${ret.firstName} ${ret.lastName}`.trim(); delete ret._id; delete ret.__v; delete ret.password; diff --git a/backend/src/models/user.model.ts b/backend/src/models/user.model.ts new file mode 100644 index 00000000..a57d828f --- /dev/null +++ b/backend/src/models/user.model.ts @@ -0,0 +1,85 @@ +import { Schema, model } from 'mongoose'; +import bcrypt from 'bcrypt'; +import { IUserDocument, UserRole } from '../interfaces/user.interface'; + +const userSchema = new Schema( + { + userId: { + type: String, + unique: true, + required: true, + }, + name: { + type: String, + required: [true, 'Name is required'], + trim: true, + }, + email: { + type: String, + required: [true, 'Email is required'], + unique: true, + lowercase: true, + trim: true, + }, + password: { + type: String, + required: [true, 'Password is required'], + minlength: [8, 'Password must be at least 8 characters'], + select: false, + }, + role: { + type: String, + enum: Object.values(UserRole), + required: true, + }, + stateCode: String, + districtCode: String, + blockCode: String, + schoolId: String, + assignedSchools: [String], + isActive: { + type: Boolean, + default: true, + }, + isBanned: { + type: Boolean, + default: false, + }, + delayedAttemptsCount: { + type: Number, + default: 0, + }, + }, + { + timestamps: true, + toJSON: { + transform(_doc, ret) { + ret.id = ret._id; + delete ret._id; + delete ret.__v; + delete ret.password; + return ret; + }, + }, + } +); + +userSchema.pre('save', async function (next) { + if (!this.isModified('password')) return next(); + + try { + const salt = await bcrypt.genSalt(10); + this.password = await bcrypt.hash(this.password, salt); + next(); + } catch (err: any) { + next(err); + } +}); + +userSchema.methods.comparePassword = async function ( + candidatePassword: string +): Promise { + return bcrypt.compare(candidatePassword, this.password); +}; + +export const User = model('User', userSchema); diff --git a/backend/src/repositories/user.repository.ts b/backend/src/repositories/user.repository.ts new file mode 100644 index 00000000..333bdb4e --- /dev/null +++ b/backend/src/repositories/user.repository.ts @@ -0,0 +1,17 @@ +import { User } from '../models/user.model'; +import { IUser } from '../interfaces/user.interface'; + +export class UserRepository { + async create(data: Partial) { + const user = new User(data); + return user.save(); + } + + async findByEmail(email: string) { + return User.findOne({ email }).select('+password'); + } + + async findByUserId(userId: string) { + return User.findOne({ userId }); + } +} diff --git a/backend/src/routes/auth.routes.ts b/backend/src/routes/auth.routes.ts index 9f001a30..38bd0df0 100644 --- a/backend/src/routes/auth.routes.ts +++ b/backend/src/routes/auth.routes.ts @@ -3,9 +3,12 @@ import { AuthController } from '../controllers/auth.controller'; import { validate } from '../middlewares/validate'; import { loginValidator } from '../validators/teacher.validator'; +import { authenticate } from '../middlewares/auth'; + const router = Router(); const controller = new AuthController(); router.post('/login', loginValidator, validate, controller.login); +router.get('/me', authenticate, controller.me); export default router; diff --git a/backend/src/services/auth.service.ts b/backend/src/services/auth.service.ts new file mode 100644 index 00000000..66b6ea07 --- /dev/null +++ b/backend/src/services/auth.service.ts @@ -0,0 +1,93 @@ +import httpStatus from 'http-status'; +import jwt from 'jsonwebtoken'; +import { TeacherRepository } from '../repositories/teacher.repository'; +import { UserRepository } from '../repositories/user.repository'; +import { AppError } from '../middlewares/errorHandler'; + +export class AuthService { + private teacherRepository: TeacherRepository; + private userRepository: UserRepository; + + constructor() { + this.teacherRepository = new TeacherRepository(); + this.userRepository = new UserRepository(); + } + + async login(email: string, password: string) { + // 1. Try to find the user in the teachers collection + const teacher = await this.teacherRepository.findByEmail(email); + + if (teacher) { + if (!teacher.isActive) { + throw new AppError('Account is deactivated. Contact admin.', httpStatus.FORBIDDEN); + } + if (teacher.isBanned) { + throw new AppError('Account is suspended due to delayed submissions. Contact admin.', httpStatus.FORBIDDEN); + } + + const isMatch = await teacher.comparePassword(password); + if (!isMatch) { + throw new AppError('Invalid email or password', httpStatus.UNAUTHORIZED); + } + + const token = this.generateToken({ + id: teacher.teacherId, + email: teacher.email, + role: 'teacher', + schoolId: teacher.school?._id?.toString(), + }); + + return { token, user: { ...teacher.toJSON(), role: 'teacher' } }; + } + + // 2. Try to find the user in the generic users collection + const genericUser = await this.userRepository.findByEmail(email); + + if (genericUser) { + if (genericUser.isActive === false) { + throw new AppError('Account is deactivated. Contact admin.', httpStatus.FORBIDDEN); + } + if (genericUser.isBanned) { + throw new AppError('Account is suspended. Contact admin.', httpStatus.FORBIDDEN); + } + + const isMatch = await genericUser.comparePassword(password); + if (!isMatch) { + throw new AppError('Invalid email or password', httpStatus.UNAUTHORIZED); + } + + const token = this.generateToken({ + id: genericUser.userId, + email: genericUser.email, + role: genericUser.role, + schoolId: genericUser.schoolId, + }); + + return { token, user: genericUser.toJSON() }; + } + + // 3. Not found anywhere + throw new AppError('Invalid email or password', httpStatus.UNAUTHORIZED); + } + + async getMe(email: string, role: string) { + if (role === 'teacher') { + const teacher = await this.teacherRepository.findByEmail(email); + if (teacher) { + return { ...teacher.toJSON(), role: 'teacher' }; + } + } else { + const genericUser = await this.userRepository.findByEmail(email); + if (genericUser) { + return genericUser.toJSON(); + } + } + throw new AppError('User not found', httpStatus.NOT_FOUND); + } + + private generateToken(payload: any) { + const secret = process.env.JWT_SECRET || 'fallback_secret_change_in_prod'; + const expiresIn = (process.env.JWT_EXPIRES_IN || '7d') as string; + return jwt.sign(payload, secret, { expiresIn: expiresIn as any }); + } +} diff --git a/backend/src/utils/seedAuth.ts b/backend/src/utils/seedAuth.ts new file mode 100644 index 00000000..2dbcb368 --- /dev/null +++ b/backend/src/utils/seedAuth.ts @@ -0,0 +1,79 @@ +import dotenv from 'dotenv'; +import path from 'path'; +import { fileURLToPath } from 'url'; +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +dotenv.config({ path: path.resolve(__dirname, '../../../.env') }); +import { connectDB, dbStore } from '../db'; +import mongoose from 'mongoose'; +import { User } from '../models/user.model'; +import { Teacher } from '../models/teacher.model'; +import { UserRole } from '../interfaces/user.interface'; + +const DEFAULT_PASSWORD = 'Fln@2026'; + +const ADMIN_ACCOUNTS = [ + { userId: 'u_1', email: 'superadmin@fln.org', name: 'Super Admin', role: UserRole.SUPERADMIN }, + { userId: 'u_2', email: 'admin.pb@fln.org', name: 'Punjab Admin', role: UserRole.ADMIN, stateCode: 'PB' }, + { userId: 'u_3', email: 'admin.hr@fln.org', name: 'Haryana Admin', role: UserRole.ADMIN, stateCode: 'HR' }, + { userId: 'u_4', email: 'admin.up@fln.org', name: 'UP Admin', role: UserRole.ADMIN, stateCode: 'UP' }, + { userId: 'u_5', email: 'admin.rj@fln.org', name: 'Rajasthan Admin', role: UserRole.ADMIN, stateCode: 'RJ' }, + { userId: 'u_6', email: 'district.ldh@fln.org', name: 'Ludhiana Dist Admin', role: UserRole.DISTRICT_ADMIN, districtCode: 'LDH' }, + { userId: 'u_7', email: 'district.amb@fln.org', name: 'Ambala Dist Admin', role: UserRole.DISTRICT_ADMIN, districtCode: 'AMB' }, + { userId: 'u_8', email: 'block.ldh-01@fln.org', name: 'Ludhiana Block Admin', role: UserRole.BLOCK_ADMIN, blockCode: 'LDH-01' }, + { userId: 'u_9', email: 'gps-asr-021@fln.org', name: 'Punjab Principal', role: UserRole.SCHOOL, schoolId: 'gps-asr-021' }, + { userId: 'u_10', email: 'vol.rahul@fln.org', name: 'Punjab Volunteer', role: UserRole.VOLUNTEER }, + { userId: 'u_11', email: 'vol.hr.vipin@fln.org', name: 'Haryana Volunteer', role: UserRole.VOLUNTEER }, +]; + +const TEACHER_ACCOUNT = { + teacherId: 't_1', + firstName: 'Haryana', + lastName: 'Teacher', + email: 'gps-amb-003.t01@fln.org', + phoneNumber: '9999999999', + password: DEFAULT_PASSWORD, + // We need a valid ObjectId for school, we'll fetch one or create a dummy one +}; + +async function seedAuth() { + await mongoose.connect(process.env.MONGODB_URI as string); + console.log('Connected to MongoDB'); + + // 1. Clear existing generic users to avoid duplicates + await User.deleteMany({}); + console.log('Cleared existing users'); + + // 2. Insert Admin/Volunteer Accounts + for (const account of ADMIN_ACCOUNTS) { + const user = new User({ ...account, password: DEFAULT_PASSWORD }); + await user.save(); + console.log(`Created user: ${account.email}`); + } + + // 3. Handle the Teacher Account + // Delete existing to avoid duplicates + await Teacher.deleteOne({ email: TEACHER_ACCOUNT.email }); + + // Find a school to link the teacher to (Teacher schema requires a valid ObjectId for school) + const db = mongoose.connection.db; + const school = await db?.collection('schools').findOne({}); + + if (school) { + const teacher = new Teacher({ + ...TEACHER_ACCOUNT, + school: school._id, + }); + await teacher.save(); + console.log(`Created teacher: ${TEACHER_ACCOUNT.email}`); + } else { + console.log('No schools found in database, skipping teacher creation.'); + } + + console.log('Auth Seed Complete!'); + process.exit(0); +} + +seedAuth().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index f19a723c..f9d5080d 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -55,7 +55,14 @@ export default function App() { } const data = await res.json(); - setCurrentUser(data.user); + if (data.success && data.data && data.data.user) { + setCurrentUser(data.data.user); + } else if (data.user) { + // Fallback in case backend doesn't wrap in sendSuccess + setCurrentUser(data.user); + } else { + throw new Error('Invalid user payload'); + } setCurrentView('dashboard'); } catch { setToken(null); diff --git a/frontend/src/components/LoginView.tsx b/frontend/src/components/LoginView.tsx index 8d223a28..fbb7b592 100644 --- a/frontend/src/components/LoginView.tsx +++ b/frontend/src/components/LoginView.tsx @@ -51,9 +51,17 @@ export const LoginView: React.FC = ({ onLoginSuccess, onBackToHo }); const data = await res.json(); if (res.ok) { - onLoginSuccess(data.token, data.user); + if (data.success && data.data) { + // New backend format + onLoginSuccess(data.data.token, data.data.user); + } else if (data.token && data.user) { + // Old mock backend format + onLoginSuccess(data.token, data.user); + } else { + setError('Invalid response format from server'); + } } else { - setError(data.error || 'Invalid email or password'); + setError(data.message || data.error || 'Invalid email or password'); } } catch (err) { setError('Connection failed. Verify server state.'); diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 82abdbc9..29b1df1c 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -27,9 +27,9 @@ export default defineConfig(() => { // Forward all /api calls to the real backend server (Express, :3000). // Override the target with VITE_API_TARGET if the backend runs elsewhere. proxy: { - '/api': { target: process.env.VITE_API_TARGET || 'http://localhost:3000', changeOrigin: true }, - '/output': { target: process.env.VITE_API_TARGET || 'http://localhost:3000', changeOrigin: true }, - '/worksheets': { target: process.env.VITE_API_TARGET || 'http://localhost:3000', changeOrigin: true }, + '/api': { target: process.env.VITE_API_TARGET || 'http://localhost:3001', changeOrigin: true }, + '/output': { target: process.env.VITE_API_TARGET || 'http://localhost:3001', changeOrigin: true }, + '/worksheets': { target: process.env.VITE_API_TARGET || 'http://localhost:3001', changeOrigin: true }, }, }, };