diff --git a/scholarship-backend/README.md b/scholarship-backend/README.md new file mode 100644 index 0000000..281552b --- /dev/null +++ b/scholarship-backend/README.md @@ -0,0 +1,294 @@ +# πŸŽ“ GMC Scholarship Portal β€” Backend API + +> Complete Node.js/Express backend for the **Guwahati Municipal Corporation Scholarship Portal**. + +--- + +## πŸ“ Architecture Overview + +``` +scholarship-backend/ +β”œβ”€β”€ prisma/ +β”‚ └── schema.prisma # Full PostgreSQL schema (12 models) +β”œβ”€β”€ scripts/ +β”‚ └── seed.js # Database seeder (scholarships, notices, users) +β”œβ”€β”€ src/ +β”‚ β”œβ”€β”€ index.js # Server entry point +β”‚ β”œβ”€β”€ app.js # Express app (middleware, routes) +β”‚ β”œβ”€β”€ config/ +β”‚ β”‚ β”œβ”€β”€ database.js # Prisma client + connection +β”‚ β”‚ β”œβ”€β”€ redis.js # Redis client + connection +β”‚ β”‚ └── logger.js # Winston logger +β”‚ β”œβ”€β”€ routes/ +β”‚ β”‚ β”œβ”€β”€ index.js # Master router +β”‚ β”‚ β”œβ”€β”€ auth.routes.js # Authentication (OTP + password) +β”‚ β”‚ β”œβ”€β”€ scholarship.routes.js +β”‚ β”‚ β”œβ”€β”€ application.routes.js +β”‚ β”‚ β”œβ”€β”€ document.routes.js +β”‚ β”‚ β”œβ”€β”€ notice.routes.js +β”‚ β”‚ β”œβ”€β”€ profile.routes.js +β”‚ β”‚ β”œβ”€β”€ eligibility.routes.js +β”‚ β”‚ β”œβ”€β”€ notification.routes.js +β”‚ β”‚ β”œβ”€β”€ admin.routes.js +β”‚ β”‚ └── disbursement.routes.js +β”‚ β”œβ”€β”€ controllers/ # One controller per resource +β”‚ β”œβ”€β”€ middleware/ +β”‚ β”‚ β”œβ”€β”€ auth.js # JWT authenticate + role authorize +β”‚ β”‚ β”œβ”€β”€ validate.js # Joi schema validation +β”‚ β”‚ β”œβ”€β”€ upload.js # Multer file upload +β”‚ β”‚ β”œβ”€β”€ cache.js # Redis response caching +β”‚ β”‚ └── errorHandler.js # Centralized error handling +β”‚ β”œβ”€β”€ services/ +β”‚ β”‚ β”œβ”€β”€ eligibility.service.js # Scoring engine (matching logic) +β”‚ β”‚ β”œβ”€β”€ notification.service.js +β”‚ β”‚ β”œβ”€β”€ email.service.js # Nodemailer templates +β”‚ β”‚ β”œβ”€β”€ sms.service.js # MSG91 OTP delivery +β”‚ β”‚ β”œβ”€β”€ storage.service.js # Local / S3 file storage +β”‚ β”‚ └── audit.service.js # Audit log writer +β”‚ └── validators/ # Joi schemas for every endpoint +└── .env.example +``` + +--- + +## πŸ—„οΈ Database Schema + +| Model | Purpose | +|-------|---------| +| `User` | Auth β€” phone + optional email/password | +| `UserSession` | Refresh token sessions | +| `OtpToken` | OTP state (Redis-backed, DB fallback) | +| `StudentProfile` | Personal, academic & bank details | +| `Scholarship` | Scholarship definitions with eligibility rules | +| `Application` | Student ↔ Scholarship applications | +| `ApplicationStatusHistory` | Full audit trail of status changes | +| `Document` | Uploaded files (marksheets, certificates) | +| `ApplicationDocument` | Document ↔ Application junction | +| `Disbursement` | Payment records linked to approved applications | +| `Notice` | Circulars, alerts, deadlines from the frontend | +| `EligibilityCheck` | Analytics for the eligibility checker tool | +| `Notification` | In-app notifications per user | +| `AuditLog` | Admin-visible action log | + +--- + +## πŸ”‘ Authentication Flow + +### Students (OTP-based) +``` +POST /api/v1/auth/otp/send { phone: "9876543210" } +POST /api/v1/auth/otp/verify { phone: "9876543210", otp: "123456" } +``` +β†’ Returns `{ accessToken, refreshToken }` + +### Admin / Verifiers (password-based) +``` +POST /api/v1/auth/login { email, password } +``` +β†’ Returns `{ accessToken, refreshToken }` + +### Refresh +``` +POST /api/v1/auth/refresh { refreshToken } +``` + +All protected endpoints require: `Authorization: Bearer ` + +--- + +## πŸ“‹ Complete API Reference + +### Authentication +| Method | Endpoint | Auth | Description | +|--------|----------|------|-------------| +| POST | `/auth/otp/send` | Public | Send OTP to phone | +| POST | `/auth/otp/verify` | Public | Verify OTP β†’ tokens | +| POST | `/auth/register` | Public | Password-based registration | +| POST | `/auth/login` | Public | Password login | +| POST | `/auth/refresh` | Public | Rotate refresh token | +| POST | `/auth/logout` | Student | Invalidate session | +| GET | `/auth/me` | Student | Current user info | +| POST | `/auth/forgot-password` | Public | Send reset link | +| POST | `/auth/reset-password` | Public | Set new password | + +### Scholarships +| Method | Endpoint | Auth | Description | +|--------|----------|------|-------------| +| GET | `/scholarships` | Public | List (filter/search/paginate) | +| GET | `/scholarships/featured` | Public | Featured scholarship | +| GET | `/scholarships/:slug` | Public | Scholarship detail | +| GET | `/scholarships/:slug/application-status` | Student | Has user applied? | +| POST | `/scholarships` | Admin | Create scholarship | +| PUT | `/scholarships/:id` | Admin | Update scholarship | +| PATCH | `/scholarships/:id/status` | Admin | Change status | +| DELETE | `/scholarships/:id` | Super Admin | Archive | + +### Applications +| Method | Endpoint | Auth | Description | +|--------|----------|------|-------------| +| GET | `/applications` | Student | My applications | +| POST | `/applications` | Student | Start new application | +| GET | `/applications/:id` | Student/Admin | Application detail | +| PUT | `/applications/:id` | Student | Update draft | +| POST | `/applications/:id/submit` | Student | Submit for review | +| POST | `/applications/:id/cancel` | Student | Cancel draft/submitted | +| GET | `/applications/:id/timeline` | Student/Admin | Status history | +| GET | `/applications/:id/download` | Student/Admin | Download as PDF | +| POST | `/applications/:id/verify` | Verifier | Institution verify | +| GET | `/applications/admin/all` | Admin | All applications | +| PATCH | `/applications/:id/status` | Admin | Approve/reject | +| POST | `/applications/admin/bulk-approve` | Admin | Bulk approve | + +### Documents +| Method | Endpoint | Auth | Description | +|--------|----------|------|-------------| +| POST | `/documents/upload` | Student | Upload document | +| GET | `/documents` | Student | My documents | +| GET | `/documents/:id/meta` | Student/Admin | File metadata | +| GET | `/documents/:id` | Student/Admin | Stream file | +| DELETE | `/documents/:id` | Student | Delete (if unattached) | +| PATCH | `/documents/:id/verify` | Verifier/Admin | Mark verified | + +### Profile +| Method | Endpoint | Auth | Description | +|--------|----------|------|-------------| +| GET | `/profile` | Student | Get profile + docs | +| POST | `/profile` | Student | Create profile | +| PUT | `/profile` | Student | Update profile | +| PUT | `/profile/bank` | Student | Update bank details | + +### Eligibility Checker +| Method | Endpoint | Auth | Description | +|--------|----------|------|-------------| +| POST | `/eligibility/check` | Public | Match user to scholarships | +| GET | `/eligibility/result/:sessionId` | Public | Retrieve saved result | + +### Notices +| Method | Endpoint | Auth | Description | +|--------|----------|------|-------------| +| GET | `/notices` | Public | List notices (filterable) | +| GET | `/notices/:slug` | Public | Notice detail | +| POST | `/notices` | Admin | Create notice | +| PUT | `/notices/:id` | Admin | Update notice | +| DELETE | `/notices/:id` | Super Admin | Deactivate | + +### Disbursements +| Method | Endpoint | Auth | Description | +|--------|----------|------|-------------| +| GET | `/disbursements/my` | Student | My disbursements | +| GET | `/disbursements/:applicationId` | Student/Admin | Disbursement detail | +| POST | `/disbursements` | Admin | Initiate disbursement | +| PATCH | `/disbursements/:id/status` | Admin | Update status | + +### Admin +| Method | Endpoint | Auth | Description | +|--------|----------|------|-------------| +| GET | `/admin/stats` | Admin | Dashboard KPIs | +| GET | `/admin/users` | Admin | All users | +| GET | `/admin/users/:id` | Admin | User detail | +| PATCH | `/admin/users/:id/role` | Admin | Change role | +| PATCH | `/admin/users/:id/block` | Admin | Block/unblock | +| GET | `/admin/reports/applications` | Admin | Application report | +| GET | `/admin/reports/disbursements` | Admin | Disbursement report | +| GET | `/admin/reports/scholarships` | Admin | Scholarship report | +| GET | `/admin/audit-log` | Admin | Audit log | + +### Notifications +| Method | Endpoint | Auth | Description | +|--------|----------|------|-------------| +| GET | `/notifications` | Student | List notifications | +| GET | `/notifications/unread-count` | Student | Badge count | +| PATCH | `/notifications/:id/read` | Student | Mark read | +| POST | `/notifications/mark-all-read` | Student | Mark all read | +| DELETE | `/notifications/:id` | Student | Delete notification | + +--- + +## πŸš€ Quick Start + +### Prerequisites +- Node.js 18+ +- PostgreSQL 14+ +- Redis 7+ + +### Setup +```bash +# 1. Clone and install dependencies +cd scholarship-backend +npm install + +# 2. Configure environment +cp .env.example .env +# Edit .env with your database URL, JWT secret, SMTP, SMS credentials + +# 3. Run database migrations +npx prisma migrate dev --name init + +# 4. Generate Prisma client +npx prisma generate + +# 5. Seed the database +npm run seed + +# 6. Start development server +npm run dev +``` + +Server starts on http://localhost:4000 +API available at http://localhost:4000/api/v1 + +--- + +## πŸ” Security Features + +| Feature | Implementation | +|---------|---------------| +| Auth | JWT access (7d) + refresh (30d) with rotation | +| OTP | 6-digit, 10-minute expiry, 3-attempt limit, Redis-backed | +| Rate Limiting | 100 req/15min global; 10 req/15min for auth | +| File Upload | Type allowlist (PDF/JPEG/PNG/WebP), 5MB limit | +| Role Guards | STUDENT / VERIFIER / ADMIN / SUPER_ADMIN | +| Input Validation | Joi schemas on every mutating endpoint | +| Security Headers | Helmet (CSP, HSTS, etc.) | +| Audit Log | Every sensitive action is logged with userId + IP | +| Password | bcrypt (12 rounds) | + +--- + +## 🧩 Eligibility Scoring Engine + +The `POST /eligibility/check` endpoint uses a weighted scoring algorithm: + +| Criterion | Points | +|-----------|--------| +| Category match | 30 | +| Academic level match | 30 | +| Income group match | 20 | +| Percentage meets minimum | 15 | +| District match | 10 | +| Gender/minority/disability | 10 | +| Seat available | 5 | + +A scholarship is **eligible** when all hard criteria pass (score at full value). +A scholarship is **partially eligible** when ≀1 criterion fails (useful for guidance). + +--- + +## πŸ“§ Email Templates + +- Welcome email on registration +- Application confirmation with tracking link +- Password reset link +- Disbursement credit alert + +--- + +## πŸ—οΈ Production Deployment Notes + +1. **Storage**: Switch `STORAGE_TYPE=s3` and configure AWS credentials in `.env` +2. **SMS**: Set `SMS_PROVIDER=msg91` and configure MSG91 API key + OTP template +3. **PDF Generation**: Integrate `puppeteer` or `pdf-lib` in `downloadApplicationPDF` controller +4. **NSP Integration**: Add webhook handlers for National Scholarship Portal status syncs +5. **Database**: Use connection pooling (PgBouncer) and read replicas for high load +6. **Redis**: Use Redis Cluster or ElastiCache for production +7. **Monitoring**: Add Sentry for error tracking, Prometheus metrics endpoint diff --git a/scholarship-backend/package.json b/scholarship-backend/package.json new file mode 100644 index 0000000..83c2c32 --- /dev/null +++ b/scholarship-backend/package.json @@ -0,0 +1,43 @@ +{ + "name": "gmc-scholarship-portal-backend", + "version": "1.0.0", + "description": "Guwahati Municipal Corporation Scholarship Portal β€” Full Backend API", + "main": "src/index.js", + "scripts": { + "start": "node src/index.js", + "dev": "nodemon src/index.js", + "seed": "node scripts/seed.js", + "migrate": "npx prisma migrate dev", + "generate": "npx prisma generate", + "test": "jest --coverage" + }, + "dependencies": { + "@prisma/client": "^5.7.0", + "bcryptjs": "^2.4.3", + "compression": "^1.7.4", + "cors": "^2.8.5", + "dotenv": "^16.3.1", + "express": "^4.18.2", + "express-rate-limit": "^7.1.5", + "express-validator": "^7.0.1", + "helmet": "^7.1.0", + "joi": "^17.11.0", + "jsonwebtoken": "^9.0.2", + "morgan": "^1.10.0", + "multer": "^1.4.5-lts.1", + "nodemailer": "^6.9.7", + "redis": "^4.6.11", + "slugify": "^1.6.6", + "uuid": "^9.0.0", + "winston": "^3.11.0" + }, + "devDependencies": { + "jest": "^29.7.0", + "nodemon": "^3.0.2", + "prisma": "^5.7.0", + "supertest": "^6.3.3" + }, + "engines": { + "node": ">=18.0.0" + } +} diff --git a/scholarship-backend/prisma/schema.prisma b/scholarship-backend/prisma/schema.prisma new file mode 100644 index 0000000..de8e646 --- /dev/null +++ b/scholarship-backend/prisma/schema.prisma @@ -0,0 +1,443 @@ +// prisma/schema.prisma +// GMC Scholarship Portal - Complete Database Schema + +generator client { + provider = "prisma-client-js" +} + +datasource db { + provider = "postgresql" + url = env("DATABASE_URL") +} + +// ───────────────────────────────────────────── +// ENUMS +// ───────────────────────────────────────────── + +enum Role { + STUDENT + ADMIN + VERIFIER // School/institution verifier + SUPER_ADMIN +} + +enum Gender { + MALE + FEMALE + OTHER + PREFER_NOT_TO_SAY +} + +enum Category { + GENERAL + OBC + SC + ST + EWS + MINORITY +} + +enum IncomeGroup { + BELOW_1L // Below β‚Ή1,00,000 + BELOW_2L // β‚Ή1,00,001 – β‚Ή2,00,000 + BELOW_3_5L // β‚Ή2,00,001 – β‚Ή3,50,000 + BELOW_8L // β‚Ή3,50,001 – β‚Ή8,00,000 + ABOVE_8L // Above β‚Ή8,00,000 +} + +enum AcademicLevel { + PRE_MATRIC // Class 1–9 + MATRIC // Class 10 + POST_MATRIC // Class 11–12 + GRADUATION // UG + POST_GRADUATION // PG + DIPLOMA + VOCATIONAL + PHD +} + +enum ScholarshipStatus { + DRAFT + ACTIVE + CLOSING_SOON + CLOSED + ARCHIVED +} + +enum ApplicationStatus { + DRAFT + SUBMITTED + UNDER_REVIEW + INSTITUTION_VERIFIED + PENDING_APPROVAL + APPROVED + DISBURSED + REJECTED + CANCELLED + RENEWAL_DUE +} + +enum DocumentType { + MARKSHEET + INCOME_CERTIFICATE + CASTE_CERTIFICATE + AADHAAR + PHOTO + BANK_PASSBOOK + DOMICILE + DISABILITY_CERTIFICATE + MINORITY_CERTIFICATE + OTHER +} + +enum NoticeTag { + DEADLINE + NOTICE + RESULT + NEW + ALERT + INFO +} + +enum DisbursementStatus { + PENDING + INITIATED + PROCESSED + FAILED + REVERSED +} + +// ───────────────────────────────────────────── +// USERS +// ───────────────────────────────────────────── + +model User { + id String @id @default(uuid()) + email String? @unique + phone String @unique + aadhaarHash String? @unique // Hashed Aadhaar number + passwordHash String? + role Role @default(STUDENT) + isActive Boolean @default(true) + isEmailVerified Boolean @default(false) + isPhoneVerified Boolean @default(false) + lastLoginAt DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + profile StudentProfile? + applications Application[] + notifications Notification[] + sessions UserSession[] + otpTokens OtpToken[] + auditLogs AuditLog[] + + @@map("users") +} + +model UserSession { + id String @id @default(uuid()) + userId String + token String @unique + ipAddress String? + userAgent String? + expiresAt DateTime + createdAt DateTime @default(now()) + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@map("user_sessions") +} + +model OtpToken { + id String @id @default(uuid()) + userId String + code String + purpose String // "login", "phone_verify", "reset_password" + attempts Int @default(0) + expiresAt DateTime + usedAt DateTime? + createdAt DateTime @default(now()) + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@map("otp_tokens") +} + +model StudentProfile { + id String @id @default(uuid()) + userId String @unique + firstName String + lastName String + dob DateTime + gender Gender + category Category + incomeGroup IncomeGroup + academicLevel AcademicLevel + institution String + institutionCode String? + course String + yearOfStudy Int + percentage Float? + rollNumber String? + district String @default("Kamrup Metropolitan") + ward String? + address String + pincode String + bankAccountNo String? + bankName String? + bankIFSC String? + bankBranch String? + isMinority Boolean @default(false) + isDisabled Boolean @default(false) + disabilityPct Int? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + documents Document[] + + @@map("student_profiles") +} + +// ───────────────────────────────────────────── +// SCHOLARSHIPS +// ───────────────────────────────────────────── + +model Scholarship { + id String @id @default(uuid()) + slug String @unique + name String + shortName String? + issuer String // "GMC Education Cell", "Assam Govt.", etc. + description String + status ScholarshipStatus @default(DRAFT) + isFeatured Boolean @default(false) + + // Eligibility criteria + eligibleCategories Category[] + eligibleIncomeGroups IncomeGroup[] + eligibleAcademicLevels AcademicLevel[] + minPercentage Float? + minAge Int? + maxAge Int? + requiresDistrict Boolean @default(true) + onlyForGirls Boolean @default(false) + onlyForMinority Boolean @default(false) + onlyForDisabled Boolean @default(false) + + // Financial + amountPerYear Float + amountFrequency String @default("yearly") // "yearly", "monthly", "one-time" + totalSeats Int? + seatsRemaining Int? + + // Dates + applicationStartDate DateTime + applicationEndDate DateTime + academicYear String // "2025-26" + + // Documents required + requiredDocuments DocumentType[] + + // External + externalUrl String? + nspSchemeCode String? // National Scholarship Portal code + iconType String? // For frontend icon selection + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + applications Application[] + disbursements Disbursement[] + + @@map("scholarships") +} + +// ───────────────────────────────────────────── +// APPLICATIONS +// ───────────────────────────────────────────── + +model Application { + id String @id @default(uuid()) + applicationNo String @unique // GMC-2526-XXXXX + userId String + scholarshipId String + status ApplicationStatus @default(DRAFT) + academicYear String + + // Form data (snapshot at submission) + formData Json? + + // Remarks + studentRemarks String? + verifierRemarks String? + adminRemarks String? + + // Timestamps + submittedAt DateTime? + verifiedAt DateTime? + approvedAt DateTime? + rejectedAt DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + // Relations + user User @relation(fields: [userId], references: [id]) + scholarship Scholarship @relation(fields: [scholarshipId], references: [id]) + documents ApplicationDocument[] + statusHistory ApplicationStatusHistory[] + disbursement Disbursement? + + @@unique([userId, scholarshipId, academicYear]) + @@map("applications") +} + +model ApplicationStatusHistory { + id String @id @default(uuid()) + applicationId String + fromStatus ApplicationStatus? + toStatus ApplicationStatus + changedBy String // userId + remarks String? + changedAt DateTime @default(now()) + + application Application @relation(fields: [applicationId], references: [id], onDelete: Cascade) + + @@map("application_status_history") +} + +// ───────────────────────────────────────────── +// DOCUMENTS +// ───────────────────────────────────────────── + +model Document { + id String @id @default(uuid()) + profileId String + type DocumentType + filename String + originalName String + mimeType String + sizeBytes Int + storageKey String @unique // S3/local storage key + isVerified Boolean @default(false) + verifiedBy String? + uploadedAt DateTime @default(now()) + + profile StudentProfile @relation(fields: [profileId], references: [id], onDelete: Cascade) + applicationDocs ApplicationDocument[] + + @@map("documents") +} + +model ApplicationDocument { + id String @id @default(uuid()) + applicationId String + documentId String + + application Application @relation(fields: [applicationId], references: [id], onDelete: Cascade) + document Document @relation(fields: [documentId], references: [id]) + + @@unique([applicationId, documentId]) + @@map("application_documents") +} + +// ───────────────────────────────────────────── +// DISBURSEMENTS +// ───────────────────────────────────────────── + +model Disbursement { + id String @id @default(uuid()) + applicationId String @unique + scholarshipId String + amount Float + status DisbursementStatus @default(PENDING) + transactionRef String? + bankAccount String? + ifscCode String? + initiatedAt DateTime? + processedAt DateTime? + failureReason String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + application Application @relation(fields: [applicationId], references: [id]) + scholarship Scholarship @relation(fields: [scholarshipId], references: [id]) + + @@map("disbursements") +} + +// ───────────────────────────────────────────── +// NOTICES +// ───────────────────────────────────────────── + +model Notice { + id String @id @default(uuid()) + title String + body String + tag NoticeTag + issuedBy String // Dept name + publishedAt DateTime @default(now()) + expiresAt DateTime? + isPinned Boolean @default(false) + isActive Boolean @default(true) + slug String @unique + attachmentUrl String? + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@map("notices") +} + +// ───────────────────────────────────────────── +// ELIGIBILITY CHECKER +// ───────────────────────────────────────────── + +model EligibilityCheck { + id String @id @default(uuid()) + sessionId String + inputData Json // User's answers + matchedSchemas Json // List of matched scholarship IDs and scores + createdAt DateTime @default(now()) + + @@map("eligibility_checks") +} + +// ───────────────────────────────────────────── +// NOTIFICATIONS +// ───────────────────────────────────────────── + +model Notification { + id String @id @default(uuid()) + userId String + title String + body String + type String // "application_update", "deadline_reminder", "disbursement", etc. + isRead Boolean @default(false) + metadata Json? + createdAt DateTime @default(now()) + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@map("notifications") +} + +// ───────────────────────────────────────────── +// AUDIT LOG +// ───────────────────────────────────────────── + +model AuditLog { + id String @id @default(uuid()) + userId String? + action String // "APPLICATION_SUBMITTED", "STATUS_CHANGED", etc. + entityType String // "Application", "Scholarship", etc. + entityId String? + metadata Json? + ipAddress String? + createdAt DateTime @default(now()) + + user User? @relation(fields: [userId], references: [id]) + + @@map("audit_logs") +} diff --git a/scholarship-backend/scripts/seed.js b/scholarship-backend/scripts/seed.js new file mode 100644 index 0000000..c037a51 --- /dev/null +++ b/scholarship-backend/scripts/seed.js @@ -0,0 +1,292 @@ +// scripts/seed.js +// Seeds the database with sample scholarships, notices, and an admin user + +require('dotenv').config(); +const { PrismaClient } = require('@prisma/client'); +const bcrypt = require('bcryptjs'); + +const prisma = new PrismaClient(); + +const scholarships = [ + { + slug: 'pragyan-bharati-2025-26', + name: 'Pragyan Bharati Free Textbook Scheme', + shortName: 'Pragyan Bharati', + issuer: 'Assam Higher Education Dept.', + description: 'Free textbooks and β‚Ή5,000 annual scholarship for meritorious students in Higher Secondary institutions across Assam. Covers all streams – Arts, Science and Commerce.', + status: 'ACTIVE', + isFeatured: true, + eligibleCategories: [], // All categories + eligibleIncomeGroups: ['BELOW_1L','BELOW_2L','BELOW_3_5L'], + eligibleAcademicLevels: ['POST_MATRIC'], + minPercentage: 60, + requiresDistrict: false, + onlyForGirls: false, + amountPerYear: 5000, + amountFrequency: 'yearly', + totalSeats: 5000, + seatsRemaining: 3247, + applicationStartDate: new Date('2025-11-01'), + applicationEndDate: new Date('2026-03-05'), + academicYear: '2025-26', + requiredDocuments: ['MARKSHEET','INCOME_CERTIFICATE','AADHAAR','PHOTO','BANK_PASSBOOK'], + nspSchemeCode: 'PB-ASSAM-2526', + iconType: 'book', + }, + { + slug: 'pre-matric-sc-scholarship-2025-26', + name: 'Pre-Matric Scholarship for SC Students', + shortName: 'Pre-Matric SC', + issuer: 'SJED, Govt. of Assam', + description: 'Ministry of Social Justice & Empowerment funded scholarship for Class 9 & 10 SC students to reduce dropout rate and support educational continuity.', + status: 'CLOSING_SOON', + isFeatured: false, + eligibleCategories: ['SC'], + eligibleIncomeGroups: ['BELOW_2L','BELOW_1L'], + eligibleAcademicLevels: ['PRE_MATRIC'], + requiresDistrict: false, + amountPerYear: 3500, + amountFrequency: 'yearly', + totalSeats: 2000, + seatsRemaining: 120, + applicationStartDate: new Date('2025-11-15'), + applicationEndDate: new Date('2026-02-28'), + academicYear: '2025-26', + requiredDocuments: ['MARKSHEET','INCOME_CERTIFICATE','CASTE_CERTIFICATE','AADHAAR','PHOTO'], + iconType: 'award', + }, + { + slug: 'nmms-2025-26', + name: 'National Means-cum-Merit Scholarship (NMMS)', + shortName: 'NMMS', + issuer: 'Directorate of Secondary Education', + description: 'Central government scholarship of β‚Ή12,000/year for meritorious students from economically weaker sections. Awarded after a two-stage examination.', + status: 'ACTIVE', + isFeatured: false, + eligibleCategories: [], + eligibleIncomeGroups: ['BELOW_1L','BELOW_2L'], + eligibleAcademicLevels: ['PRE_MATRIC'], + minPercentage: 55, + requiresDistrict: false, + amountPerYear: 12000, + amountFrequency: 'yearly', + totalSeats: 1000, + seatsRemaining: 612, + applicationStartDate: new Date('2025-12-01'), + applicationEndDate: new Date('2026-03-05'), + academicYear: '2025-26', + requiredDocuments: ['MARKSHEET','INCOME_CERTIFICATE','AADHAAR','PHOTO'], + nspSchemeCode: 'NMMS-ASSAM-2526', + iconType: 'star', + }, + { + slug: 'orunodoi-girls-scholarship-2025-26', + name: 'Orunodoi Girls Scholarship', + shortName: 'Orunodoi Girls', + issuer: 'Assam Welfare Department', + description: 'New initiative to support girls from economically backward families pursuing higher education. Covers tuition fees and provides a monthly stipend.', + status: 'ACTIVE', + isFeatured: false, + eligibleCategories: ['GENERAL','OBC','SC','ST','EWS'], + eligibleIncomeGroups: ['BELOW_1L','BELOW_2L','BELOW_3_5L'], + eligibleAcademicLevels: ['POST_MATRIC','GRADUATION'], + onlyForGirls: true, + requiresDistrict: false, + amountPerYear: 10000, + amountFrequency: 'yearly', + totalSeats: 3000, + seatsRemaining: 2800, + applicationStartDate: new Date('2026-03-01'), + applicationEndDate: new Date('2026-05-31'), + academicYear: '2025-26', + requiredDocuments: ['MARKSHEET','INCOME_CERTIFICATE','AADHAAR','PHOTO','BANK_PASSBOOK'], + iconType: 'heart', + }, + { + slug: 'cm-special-scholarship-2024-25', + name: "Chief Minister's Special Scholarship", + shortName: "CM Special", + issuer: 'GMC Education Cell', + description: "City-level merit scholarship for students securing above 85% in Class 10 board exams from schools within Guwahati Municipal Corporation limits.", + status: 'CLOSED', + isFeatured: false, + eligibleCategories: [], + eligibleIncomeGroups: [], + eligibleAcademicLevels: ['POST_MATRIC'], + minPercentage: 85, + requiresDistrict: true, + amountPerYear: 15000, + amountFrequency: 'yearly', + totalSeats: 500, + seatsRemaining: 0, + applicationStartDate: new Date('2024-08-01'), + applicationEndDate: new Date('2024-11-30'), + academicYear: '2024-25', + requiredDocuments: ['MARKSHEET','AADHAAR','PHOTO','DOMICILE'], + iconType: 'trophy', + }, + { + slug: 'st-post-matric-scholarship-2025-26', + name: 'Post-Matric Scholarship for ST Students', + shortName: 'Post-Matric ST', + issuer: 'Tribal Affairs, Govt. of Assam', + description: 'Scholarship for Scheduled Tribe students pursuing post-secondary education. Covers maintenance allowance, study tour, thesis, and book grants.', + status: 'ACTIVE', + isFeatured: false, + eligibleCategories: ['ST'], + eligibleIncomeGroups: ['BELOW_2L','BELOW_3_5L','BELOW_1L'], + eligibleAcademicLevels: ['POST_MATRIC','GRADUATION','POST_GRADUATION','DIPLOMA'], + requiresDistrict: false, + amountPerYear: 8000, + amountFrequency: 'yearly', + totalSeats: 1500, + seatsRemaining: 890, + applicationStartDate: new Date('2025-10-01'), + applicationEndDate: new Date('2026-02-28'), + academicYear: '2025-26', + requiredDocuments: ['MARKSHEET','INCOME_CERTIFICATE','CASTE_CERTIFICATE','AADHAAR','PHOTO','BANK_PASSBOOK'], + iconType: 'users', + }, +]; + +const notices = [ + { + slug: 'nmms-deadline-extended-march-2026', + title: 'NMMS Application deadline extended to 5 March 2026', + body: 'The National Means-cum-Merit Scholarship application deadline has been extended to 5 March 2026. Students must submit their completed applications before midnight. Incomplete applications will not be considered.', + tag: 'DEADLINE', + issuedBy: 'Directorate of Secondary Education', + isPinned: true, + publishedAt: new Date('2026-02-26'), + expiresAt: new Date('2026-03-05'), + }, + { + slug: 'pragyan-bharati-renewal-open-2026', + title: 'Pragyan Bharati FY 2025–26: Renewal applications now open', + body: 'Students who received the Pragyan Bharati scholarship in FY 2024-25 may now apply for renewal on the National Scholarship Portal. Ensure your bank details are updated before applying.', + tag: 'NOTICE', + issuedBy: 'Assam Higher Education Dept.', + isPinned: false, + publishedAt: new Date('2026-02-24'), + }, + { + slug: 'cm-special-merit-list-published-2024-25', + title: "CM Special Scholarship 2024–25 merit list published", + body: '412 students from Guwahati have been selected for the Chief Minister\'s Special Scholarship 2024–25. The merit list is available on the GMC Education Cell website. Selected students must verify their bank account details by 15 March 2026.', + tag: 'RESULT', + issuedBy: 'GMC Education Cell', + isPinned: false, + publishedAt: new Date('2026-02-22'), + }, + { + slug: 'orunodoi-girls-scholarship-launched-2025-26', + title: 'Orunodoi Girls Scholarship 2025–26 launched', + body: 'The Assam Welfare Department has launched the Orunodoi Girls Scholarship for 2025–26. Applications open from 1 March 2026. This scholarship supports girls from economically backward families pursuing post-secondary education.', + tag: 'NEW', + issuedBy: 'Assam Welfare Department', + isPinned: false, + publishedAt: new Date('2026-02-20'), + }, + { + slug: 'pre-matric-sc-incomplete-alert-2026', + title: 'Alert: Incomplete Pre-Matric SC Scholarship applications will be rejected', + body: 'Students who have submitted incomplete applications for the Pre-Matric SC Scholarship are advised to complete their submissions immediately. Applications without all required documents will be summarily rejected after 28 February 2026.', + tag: 'ALERT', + issuedBy: 'SJED, Govt. of Assam', + isPinned: true, + publishedAt: new Date('2026-02-18'), + expiresAt: new Date('2026-02-28'), + }, +]; + +async function main() { + console.log('🌱 Seeding database...'); + + // Create admin user + const adminHash = await bcrypt.hash('Admin@1234', 12); + const admin = await prisma.user.upsert({ + where: { phone: '9000000000' }, + update: {}, + create: { + phone: '9000000000', + email: 'admin@gmcscholarship.in', + passwordHash: adminHash, + role: 'SUPER_ADMIN', + isActive: true, + isPhoneVerified: true, + isEmailVerified: true, + }, + }); + console.log(`βœ… Admin user: ${admin.email} | Phone: ${admin.phone} | Password: Admin@1234`); + + // Create demo student user + const studentHash = await bcrypt.hash('Student@1234', 12); + const student = await prisma.user.upsert({ + where: { phone: '9876543210' }, + update: {}, + create: { + phone: '9876543210', + email: 'student@example.com', + passwordHash: studentHash, + role: 'STUDENT', + isPhoneVerified: true, + }, + }); + + // Create demo student profile + await prisma.studentProfile.upsert({ + where: { userId: student.id }, + update: {}, + create: { + userId: student.id, + firstName: 'Priya', + lastName: 'Deka', + dob: new Date('2006-05-14'), + gender: 'FEMALE', + category: 'OBC', + incomeGroup: 'BELOW_2L', + academicLevel: 'POST_MATRIC', + institution: 'Cotton College Govt. Higher Secondary School', + course: 'Higher Secondary (Science)', + yearOfStudy: 1, + percentage: 78.5, + district: 'Kamrup Metropolitan', + address: 'Guwahati, Assam', + pincode: '781001', + isMinority: false, + isDisabled: false, + }, + }); + console.log(`βœ… Student user: ${student.phone} | Password: Student@1234`); + + // Seed scholarships + for (const s of scholarships) { + await prisma.scholarship.upsert({ + where: { slug: s.slug }, + update: s, + create: s, + }); + } + console.log(`βœ… ${scholarships.length} scholarships seeded`); + + // Seed notices + for (const n of notices) { + await prisma.notice.upsert({ + where: { slug: n.slug }, + update: n, + create: n, + }); + } + console.log(`βœ… ${notices.length} notices seeded`); + + console.log('\nπŸŽ‰ Seeding complete!'); + console.log('─────────────────────────────────'); + console.log('Admin login: POST /api/v1/auth/login'); + console.log(' email: admin@gmcscholarship.in'); + console.log(' password: Admin@1234'); + console.log('Student OTP: POST /api/v1/auth/otp/send { phone: "9876543210" }'); +} + +main() + .catch(console.error) + .finally(() => prisma.$disconnect()); diff --git a/scholarship-backend/src/app.js b/scholarship-backend/src/app.js new file mode 100644 index 0000000..a66d3c2 --- /dev/null +++ b/scholarship-backend/src/app.js @@ -0,0 +1,99 @@ +// src/app.js +// Express application setup β€” middleware, routes, error handling + +const express = require('express'); +const helmet = require('helmet'); +const cors = require('cors'); +const compression = require('compression'); +const morgan = require('morgan'); +const rateLimit = require('express-rate-limit'); +const path = require('path'); + +const logger = require('./config/logger'); +const apiRouter = require('./routes'); +const { errorHandler, notFoundHandler } = require('./middleware/errorHandler'); + +const app = express(); + +// ───────────────────────────────────────────── +// Security Headers +// ───────────────────────────────────────────── +app.use(helmet({ + crossOriginResourcePolicy: { policy: 'cross-origin' }, +})); + +// ───────────────────────────────────────────── +// CORS +// ───────────────────────────────────────────── +const allowedOrigins = (process.env.ALLOWED_ORIGINS || '').split(',').map(o => o.trim()); +app.use(cors({ + origin: (origin, cb) => { + if (!origin || allowedOrigins.includes(origin)) return cb(null, true); + cb(new Error(`Origin ${origin} not allowed by CORS`)); + }, + credentials: true, + methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'], + allowedHeaders: ['Content-Type', 'Authorization', 'X-Request-ID'], +})); +app.options('*', cors()); + +// ───────────────────────────────────────────── +// Body Parsers +// ───────────────────────────────────────────── +app.use(express.json({ limit: '2mb' })); +app.use(express.urlencoded({ extended: true, limit: '2mb' })); + +// ───────────────────────────────────────────── +// Compression +// ───────────────────────────────────────────── +app.use(compression()); + +// ───────────────────────────────────────────── +// HTTP Logging +// ───────────────────────────────────────────── +app.use(morgan('combined', { + stream: { write: (msg) => logger.http(msg.trim()) }, + skip: (req) => req.url === '/health', +})); + +// ───────────────────────────────────────────── +// Global Rate Limiting +// ───────────────────────────────────────────── +app.use(rateLimit({ + windowMs: parseInt(process.env.RATE_LIMIT_WINDOW_MS) || 15 * 60 * 1000, + max: parseInt(process.env.RATE_LIMIT_MAX) || 100, + standardHeaders: true, + legacyHeaders: false, + message: { success: false, message: 'Too many requests, please try again later.' }, +})); + +// ───────────────────────────────────────────── +// Static Files (uploaded documents) +// ───────────────────────────────────────────── +app.use('/uploads', express.static(path.join(__dirname, '../uploads'))); + +// ───────────────────────────────────────────── +// Health Check +// ───────────────────────────────────────────── +app.get('/health', (req, res) => { + res.json({ + status: 'ok', + service: 'GMC Scholarship Portal API', + version: '1.0.0', + timestamp: new Date().toISOString(), + }); +}); + +// ───────────────────────────────────────────── +// API Routes +// ───────────────────────────────────────────── +const prefix = process.env.API_PREFIX || '/api/v1'; +app.use(prefix, apiRouter); + +// ───────────────────────────────────────────── +// Error Handlers (must be last) +// ───────────────────────────────────────────── +app.use(notFoundHandler); +app.use(errorHandler); + +module.exports = app; diff --git a/scholarship-backend/src/config/database.js b/scholarship-backend/src/config/database.js new file mode 100644 index 0000000..9544634 --- /dev/null +++ b/scholarship-backend/src/config/database.js @@ -0,0 +1,26 @@ +// src/config/database.js +const { PrismaClient } = require('@prisma/client'); +const logger = require('./logger'); + +const prisma = new PrismaClient({ + log: [ + { level: 'query', emit: 'event' }, + { level: 'error', emit: 'stdout' }, + { level: 'warn', emit: 'stdout' }, + ], +}); + +// Log slow queries in development +if (process.env.NODE_ENV === 'development') { + prisma.$on('query', (e) => { + if (e.duration > 100) { + logger.warn(`Slow query (${e.duration}ms): ${e.query.substring(0, 120)}`); + } + }); +} + +async function connectDB() { + await prisma.$connect(); +} + +module.exports = { prisma, connectDB }; diff --git a/scholarship-backend/src/config/logger.js b/scholarship-backend/src/config/logger.js new file mode 100644 index 0000000..4724a2c --- /dev/null +++ b/scholarship-backend/src/config/logger.js @@ -0,0 +1,28 @@ +// src/config/logger.js +const winston = require('winston'); +const path = require('path'); +const fs = require('fs'); + +const LOG_DIR = process.env.LOG_DIR || './logs'; +if (!fs.existsSync(LOG_DIR)) fs.mkdirSync(LOG_DIR, { recursive: true }); + +const { combine, timestamp, printf, colorize, errors } = winston.format; + +const logFormat = printf(({ level, message, timestamp, stack }) => + `${timestamp} [${level}]: ${stack || message}` +); + +const logger = winston.createLogger({ + level: process.env.LOG_LEVEL || 'info', + format: combine(timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }), errors({ stack: true }), logFormat), + transports: [ + new winston.transports.Console({ + format: combine(colorize(), timestamp({ format: 'HH:mm:ss' }), logFormat), + }), + new winston.transports.File({ filename: path.join(LOG_DIR, 'error.log'), level: 'error' }), + new winston.transports.File({ filename: path.join(LOG_DIR, 'combined.log') }), + ], + exitOnError: false, +}); + +module.exports = logger; diff --git a/scholarship-backend/src/config/redis.js b/scholarship-backend/src/config/redis.js new file mode 100644 index 0000000..7a3b9ee --- /dev/null +++ b/scholarship-backend/src/config/redis.js @@ -0,0 +1,17 @@ +// src/config/redis.js +const { createClient } = require('redis'); +const logger = require('./logger'); + +const client = createClient({ + url: process.env.REDIS_URL || 'redis://localhost:6379', + socket: { reconnectStrategy: (retries) => Math.min(retries * 100, 3000) }, +}); + +client.on('error', (err) => logger.error('Redis error:', err.message)); +client.on('reconnecting', () => logger.warn('Redis reconnecting...')); + +async function connectRedis() { + await client.connect(); +} + +module.exports = { client, connectRedis }; diff --git a/scholarship-backend/src/controllers/admin.controller.js b/scholarship-backend/src/controllers/admin.controller.js new file mode 100644 index 0000000..e7e49ab --- /dev/null +++ b/scholarship-backend/src/controllers/admin.controller.js @@ -0,0 +1,182 @@ +// src/controllers/admin.controller.js +const prisma = require('../config/database').prisma; +const { AppError } = require('../middleware/errorHandler'); + +/** + * GET /admin/stats + * Dashboard KPIs. + */ +exports.getDashboardStats = async (req, res, next) => { + try { + const [ + totalApplications, + approvedApplications, + pendingApplications, + totalDisbursed, + totalStudents, + totalScholarships, + recentApplications, + ] = await Promise.all([ + prisma.application.count(), + prisma.application.count({ where: { status: 'APPROVED' } }), + prisma.application.count({ where: { status: { in: ['SUBMITTED', 'UNDER_REVIEW', 'INSTITUTION_VERIFIED', 'PENDING_APPROVAL'] } } }), + prisma.disbursement.aggregate({ _sum: { amount: true }, where: { status: 'PROCESSED' } }), + prisma.user.count({ where: { role: 'STUDENT' } }), + prisma.scholarship.count({ where: { status: { in: ['ACTIVE', 'CLOSING_SOON'] } } }), + prisma.application.findMany({ + take: 5, orderBy: { updatedAt: 'desc' }, + include: { + user: { select: { phone: true } }, + scholarship: { select: { name: true } }, + }, + }), + ]); + + // Application status breakdown + const statusBreakdown = await prisma.application.groupBy({ + by: ['status'], + _count: true, + }); + + // Applications per scholarship + const perScholarship = await prisma.application.groupBy({ + by: ['scholarshipId'], + _count: true, + orderBy: { _count: { scholarshipId: 'desc' } }, + take: 5, + }); + + res.json({ + success: true, + data: { + kpis: { + totalApplications, + approvedApplications, + pendingApplications, + totalDisbursedAmount: totalDisbursed._sum.amount || 0, + totalStudents, + activeScholarships: totalScholarships, + }, + statusBreakdown: statusBreakdown.map(s => ({ status: s.status, count: s._count })), + recentApplications, + }, + }); + } catch (err) { next(err); } +}; + +exports.listUsers = async (req, res, next) => { + try { + const { role, q, page = 1, limit = 20 } = req.query; + const skip = (parseInt(page) - 1) * parseInt(limit); + const where = {}; + if (role) where.role = role; + if (q) { + where.OR = [ + { phone: { contains: q } }, + { email: { contains: q, mode: 'insensitive' } }, + ]; + } + + const [users, total] = await Promise.all([ + prisma.user.findMany({ + where, skip, take: parseInt(limit), + select: { + id: true, phone: true, email: true, role: true, + isActive: true, lastLoginAt: true, createdAt: true, + profile: { select: { firstName: true, lastName: true, institution: true } }, + _count: { select: { applications: true } }, + }, + orderBy: { createdAt: 'desc' }, + }), + prisma.user.count({ where }), + ]); + + res.json({ success: true, data: users, meta: { total } }); + } catch (err) { next(err); } +}; + +exports.getUser = async (req, res, next) => { + try { + const user = await prisma.user.findUnique({ + where: { id: req.params.id }, + include: { + profile: { include: { documents: true } }, + applications: { include: { scholarship: { select: { name: true } } } }, + }, + }); + if (!user) throw new AppError('User not found', 404); + const { passwordHash, ...safe } = user; + res.json({ success: true, data: safe }); + } catch (err) { next(err); } +}; + +exports.changeUserRole = async (req, res, next) => { + try { + const { role } = req.body; + const user = await prisma.user.update({ where: { id: req.params.id }, data: { role } }); + res.json({ success: true, data: { id: user.id, role: user.role } }); + } catch (err) { next(err); } +}; + +exports.blockUser = async (req, res, next) => { + try { + const { block } = req.body; + await prisma.user.update({ where: { id: req.params.id }, data: { isActive: !block } }); + // Invalidate all sessions + if (block) await prisma.userSession.deleteMany({ where: { userId: req.params.id } }); + res.json({ success: true, message: `User ${block ? 'blocked' : 'unblocked'}` }); + } catch (err) { next(err); } +}; + +exports.applicationReport = async (req, res, next) => { + try { + const { year } = req.query; + const where = year ? { academicYear: year } : {}; + + const breakdown = await prisma.application.groupBy({ + by: ['status', 'academicYear'], + _count: true, + where, + }); + + res.json({ success: true, data: breakdown }); + } catch (err) { next(err); } +}; + +exports.disbursementReport = async (req, res, next) => { + try { + const agg = await prisma.disbursement.groupBy({ + by: ['status'], + _sum: { amount: true }, + _count: true, + }); + res.json({ success: true, data: agg }); + } catch (err) { next(err); } +}; + +exports.scholarshipReport = async (req, res, next) => { + try { + const scholarships = await prisma.scholarship.findMany({ + include: { _count: { select: { applications: true, disbursements: true } } }, + }); + res.json({ success: true, data: scholarships }); + } catch (err) { next(err); } +}; + +exports.getAuditLog = async (req, res, next) => { + try { + const { page = 1, limit = 30, action, userId } = req.query; + const skip = (parseInt(page) - 1) * parseInt(limit); + const where = {}; + if (action) where.action = { contains: action, mode: 'insensitive' }; + if (userId) where.userId = userId; + + const logs = await prisma.auditLog.findMany({ + where, skip, take: parseInt(limit), + orderBy: { createdAt: 'desc' }, + include: { user: { select: { phone: true, email: true } } }, + }); + + res.json({ success: true, data: logs }); + } catch (err) { next(err); } +}; diff --git a/scholarship-backend/src/controllers/application.controller.js b/scholarship-backend/src/controllers/application.controller.js new file mode 100644 index 0000000..c0533cf --- /dev/null +++ b/scholarship-backend/src/controllers/application.controller.js @@ -0,0 +1,443 @@ +// src/controllers/application.controller.js + +const prisma = require('../config/database').prisma; +const { AppError } = require('../middleware/errorHandler'); +const auditService = require('../services/audit.service'); +const notificationService = require('../services/notification.service'); +const emailService = require('../services/email.service'); +const eligibilityService = require('../services/eligibility.service'); + +// ─── Helpers ────────────────────────────────────────────── +function generateApplicationNo(year) { + const code = Math.random().toString(36).substring(2, 7).toUpperCase(); + return `GMC-${year.replace('-', '')}-${code}`; +} + +async function assertOwnerOrAdmin(application, userId, role) { + if (application.userId !== userId && !['ADMIN', 'SUPER_ADMIN', 'VERIFIER'].includes(role)) { + throw new AppError('Access denied', 403); + } +} + +// ─── Controllers ───────────────────────────────────────── + +/** + * GET /applications + * Student: list own applications. + */ +exports.listMyApplications = async (req, res, next) => { + try { + const applications = await prisma.application.findMany({ + where: { userId: req.user.id }, + include: { + scholarship: { select: { name: true, slug: true, amountPerYear: true, iconType: true } }, + disbursement: { select: { status: true, amount: true } }, + }, + orderBy: { createdAt: 'desc' }, + }); + res.json({ success: true, data: applications }); + } catch (err) { next(err); } +}; + +/** + * POST /applications + * Start a new scholarship application. + */ +exports.createApplication = async (req, res, next) => { + try { + const { scholarshipId } = req.body; + const userId = req.user.id; + + // Ensure scholarship exists and is open + const scholarship = await prisma.scholarship.findUnique({ where: { id: scholarshipId } }); + if (!scholarship) throw new AppError('Scholarship not found', 404); + if (scholarship.status !== 'ACTIVE' && scholarship.status !== 'CLOSING_SOON') { + throw new AppError('This scholarship is not accepting applications', 400); + } + if (new Date() > scholarship.applicationEndDate) { + throw new AppError('Application deadline has passed', 400); + } + + // Ensure student has a profile + const profile = await prisma.studentProfile.findUnique({ where: { userId } }); + if (!profile) throw new AppError('Please complete your profile before applying', 400); + + // Check eligibility + const eligibility = await eligibilityService.checkStudentEligibility(profile, scholarship); + if (!eligibility.eligible) { + throw new AppError(`Not eligible: ${eligibility.reason}`, 400); + } + + // Prevent duplicate application + const existing = await prisma.application.findFirst({ + where: { userId, scholarshipId, academicYear: scholarship.academicYear }, + }); + if (existing) throw new AppError('You have already applied for this scholarship this year', 409); + + // Check seats + if (scholarship.seatsRemaining !== null && scholarship.seatsRemaining <= 0) { + throw new AppError('No seats remaining for this scholarship', 400); + } + + const applicationNo = generateApplicationNo(scholarship.academicYear); + + const application = await prisma.application.create({ + data: { + applicationNo, + userId, + scholarshipId, + status: 'DRAFT', + academicYear: scholarship.academicYear, + formData: { profileSnapshot: profile }, + }, + }); + + await auditService.log({ + action: 'APPLICATION_CREATED', userId, entityType: 'Application', + entityId: application.id, req, + }); + + res.status(201).json({ success: true, data: application }); + } catch (err) { next(err); } +}; + +/** + * GET /applications/:id + */ +exports.getApplication = async (req, res, next) => { + try { + const application = await prisma.application.findUnique({ + where: { id: req.params.id }, + include: { + scholarship: true, + documents: { include: { document: true } }, + statusHistory: { orderBy: { changedAt: 'asc' } }, + disbursement: true, + }, + }); + if (!application) throw new AppError('Application not found', 404); + await assertOwnerOrAdmin(application, req.user.id, req.user.role); + res.json({ success: true, data: application }); + } catch (err) { next(err); } +}; + +/** + * PUT /applications/:id + * Update draft (add document references, remarks). + */ +exports.updateApplication = async (req, res, next) => { + try { + const application = await prisma.application.findUnique({ where: { id: req.params.id } }); + if (!application) throw new AppError('Application not found', 404); + await assertOwnerOrAdmin(application, req.user.id, req.user.role); + + if (!['DRAFT'].includes(application.status)) { + throw new AppError('Only draft applications can be edited', 400); + } + + const { documentIds, studentRemarks, formData } = req.body; + + const updated = await prisma.$transaction(async (tx) => { + const app = await tx.application.update({ + where: { id: application.id }, + data: { studentRemarks, formData }, + }); + + // Sync document attachments + if (documentIds && Array.isArray(documentIds)) { + await tx.applicationDocument.deleteMany({ where: { applicationId: app.id } }); + await tx.applicationDocument.createMany({ + data: documentIds.map(docId => ({ applicationId: app.id, documentId: docId })), + skipDuplicates: true, + }); + } + + return app; + }); + + res.json({ success: true, data: updated }); + } catch (err) { next(err); } +}; + +/** + * POST /applications/:id/submit + * Submit a draft application for review. + */ +exports.submitApplication = async (req, res, next) => { + try { + const application = await prisma.application.findUnique({ + where: { id: req.params.id }, + include: { + scholarship: true, + documents: { include: { document: true } }, + }, + }); + if (!application) throw new AppError('Application not found', 404); + if (application.userId !== req.user.id) throw new AppError('Access denied', 403); + if (application.status !== 'DRAFT') throw new AppError('Only draft applications can be submitted', 400); + + // Validate required documents are attached + const attachedTypes = application.documents.map(d => d.document.type); + const missingDocs = application.scholarship.requiredDocuments.filter(t => !attachedTypes.includes(t)); + if (missingDocs.length > 0) { + throw new AppError(`Missing required documents: ${missingDocs.join(', ')}`, 400); + } + + const updated = await prisma.$transaction(async (tx) => { + const app = await tx.application.update({ + where: { id: application.id }, + data: { status: 'SUBMITTED', submittedAt: new Date() }, + }); + + await tx.applicationStatusHistory.create({ + data: { + applicationId: app.id, + fromStatus: 'DRAFT', + toStatus: 'SUBMITTED', + changedBy: req.user.id, + remarks: 'Submitted by student', + }, + }); + + return app; + }); + + // Notifications + await notificationService.notify(req.user.id, { + title: 'Application Submitted', + body: `Your application #${application.applicationNo} for ${application.scholarship.name} has been submitted successfully.`, + type: 'application_update', + meta: { applicationId: application.id }, + }); + + // Email confirmation + const user = await prisma.user.findUnique({ where: { id: req.user.id } }); + if (user?.email) { + await emailService.sendApplicationConfirmation(user.email, application); + } + + await auditService.log({ + action: 'APPLICATION_SUBMITTED', userId: req.user.id, + entityType: 'Application', entityId: application.id, req, + }); + + res.json({ success: true, message: 'Application submitted successfully', data: updated }); + } catch (err) { next(err); } +}; + +/** + * POST /applications/:id/cancel + */ +exports.cancelApplication = async (req, res, next) => { + try { + const application = await prisma.application.findUnique({ where: { id: req.params.id } }); + if (!application) throw new AppError('Application not found', 404); + if (application.userId !== req.user.id) throw new AppError('Access denied', 403); + if (!['DRAFT', 'SUBMITTED'].includes(application.status)) { + throw new AppError('Application cannot be cancelled at this stage', 400); + } + + await prisma.$transaction([ + prisma.application.update({ + where: { id: application.id }, + data: { status: 'CANCELLED' }, + }), + prisma.applicationStatusHistory.create({ + data: { + applicationId: application.id, + fromStatus: application.status, + toStatus: 'CANCELLED', + changedBy: req.user.id, + remarks: req.body.reason || 'Cancelled by student', + }, + }), + ]); + + res.json({ success: true, message: 'Application cancelled' }); + } catch (err) { next(err); } +}; + +/** + * GET /applications/:id/timeline + */ +exports.getApplicationTimeline = async (req, res, next) => { + try { + const application = await prisma.application.findUnique({ + where: { id: req.params.id }, + select: { userId: true, applicationNo: true, statusHistory: { orderBy: { changedAt: 'asc' } } }, + }); + if (!application) throw new AppError('Not found', 404); + await assertOwnerOrAdmin(application, req.user.id, req.user.role); + res.json({ success: true, data: application.statusHistory }); + } catch (err) { next(err); } +}; + +/** + * GET /applications/:id/download + * Returns application data (PDF generation would happen client-side or via a PDF service). + */ +exports.downloadApplicationPDF = async (req, res, next) => { + try { + const application = await prisma.application.findUnique({ + where: { id: req.params.id }, + include: { scholarship: true, documents: { include: { document: true } } }, + }); + if (!application) throw new AppError('Not found', 404); + await assertOwnerOrAdmin(application, req.user.id, req.user.role); + + // In production: generate PDF with puppeteer/pdf-lib and stream it + res.json({ + success: true, + message: 'PDF generation β€” pipe to pdf-lib/puppeteer in production', + data: application, + }); + } catch (err) { next(err); } +}; + +/** + * POST /applications/:id/verify (Verifier) + */ +exports.verifyApplication = async (req, res, next) => { + try { + const { remarks } = req.body; + const application = await prisma.application.findUnique({ where: { id: req.params.id } }); + if (!application) throw new AppError('Not found', 404); + if (application.status !== 'SUBMITTED') { + throw new AppError('Only submitted applications can be verified', 400); + } + + await prisma.$transaction([ + prisma.application.update({ + where: { id: application.id }, + data: { status: 'INSTITUTION_VERIFIED', verifiedAt: new Date(), verifierRemarks: remarks }, + }), + prisma.applicationStatusHistory.create({ + data: { + applicationId: application.id, + fromStatus: 'SUBMITTED', + toStatus: 'INSTITUTION_VERIFIED', + changedBy: req.user.id, + remarks, + }, + }), + ]); + + await notificationService.notify(application.userId, { + title: 'Application Verified', + body: `Your application #${application.applicationNo} has been verified by your institution.`, + type: 'application_update', + meta: { applicationId: application.id }, + }); + + res.json({ success: true, message: 'Application verified' }); + } catch (err) { next(err); } +}; + +/** + * GET /applications/admin/all (Admin) + */ +exports.listAllApplications = async (req, res, next) => { + try { + const { status, scholarshipId, q, page = 1, limit = 20 } = req.query; + const skip = (parseInt(page) - 1) * parseInt(limit); + + const where = {}; + if (status) where.status = status; + if (scholarshipId) where.scholarshipId = scholarshipId; + if (q) { + where.OR = [ + { applicationNo: { contains: q, mode: 'insensitive' } }, + { user: { phone: { contains: q } } }, + ]; + } + + const [applications, total] = await Promise.all([ + prisma.application.findMany({ + where, skip, + take: parseInt(limit), + include: { + user: { select: { phone: true, email: true } }, + scholarship: { select: { name: true } }, + }, + orderBy: { updatedAt: 'desc' }, + }), + prisma.application.count({ where }), + ]); + + res.json({ + success: true, + data: applications, + meta: { total, page: parseInt(page), limit: parseInt(limit) }, + }); + } catch (err) { next(err); } +}; + +/** + * PATCH /applications/:id/status (Admin) + */ +exports.changeApplicationStatus = async (req, res, next) => { + try { + const { status, remarks } = req.body; + const application = await prisma.application.findUnique({ where: { id: req.params.id } }); + if (!application) throw new AppError('Not found', 404); + + const updateData = { status, adminRemarks: remarks }; + if (status === 'APPROVED') updateData.approvedAt = new Date(); + if (status === 'REJECTED') updateData.rejectedAt = new Date(); + + await prisma.$transaction([ + prisma.application.update({ where: { id: application.id }, data: updateData }), + prisma.applicationStatusHistory.create({ + data: { + applicationId: application.id, + fromStatus: application.status, + toStatus: status, + changedBy: req.user.id, + remarks, + }, + }), + ]); + + await notificationService.notify(application.userId, { + title: `Application ${status === 'APPROVED' ? 'Approved βœ…' : 'Status Updated'}`, + body: `Your application #${application.applicationNo} status changed to: ${status}.`, + type: 'application_update', + meta: { applicationId: application.id, status }, + }); + + await auditService.log({ + action: `APPLICATION_${status}`, userId: req.user.id, + entityType: 'Application', entityId: application.id, req, meta: { remarks }, + }); + + res.json({ success: true, message: `Application ${status.toLowerCase()}` }); + } catch (err) { next(err); } +}; + +/** + * POST /applications/admin/bulk-approve (Admin) + */ +exports.bulkApprove = async (req, res, next) => { + try { + const { applicationIds, remarks } = req.body; + + const results = await Promise.allSettled( + applicationIds.map(id => + prisma.$transaction([ + prisma.application.update({ where: { id }, data: { status: 'APPROVED', approvedAt: new Date() } }), + prisma.applicationStatusHistory.create({ + data: { + applicationId: id, fromStatus: 'PENDING_APPROVAL', + toStatus: 'APPROVED', changedBy: req.user.id, remarks: remarks || 'Bulk approved', + }, + }), + ]) + ) + ); + + const succeeded = results.filter(r => r.status === 'fulfilled').length; + const failed = results.filter(r => r.status === 'rejected').length; + + res.json({ success: true, message: `${succeeded} approved, ${failed} failed` }); + } catch (err) { next(err); } +}; diff --git a/scholarship-backend/src/controllers/auth.controller.js b/scholarship-backend/src/controllers/auth.controller.js new file mode 100644 index 0000000..f9f24a0 --- /dev/null +++ b/scholarship-backend/src/controllers/auth.controller.js @@ -0,0 +1,313 @@ +// src/controllers/auth.controller.js +// Handles registration, OTP-based login, JWT lifecycle, password reset + +const bcrypt = require('bcryptjs'); +const jwt = require('jsonwebtoken'); +const { v4: uuidv4 } = require('uuid'); +const prisma = require('../config/database').prisma; +const redis = require('../config/redis').client; +const logger = require('../config/logger'); +const { AppError } = require('../middleware/errorHandler'); +const smsService = require('../services/sms.service'); +const emailService = require('../services/email.service'); +const auditService = require('../services/audit.service'); + +const OTP_EXPIRES = parseInt(process.env.OTP_EXPIRES_MINUTES || '10') * 60; // seconds +const OTP_MAX_TRIES = parseInt(process.env.OTP_MAX_ATTEMPTS || '3'); + +// ── Helpers ─────────────────────────────────────────────── +function generateOtp() { + return Math.floor(100000 + Math.random() * 900000).toString(); +} + +function generateTokens(userId, role) { + const accessToken = jwt.sign( + { sub: userId, role }, + process.env.JWT_SECRET, + { expiresIn: process.env.JWT_EXPIRES_IN || '7d' } + ); + const refreshToken = jwt.sign( + { sub: userId, type: 'refresh' }, + process.env.JWT_SECRET, + { expiresIn: process.env.JWT_REFRESH_EXPIRES_IN || '30d' } + ); + return { accessToken, refreshToken }; +} + +// ── Controllers ────────────────────────────────────────── + +/** + * POST /auth/otp/send + * Send a 6-digit OTP to the given phone number. + */ +exports.sendOtp = async (req, res, next) => { + try { + const { phone } = req.body; + + // Rate-check: max 3 OTPs per phone per 15 min + const ratioKey = `otp_rate:${phone}`; + const sends = await redis.incr(ratioKey); + if (sends === 1) await redis.expire(ratioKey, 900); + if (sends > 3) throw new AppError('OTP limit reached for this number. Try again in 15 minutes.', 429); + + // Upsert user by phone + const user = await prisma.user.upsert({ + where: { phone }, + update: {}, + create: { phone }, + }); + + // Generate and store OTP (hashed in Redis) + const otp = generateOtp(); + const otpKey = `otp:${phone}`; + await redis.setEx(otpKey, OTP_EXPIRES, JSON.stringify({ code: otp, attempts: 0, userId: user.id })); + + // Send SMS + await smsService.sendOtp(phone, otp); + logger.info(`OTP sent to ${phone.replace(/\d(?=\d{4})/g, '*')}`); + + res.json({ success: true, message: 'OTP sent successfully', expiresIn: OTP_EXPIRES }); + } catch (err) { next(err); } +}; + +/** + * POST /auth/otp/verify + * Verify OTP and return JWT pair. + */ +exports.verifyOtp = async (req, res, next) => { + try { + const { phone, otp } = req.body; + + const otpKey = `otp:${phone}`; + const stored = await redis.get(otpKey); + + if (!stored) throw new AppError('OTP expired or not found. Please request a new one.', 400); + + const data = JSON.parse(stored); + if (data.attempts >= OTP_MAX_TRIES) { + await redis.del(otpKey); + throw new AppError('Too many incorrect OTP attempts. Please request a new OTP.', 400); + } + + if (data.code !== otp) { + data.attempts++; + await redis.setEx(otpKey, OTP_EXPIRES, JSON.stringify(data)); + throw new AppError(`Incorrect OTP. ${OTP_MAX_TRIES - data.attempts} attempts remaining.`, 400); + } + + // OTP valid β€” clean up + await redis.del(otpKey); + + const user = await prisma.user.update({ + where: { id: data.userId }, + data: { isPhoneVerified: true, lastLoginAt: new Date() }, + }); + + const { accessToken, refreshToken } = generateTokens(user.id, user.role); + + // Persist session + await prisma.userSession.create({ + data: { + userId: user.id, + token: refreshToken, + ipAddress: req.ip, + userAgent: req.headers['user-agent'], + expiresAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000), + }, + }); + + await auditService.log({ action: 'LOGIN', userId: user.id, req, meta: { method: 'OTP' } }); + + res.json({ + success: true, + message: 'Login successful', + data: { accessToken, refreshToken, user: { id: user.id, phone: user.phone, role: user.role } }, + }); + } catch (err) { next(err); } +}; + +/** + * POST /auth/register + * Password-based registration for admin/verifier accounts. + */ +exports.register = async (req, res, next) => { + try { + const { email, phone, password, role = 'STUDENT' } = req.body; + + const existingEmail = email && await prisma.user.findUnique({ where: { email } }); + if (existingEmail) throw new AppError('Email already registered', 409); + + const passwordHash = await bcrypt.hash(password, 12); + + const user = await prisma.user.create({ + data: { email, phone, passwordHash, role }, + select: { id: true, email: true, phone: true, role: true, createdAt: true }, + }); + + await auditService.log({ action: 'REGISTER', userId: user.id, req }); + + if (email) await emailService.sendWelcomeEmail(email); + + const { accessToken, refreshToken } = generateTokens(user.id, user.role); + + res.status(201).json({ + success: true, + message: 'Registration successful', + data: { accessToken, refreshToken, user }, + }); + } catch (err) { next(err); } +}; + +/** + * POST /auth/login + * Password-based login. + */ +exports.login = async (req, res, next) => { + try { + const { email, password } = req.body; + + const user = await prisma.user.findUnique({ where: { email } }); + if (!user || !user.passwordHash) throw new AppError('Invalid credentials', 401); + if (!user.isActive) throw new AppError('Your account has been suspended. Contact support.', 403); + + const valid = await bcrypt.compare(password, user.passwordHash); + if (!valid) throw new AppError('Invalid credentials', 401); + + await prisma.user.update({ where: { id: user.id }, data: { lastLoginAt: new Date() } }); + + const { accessToken, refreshToken } = generateTokens(user.id, user.role); + + await prisma.userSession.create({ + data: { + userId: user.id, token: refreshToken, + ipAddress: req.ip, userAgent: req.headers['user-agent'], + expiresAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000), + }, + }); + + await auditService.log({ action: 'LOGIN', userId: user.id, req, meta: { method: 'PASSWORD' } }); + + res.json({ + success: true, + data: { + accessToken, refreshToken, + user: { id: user.id, email: user.email, phone: user.phone, role: user.role }, + }, + }); + } catch (err) { next(err); } +}; + +/** + * POST /auth/refresh + * Use refresh token to get a new access token. + */ +exports.refreshToken = async (req, res, next) => { + try { + const { refreshToken } = req.body; + if (!refreshToken) throw new AppError('Refresh token required', 400); + + let payload; + try { + payload = jwt.verify(refreshToken, process.env.JWT_SECRET); + } catch { + throw new AppError('Invalid or expired refresh token', 401); + } + + const session = await prisma.userSession.findUnique({ where: { token: refreshToken } }); + if (!session || session.expiresAt < new Date()) { + throw new AppError('Session expired. Please login again.', 401); + } + + const user = await prisma.user.findUnique({ where: { id: payload.sub } }); + if (!user || !user.isActive) throw new AppError('User not found or suspended', 401); + + const { accessToken, refreshToken: newRefresh } = generateTokens(user.id, user.role); + + // Rotate refresh token + await prisma.userSession.update({ + where: { id: session.id }, + data: { token: newRefresh, expiresAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000) }, + }); + + res.json({ success: true, data: { accessToken, refreshToken: newRefresh } }); + } catch (err) { next(err); } +}; + +/** + * POST /auth/logout + */ +exports.logout = async (req, res, next) => { + try { + const { refreshToken } = req.body; + if (refreshToken) { + await prisma.userSession.deleteMany({ where: { token: refreshToken, userId: req.user.id } }); + } + await auditService.log({ action: 'LOGOUT', userId: req.user.id, req }); + res.json({ success: true, message: 'Logged out successfully' }); + } catch (err) { next(err); } +}; + +/** + * GET /auth/me + */ +exports.getMe = async (req, res, next) => { + try { + const user = await prisma.user.findUnique({ + where: { id: req.user.id }, + select: { + id: true, email: true, phone: true, role: true, + isEmailVerified: true, isPhoneVerified: true, + lastLoginAt: true, createdAt: true, + profile: { + select: { + firstName: true, lastName: true, dob: true, gender: true, + category: true, academicLevel: true, institution: true, + }, + }, + }, + }); + res.json({ success: true, data: user }); + } catch (err) { next(err); } +}; + +/** + * POST /auth/forgot-password + */ +exports.forgotPassword = async (req, res, next) => { + try { + const { email } = req.body; + const user = await prisma.user.findUnique({ where: { email } }); + + // Always respond the same regardless of whether the user exists (security) + if (user && user.email) { + const token = uuidv4(); + const tokenKey = `pwd_reset:${token}`; + await redis.setEx(tokenKey, 3600, user.id); // 1 hour + await emailService.sendPasswordReset(email, token); + } + + res.json({ success: true, message: 'If this email is registered, a reset link has been sent.' }); + } catch (err) { next(err); } +}; + +/** + * POST /auth/reset-password + */ +exports.resetPassword = async (req, res, next) => { + try { + const { token, password } = req.body; + const tokenKey = `pwd_reset:${token}`; + const userId = await redis.get(tokenKey); + + if (!userId) throw new AppError('Reset link is invalid or has expired.', 400); + + const passwordHash = await bcrypt.hash(password, 12); + await prisma.user.update({ where: { id: userId }, data: { passwordHash } }); + await redis.del(tokenKey); + + // Invalidate all sessions + await prisma.userSession.deleteMany({ where: { userId } }); + + res.json({ success: true, message: 'Password reset successfully. Please login.' }); + } catch (err) { next(err); } +}; diff --git a/scholarship-backend/src/controllers/disbursement.controller.js b/scholarship-backend/src/controllers/disbursement.controller.js new file mode 100644 index 0000000..ad641d7 --- /dev/null +++ b/scholarship-backend/src/controllers/disbursement.controller.js @@ -0,0 +1,131 @@ +// src/controllers/disbursement.controller.js +const prisma = require('../config/database').prisma; +const { AppError } = require('../middleware/errorHandler'); +const notificationService = require('../services/notification.service'); +const auditService = require('../services/audit.service'); + +exports.listMyDisbursements = async (req, res, next) => { + try { + const applications = await prisma.application.findMany({ + where: { userId: req.user.id }, + select: { id: true }, + }); + const applicationIds = applications.map(a => a.id); + + const disbursements = await prisma.disbursement.findMany({ + where: { applicationId: { in: applicationIds } }, + include: { + scholarship: { select: { name: true } }, + application: { select: { applicationNo: true } }, + }, + orderBy: { createdAt: 'desc' }, + }); + + res.json({ success: true, data: disbursements }); + } catch (err) { next(err); } +}; + +exports.getDisbursement = async (req, res, next) => { + try { + const disb = await prisma.disbursement.findUnique({ + where: { applicationId: req.params.applicationId }, + include: { + scholarship: { select: { name: true, amountPerYear: true } }, + application: { select: { applicationNo: true, userId: true } }, + }, + }); + if (!disb) throw new AppError('Disbursement not found', 404); + + if (disb.application.userId !== req.user.id && !['ADMIN','SUPER_ADMIN'].includes(req.user.role)) { + throw new AppError('Access denied', 403); + } + + res.json({ success: true, data: disb }); + } catch (err) { next(err); } +}; + +exports.initiateDisbursement = async (req, res, next) => { + try { + const { applicationId } = req.body; + + const application = await prisma.application.findUnique({ + where: { id: applicationId }, + include: { scholarship: true }, + }); + if (!application) throw new AppError('Application not found', 404); + if (application.status !== 'APPROVED') throw new AppError('Only approved applications can be disbursed', 400); + + const existing = await prisma.disbursement.findUnique({ where: { applicationId } }); + if (existing) throw new AppError('Disbursement already initiated for this application', 409); + + // Get bank details from student profile + const profile = await prisma.studentProfile.findUnique({ where: { userId: application.userId } }); + if (!profile?.bankAccountNo || !profile?.bankIFSC) { + throw new AppError('Student has not linked a bank account', 400); + } + + const disbursement = await prisma.$transaction(async (tx) => { + const d = await tx.disbursement.create({ + data: { + applicationId, + scholarshipId: application.scholarshipId, + amount: application.scholarship.amountPerYear, + status: 'INITIATED', + bankAccount: profile.bankAccountNo, + ifscCode: profile.bankIFSC, + initiatedAt: new Date(), + }, + }); + + await tx.application.update({ + where: { id: applicationId }, + data: { status: 'DISBURSED' }, + }); + + return d; + }); + + await notificationService.notify(application.userId, { + title: 'πŸ’° Scholarship Disbursement Initiated', + body: `β‚Ή${application.scholarship.amountPerYear.toLocaleString('en-IN')} is being transferred to your bank account for ${application.scholarship.name}.`, + type: 'disbursement', + meta: { disbursementId: disbursement.id }, + }); + + await auditService.log({ + action: 'DISBURSEMENT_INITIATED', userId: req.user.id, + entityType: 'Disbursement', entityId: disbursement.id, req, + meta: { amount: disbursement.amount }, + }); + + res.status(201).json({ success: true, data: disbursement }); + } catch (err) { next(err); } +}; + +exports.updateDisbursementStatus = async (req, res, next) => { + try { + const { status, transactionRef, failureReason } = req.body; + + const disb = await prisma.disbursement.update({ + where: { id: req.params.id }, + data: { + status, + transactionRef, + failureReason, + processedAt: ['PROCESSED', 'FAILED'].includes(status) ? new Date() : undefined, + }, + include: { application: true }, + }); + + await notificationService.notify(disb.application.userId, { + title: status === 'PROCESSED' ? 'βœ… Scholarship Amount Credited!' : '❌ Disbursement Failed', + body: status === 'PROCESSED' + ? `β‚Ή${disb.amount.toLocaleString('en-IN')} credited to your bank account. Ref: ${transactionRef}` + : `Disbursement failed: ${failureReason}. Contact support.`, + type: 'disbursement', + meta: { disbursementId: disb.id }, + }); + + res.json({ success: true, data: disb }); + } catch (err) { next(err); } +}; diff --git a/scholarship-backend/src/controllers/document.controller.js b/scholarship-backend/src/controllers/document.controller.js new file mode 100644 index 0000000..362e75c --- /dev/null +++ b/scholarship-backend/src/controllers/document.controller.js @@ -0,0 +1,154 @@ +// src/controllers/document.controller.js + +const path = require('path'); +const fs = require('fs'); +const prisma = require('../config/database').prisma; +const { AppError } = require('../middleware/errorHandler'); +const storageService = require('../services/storage.service'); +const auditService = require('../services/audit.service'); + +/** + * POST /documents/upload + * Upload a document file and create a Document record. + */ +exports.uploadDocument = async (req, res, next) => { + try { + if (!req.file) throw new AppError('No file uploaded', 400); + + const { type } = req.body; + if (!type) throw new AppError('Document type is required', 400); + + // Find or create profile + const profile = await prisma.studentProfile.findUnique({ where: { userId: req.user.id } }); + if (!profile) throw new AppError('Please complete your profile first', 400); + + const maxMB = parseInt(process.env.MAX_FILE_SIZE_MB || '5'); + if (req.file.size > maxMB * 1024 * 1024) { + throw new AppError(`File size must be under ${maxMB}MB`, 400); + } + + const ALLOWED_MIMES = ['image/jpeg','image/png','image/webp','application/pdf']; + if (!ALLOWED_MIMES.includes(req.file.mimetype)) { + throw new AppError('Only JPEG, PNG, WebP, or PDF files are allowed', 400); + } + + // Upload to storage (S3 or local) + const storageKey = await storageService.upload(req.file); + + const document = await prisma.document.create({ + data: { + profileId: profile.id, + type, + filename: req.file.filename || path.basename(storageKey), + originalName: req.file.originalname, + mimeType: req.file.mimetype, + sizeBytes: req.file.size, + storageKey, + }, + }); + + await auditService.log({ + action: 'DOCUMENT_UPLOADED', userId: req.user.id, + entityType: 'Document', entityId: document.id, req, + meta: { type, filename: req.file.originalname }, + }); + + res.status(201).json({ success: true, data: document }); + } catch (err) { next(err); } +}; + +/** + * GET /documents + */ +exports.listMyDocuments = async (req, res, next) => { + try { + const profile = await prisma.studentProfile.findUnique({ where: { userId: req.user.id } }); + if (!profile) return res.json({ success: true, data: [] }); + + const documents = await prisma.document.findMany({ + where: { profileId: profile.id }, + orderBy: { uploadedAt: 'desc' }, + }); + + res.json({ success: true, data: documents }); + } catch (err) { next(err); } +}; + +/** + * GET /documents/:id/meta + */ +exports.getDocumentMeta = async (req, res, next) => { + try { + const doc = await prisma.document.findUnique({ where: { id: req.params.id } }); + if (!doc) throw new AppError('Document not found', 404); + + const profile = await prisma.studentProfile.findUnique({ where: { id: doc.profileId } }); + if (profile.userId !== req.user.id && !['ADMIN','SUPER_ADMIN','VERIFIER'].includes(req.user.role)) { + throw new AppError('Access denied', 403); + } + + res.json({ success: true, data: doc }); + } catch (err) { next(err); } +}; + +/** + * GET /documents/:id + * Download / stream the actual file. + */ +exports.downloadDocument = async (req, res, next) => { + try { + const doc = await prisma.document.findUnique({ where: { id: req.params.id } }); + if (!doc) throw new AppError('Document not found', 404); + + const profile = await prisma.studentProfile.findUnique({ where: { id: doc.profileId } }); + if (profile.userId !== req.user.id && !['ADMIN','SUPER_ADMIN','VERIFIER'].includes(req.user.role)) { + throw new AppError('Access denied', 403); + } + + const fileStream = await storageService.getStream(doc.storageKey); + res.setHeader('Content-Type', doc.mimeType); + res.setHeader('Content-Disposition', `inline; filename="${doc.originalName}"`); + fileStream.pipe(res); + } catch (err) { next(err); } +}; + +/** + * DELETE /documents/:id + */ +exports.deleteDocument = async (req, res, next) => { + try { + const doc = await prisma.document.findUnique({ + where: { id: req.params.id }, + include: { profile: true }, + }); + if (!doc) throw new AppError('Document not found', 404); + if (doc.profile.userId !== req.user.id) throw new AppError('Access denied', 403); + + // Check if attached to a submitted/approved application + const attached = await prisma.applicationDocument.findFirst({ + where: { + documentId: doc.id, + application: { status: { notIn: ['DRAFT', 'CANCELLED', 'REJECTED'] } }, + }, + }); + if (attached) throw new AppError('Cannot delete document attached to an active application', 400); + + await storageService.delete(doc.storageKey); + await prisma.document.delete({ where: { id: doc.id } }); + + res.json({ success: true, message: 'Document deleted' }); + } catch (err) { next(err); } +}; + +/** + * PATCH /documents/:id/verify (Admin/Verifier) + */ +exports.verifyDocument = async (req, res, next) => { + try { + const doc = await prisma.document.update({ + where: { id: req.params.id }, + data: { isVerified: true, verifiedBy: req.user.id }, + }); + res.json({ success: true, data: doc }); + } catch (err) { next(err); } +}; diff --git a/scholarship-backend/src/controllers/eligibility.controller.js b/scholarship-backend/src/controllers/eligibility.controller.js new file mode 100644 index 0000000..376a187 --- /dev/null +++ b/scholarship-backend/src/controllers/eligibility.controller.js @@ -0,0 +1,70 @@ +// src/controllers/eligibility.controller.js +// AI-assisted eligibility checker β€” matches user inputs to scholarship criteria + +const { v4: uuidv4 } = require('uuid'); +const prisma = require('../config/database').prisma; +const { AppError } = require('../middleware/errorHandler'); +const eligibilityService = require('../services/eligibility.service'); + +/** + * POST /eligibility/check + * Body: { gender, dob, category, incomeGroup, academicLevel, district, percentage, isMinority, isDisabled } + */ +exports.checkEligibility = async (req, res, next) => { + try { + const inputs = req.body; + const sessionId = uuidv4(); + + // Fetch all active scholarships + const scholarships = await prisma.scholarship.findMany({ + where: { status: { in: ['ACTIVE', 'CLOSING_SOON'] } }, + }); + + // Score each scholarship against user inputs + const results = scholarships.map(s => { + const score = eligibilityService.scoreScholarship(inputs, s); + return { scholarship: s, ...score }; + }); + + // Sort: fully eligible first, then partial, then ineligible + const matched = results + .filter(r => r.eligible || r.partiallyEligible) + .sort((a, b) => b.score - a.score); + + const ineligible = results.filter(r => !r.eligible && !r.partiallyEligible); + + // Persist the check for analytics + await prisma.eligibilityCheck.create({ + data: { + sessionId, + inputData: inputs, + matchedSchemas: matched.map(m => ({ id: m.scholarship.id, score: m.score })), + }, + }); + + res.json({ + success: true, + sessionId, + data: { + eligible: matched.filter(r => r.eligible).map(r => ({ ...r.scholarship, score: r.score, reasons: r.reasons })), + partial: matched.filter(r => r.partiallyEligible).map(r => ({ ...r.scholarship, score: r.score, missingCriteria: r.missingCriteria })), + ineligible: ineligible.slice(0, 5).map(r => ({ id: r.scholarship.id, name: r.scholarship.name, reason: r.reason })), + totalChecked: scholarships.length, + }, + }); + } catch (err) { next(err); } +}; + +/** + * GET /eligibility/result/:sessionId + */ +exports.getResult = async (req, res, next) => { + try { + const check = await prisma.eligibilityCheck.findFirst({ + where: { sessionId: req.params.sessionId }, + orderBy: { createdAt: 'desc' }, + }); + if (!check) throw new AppError('Eligibility check session not found', 404); + res.json({ success: true, data: check }); + } catch (err) { next(err); } +}; diff --git a/scholarship-backend/src/controllers/notice.controller.js b/scholarship-backend/src/controllers/notice.controller.js new file mode 100644 index 0000000..a75ed61 --- /dev/null +++ b/scholarship-backend/src/controllers/notice.controller.js @@ -0,0 +1,57 @@ +// src/controllers/notice.controller.js +const prisma = require('../config/database').prisma; +const slugify = require('slugify'); +const { AppError } = require('../middleware/errorHandler'); + +exports.listNotices = async (req, res, next) => { + try { + const { tag, limit = 20, page = 1 } = req.query; + const skip = (parseInt(page) - 1) * parseInt(limit); + const where = { isActive: true }; + if (tag) where.tag = tag; + + const [notices, total] = await Promise.all([ + prisma.notice.findMany({ + where, skip, take: parseInt(limit), + orderBy: [{ isPinned: 'desc' }, { publishedAt: 'desc' }], + }), + prisma.notice.count({ where }), + ]); + + res.json({ success: true, data: notices, meta: { total } }); + } catch (err) { next(err); } +}; + +exports.getNotice = async (req, res, next) => { + try { + const notice = await prisma.notice.findUnique({ where: { slug: req.params.slug } }); + if (!notice) throw new AppError('Notice not found', 404); + res.json({ success: true, data: notice }); + } catch (err) { next(err); } +}; + +exports.createNotice = async (req, res, next) => { + try { + const { title, ...rest } = req.body; + let slug = slugify(title, { lower: true, strict: true }); + const existing = await prisma.notice.findUnique({ where: { slug } }); + if (existing) slug = `${slug}-${Date.now()}`; + + const notice = await prisma.notice.create({ data: { title, slug, ...rest } }); + res.status(201).json({ success: true, data: notice }); + } catch (err) { next(err); } +}; + +exports.updateNotice = async (req, res, next) => { + try { + const notice = await prisma.notice.update({ where: { id: req.params.id }, data: req.body }); + res.json({ success: true, data: notice }); + } catch (err) { next(err); } +}; + +exports.deleteNotice = async (req, res, next) => { + try { + await prisma.notice.update({ where: { id: req.params.id }, data: { isActive: false } }); + res.json({ success: true, message: 'Notice deactivated' }); + } catch (err) { next(err); } +}; diff --git a/scholarship-backend/src/controllers/notification.controller.js b/scholarship-backend/src/controllers/notification.controller.js new file mode 100644 index 0000000..b5a819b --- /dev/null +++ b/scholarship-backend/src/controllers/notification.controller.js @@ -0,0 +1,58 @@ +// src/controllers/notification.controller.js +const prisma = require('../config/database').prisma; + +exports.listNotifications = async (req, res, next) => { + try { + const { limit = 30, page = 1 } = req.query; + const skip = (parseInt(page) - 1) * parseInt(limit); + + const [notifications, total] = await Promise.all([ + prisma.notification.findMany({ + where: { userId: req.user.id }, + skip, take: parseInt(limit), + orderBy: { createdAt: 'desc' }, + }), + prisma.notification.count({ where: { userId: req.user.id } }), + ]); + + res.json({ success: true, data: notifications, meta: { total } }); + } catch (err) { next(err); } +}; + +exports.getUnreadCount = async (req, res, next) => { + try { + const count = await prisma.notification.count({ + where: { userId: req.user.id, isRead: false }, + }); + res.json({ success: true, data: { unreadCount: count } }); + } catch (err) { next(err); } +}; + +exports.markRead = async (req, res, next) => { + try { + await prisma.notification.updateMany({ + where: { id: req.params.id, userId: req.user.id }, + data: { isRead: true }, + }); + res.json({ success: true }); + } catch (err) { next(err); } +}; + +exports.markAllRead = async (req, res, next) => { + try { + await prisma.notification.updateMany({ + where: { userId: req.user.id, isRead: false }, + data: { isRead: true }, + }); + res.json({ success: true, message: 'All notifications marked as read' }); + } catch (err) { next(err); } +}; + +exports.deleteNotification = async (req, res, next) => { + try { + await prisma.notification.deleteMany({ + where: { id: req.params.id, userId: req.user.id }, + }); + res.json({ success: true }); + } catch (err) { next(err); } +}; diff --git a/scholarship-backend/src/controllers/profile.controller.js b/scholarship-backend/src/controllers/profile.controller.js new file mode 100644 index 0000000..7fa1ce3 --- /dev/null +++ b/scholarship-backend/src/controllers/profile.controller.js @@ -0,0 +1,52 @@ +// src/controllers/profile.controller.js +const prisma = require('../config/database').prisma; +const { AppError } = require('../middleware/errorHandler'); + +exports.getProfile = async (req, res, next) => { + try { + const profile = await prisma.studentProfile.findUnique({ + where: { userId: req.user.id }, + include: { documents: { orderBy: { uploadedAt: 'desc' } } }, + }); + if (!profile) return res.json({ success: true, data: null }); + res.json({ success: true, data: profile }); + } catch (err) { next(err); } +}; + +exports.createProfile = async (req, res, next) => { + try { + const existing = await prisma.studentProfile.findUnique({ where: { userId: req.user.id } }); + if (existing) throw new AppError('Profile already exists. Use PUT to update.', 409); + + const profile = await prisma.studentProfile.create({ + data: { ...req.body, userId: req.user.id }, + }); + res.status(201).json({ success: true, data: profile }); + } catch (err) { next(err); } +}; + +exports.updateProfile = async (req, res, next) => { + try { + // Disallow changing bank details via this endpoint + const { bankAccountNo, bankName, bankIFSC, bankBranch, ...safeData } = req.body; + + const profile = await prisma.studentProfile.update({ + where: { userId: req.user.id }, + data: safeData, + }); + res.json({ success: true, data: profile }); + } catch (err) { next(err); } +}; + +exports.updateBankDetails = async (req, res, next) => { + try { + const { bankAccountNo, bankName, bankIFSC, bankBranch } = req.body; + if (!bankAccountNo || !bankIFSC) throw new AppError('Account number and IFSC are required', 400); + + const profile = await prisma.studentProfile.update({ + where: { userId: req.user.id }, + data: { bankAccountNo, bankName, bankIFSC, bankBranch }, + }); + res.json({ success: true, data: { bankAccountNo: profile.bankAccountNo, bankName: profile.bankName } }); + } catch (err) { next(err); } +}; diff --git a/scholarship-backend/src/controllers/scholarship.controller.js b/scholarship-backend/src/controllers/scholarship.controller.js new file mode 100644 index 0000000..5f3d70c --- /dev/null +++ b/scholarship-backend/src/controllers/scholarship.controller.js @@ -0,0 +1,207 @@ +// src/controllers/scholarship.controller.js + +const prisma = require('../config/database').prisma; +const slugify = require('slugify'); +const { AppError } = require('../middleware/errorHandler'); +const auditService = require('../services/audit.service'); +const eligibilityService = require('../services/eligibility.service'); + +// ─── Helpers ────────────────────────────────────────────── +function buildWhereClause(query) { + const { status, category, level, q, featured } = query; + const where = {}; + + if (status) where.status = status; + if (featured) where.isFeatured = featured === 'true'; + if (category) where.eligibleCategories = { has: category }; + if (level) where.eligibleAcademicLevels = { has: level }; + if (q) { + where.OR = [ + { name: { contains: q, mode: 'insensitive' } }, + { issuer: { contains: q, mode: 'insensitive' } }, + { description: { contains: q, mode: 'insensitive' } }, + ]; + } + + return where; +} + +// ─── Controllers ───────────────────────────────────────── + +/** + * GET /scholarships + * Public: list with filters, search, pagination. + */ +exports.listScholarships = async (req, res, next) => { + try { + const page = Math.max(1, parseInt(req.query.page || '1')); + const limit = Math.min(50, parseInt(req.query.limit || '12')); + const skip = (page - 1) * limit; + + const where = buildWhereClause(req.query); + + const [scholarships, total] = await Promise.all([ + prisma.scholarship.findMany({ + where, + skip, + take: limit, + orderBy: [ + { isFeatured: 'desc' }, + { applicationEndDate: 'asc' }, + ], + select: { + id: true, slug: true, name: true, shortName: true, + issuer: true, status: true, isFeatured: true, + amountPerYear: true, amountFrequency: true, + eligibleCategories: true, eligibleAcademicLevels: true, + applicationStartDate: true, applicationEndDate: true, + academicYear: true, totalSeats: true, seatsRemaining: true, + description: true, iconType: true, + }, + }), + prisma.scholarship.count({ where }), + ]); + + res.json({ + success: true, + data: scholarships, + meta: { total, page, limit, totalPages: Math.ceil(total / limit) }, + }); + } catch (err) { next(err); } +}; + +/** + * GET /scholarships/featured + * Returns the single featured/highlighted scholarship. + */ +exports.getFeaturedScholarship = async (req, res, next) => { + try { + const scholarship = await prisma.scholarship.findFirst({ + where: { isFeatured: true, status: 'ACTIVE' }, + orderBy: { applicationEndDate: 'asc' }, + }); + + if (!scholarship) { + // Fall back to the most recently updated active scholarship + const fallback = await prisma.scholarship.findFirst({ + where: { status: 'ACTIVE' }, + orderBy: { updatedAt: 'desc' }, + }); + return res.json({ success: true, data: fallback }); + } + + res.json({ success: true, data: scholarship }); + } catch (err) { next(err); } +}; + +/** + * GET /scholarships/:slug + */ +exports.getScholarship = async (req, res, next) => { + try { + const scholarship = await prisma.scholarship.findUnique({ + where: { slug: req.params.slug }, + }); + if (!scholarship) throw new AppError('Scholarship not found', 404); + + // Enrich with real-time application count + const applicationCount = await prisma.application.count({ + where: { scholarshipId: scholarship.id }, + }); + + res.json({ success: true, data: { ...scholarship, applicationCount } }); + } catch (err) { next(err); } +}; + +/** + * GET /scholarships/:slug/application-status + * Returns the user's application for this scholarship (if any). + */ +exports.getApplicationStatus = async (req, res, next) => { + try { + const scholarship = await prisma.scholarship.findUnique({ + where: { slug: req.params.slug }, + select: { id: true }, + }); + if (!scholarship) throw new AppError('Scholarship not found', 404); + + const application = await prisma.application.findFirst({ + where: { userId: req.user.id, scholarshipId: scholarship.id }, + select: { id: true, applicationNo: true, status: true, submittedAt: true }, + }); + + res.json({ success: true, data: { hasApplied: !!application, application } }); + } catch (err) { next(err); } +}; + +/** + * POST /scholarships + * Admin: create a new scholarship. + */ +exports.createScholarship = async (req, res, next) => { + try { + const { name, ...rest } = req.body; + + let slug = slugify(name, { lower: true, strict: true }); + const existing = await prisma.scholarship.findUnique({ where: { slug } }); + if (existing) slug = `${slug}-${Date.now()}`; + + const scholarship = await prisma.scholarship.create({ + data: { name, slug, ...rest }, + }); + + await auditService.log({ + action: 'SCHOLARSHIP_CREATED', userId: req.user.id, + entityType: 'Scholarship', entityId: scholarship.id, req, + }); + + res.status(201).json({ success: true, data: scholarship }); + } catch (err) { next(err); } +}; + +/** + * PUT /scholarships/:id + * Admin: update a scholarship. + */ +exports.updateScholarship = async (req, res, next) => { + try { + const scholarship = await prisma.scholarship.update({ + where: { id: req.params.id }, + data: req.body, + }); + + await auditService.log({ + action: 'SCHOLARSHIP_UPDATED', userId: req.user.id, + entityType: 'Scholarship', entityId: scholarship.id, req, + }); + + res.json({ success: true, data: scholarship }); + } catch (err) { next(err); } +}; + +/** + * PATCH /scholarships/:id/status + */ +exports.updateScholarshipStatus = async (req, res, next) => { + try { + const { status } = req.body; + const scholarship = await prisma.scholarship.update({ + where: { id: req.params.id }, + data: { status }, + }); + res.json({ success: true, data: scholarship }); + } catch (err) { next(err); } +}; + +/** + * DELETE /scholarships/:id + */ +exports.deleteScholarship = async (req, res, next) => { + try { + await prisma.scholarship.update({ + where: { id: req.params.id }, + data: { status: 'ARCHIVED' }, + }); + res.json({ success: true, message: 'Scholarship archived' }); + } catch (err) { next(err); } +}; diff --git a/scholarship-backend/src/index.js b/scholarship-backend/src/index.js new file mode 100644 index 0000000..caba8f7 --- /dev/null +++ b/scholarship-backend/src/index.js @@ -0,0 +1,47 @@ +// src/index.js +// GMC Scholarship Portal β€” Server Entry Point + +require('dotenv').config(); +const app = require('./app'); +const logger = require('./config/logger'); +const { connectRedis } = require('./config/redis'); +const { connectDB } = require('./config/database'); + +const PORT = process.env.PORT || 4000; + +async function bootstrap() { + try { + // Connect to database + await connectDB(); + logger.info('βœ… Database connected'); + + // Connect to Redis + await connectRedis(); + logger.info('βœ… Redis connected'); + + // Start server + const server = app.listen(PORT, () => { + logger.info(`πŸš€ GMC Scholarship API running on port ${PORT} [${process.env.NODE_ENV}]`); + logger.info(`πŸ“– Docs: http://localhost:${PORT}/api/v1/docs`); + }); + + // Graceful shutdown + const shutdown = async (signal) => { + logger.info(`${signal} received β€” shutting down gracefully`); + server.close(() => { + logger.info('HTTP server closed'); + process.exit(0); + }); + setTimeout(() => process.exit(1), 10000); + }; + + process.on('SIGTERM', () => shutdown('SIGTERM')); + process.on('SIGINT', () => shutdown('SIGINT')); + + } catch (err) { + logger.error('Failed to start server:', err); + process.exit(1); + } +} + +bootstrap(); diff --git a/scholarship-backend/src/middleware/auth.js b/scholarship-backend/src/middleware/auth.js new file mode 100644 index 0000000..6450651 --- /dev/null +++ b/scholarship-backend/src/middleware/auth.js @@ -0,0 +1,50 @@ +// src/middleware/auth.js +// JWT authentication & role-based authorization middleware + +const jwt = require('jsonwebtoken'); +const prisma = require('../config/database').prisma; +const { AppError } = require('./errorHandler'); + +/** + * authenticate β€” validates Bearer JWT, attaches req.user + */ +exports.authenticate = async (req, res, next) => { + try { + const header = req.headers.authorization; + if (!header || !header.startsWith('Bearer ')) { + throw new AppError('Authentication required. Please login.', 401); + } + + const token = header.split(' ')[1]; + let payload; + try { + payload = jwt.verify(token, process.env.JWT_SECRET); + } catch (err) { + if (err.name === 'TokenExpiredError') throw new AppError('Session expired. Please login again.', 401); + throw new AppError('Invalid authentication token', 401); + } + + const user = await prisma.user.findUnique({ + where: { id: payload.sub }, + select: { id: true, role: true, isActive: true }, + }); + + if (!user) throw new AppError('User not found', 401); + if (!user.isActive) throw new AppError('Your account has been suspended', 403); + + req.user = user; + next(); + } catch (err) { next(err); } +}; + +/** + * authorize β€” role guard (use after authenticate) + * Usage: authorize('ADMIN', 'SUPER_ADMIN') + */ +exports.authorize = (...roles) => (req, res, next) => { + if (!req.user) return next(new AppError('Authentication required', 401)); + if (!roles.includes(req.user.role)) { + return next(new AppError(`Access restricted to: ${roles.join(', ')}`, 403)); + } + next(); +}; diff --git a/scholarship-backend/src/middleware/cache.js b/scholarship-backend/src/middleware/cache.js new file mode 100644 index 0000000..b3a285d --- /dev/null +++ b/scholarship-backend/src/middleware/cache.js @@ -0,0 +1,42 @@ +// src/middleware/cache.js +// Redis-based response caching middleware + +const { client: redis } = require('../config/redis'); +const logger = require('../config/logger'); + +/** + * cacheMiddleware(ttlSeconds) + * Caches GET responses in Redis. Cache key = URL + query string. + */ +exports.cacheMiddleware = (ttlSeconds = 60) => async (req, res, next) => { + if (req.method !== 'GET') return next(); + + const cacheKey = `cache:${req.originalUrl}`; + + try { + const cached = await redis.get(cacheKey); + if (cached) { + res.setHeader('X-Cache', 'HIT'); + return res.json(JSON.parse(cached)); + } + } catch (err) { + // Redis unavailable β€” fall through to real handler + logger.warn('Cache read failed:', err.message); + } + + // Intercept the response to cache it + const originalJson = res.json.bind(res); + res.json = async (body) => { + try { + if (res.statusCode === 200) { + await redis.setEx(cacheKey, ttlSeconds, JSON.stringify(body)); + } + } catch (err) { + logger.warn('Cache write failed:', err.message); + } + res.setHeader('X-Cache', 'MISS'); + return originalJson(body); + }; + + next(); +}; diff --git a/scholarship-backend/src/middleware/errorHandler.js b/scholarship-backend/src/middleware/errorHandler.js new file mode 100644 index 0000000..26b5f6f --- /dev/null +++ b/scholarship-backend/src/middleware/errorHandler.js @@ -0,0 +1,62 @@ +// src/middleware/errorHandler.js +const logger = require('../config/logger'); + +// Custom application error class +class AppError extends Error { + constructor(message, statusCode = 500, code = null) { + super(message); + this.statusCode = statusCode; + this.code = code; + this.isOperational = true; + Error.captureStackTrace(this, this.constructor); + } +} + +// 404 handler +const notFoundHandler = (req, res, next) => { + next(new AppError(`Route ${req.method} ${req.path} not found`, 404)); +}; + +// Central error handler +const errorHandler = (err, req, res, next) => { + // Prisma errors + if (err.code === 'P2025') { + return res.status(404).json({ success: false, message: 'Record not found' }); + } + if (err.code === 'P2002') { + const field = err.meta?.target?.join(', ') || 'field'; + return res.status(409).json({ success: false, message: `Duplicate value for: ${field}` }); + } + if (err.code === 'P2003') { + return res.status(400).json({ success: false, message: 'Related record not found' }); + } + + // Validation errors (express-validator / joi) + if (err.name === 'ValidationError') { + return res.status(400).json({ success: false, message: 'Validation failed', errors: err.details }); + } + + const statusCode = err.statusCode || err.status || 500; + const message = err.isOperational ? err.message : 'An unexpected error occurred. Please try again.'; + + // Log non-operational errors + if (!err.isOperational) { + logger.error('Unexpected error:', { + message: err.message, + stack: err.stack, + url: req.url, + method: req.method, + userId: req.user?.id, + }); + } + + res.status(statusCode).json({ + success: false, + message, + ...(process.env.NODE_ENV === 'development' && !err.isOperational + ? { stack: err.stack } + : {}), + }); +}; + +module.exports = { AppError, errorHandler, notFoundHandler }; diff --git a/scholarship-backend/src/middleware/upload.js b/scholarship-backend/src/middleware/upload.js new file mode 100644 index 0000000..de67e84 --- /dev/null +++ b/scholarship-backend/src/middleware/upload.js @@ -0,0 +1,48 @@ +// src/middleware/upload.js +// Multer configuration for document uploads + +const multer = require('multer'); +const path = require('path'); +const fs = require('fs'); +const { AppError } = require('./errorHandler'); + +const UPLOAD_DIR = process.env.UPLOAD_DIR || './uploads'; + +// Ensure upload directory exists +if (!fs.existsSync(UPLOAD_DIR)) { + fs.mkdirSync(UPLOAD_DIR, { recursive: true }); +} + +const storage = multer.diskStorage({ + destination: (req, file, cb) => { + const userDir = path.join(UPLOAD_DIR, req.user?.id || 'temp'); + fs.mkdirSync(userDir, { recursive: true }); + cb(null, userDir); + }, + filename: (req, file, cb) => { + const ext = path.extname(file.originalname).toLowerCase(); + const name = `${Date.now()}-${Math.random().toString(36).substring(2, 8)}${ext}`; + cb(null, name); + }, +}); + +const fileFilter = (req, file, cb) => { + const allowed = [ + 'image/jpeg', 'image/png', 'image/webp', + 'application/pdf', + ]; + if (allowed.includes(file.mimetype)) { + cb(null, true); + } else { + cb(new AppError('File type not allowed. Use JPEG, PNG, WebP, or PDF.', 400), false); + } +}; + +const MAX_MB = parseInt(process.env.MAX_FILE_SIZE_MB || '5'); +const MAX_SIZE = MAX_MB * 1024 * 1024; + +exports.uploadMiddleware = multer({ + storage, + fileFilter, + limits: { fileSize: MAX_SIZE }, +}); diff --git a/scholarship-backend/src/middleware/validate.js b/scholarship-backend/src/middleware/validate.js new file mode 100644 index 0000000..3fee4e4 --- /dev/null +++ b/scholarship-backend/src/middleware/validate.js @@ -0,0 +1,22 @@ +// src/middleware/validate.js +// Joi schema validation middleware + +const { AppError } = require('./errorHandler'); + +const validate = (schema) => (req, res, next) => { + const { error, value } = schema.validate(req.body, { + abortEarly: false, + stripUnknown: true, + allowUnknown: false, + }); + + if (error) { + const details = error.details.map(d => ({ field: d.path.join('.'), message: d.message })); + return res.status(400).json({ success: false, message: 'Validation failed', errors: details }); + } + + req.body = value; + next(); +}; + +module.exports = validate; diff --git a/scholarship-backend/src/routes/admin.routes.js b/scholarship-backend/src/routes/admin.routes.js new file mode 100644 index 0000000..b3b2a0b --- /dev/null +++ b/scholarship-backend/src/routes/admin.routes.js @@ -0,0 +1,26 @@ +// src/routes/admin.routes.js +const express = require('express'); +const router = express.Router(); +const controller = require('../controllers/admin.controller'); +const { authenticate, authorize } = require('../middleware/auth'); + +router.use(authenticate, authorize('ADMIN', 'SUPER_ADMIN')); + +// Dashboard statistics +router.get('/stats', controller.getDashboardStats); + +// User management +router.get('/users', controller.listUsers); +router.get('/users/:id', controller.getUser); +router.patch('/users/:id/role', controller.changeUserRole); +router.patch('/users/:id/block', controller.blockUser); + +// Reports +router.get('/reports/applications', controller.applicationReport); +router.get('/reports/disbursements', controller.disbursementReport); +router.get('/reports/scholarships', controller.scholarshipReport); + +// Audit log +router.get('/audit-log', controller.getAuditLog); + +module.exports = router; diff --git a/scholarship-backend/src/routes/application.routes.js b/scholarship-backend/src/routes/application.routes.js new file mode 100644 index 0000000..91da5e1 --- /dev/null +++ b/scholarship-backend/src/routes/application.routes.js @@ -0,0 +1,70 @@ +// src/routes/application.routes.js + +const express = require('express'); +const router = express.Router(); +const controller = require('../controllers/application.controller'); +const { authenticate, authorize } = require('../middleware/auth'); +const validate = require('../middleware/validate'); +const schema = require('../validators/application.validator'); + +// All application routes require authentication +router.use(authenticate); + +// ── Student Routes ─────────────────────────────────────── + +// List own applications +router.get('/', controller.listMyApplications); + +// Create (start) a new application +router.post('/', validate(schema.create), controller.createApplication); + +// Get a specific application (student sees own; admin sees any) +router.get('/:id', controller.getApplication); + +// Update draft application +router.put('/:id', validate(schema.update), controller.updateApplication); + +// Submit a draft application +router.post('/:id/submit', controller.submitApplication); + +// Cancel an application (student can cancel DRAFT or SUBMITTED) +router.post('/:id/cancel', controller.cancelApplication); + +// Track application status / timeline +router.get('/:id/timeline', controller.getApplicationTimeline); + +// Download application as PDF +router.get('/:id/download', controller.downloadApplicationPDF); + +// ── Verifier Routes ────────────────────────────────────── + +// Institutional verifier marks application as verified +router.post('/:id/verify', + authorize('VERIFIER', 'ADMIN', 'SUPER_ADMIN'), + validate(schema.verify), + controller.verifyApplication +); + +// ── Admin Routes ───────────────────────────────────────── + +// List ALL applications (with filters) +router.get('/admin/all', + authorize('ADMIN', 'SUPER_ADMIN'), + controller.listAllApplications +); + +// Approve / reject / put on hold +router.patch('/:id/status', + authorize('ADMIN', 'SUPER_ADMIN'), + validate(schema.changeStatus), + controller.changeApplicationStatus +); + +// Bulk approve +router.post('/admin/bulk-approve', + authorize('ADMIN', 'SUPER_ADMIN'), + validate(schema.bulkAction), + controller.bulkApprove +); + +module.exports = router; diff --git a/scholarship-backend/src/routes/auth.routes.js b/scholarship-backend/src/routes/auth.routes.js new file mode 100644 index 0000000..5781c37 --- /dev/null +++ b/scholarship-backend/src/routes/auth.routes.js @@ -0,0 +1,40 @@ +// src/routes/auth.routes.js + +const express = require('express'); +const router = express.Router(); +const rateLimit = require('express-rate-limit'); + +const authController = require('../controllers/auth.controller'); +const { authenticate } = require('../middleware/auth'); +const validate = require('../middleware/validate'); +const authSchema = require('../validators/auth.validator'); + +// Strict rate limit for auth endpoints +const authLimiter = rateLimit({ + windowMs: 15 * 60 * 1000, // 15 minutes + max: parseInt(process.env.AUTH_RATE_LIMIT_MAX) || 10, + message: { success: false, message: 'Too many auth attempts. Please wait 15 minutes.' }, + standardHeaders: true, + legacyHeaders: false, +}); + +// OTP-based login (Aadhaar-linked phone) +router.post('/otp/send', authLimiter, validate(authSchema.sendOtp), authController.sendOtp); +router.post('/otp/verify', authLimiter, validate(authSchema.verifyOtp), authController.verifyOtp); + +// Password-based login (admin / verifiers) +router.post('/register', authLimiter, validate(authSchema.register), authController.register); +router.post('/login', authLimiter, validate(authSchema.login), authController.login); + +// Token management +router.post('/refresh', validate(authSchema.refresh), authController.refreshToken); +router.post('/logout', authenticate, authController.logout); + +// Current user +router.get('/me', authenticate, authController.getMe); + +// Password reset +router.post('/forgot-password', authLimiter, validate(authSchema.forgotPassword), authController.forgotPassword); +router.post('/reset-password', authLimiter, validate(authSchema.resetPassword), authController.resetPassword); + +module.exports = router; diff --git a/scholarship-backend/src/routes/disbursement.routes.js b/scholarship-backend/src/routes/disbursement.routes.js new file mode 100644 index 0000000..5243daa --- /dev/null +++ b/scholarship-backend/src/routes/disbursement.routes.js @@ -0,0 +1,23 @@ +// src/routes/disbursement.routes.js +const express = require('express'); +const router = express.Router(); +const controller = require('../controllers/disbursement.controller'); +const { authenticate, authorize } = require('../middleware/auth'); + +router.use(authenticate); + +// Student: view own disbursement +router.get('/my', controller.listMyDisbursements); +router.get('/:applicationId', controller.getDisbursement); + +// Admin: initiate and process +router.post('/', + authorize('ADMIN', 'SUPER_ADMIN'), + controller.initiateDisbursement +); +router.patch('/:id/status', + authorize('ADMIN', 'SUPER_ADMIN'), + controller.updateDisbursementStatus +); + +module.exports = router; diff --git a/scholarship-backend/src/routes/document.routes.js b/scholarship-backend/src/routes/document.routes.js new file mode 100644 index 0000000..12be7b4 --- /dev/null +++ b/scholarship-backend/src/routes/document.routes.js @@ -0,0 +1,36 @@ +// src/routes/document.routes.js + +const express = require('express'); +const router = express.Router(); +const controller = require('../controllers/document.controller'); +const { authenticate, authorize } = require('../middleware/auth'); +const { uploadMiddleware } = require('../middleware/upload'); + +router.use(authenticate); + +// Upload a document (student uploads for their profile) +// Supports single file; field name = "file"; type in body +router.post('/upload', + uploadMiddleware.single('file'), + controller.uploadDocument +); + +// List all documents for the current student +router.get('/', controller.listMyDocuments); + +// Get metadata for a single document +router.get('/:id/meta', controller.getDocumentMeta); + +// Download / view a document (scoped: student sees own; admin sees any) +router.get('/:id', controller.downloadDocument); + +// Delete a document (only if not attached to a submitted application) +router.delete('/:id', controller.deleteDocument); + +// Admin: mark a document as verified +router.patch('/:id/verify', + authorize('ADMIN', 'VERIFIER', 'SUPER_ADMIN'), + controller.verifyDocument +); + +module.exports = router; diff --git a/scholarship-backend/src/routes/eligibility.routes.js b/scholarship-backend/src/routes/eligibility.routes.js new file mode 100644 index 0000000..52f642c --- /dev/null +++ b/scholarship-backend/src/routes/eligibility.routes.js @@ -0,0 +1,14 @@ +// src/routes/eligibility.routes.js +const express = require('express'); +const router = express.Router(); +const controller = require('../controllers/eligibility.controller'); +const validate = require('../middleware/validate'); +const schema = require('../validators/eligibility.validator'); + +// Public β€” no auth required for eligibility check +router.post('/check', validate(schema.check), controller.checkEligibility); + +// Get previous check result by session ID +router.get('/result/:sessionId', controller.getResult); + +module.exports = router; diff --git a/scholarship-backend/src/routes/index.js b/scholarship-backend/src/routes/index.js new file mode 100644 index 0000000..3584f3b --- /dev/null +++ b/scholarship-backend/src/routes/index.js @@ -0,0 +1,63 @@ +// src/routes/index.js +// Master API router β€” mounts all sub-routers + +const express = require('express'); +const router = express.Router(); + +const authRoutes = require('./auth.routes'); +const scholarshipRoutes = require('./scholarship.routes'); +const applicationRoutes = require('./application.routes'); +const documentRoutes = require('./document.routes'); +const noticeRoutes = require('./notice.routes'); +const profileRoutes = require('./profile.routes'); +const eligibilityRoutes = require('./eligibility.routes'); +const notificationRoutes = require('./notification.routes'); +const adminRoutes = require('./admin.routes'); +const disbursementRoutes = require('./disbursement.routes'); + +router.use('/auth', authRoutes); +router.use('/scholarships', scholarshipRoutes); +router.use('/applications', applicationRoutes); +router.use('/documents', documentRoutes); +router.use('/notices', noticeRoutes); +router.use('/profile', profileRoutes); +router.use('/eligibility', eligibilityRoutes); +router.use('/notifications', notificationRoutes); +router.use('/admin', adminRoutes); +router.use('/disbursements', disbursementRoutes); + +// API reference +router.get('/', (req, res) => { + res.json({ + name: 'GMC Scholarship Portal API', + version: '1.0.0', + endpoints: [ + 'POST /auth/register', + 'POST /auth/login', + 'POST /auth/otp/send', + 'POST /auth/otp/verify', + 'POST /auth/refresh', + 'POST /auth/logout', + 'GET /scholarships', + 'GET /scholarships/:slug', + 'POST /scholarships (admin)', + 'GET /applications', + 'POST /applications', + 'GET /applications/:id', + 'PATCH /applications/:id/status (admin)', + 'POST /documents/upload', + 'GET /documents/:id', + 'GET /notices', + 'POST /notices (admin)', + 'GET /profile', + 'PUT /profile', + 'POST /eligibility/check', + 'GET /notifications', + 'PATCH /notifications/:id/read', + 'GET /admin/stats', + 'GET /disbursements/:applicationId', + ], + }); +}); + +module.exports = router; diff --git a/scholarship-backend/src/routes/notice.routes.js b/scholarship-backend/src/routes/notice.routes.js new file mode 100644 index 0000000..c44dc56 --- /dev/null +++ b/scholarship-backend/src/routes/notice.routes.js @@ -0,0 +1,26 @@ +// src/routes/notice.routes.js +const express = require('express'); +const router = express.Router(); +const controller = require('../controllers/notice.controller'); +const { authenticate, authorize } = require('../middleware/auth'); +const validate = require('../middleware/validate'); +const schema = require('../validators/notice.validator'); +const { cacheMiddleware } = require('../middleware/cache'); + +router.get('/', cacheMiddleware(30), controller.listNotices); +router.get('/:slug', controller.getNotice); + +router.post('/', + authenticate, authorize('ADMIN', 'SUPER_ADMIN'), + validate(schema.create), controller.createNotice +); +router.put('/:id', + authenticate, authorize('ADMIN', 'SUPER_ADMIN'), + validate(schema.update), controller.updateNotice +); +router.delete('/:id', + authenticate, authorize('SUPER_ADMIN'), + controller.deleteNotice +); + +module.exports = router; diff --git a/scholarship-backend/src/routes/notification.routes.js b/scholarship-backend/src/routes/notification.routes.js new file mode 100644 index 0000000..0171fab --- /dev/null +++ b/scholarship-backend/src/routes/notification.routes.js @@ -0,0 +1,15 @@ +// src/routes/notification.routes.js +const express = require('express'); +const router = express.Router(); +const controller = require('../controllers/notification.controller'); +const { authenticate } = require('../middleware/auth'); + +router.use(authenticate); + +router.get('/', controller.listNotifications); +router.get('/unread-count', controller.getUnreadCount); +router.patch('/:id/read', controller.markRead); +router.post('/mark-all-read', controller.markAllRead); +router.delete('/:id', controller.deleteNotification); + +module.exports = router; diff --git a/scholarship-backend/src/routes/profile.routes.js b/scholarship-backend/src/routes/profile.routes.js new file mode 100644 index 0000000..2d4f0b8 --- /dev/null +++ b/scholarship-backend/src/routes/profile.routes.js @@ -0,0 +1,18 @@ +// src/routes/profile.routes.js +const express = require('express'); +const router = express.Router(); +const controller = require('../controllers/profile.controller'); +const { authenticate } = require('../middleware/auth'); +const validate = require('../middleware/validate'); +const schema = require('../validators/profile.validator'); + +router.use(authenticate); + +router.get('/', controller.getProfile); +router.post('/', validate(schema.create), controller.createProfile); +router.put('/', validate(schema.update), controller.updateProfile); + +// Bank account details (separate endpoint for sensitivity) +router.put('/bank', validate(schema.updateBank), controller.updateBankDetails); + +module.exports = router; diff --git a/scholarship-backend/src/routes/scholarship.routes.js b/scholarship-backend/src/routes/scholarship.routes.js new file mode 100644 index 0000000..302f74d --- /dev/null +++ b/scholarship-backend/src/routes/scholarship.routes.js @@ -0,0 +1,49 @@ +// src/routes/scholarship.routes.js + +const express = require('express'); +const router = express.Router(); +const controller = require('../controllers/scholarship.controller'); +const { authenticate, authorize } = require('../middleware/auth'); +const validate = require('../middleware/validate'); +const schema = require('../validators/scholarship.validator'); +const { cacheMiddleware } = require('../middleware/cache'); + +// ── Public ────────────────────────────────────────────── +// GET /scholarships?status=ACTIVE&category=SC&level=POST_MATRIC&q=nmms&page=1&limit=12 +router.get('/', cacheMiddleware(60), controller.listScholarships); + +// GET /scholarships/featured +router.get('/featured', cacheMiddleware(120), controller.getFeaturedScholarship); + +// GET /scholarships/:slug +router.get('/:slug', cacheMiddleware(60), controller.getScholarship); + +// ── Authenticated ──────────────────────────────────────── +// Check if user has already applied for a scholarship +router.get('/:slug/application-status', authenticate, controller.getApplicationStatus); + +// ── Admin only ─────────────────────────────────────────── +router.post('/', + authenticate, authorize('ADMIN', 'SUPER_ADMIN'), + validate(schema.create), + controller.createScholarship +); + +router.put('/:id', + authenticate, authorize('ADMIN', 'SUPER_ADMIN'), + validate(schema.update), + controller.updateScholarship +); + +router.patch('/:id/status', + authenticate, authorize('ADMIN', 'SUPER_ADMIN'), + validate(schema.updateStatus), + controller.updateScholarshipStatus +); + +router.delete('/:id', + authenticate, authorize('SUPER_ADMIN'), + controller.deleteScholarship +); + +module.exports = router; diff --git a/scholarship-backend/src/services/audit.service.js b/scholarship-backend/src/services/audit.service.js new file mode 100644 index 0000000..aac314f --- /dev/null +++ b/scholarship-backend/src/services/audit.service.js @@ -0,0 +1,20 @@ +// src/services/audit.service.js +const prisma = require('../config/database').prisma; +const logger = require('../config/logger'); + +exports.log = async ({ action, userId, entityType, entityId, req, meta }) => { + try { + await prisma.auditLog.create({ + data: { + userId: userId || null, + action, + entityType: entityType || null, + entityId: entityId || null, + metadata: meta || {}, + ipAddress: req?.ip || null, + }, + }); + } catch (err) { + logger.error('Audit log failed:', err.message); + } +}; diff --git a/scholarship-backend/src/services/eligibility.service.js b/scholarship-backend/src/services/eligibility.service.js new file mode 100644 index 0000000..a46ae68 --- /dev/null +++ b/scholarship-backend/src/services/eligibility.service.js @@ -0,0 +1,158 @@ +// src/services/eligibility.service.js +// Core eligibility scoring engine for the AI-assisted checker + +/** + * scoreScholarship β€” scores a scholarship against user-provided criteria. + * Returns: { eligible, partiallyEligible, score, reasons, missingCriteria, reason } + */ +exports.scoreScholarship = (inputs, scholarship) => { + const { + gender, category, incomeGroup, academicLevel, + district, percentage, isMinority, isDisabled, dob, + } = inputs; + + const reasons = []; + const missingCriteria = []; + let score = 0; + let eligibilityFails = 0; + + // ── Category ──────────────────────────────────────────── + if (scholarship.eligibleCategories.length > 0) { + if (scholarship.eligibleCategories.includes(category)) { + score += 30; + reasons.push(`Category ${category} matches`); + } else { + eligibilityFails++; + missingCriteria.push(`Category must be one of: ${scholarship.eligibleCategories.join(', ')}`); + } + } else { + score += 10; // Open to all categories + } + + // ── Academic Level ─────────────────────────────────────── + if (scholarship.eligibleAcademicLevels.length > 0) { + if (scholarship.eligibleAcademicLevels.includes(academicLevel)) { + score += 30; + reasons.push(`Academic level ${academicLevel} matches`); + } else { + eligibilityFails++; + missingCriteria.push(`Academic level must be: ${scholarship.eligibleAcademicLevels.join(', ')}`); + } + } else { + score += 10; + } + + // ── Income Group ───────────────────────────────────────── + if (scholarship.eligibleIncomeGroups.length > 0) { + if (scholarship.eligibleIncomeGroups.includes(incomeGroup)) { + score += 20; + reasons.push(`Income group matches`); + } else { + eligibilityFails++; + missingCriteria.push(`Annual family income must be in: ${scholarship.eligibleIncomeGroups.join(', ')}`); + } + } else { + score += 5; + } + + // ── Gender Restriction ─────────────────────────────────── + if (scholarship.onlyForGirls && gender !== 'FEMALE') { + eligibilityFails++; + missingCriteria.push('This scholarship is exclusively for female students'); + } else if (scholarship.onlyForGirls) { + score += 10; + } + + // ── Minority ───────────────────────────────────────────── + if (scholarship.onlyForMinority && !isMinority) { + eligibilityFails++; + missingCriteria.push('This scholarship requires minority community membership'); + } else if (scholarship.onlyForMinority) { + score += 10; + } + + // ── Disability ─────────────────────────────────────────── + if (scholarship.onlyForDisabled && !isDisabled) { + eligibilityFails++; + missingCriteria.push('This scholarship requires a disability certificate'); + } + + // ── District ───────────────────────────────────────────── + if (scholarship.requiresDistrict && district !== 'Kamrup Metropolitan') { + eligibilityFails++; + missingCriteria.push('Must be a resident of Kamrup Metropolitan district (Guwahati)'); + } else if (scholarship.requiresDistrict) { + score += 10; + } + + // ── Minimum Percentage ─────────────────────────────────── + if (scholarship.minPercentage !== null && percentage !== undefined) { + if (parseFloat(percentage) >= scholarship.minPercentage) { + score += 15; + reasons.push(`Academic percentage ${percentage}% meets minimum ${scholarship.minPercentage}%`); + } else { + eligibilityFails++; + missingCriteria.push(`Minimum ${scholarship.minPercentage}% required in last exam`); + } + } + + // ── Age ────────────────────────────────────────────────── + if (dob && (scholarship.minAge || scholarship.maxAge)) { + const age = Math.floor((new Date() - new Date(dob)) / (365.25 * 24 * 3600 * 1000)); + if (scholarship.minAge && age < scholarship.minAge) { + eligibilityFails++; + missingCriteria.push(`Minimum age is ${scholarship.minAge} years`); + } else if (scholarship.maxAge && age > scholarship.maxAge) { + eligibilityFails++; + missingCriteria.push(`Maximum age is ${scholarship.maxAge} years`); + } else { + score += 10; + } + } + + // ── Seat availability ──────────────────────────────────── + if (scholarship.seatsRemaining !== null) { + if (scholarship.seatsRemaining <= 0) { + return { + eligible: false, partiallyEligible: false, + score: 0, reason: 'No seats remaining', reasons: [], missingCriteria: [], + }; + } + score += 5; + } + + const eligible = eligibilityFails === 0; + const partiallyEligible = !eligible && eligibilityFails <= 1; + + return { + eligible, + partiallyEligible, + score: eligible ? score : Math.round(score * 0.5), + reasons, + missingCriteria, + reason: missingCriteria[0] || null, + }; +}; + +/** + * checkStudentEligibility β€” checks an existing StudentProfile against a Scholarship. + * Used during application creation. + */ +exports.checkStudentEligibility = async (profile, scholarship) => { + const result = exports.scoreScholarship({ + category: profile.category, + incomeGroup: profile.incomeGroup, + academicLevel: profile.academicLevel, + gender: profile.gender, + district: profile.district, + percentage: profile.percentage, + isMinority: profile.isMinority, + isDisabled: profile.isDisabled, + dob: profile.dob, + }, scholarship); + + return { + eligible: result.eligible, + reason: result.missingCriteria[0] || null, + }; +}; diff --git a/scholarship-backend/src/services/email.service.js b/scholarship-backend/src/services/email.service.js new file mode 100644 index 0000000..11f9fd2 --- /dev/null +++ b/scholarship-backend/src/services/email.service.js @@ -0,0 +1,97 @@ +// src/services/email.service.js +// Nodemailer-based email service + +const nodemailer = require('nodemailer'); +const logger = require('../config/logger'); + +let transporter; + +function getTransporter() { + if (!transporter) { + transporter = nodemailer.createTransport({ + host: process.env.SMTP_HOST, + port: parseInt(process.env.SMTP_PORT || '587'), + secure: false, + auth: { + user: process.env.SMTP_USER, + pass: process.env.SMTP_PASS, + }, + }); + } + return transporter; +} + +async function sendMail({ to, subject, html }) { + try { + const info = await getTransporter().sendMail({ + from: process.env.EMAIL_FROM, + to, subject, html, + }); + logger.info(`Email sent to ${to}: ${info.messageId}`); + return info; + } catch (err) { + logger.error(`Failed to send email to ${to}:`, err.message); + // Don't throw β€” email failure should not break the flow + } +} + +exports.sendWelcomeEmail = async (to) => sendMail({ + to, + subject: 'Welcome to GMC Scholarship Portal', + html: ` +
+

Welcome to the GMC Scholarship Portal

+

Thank you for registering. You can now apply for scholarships from Guwahati Municipal Corporation and the Government of Assam.

+

Visit the portal to get started.

+
+ Guwahati Municipal Corporation Β· Government of Assam +
+ `, +}); + +exports.sendApplicationConfirmation = async (to, application) => sendMail({ + to, + subject: `Application Submitted β€” ${application.applicationNo}`, + html: ` +
+

Application Submitted Successfully

+

Application No: ${application.applicationNo}

+

Scholarship: ${application.scholarship?.name}

+

Your application is now under review. You will be notified of any updates.

+ + Track Application + +
+ Helpline: 1800-180-5000 | GMC Education Cell +
+ `, +}); + +exports.sendPasswordReset = async (to, token) => sendMail({ + to, + subject: 'GMC Scholarship Portal β€” Password Reset', + html: ` +
+

Reset Your Password

+

Click the button below to reset your password. This link expires in 1 hour.

+ + Reset Password + +

If you did not request this, ignore this email.

+
+ `, +}); + +exports.sendDisbursementAlert = async (to, { amount, scholarshipName, transactionRef }) => sendMail({ + to, + subject: 'πŸ’° Scholarship Amount Credited!', + html: ` +
+

Scholarship Disbursement Successful

+

β‚Ή${amount.toLocaleString('en-IN')} from ${scholarshipName} has been credited to your bank account.

+

Transaction Ref: ${transactionRef}

+
+ `, +}); diff --git a/scholarship-backend/src/services/notification.service.js b/scholarship-backend/src/services/notification.service.js new file mode 100644 index 0000000..cc6cb15 --- /dev/null +++ b/scholarship-backend/src/services/notification.service.js @@ -0,0 +1,29 @@ +// src/services/notification.service.js +const prisma = require('../config/database').prisma; +const logger = require('../config/logger'); + +exports.notify = async (userId, { title, body, type, meta = {} }) => { + try { + await prisma.notification.create({ + data: { userId, title, body, type, metadata: meta }, + }); + } catch (err) { + logger.error('Failed to create notification:', err.message); + } +}; + +exports.notifyMany = async (userIds, payload) => { + try { + await prisma.notification.createMany({ + data: userIds.map(userId => ({ + userId, + title: payload.title, + body: payload.body, + type: payload.type, + metadata: payload.meta || {}, + })), + }); + } catch (err) { + logger.error('Failed to create bulk notifications:', err.message); + } +}; diff --git a/scholarship-backend/src/services/sms.service.js b/scholarship-backend/src/services/sms.service.js new file mode 100644 index 0000000..d759338 --- /dev/null +++ b/scholarship-backend/src/services/sms.service.js @@ -0,0 +1,28 @@ +// src/services/sms.service.js +// SMS OTP delivery β€” MSG91 or mock for dev + +const logger = require('../config/logger'); + +exports.sendOtp = async (phone, otp) => { + if (process.env.NODE_ENV === 'development') { + logger.info(`[DEV SMS] OTP for ${phone}: ${otp}`); + return; + } + + if (process.env.SMS_PROVIDER === 'msg91') { + const url = 'https://api.msg91.com/api/v5/otp'; + const res = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'authkey': process.env.MSG91_API_KEY }, + body: JSON.stringify({ + template_id: process.env.MSG91_TEMPLATE_OTP, + mobile: `91${phone}`, + otp, + }), + }); + if (!res.ok) throw new Error(`MSG91 error: ${await res.text()}`); + } else { + // Fallback: log OTP (replace with your SMS provider) + logger.warn(`[SMS FALLBACK] OTP for ${phone}: ${otp}`); + } +}; diff --git a/scholarship-backend/src/services/storage.service.js b/scholarship-backend/src/services/storage.service.js new file mode 100644 index 0000000..68158e9 --- /dev/null +++ b/scholarship-backend/src/services/storage.service.js @@ -0,0 +1,61 @@ +// src/services/storage.service.js +// Abstracted storage β€” local filesystem or AWS S3 + +const fs = require('fs'); +const path = require('path'); +const logger = require('../config/logger'); + +const STORAGE_TYPE = process.env.STORAGE_TYPE || 'local'; +const UPLOAD_DIR = process.env.UPLOAD_DIR || './uploads'; + +// ── Local storage ───────────────────────────────────────── + +const localUpload = async (file) => { + // File already on disk from multer β€” just return relative path + return path.relative(UPLOAD_DIR, file.path).replace(/\\/g, '/'); +}; + +const localGetStream = async (storageKey) => { + const fullPath = path.join(UPLOAD_DIR, storageKey); + if (!fs.existsSync(fullPath)) throw new Error('File not found'); + return fs.createReadStream(fullPath); +}; + +const localDelete = async (storageKey) => { + const fullPath = path.join(UPLOAD_DIR, storageKey); + if (fs.existsSync(fullPath)) fs.unlinkSync(fullPath); +}; + +// ── S3 storage ──────────────────────────────────────────── +// Uncomment and configure for production: +// +// const { S3Client, PutObjectCommand, GetObjectCommand, DeleteObjectCommand } = require('@aws-sdk/client-s3'); +// const { getSignedUrl } = require('@aws-sdk/s3-request-presigner'); +// +// const s3 = new S3Client({ +// region: process.env.AWS_REGION, +// credentials: { accessKeyId: process.env.AWS_ACCESS_KEY_ID, secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY }, +// }); +// const BUCKET = process.env.AWS_S3_BUCKET; +// +// const s3Upload = async (file) => { +// const key = `documents/${Date.now()}-${file.originalname}`; +// const body = fs.readFileSync(file.path); +// await s3.send(new PutObjectCommand({ Bucket: BUCKET, Key: key, Body: body, ContentType: file.mimetype })); +// fs.unlinkSync(file.path); // clean up local temp +// return key; +// }; +// +// const s3GetStream = async (key) => { +// const cmd = new GetObjectCommand({ Bucket: BUCKET, Key: key }); +// const res = await s3.send(cmd); +// return res.Body; +// }; +// +// const s3Delete = async (key) => { +// await s3.send(new DeleteObjectCommand({ Bucket: BUCKET, Key: key })); +// }; + +exports.upload = STORAGE_TYPE === 's3' ? (() => { throw new Error('Install @aws-sdk/client-s3 for S3 support'); }) : localUpload; +exports.getStream = STORAGE_TYPE === 's3' ? (() => { throw new Error('S3 not configured'); }) : localGetStream; +exports.delete = STORAGE_TYPE === 's3' ? (() => { throw new Error('S3 not configured'); }) : localDelete; diff --git a/scholarship-backend/src/validators/application.validator.js b/scholarship-backend/src/validators/application.validator.js new file mode 100644 index 0000000..a584228 --- /dev/null +++ b/scholarship-backend/src/validators/application.validator.js @@ -0,0 +1,31 @@ +// src/validators/application.validator.js +const Joi = require('joi'); + +const APP_STATUSES = [ + 'DRAFT','SUBMITTED','UNDER_REVIEW','INSTITUTION_VERIFIED', + 'PENDING_APPROVAL','APPROVED','DISBURSED','REJECTED','CANCELLED','RENEWAL_DUE', +]; + +exports.create = Joi.object({ + scholarshipId: Joi.string().uuid().required(), +}); + +exports.update = Joi.object({ + documentIds: Joi.array().items(Joi.string().uuid()).optional(), + studentRemarks: Joi.string().max(500).optional().allow('', null), + formData: Joi.object().optional(), +}); + +exports.verify = Joi.object({ + remarks: Joi.string().max(500).optional().allow('', null), +}); + +exports.changeStatus = Joi.object({ + status: Joi.string().valid(...APP_STATUSES).required(), + remarks: Joi.string().max(500).optional().allow('', null), +}); + +exports.bulkAction = Joi.object({ + applicationIds: Joi.array().items(Joi.string().uuid()).min(1).required(), + remarks: Joi.string().max(500).optional(), +}); diff --git a/scholarship-backend/src/validators/auth.validator.js b/scholarship-backend/src/validators/auth.validator.js new file mode 100644 index 0000000..88ed666 --- /dev/null +++ b/scholarship-backend/src/validators/auth.validator.js @@ -0,0 +1,41 @@ +// src/validators/auth.validator.js +const Joi = require('joi'); + +const phone = Joi.string().pattern(/^[6-9]\d{9}$/).required().messages({ + 'string.pattern.base': 'Enter a valid 10-digit Indian mobile number', +}); + +exports.sendOtp = Joi.object({ phone }); + +exports.verifyOtp = Joi.object({ + phone, + otp: Joi.string().length(6).pattern(/^\d+$/).required().messages({ + 'string.length': 'OTP must be 6 digits', + 'string.pattern.base': 'OTP must contain only digits', + }), +}); + +exports.register = Joi.object({ + email: Joi.string().email().required(), + phone: Joi.string().pattern(/^[6-9]\d{9}$/).required(), + password: Joi.string().min(8).required().messages({ 'string.min': 'Password must be at least 8 characters' }), + role: Joi.string().valid('STUDENT', 'ADMIN', 'VERIFIER').default('STUDENT'), +}); + +exports.login = Joi.object({ + email: Joi.string().email().required(), + password: Joi.string().required(), +}); + +exports.refresh = Joi.object({ + refreshToken: Joi.string().required(), +}); + +exports.forgotPassword = Joi.object({ + email: Joi.string().email().required(), +}); + +exports.resetPassword = Joi.object({ + token: Joi.string().uuid().required(), + password: Joi.string().min(8).required(), +}); diff --git a/scholarship-backend/src/validators/eligibility.validator.js b/scholarship-backend/src/validators/eligibility.validator.js new file mode 100644 index 0000000..298862f --- /dev/null +++ b/scholarship-backend/src/validators/eligibility.validator.js @@ -0,0 +1,14 @@ +// src/validators/eligibility.validator.js +const Joi = require('joi'); + +exports.check = Joi.object({ + gender: Joi.string().valid('MALE','FEMALE','OTHER','PREFER_NOT_TO_SAY').required(), + dob: Joi.date().iso().max('now').optional().allow(null), + category: Joi.string().valid('GENERAL','OBC','SC','ST','EWS','MINORITY').required(), + incomeGroup: Joi.string().valid('BELOW_1L','BELOW_2L','BELOW_3_5L','BELOW_8L','ABOVE_8L').required(), + academicLevel: Joi.string().valid('PRE_MATRIC','MATRIC','POST_MATRIC','GRADUATION','POST_GRADUATION','DIPLOMA','VOCATIONAL','PHD').required(), + district: Joi.string().optional().default('Kamrup Metropolitan'), + percentage: Joi.number().min(0).max(100).optional().allow(null), + isMinority: Joi.boolean().default(false), + isDisabled: Joi.boolean().default(false), +}); diff --git a/scholarship-backend/src/validators/notice.validator.js b/scholarship-backend/src/validators/notice.validator.js new file mode 100644 index 0000000..fcbdfab --- /dev/null +++ b/scholarship-backend/src/validators/notice.validator.js @@ -0,0 +1,18 @@ +// src/validators/notice.validator.js +const Joi = require('joi'); + +exports.create = Joi.object({ + title: Joi.string().min(5).max(200).required(), + body: Joi.string().min(10).required(), + tag: Joi.string().valid('DEADLINE','NOTICE','RESULT','NEW','ALERT','INFO').required(), + issuedBy: Joi.string().required(), + publishedAt: Joi.date().iso().optional(), + expiresAt: Joi.date().iso().optional().allow(null), + isPinned: Joi.boolean().default(false), + attachmentUrl: Joi.string().uri().optional().allow('', null), +}); + +exports.update = exports.create.fork( + Object.keys(exports.create.describe().keys), + (schema) => schema.optional() +); diff --git a/scholarship-backend/src/validators/profile.validator.js b/scholarship-backend/src/validators/profile.validator.js new file mode 100644 index 0000000..70a3c9d --- /dev/null +++ b/scholarship-backend/src/validators/profile.validator.js @@ -0,0 +1,46 @@ +// src/validators/profile.validator.js +const Joi = require('joi'); + +const GENDERS = ['MALE','FEMALE','OTHER','PREFER_NOT_TO_SAY']; +const CATEGORIES = ['GENERAL','OBC','SC','ST','EWS','MINORITY']; +const INCOME_GROUPS = ['BELOW_1L','BELOW_2L','BELOW_3_5L','BELOW_8L','ABOVE_8L']; +const ACAD_LEVELS = ['PRE_MATRIC','MATRIC','POST_MATRIC','GRADUATION','POST_GRADUATION','DIPLOMA','VOCATIONAL','PHD']; + +exports.create = Joi.object({ + firstName: Joi.string().min(2).max(50).required(), + lastName: Joi.string().min(2).max(50).required(), + dob: Joi.date().iso().max('now').required(), + gender: Joi.string().valid(...GENDERS).required(), + category: Joi.string().valid(...CATEGORIES).required(), + incomeGroup: Joi.string().valid(...INCOME_GROUPS).required(), + academicLevel: Joi.string().valid(...ACAD_LEVELS).required(), + institution: Joi.string().min(3).max(200).required(), + institutionCode: Joi.string().optional().allow('', null), + course: Joi.string().min(2).max(100).required(), + yearOfStudy: Joi.number().integer().min(1).max(10).required(), + percentage: Joi.number().min(0).max(100).optional().allow(null), + rollNumber: Joi.string().optional().allow('', null), + district: Joi.string().required(), + ward: Joi.string().optional().allow('', null), + address: Joi.string().min(10).max(300).required(), + pincode: Joi.string().pattern(/^\d{6}$/).required().messages({ 'string.pattern.base': 'Enter a valid 6-digit PIN code' }), + isMinority: Joi.boolean().default(false), + isDisabled: Joi.boolean().default(false), + disabilityPct: Joi.number().integer().min(1).max(100).optional().allow(null), +}); + +exports.update = exports.create.fork( + Object.keys(exports.create.describe().keys), + (schema) => schema.optional() +); + +exports.updateBank = Joi.object({ + bankAccountNo: Joi.string().pattern(/^\d{9,18}$/).required().messages({ + 'string.pattern.base': 'Enter a valid bank account number (9-18 digits)', + }), + bankName: Joi.string().required(), + bankIFSC: Joi.string().pattern(/^[A-Z]{4}0[A-Z0-9]{6}$/).required().messages({ + 'string.pattern.base': 'Enter a valid IFSC code (e.g. SBIN0001234)', + }), + bankBranch: Joi.string().optional().allow('', null), +}); diff --git a/scholarship-backend/src/validators/scholarship.validator.js b/scholarship-backend/src/validators/scholarship.validator.js new file mode 100644 index 0000000..6f63456 --- /dev/null +++ b/scholarship-backend/src/validators/scholarship.validator.js @@ -0,0 +1,47 @@ +// src/validators/scholarship.validator.js +const Joi = require('joi'); + +const CATEGORIES = ['GENERAL','OBC','SC','ST','EWS','MINORITY']; +const INCOME_GROUPS = ['BELOW_1L','BELOW_2L','BELOW_3_5L','BELOW_8L','ABOVE_8L']; +const ACADEMIC_LVLS = ['PRE_MATRIC','MATRIC','POST_MATRIC','GRADUATION','POST_GRADUATION','DIPLOMA','VOCATIONAL','PHD']; +const STATUS_VALS = ['DRAFT','ACTIVE','CLOSING_SOON','CLOSED','ARCHIVED']; +const DOC_TYPES = ['MARKSHEET','INCOME_CERTIFICATE','CASTE_CERTIFICATE','AADHAAR','PHOTO','BANK_PASSBOOK','DOMICILE','DISABILITY_CERTIFICATE','MINORITY_CERTIFICATE','OTHER']; + +exports.create = Joi.object({ + name: Joi.string().min(5).max(200).required(), + shortName: Joi.string().max(50).optional(), + issuer: Joi.string().required(), + description: Joi.string().min(20).required(), + status: Joi.string().valid(...STATUS_VALS).default('DRAFT'), + isFeatured: Joi.boolean().default(false), + eligibleCategories: Joi.array().items(Joi.string().valid(...CATEGORIES)).default([]), + eligibleIncomeGroups: Joi.array().items(Joi.string().valid(...INCOME_GROUPS)).default([]), + eligibleAcademicLevels: Joi.array().items(Joi.string().valid(...ACADEMIC_LVLS)).default([]), + minPercentage: Joi.number().min(0).max(100).optional().allow(null), + minAge: Joi.number().integer().min(5).max(40).optional().allow(null), + maxAge: Joi.number().integer().min(5).max(40).optional().allow(null), + requiresDistrict: Joi.boolean().default(true), + onlyForGirls: Joi.boolean().default(false), + onlyForMinority: Joi.boolean().default(false), + onlyForDisabled: Joi.boolean().default(false), + amountPerYear: Joi.number().positive().required(), + amountFrequency: Joi.string().valid('yearly','monthly','one-time').default('yearly'), + totalSeats: Joi.number().integer().positive().optional().allow(null), + seatsRemaining: Joi.number().integer().min(0).optional().allow(null), + applicationStartDate: Joi.date().iso().required(), + applicationEndDate: Joi.date().iso().greater(Joi.ref('applicationStartDate')).required(), + academicYear: Joi.string().pattern(/^\d{4}-\d{2}$/).required(), + requiredDocuments: Joi.array().items(Joi.string().valid(...DOC_TYPES)).required(), + externalUrl: Joi.string().uri().optional().allow('', null), + nspSchemeCode: Joi.string().optional().allow('', null), + iconType: Joi.string().optional().allow('', null), +}); + +exports.update = exports.create.fork( + Object.keys(exports.create.describe().keys), + (schema) => schema.optional() +); + +exports.updateStatus = Joi.object({ + status: Joi.string().valid(...STATUS_VALS).required(), +}); diff --git a/scholarship_portal (2).html b/scholarship_portal (2).html new file mode 100644 index 0000000..948f84a --- /dev/null +++ b/scholarship_portal (2).html @@ -0,0 +1,1315 @@ + + + + + + Scholarship Portal β€” Guwahati Municipal Corporation + + + + + + + + + + + + + + + +
+
+
+
Education & Welfare
+
Scholarship Portal
+
Discover and apply for government and municipal scholarships available to eligible residents of Guwahati. Find the right opportunity for your academic journey.
+
+
+
+
14
+
Active Schemes
+
+
+
β‚Ή1.2Cr
+
Disbursed FY26
+
+
+
3,800+
+
Beneficiaries
+
+
+
+
+ + +
+ + +
+
+ +
Available Scholarships
+
+ View All Schemes +
+ + +
+ + + + + + + + +
+ + + + + +
+ + +
+
+
+ +
+ Open +
+
Pre-Matric Scholarship for SC Students
+
Ministry of Social Justice & Empowerment
+
Financial assistance to SC students studying in Class 9 & 10 to reduce dropout rates and encourage academic continuity.
+
+ SC Students + Class 9–10 +
+
β‚Ή3,500 / year
+
Day Scholar Rate
+ +
+ + +
+
+
+ +
+ Open +
+
Chief Minister's Special Scholarship
+
Assam State Government Β· GMC
+
Merit-cum-means scholarship for top-performing students from Guwahati wards pursuing undergraduate degrees at state universities.
+
+ Merit Based + UG Degree +
+
β‚Ή18,000 / year
+
Annual Award
+ +
+ + +
+
+
+ +
+ Closing Soon +
+
National Means-cum-Merit Scholarship
+
Ministry of Education, Govt. of India
+
Central government scheme for Class 9–12 students from low-income families to prevent dropout at secondary level.
+
+ All Categories + Class 9–12 +
+
β‚Ή12,000 / year
+
Central Award
+ +
+ + +
+
+
+ +
+ New +
+
Orunodoi Scholarship for Girls
+
Govt. of Assam Β· Welfare Dept.
+
Exclusive scholarship for girl students from BPL families, covering tuition fees and providing a monthly stipend throughout their education.
+
+ Girls Only + BPL Family +
+
β‚Ή8,000 / year
+
+ Monthly Stipend β‚Ή500
+ +
+ + +
+
+
+ +
+ Open +
+
Post-Matric Scholarship for ST Students
+
Ministry of Tribal Affairs, Govt. of India
+
Centrally-sponsored scheme for ST students studying post-matric or post-secondary courses to promote higher education.
+
+ ST Students + Post-Matric +
+
β‚Ή10,000 / year
+
Non-Refundable
+ +
+ + +
+
+
+ +
+ Closed +
+
Minority Community Scholarship (Pre-Matric)
+
National Minority Dev. Finance Corp.
+
Financial support for minority community students in pre-matric stage to ensure equitable access to quality education.
+
+ Minority + Class 1–10 +
+
β‚Ή5,000 / year
+
Applications Closed
+ +
+ +
+ + +
+
+
Smart Tool
+
Not sure which scholarship applies to you?
+
Use our AI-assisted eligibility checker. Answer a few quick questions about your income, category, and academic level β€” we'll match you with the right schemes.
+
+
+ +
+
+ + +
+ +
+
+
+ +
Notices & Updates
+
+ View All +
+
+
+ Scholarship Circulars + Subscribe β†’ +
+
+ Deadline +
+
NMMS Application deadline extended to 5 March 2026 β€” submit before midnight
+
26 Feb 2026 Β· Directorate of Secondary Education
+
+
+
+ Notice +
+
Pragyan Bharati FY 2025–26: Renewal applications now open on National Scholarship Portal
+
24 Feb 2026 Β· Assam Higher Education Dept.
+
+
+
+ Result +
+
CM Special Scholarship 2024–25 merit list published β€” 412 students from Guwahati selected
+
22 Feb 2026 Β· GMC Education Cell
+
+
+
+ New +
+
Orunodoi Girls Scholarship 2025–26 launched β€” applications open from 1 March 2026
+
20 Feb 2026 Β· Assam Welfare Department
+
+
+
+ Alert +
+
Incomplete applications for Pre-Matric SC Scholarship will be rejected after 28 Feb 2026
+
18 Feb 2026 Β· SJED, Govt. of Assam
+
+
+
+
+ +
+
+
+ +
Quick Access
+
+
+
+
Helpful Resources
+
πŸ” Check Eligibility
+
πŸ“‹ Track Application
+
πŸ“„ Download Documents
+
🏦 Link Bank Account
+
πŸ“ž Helpline: 1800-180-5000
+
πŸ€– AI Document Check
+
🌐 NSP National Portal
+
+
+ +
+ + +
+
+ +
How to Apply
+
+
+
+
+
+
+
Check Eligibility
+
Use our tool to find scholarships matching your profile and academic level
+
+
+
+
Register & Login
+
Sign up on this portal or NSP with your Aadhaar-linked mobile number
+
+
+
3
+
Fill & Upload
+
Complete the application form and upload income certificate, marksheets & photo
+
+
+
4
+
Track & Receive
+
Monitor approval status and get disbursement directly to your linked bank account
+
+
+
+ +
+ + + + + + +