Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
87 changes: 87 additions & 0 deletions CLAUDE_UPDATED.md
Original file line number Diff line number Diff line change
@@ -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 <token>` 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.
4 changes: 2 additions & 2 deletions backend/src/config/environment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
18 changes: 15 additions & 3 deletions backend/src/controllers/auth.controller.ts
Original file line number Diff line number Diff line change
@@ -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);
}
}
}
3 changes: 2 additions & 1 deletion backend/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import 'dotenv/config';
import dotenv from 'dotenv';
import express from 'express';
import path from 'path';
import { fileURLToPath } from 'url';
Expand All @@ -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;
Expand Down
30 changes: 30 additions & 0 deletions backend/src/interfaces/user.interface.ts
Original file line number Diff line number Diff line change
@@ -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<boolean>;
}
1 change: 1 addition & 0 deletions backend/src/models/teacher.model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ const teacherSchema = new Schema<ITeacherDocument>(
toJSON: {
transform(_doc, ret) {
ret.id = ret._id;
ret.name = `${ret.firstName} ${ret.lastName}`.trim();
delete ret._id;
delete ret.__v;
delete ret.password;
Expand Down
85 changes: 85 additions & 0 deletions backend/src/models/user.model.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import { Schema, model } from 'mongoose';
import bcrypt from 'bcrypt';
import { IUserDocument, UserRole } from '../interfaces/user.interface';

const userSchema = new Schema<IUserDocument>(
{
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<boolean> {
return bcrypt.compare(candidatePassword, this.password);
};

export const User = model<IUserDocument>('User', userSchema);
17 changes: 17 additions & 0 deletions backend/src/repositories/user.repository.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { User } from '../models/user.model';
import { IUser } from '../interfaces/user.interface';

export class UserRepository {
async create(data: Partial<IUser>) {
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 });
}
}
3 changes: 3 additions & 0 deletions backend/src/routes/auth.routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
93 changes: 93 additions & 0 deletions backend/src/services/auth.service.ts
Original file line number Diff line number Diff line change
@@ -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 });
}
}
Loading