diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..6d40cbd --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,91 @@ +name: CI + +on: + push: + branches: [main, dev] + pull_request: + branches: [main] + +jobs: + # ─── Backend ─────────────────────────────────────────────────────────────── + backend: + name: Backend (Java 17 / Maven) + runs-on: ubuntu-latest + + services: + postgres: + image: postgres:15 + env: + POSTGRES_DB: supportsystem_test + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + ports: + - 5432:5432 + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + + env: + SPRING_PROFILES_ACTIVE: test + SPRING_DATASOURCE_URL: jdbc:postgresql://localhost:5432/supportsystem_test + SPRING_DATASOURCE_USERNAME: postgres + SPRING_DATASOURCE_PASSWORD: postgres + JWT_SECRET: ci-test-secret-that-is-long-enough-for-hmac-sha256 + SENDGRID_API_KEY: dummy-key-for-ci + SENDGRID_FROM_EMAIL: ci@example.com + + steps: + - uses: actions/checkout@v4 + + - name: Set up Java 17 + uses: actions/setup-java@v4 + with: + java-version: '17' + distribution: 'temurin' + cache: maven + + - name: Build and test + run: mvn --batch-mode verify + + - name: Upload test reports + if: always() + uses: actions/upload-artifact@v4 + with: + name: backend-test-reports + path: target/surefire-reports/ + + # ─── Frontend ────────────────────────────────────────────────────────────── + frontend: + name: Frontend (Node / Vite) + runs-on: ubuntu-latest + + defaults: + run: + working-directory: frontend + + steps: + - uses: actions/checkout@v4 + + - name: Set up Node 20 + uses: actions/setup-node@v4 + with: + node-version: '20' + cache: npm + cache-dependency-path: frontend/package-lock.json + + - name: Install dependencies + run: npm ci + + - name: Type-check + run: npx tsc --noEmit + + - name: Build + run: npm run build + + - name: Upload build artifact + uses: actions/upload-artifact@v4 + with: + name: frontend-dist + path: frontend/dist/ \ No newline at end of file diff --git a/README.md b/README.md index 77eaf6c..c17fee4 100644 --- a/README.md +++ b/README.md @@ -1,94 +1,277 @@ # Community Help Portal -**A full-stack web application connecting citizens with volunteers for community assistance.** +> A full-stack web application connecting citizens with volunteers for community assistance in Rwanda. + +[![Backend](https://img.shields.io/badge/Backend-Spring%20Boot%203.5-6db33f?style=flat-square&logo=springboot&logoColor=white)](https://community-support-system.fly.dev) +[![Frontend](https://img.shields.io/badge/Frontend-React%2019-61dafb?style=flat-square&logo=react&logoColor=black)](https://community-support-system.vercel.app) +[![Java](https://img.shields.io/badge/Java-17-ed8b00?style=flat-square&logo=openjdk&logoColor=white)](https://openjdk.org/projects/jdk/17/) +[![PostgreSQL](https://img.shields.io/badge/PostgreSQL-17-336791?style=flat-square&logo=postgresql&logoColor=white)](https://www.postgresql.org/) +[![License](https://img.shields.io/badge/License-MIT-black?style=flat-square)](LICENSE) + +--- ## Live Deployment -- **Backend API**: [community-support-system.fly.dev](https://community-support-system.fly.dev) -- **Frontend App**: [community-support-system.vercel.app](https://community-support-system.vercel.app) -- **API Docs**: [Swagger UI](https://community-support-system.fly.dev/swagger-ui.html) +| Service | URL | +|---------|-----| +| Frontend App | [community-support-system.vercel.app](https://community-support-system.vercel.app) | +| Backend API | [community-support-system.fly.dev](https://community-support-system.fly.dev) | +| Swagger UI | [/swagger-ui.html](https://community-support-system.fly.dev/swagger-ui.html) | +| Health Check | [/actuator/health](https://community-support-system.fly.dev/actuator/health) | -## Technology Stack +--- + +## System Architecture + +``` +┌─────────────────────────────────────────────────────────────────────┐ +│ CLIENT LAYER │ +│ │ +│ ┌──────────────────────────────────────────────────────────────┐ │ +│ │ React 19 + TypeScript + Vite │ │ +│ │ (Vercel — CDN Edge) │ │ +│ │ │ │ +│ │ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │ │ +│ │ │ Citizen │ │Volunteer │ │ Admin │ │ Landing │ │ │ +│ │ │Dashboard │ │Dashboard │ │Dashboard │ │ Page │ │ │ +│ │ └──────────┘ └──────────┘ └──────────┘ └──────────┘ │ │ +│ │ │ │ +│ │ Tailwind CSS (B&W) │ Recharts │ React Query │ │ +│ │ Lucide Icons │ i18next │ Axios │ │ +│ └──────────────────────────────────────────────────────────────┘ │ +│ │ HTTPS │ +└──────────────────────────────┼──────────────────────────────────────┘ + │ +┌──────────────────────────────┼──────────────────────────────────────┐ +│ API GATEWAY LAYER │ +│ │ +│ Spring Security + JWT Filter │ +│ CORS Policy + Rate Limiting │ +│ │ +└──────────────────────────────┼──────────────────────────────────────┘ + │ +┌──────────────────────────────┼──────────────────────────────────────┐ +│ APPLICATION LAYER │ +│ Spring Boot 3.5.6 — Java 17 │ +│ (Fly.io — Frankfurt) │ +│ │ +│ ┌────────────┐ ┌────────────┐ ┌────────────┐ ┌────────────────┐ │ +│ │ Auth │ │ Requests │ │Assignments │ │ Notifications │ │ +│ │ Controller │ │ Controller │ │ Controller │ │ Controller │ │ +│ └────────────┘ └────────────┘ └────────────┘ └────────────────┘ │ +│ ┌────────────┐ ┌────────────┐ ┌────────────┐ ┌────────────────┐ │ +│ │ Analytics │ │Gamification│ │Availability│ │ Admin │ │ +│ │ Controller │ │ Controller │ │ Controller │ │ Controller │ │ +│ └────────────┘ └────────────┘ └────────────┘ └────────────────┘ │ +│ │ +│ ┌───────────────────────────────────────────────────────────────┐ │ +│ │ SERVICE LAYER │ │ +│ │ UserService │ RequestService │ AssignmentService │ │ +│ │ NotificationService │ AnalyticsService │ LeaderboardService │ │ +│ │ AchievementService │ EmailService │ AuthService │ │ +│ └───────────────────────────────────────────────────────────────┘ │ +│ │ +└────────────────────┬──────────────────────────┬─────────────────────┘ + │ │ + ┌───────────┘ └──────────────┐ + ▼ ▼ +┌─────────────────────────┐ ┌──────────────────────────┐ +│ DATA LAYER │ │ EXTERNAL SERVICES │ +│ │ │ │ +│ PostgreSQL 17 │ │ SendGrid (Email) │ +│ (Fly.io Managed DB) │ │ Rwanda Location API │ +│ │ │ (Administrative data) │ +│ 9 Tables: │ │ │ +│ • users │ └──────────────────────────┘ +│ • locations │ +│ • requests │ +│ • assignments │ +│ • notifications │ +│ • skills │ +│ • user_skills (M:N) │ +│ • user_settings │ +│ • achievements │ +└──────────────────────────┘ +``` + +### User Roles & Flow + +``` + CITIZEN VOLUNTEER ADMIN + │ │ │ + │ Register / Login │ Register / Login │ Login + │ │ │ + ├─ Create Request ├─ Browse Requests ├─ Manage Users + ├─ Track Status ├─ Accept Assignment ├─ Moderate Requests + ├─ View Notifications ├─ Complete Task ├─ Broadcast Alerts + ├─ Rate Volunteer ├─ Earn Points/Badges ├─ View Analytics + └─ Book Appointment └─ View Leaderboard └─ System Settings +``` -**Backend** -- Spring Boot 3.5.6, Java 17 -- PostgreSQL 17+ -- JWT Authentication (Spring Security) -- SendGrid (email verification) -- Swagger / OpenAPI 3.0 +--- -**Frontend** -- React 19 + TypeScript + Vite -- Tailwind CSS (black & white / grayscale design system) -- Recharts, React Query, React i18next (EN / FR) -- Lucide icons +## Technology Stack -**Deployment** -- Backend: Fly.io -- Frontend: Vercel +### Backend +| Technology | Version | Purpose | +|-----------|---------|---------| +| ![Java](https://img.shields.io/badge/-Java-ed8b00?logo=openjdk&logoColor=white&style=flat-square) Java | 17 | Core language | +| ![Spring Boot](https://img.shields.io/badge/-Spring%20Boot-6db33f?logo=springboot&logoColor=white&style=flat-square) Spring Boot | 3.5.6 | Application framework | +| ![Spring Security](https://img.shields.io/badge/-Spring%20Security-6db33f?logo=springsecurity&logoColor=white&style=flat-square) Spring Security | 6.x | Auth, CORS, role guards | +| ![PostgreSQL](https://img.shields.io/badge/-PostgreSQL-336791?logo=postgresql&logoColor=white&style=flat-square) PostgreSQL | 17+ | Primary database | +| ![JWT](https://img.shields.io/badge/-JWT-000000?logo=jsonwebtokens&logoColor=white&style=flat-square) JWT | — | Stateless authentication | +| ![Swagger](https://img.shields.io/badge/-Swagger-85ea2d?logo=swagger&logoColor=black&style=flat-square) Swagger / OpenAPI | 3.0 | API documentation | +| ![SendGrid](https://img.shields.io/badge/-SendGrid-1a82e2?logo=twilio&logoColor=white&style=flat-square) SendGrid | — | Transactional email | +| ![Maven](https://img.shields.io/badge/-Maven-c71a36?logo=apachemaven&logoColor=white&style=flat-square) Maven | 3.9+ | Build tool | +| ![Docker](https://img.shields.io/badge/-Docker-2496ed?logo=docker&logoColor=white&style=flat-square) Docker | — | Containerisation | + +### Frontend +| Technology | Version | Purpose | +|-----------|---------|---------| +| ![React](https://img.shields.io/badge/-React-61dafb?logo=react&logoColor=black&style=flat-square) React | 19 | UI library | +| ![TypeScript](https://img.shields.io/badge/-TypeScript-3178c6?logo=typescript&logoColor=white&style=flat-square) TypeScript | 5.x | Type safety | +| ![Vite](https://img.shields.io/badge/-Vite-646cff?logo=vite&logoColor=white&style=flat-square) Vite | 6.x | Build tool | +| ![Tailwind CSS](https://img.shields.io/badge/-Tailwind%20CSS-06b6d4?logo=tailwindcss&logoColor=white&style=flat-square) Tailwind CSS | 3.x | Utility-first styling | +| ![Axios](https://img.shields.io/badge/-Axios-5a29e4?logo=axios&logoColor=white&style=flat-square) Axios | — | HTTP client | +| ![React Query](https://img.shields.io/badge/-React%20Query-ff4154?logo=reactquery&logoColor=white&style=flat-square) React Query | 5.x | Server state management | +| ![Recharts](https://img.shields.io/badge/-Recharts-22b5bf?logo=chart.js&logoColor=white&style=flat-square) Recharts | — | Analytics charts | +| ![i18next](https://img.shields.io/badge/-i18next-26a69a?logo=i18next&logoColor=white&style=flat-square) i18next | — | Internationalisation | +| ![Lucide](https://img.shields.io/badge/-Lucide%20Icons-f56565?logo=lucide&logoColor=white&style=flat-square) Lucide | — | Icon set | + +### Infrastructure +| Service | Provider | Purpose | +|---------|----------|---------| +| ![Fly.io](https://img.shields.io/badge/-Fly.io-7c3aed?logo=flydotio&logoColor=white&style=flat-square) Backend Hosting | Fly.io | Frankfurt region | +| ![Vercel](https://img.shields.io/badge/-Vercel-000000?logo=vercel&logoColor=white&style=flat-square) Frontend Hosting | Vercel | CDN edge deployment | +| ![GitHub](https://img.shields.io/badge/-GitHub-181717?logo=github&logoColor=white&style=flat-square) Source Control | GitHub | CI/CD via Vercel + Fly | +| ![PostgreSQL](https://img.shields.io/badge/-PostgreSQL-336791?logo=postgresql&logoColor=white&style=flat-square) Database | Fly.io Managed | Persistent cloud DB | + +--- + +## Internationalisation (i18n) + +The entire UI is available in two languages, switchable at runtime without page reload: + +| Language | Code | Flag | Coverage | +|----------|------|------|----------| +| English | `en` | 🇬🇧 | 100% — all pages, labels, errors, toasts | +| French | `fr` | 🇫🇷 | 100% — all pages, labels, errors, toasts | + +**Implementation:** +- `react-i18next` with JSON locale files (`frontend/src/i18n/locales/en.json`, `fr.json`) +- `LanguageContext` syncs both i18n systems on language switch +- Language preference persisted in `localStorage` + +--- + +## Design System + +The UI uses a **strict black & white / grayscale palette** — no colour anywhere in the interface. + +| Token | Value | Usage | +|-------|-------|-------| +| Primary | `#000000` | Buttons, headings, active states | +| Secondary | `#333333` | Sub-headings, icons | +| Muted | `#777777` | Placeholder text, borders | +| Surface | `#f5f5f5` | Card backgrounds (light mode) | +| Surface Dark | `#1e1e1e` | Card backgrounds (dark mode) | +| Background | `#ffffff` / `#111111` | Page background | + +- Dark mode via Tailwind `dark:` classes, toggled with `class` strategy +- Charts use grayscale hex: `#000000`, `#333333`, `#555555`, `#777777`, `#aaaaaa`, `#bbbbbb` +- No teal, blue, orange, green, or yellow used anywhere + +--- ## Database Schema -**9 Tables**: 7 core entities + 2 junction tables +**9 Tables** — 7 core entities + 2 junction tables + +``` +users ──────────────────────────────────────────────────────────────┐ + │ userId, name, email, role, province, emailVerified, twoFactor │ + │ │ + ├──< requests >────────────────────────────────────────────────── │ + │ requestId, title, description, category, status, citizenId │ + │ │ + ├──< assignments >─────────────────────────────────────────────── │ + │ assignmentId, requestId, volunteerId, acceptedAt, completedAt│ + │ │ + ├──< notifications >───────────────────────────────────────────── │ + │ notificationId, message, isRead, userId │ + │ │ + ├──< user_skills (M:N) >──────────────── skills ───────────────── │ + │ userId, skillId skillId, skillName │ + │ │ + ├──< user_settings >───────────────────────────────────────────── │ + │ userId, emailNotifications, smsNotifications │ + │ │ + └──< achievements >────────────────────────────────────────────── │ + achievementId, name, description, userId ──────┘ + +locations + locationId, province, district, provinceCode + (30 districts across 5 provinces) +``` -| Table | Description | -|-------|-------------| -| Locations | Rwandan administrative hierarchy (30 districts) | -| Users | Citizens, Volunteers, Admins with role-based access | -| Requests | Help requests with status tracking | -| Assignments | Volunteer task assignments | -| Notifications | User alerts system | -| Skills | Volunteer capabilities (46 skills) | -| User_Skills | Many-to-many junction | -| User_Settings | Notification preferences | -| Achievements | Gamification badges and rewards | +--- ## API Endpoints (160+ Total) -| Group | Endpoints | Notes | -|-------|-----------|-------| -| Locations | 11 | Province/district filtering, Rwanda API integration | -| Users | 25 | Role-based access, location hierarchy, skills | -| Requests | 16 | Status tracking, citizen stats endpoint | -| Assignments | 11 | Volunteer task management | -| Notifications | 15 | Real-time alerts, read/unread status, stats | -| Skills | 13 | Volunteer capabilities | -| Rwanda API | 5 | Live location data (Province to Village) | -| Settings | 4 | User preferences | -| Analytics | 7 | Dashboard stats, province breakdown, growth metrics | -| Admin | 10 | User moderation, request moderation, broadcast | -| Gamification | 3 | Points, levels, leaderboard | -| Availability | 3 | Volunteer scheduling and status | -| Ratings | 4 | Volunteer reviews and averages | -| Appointments | 4 | Citizen-volunteer scheduling | -| Expenses | 5 | Expense tracking and approvals | -| Categories | 1 | Request categories | +| Group | Count | Key Endpoints | +|-------|-------|---------------| +| Auth | 12 | login, register, verify-email, forgot-password, 2FA | +| Users | 25 | CRUD, role filter, skills, location hierarchy | +| Requests | 16 | create, status update, citizen stats | +| Assignments | 11 | accept, complete, volunteer dashboard | +| Notifications | 15 | list, mark-read, stats, broadcast | +| Skills | 13 | CRUD, popular skills | +| Locations | 11 | provinces, districts, Rwanda hierarchy | +| Rwanda API | 5 | Province → District → Sector → Cell → Village | +| Analytics | 7 | dashboard stats, province breakdown, growth | +| Admin | 10 | moderate requests, lock users, broadcast | +| Gamification | 3 | profile, leaderboard, add points | +| Availability | 3 | get, save, update status | +| Ratings | 4 | create, by volunteer, by assignment, average | +| Appointments | 4 | create, list, update, cancel | +| Expenses | 5 | submit, list, approve, reject | +| Settings | 4 | user preferences | +| Categories | 1 | list all categories | + +Full documentation: [Swagger UI](https://community-support-system.fly.dev/swagger-ui.html) +Postman collection: `Community_Support_System.postman_collection.json` + +--- ## Quick Setup ### Prerequisites -- Java 17 -- Maven 3.8+ -- PostgreSQL 14+ -- Node.js 20+ (for frontend) +| Tool | Version | +|------|---------| +| Java (JDK) | 17 | +| Maven | 3.8+ | +| PostgreSQL | 14+ | +| Node.js | 20+ | +| npm | 9+ | -### Backend +### 1 — Backend (Spring Boot) ```bash git clone https://github.com/manziosee/community-support-system.git cd community-support-system -# Create PostgreSQL database +# Create database psql -U postgres -c "CREATE DATABASE community_support_system_db;" -# Run with dev profile +# Run dev profile (test controllers active, H2/Postgres, debug logs) SPRING_PROFILES_ACTIVE=dev ./mvnw spring-boot:run ``` -Backend runs on `http://localhost:8080` +Backend: `http://localhost:8080` +Swagger: `http://localhost:8080/swagger-ui.html` -### Frontend +### 2 — Frontend (React) ```bash cd frontend @@ -96,80 +279,133 @@ npm install npm run dev ``` -Frontend runs on `http://localhost:3000` +Frontend: `http://localhost:3000` -### Production Deployment +### 3 — Full Stack with Docker Compose + +```bash +# Copy and edit environment variables +cp .env.example .env + +docker compose up --build +``` + +| Service | URL | +|---------|-----| +| Frontend | http://localhost:3000 | +| Backend | http://localhost:8080 | +| PostgreSQL | localhost:5432 | + +### 4 — Production Deploy (Fly.io) ```bash -# Deploy backend to Fly.io flyctl deploy ``` -## Environment Configuration +--- + +## Environment Variables -**Local (dev profile)** -- Database: `jdbc:postgresql://localhost:5432/community_support_system_db` -- Server: `http://localhost:8080` +| Variable | Required | Description | +|----------|----------|-------------| +| `DATABASE_URL` | Yes | PostgreSQL JDBC URL | +| `JWT_SECRET` | Yes | Secret key for JWT signing | +| `SENDGRID_API_KEY` | Email | SendGrid API key | +| `SENDGRID_FROM_EMAIL` | Email | Verified sender address | +| `SENDGRID_ENABLED` | No | `true` to enable email (default: `false`) | +| `CORS_ALLOWED_ORIGINS` | Yes | Comma-separated allowed origins | +| `FRONTEND_URL` | Yes | Frontend base URL for email links | +| `SPRING_PROFILES_ACTIVE` | Yes | `dev`, `fly`, or `docker` | -**Production (fly profile)** -- Database: Fly.io PostgreSQL (auto-configured) -- Server: `https://community-support-system.fly.dev` -- Email: SendGrid enabled +--- + +## Security + +- JWT tokens with configurable expiry (default 24h) +- Email verification required before login +- Optional Two-Factor Authentication (email OTP) +- Account lockout after failed attempts +- Role-based access: `@PreAuthorize("hasRole('ADMIN')")` on all admin endpoints +- CORS restricted to known origins (no wildcard in production) +- Global exception handler — no stack traces leaked to clients +- Test/debug controllers only active under `@Profile("dev")` + +--- ## Features -- Complete CRUD Operations for all entities -- Rwanda Location Integration - 5-level hierarchy (Province to Village) -- Email Verification via SendGrid -- JWT Authentication with role-based access (Citizens, Volunteers, Admins) -- Real-time Notifications for assignment updates -- Analytics Dashboard with real DB-backed statistics -- Gamification system - points, levels, achievements, leaderboard -- Volunteer availability scheduling -- Expense tracking and admin approvals -- Internationalisation - English and French -- API Documentation via Swagger UI -- Health Monitoring via Spring Actuator -- Postman collection with 160+ documented endpoints +| Feature | Details | +|---------|---------| +| CRUD Operations | All 9 entities with full create/read/update/delete | +| Rwanda Locations | 5-level hierarchy: Province → District → Sector → Cell → Village | +| Email Verification | SendGrid — token-based, expires in 24h | +| Two-Factor Auth | Email OTP with backup codes | +| Role-Based Access | Citizen, Volunteer, Admin with method-level enforcement | +| Notifications | Real-time assignment alerts, read/unread tracking | +| Analytics | DB-backed stats, growth metrics, province breakdown | +| Gamification | Points, levels, achievements, leaderboard | +| Availability | Volunteer schedule management | +| Ratings | Post-assignment volunteer reviews with averages | +| Appointments | Citizen-volunteer scheduling | +| Expenses | Claim submission and admin approval flow | +| Internationalisation | English and French, switchable at runtime | +| Dark Mode | System-aware + manual toggle, full Tailwind dark: coverage | +| Postman Collection | 160+ pre-built requests with examples | +| Swagger / OpenAPI | Auto-generated, browsable API docs | + +--- ## Admin Access -Default admin account (dev only): -- Email: `darkosee23@gmail.com` -- Password: `admin123` -- Role: `ADMIN` +Default account created on first boot (dev / local only): + +| Field | Value | +|-------|-------| +| Email | `darkosee23@gmail.com` | +| Password | `admin123` | +| Role | `ADMIN` | -## API Testing +> Change this password immediately in any non-local environment. + +--- + +## API Quick Test ```bash -# Health check -curl http://localhost:8080/health +BASE=http://localhost:8080 -# Get all locations -curl http://localhost:8080/api/locations +# Health +curl $BASE/health -# Get all skills -curl http://localhost:8080/api/skills +# All locations +curl $BASE/api/locations -# Get volunteers -curl http://localhost:8080/api/users/role/VOLUNTEER +# Login +curl -X POST $BASE/api/auth/login \ + -H "Content-Type: application/json" \ + -d '{"email":"darkosee23@gmail.com","password":"admin123"}' -# Analytics dashboard -curl http://localhost:8080/api/analytics/dashboard +# Analytics dashboard (requires Bearer token) +curl $BASE/api/analytics/dashboard \ + -H "Authorization: Bearer " # Gamification leaderboard -curl http://localhost:8080/api/gamification/leaderboard +curl $BASE/api/gamification/leaderboard # Volunteer availability -curl http://localhost:8080/api/availability/volunteer/1 +curl $BASE/api/availability/volunteer/1 ``` -Import `Community_Support_System.postman_collection.json` into Postman for the full collection. +--- ## Contributing 1. Fork the repository -2. Create a feature branch (`git checkout -b feature/my-feature`) -3. Commit your changes (`git commit -m 'feat: add my feature'`) -4. Push to the branch (`git push origin feature/my-feature`) -5. Open a Pull Request against `main` \ No newline at end of file +2. Create a feature branch — `git checkout -b feature/my-feature` +3. Commit using conventional commits — `git commit -m 'feat: add my feature'` +4. Push — `git push origin feature/my-feature` +5. Open a Pull Request against `main` + +--- + +*Built for Rwanda community assistance — connecting people who need help with people who can help.* \ No newline at end of file diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 617a890..f6f561c 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,5 +1,7 @@ import React, { Suspense, lazy } from 'react'; import { BrowserRouter as Router, Routes, Route, Navigate, useLocation } from 'react-router-dom'; +import ErrorBoundary from './components/common/ErrorBoundary'; +import NotFoundPage from './pages/NotFoundPage'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { Toaster } from 'react-hot-toast'; import { AuthProvider, useAuth } from './contexts/AuthContext'; @@ -178,6 +180,7 @@ const AnimatedRoutes: React.FC = () => { {/* Landing & misc */} } /> } /> + } /> @@ -188,6 +191,7 @@ const AnimatedRoutes: React.FC = () => { // ─── App ────────────────────────────────────────────────────────────────────── function App() { return ( + @@ -225,6 +229,7 @@ function App() { + ); } diff --git a/frontend/src/components/common/ErrorBoundary.tsx b/frontend/src/components/common/ErrorBoundary.tsx new file mode 100644 index 0000000..76bce7b --- /dev/null +++ b/frontend/src/components/common/ErrorBoundary.tsx @@ -0,0 +1,77 @@ +import React, { Component, ErrorInfo } from 'react'; + +interface Props { + children: React.ReactNode; + fallback?: React.ReactNode; +} + +interface State { + hasError: boolean; + error: Error | null; +} + +class ErrorBoundary extends Component { + constructor(props: Props) { + super(props); + this.state = { hasError: false, error: null }; + } + + static getDerivedStateFromError(error: Error): State { + return { hasError: true, error }; + } + + componentDidCatch(error: Error, info: ErrorInfo) { + console.error('ErrorBoundary caught:', error, info.componentStack); + } + + handleReset = () => { + this.setState({ hasError: false, error: null }); + }; + + render() { + if (this.state.hasError) { + if (this.props.fallback) return this.props.fallback; + + return ( +
+
+
+ + + +
+
+

Something went wrong

+

+ An unexpected error occurred. Please try refreshing the page. +

+ {this.state.error && ( +

+ {this.state.error.message} +

+ )} +
+
+ + +
+
+
+ ); + } + + return this.props.children; + } +} + +export default ErrorBoundary; \ No newline at end of file diff --git a/frontend/src/pages/NotFoundPage.tsx b/frontend/src/pages/NotFoundPage.tsx new file mode 100644 index 0000000..449320d --- /dev/null +++ b/frontend/src/pages/NotFoundPage.tsx @@ -0,0 +1,36 @@ +import React from 'react'; +import { Link, useNavigate } from 'react-router-dom'; + +const NotFoundPage: React.FC = () => { + const navigate = useNavigate(); + + return ( +
+
+
+

404

+

Page not found

+

+ The page you are looking for does not exist or has been moved. +

+
+
+ + + Go home + +
+
+
+ ); +}; + +export default NotFoundPage; \ No newline at end of file diff --git a/frontend/src/pages/admin/AdminAnalyticsPage.tsx b/frontend/src/pages/admin/AdminAnalyticsPage.tsx index 81a55c9..a6eaee5 100644 --- a/frontend/src/pages/admin/AdminAnalyticsPage.tsx +++ b/frontend/src/pages/admin/AdminAnalyticsPage.tsx @@ -7,7 +7,7 @@ import { import Card from '../../components/common/Card'; import Button from '../../components/common/Button'; import { exportToCSV } from '../../utils/exportUtils'; -import LoadingSpinner from '../../components/common/LoadingSpinner'; +import { StatCardSkeleton } from '../../components/common/SkeletonLoader'; import { api } from '../../services/api'; interface AnalyticsData { @@ -96,7 +96,16 @@ const AdminAnalyticsPage: React.FC = () => { } }; - if (isLoading) return ; + if (isLoading) return ( +
+
+ +
+
+ +
+
+ ); if (error) { return ( diff --git a/frontend/src/pages/admin/AdminRequestsPage.tsx b/frontend/src/pages/admin/AdminRequestsPage.tsx index 40544b3..b8a9d8c 100644 --- a/frontend/src/pages/admin/AdminRequestsPage.tsx +++ b/frontend/src/pages/admin/AdminRequestsPage.tsx @@ -6,7 +6,7 @@ import { RequestStatus } from '../../types'; import Card from '../../components/common/Card'; import Button from '../../components/common/Button'; import Badge from '../../components/common/Badge'; -import LoadingSpinner from '../../components/common/LoadingSpinner'; +import { TableSkeleton } from '../../components/common/SkeletonLoader'; import EmptyState from '../../components/common/EmptyState'; import { exportToCSV } from '../../utils/exportUtils'; import Modal from '../../components/common/Modal'; @@ -270,7 +270,7 @@ const AdminRequestsPage: React.FC = () => { }; if (isLoading) { - return ; + return ; } return ( diff --git a/frontend/src/pages/admin/AdminUsersPage.tsx b/frontend/src/pages/admin/AdminUsersPage.tsx index b353bf4..f2fefa4 100644 --- a/frontend/src/pages/admin/AdminUsersPage.tsx +++ b/frontend/src/pages/admin/AdminUsersPage.tsx @@ -7,7 +7,7 @@ import { UserRole } from '../../types'; import Card from '../../components/common/Card'; import Button from '../../components/common/Button'; import Badge from '../../components/common/Badge'; -import LoadingSpinner from '../../components/common/LoadingSpinner'; +import { TableSkeleton, StatCardSkeleton } from '../../components/common/SkeletonLoader'; import EmptyState from '../../components/common/EmptyState'; import Modal from '../../components/common/Modal'; import Input from '../../components/common/Input'; @@ -390,7 +390,17 @@ const AdminUsersPage: React.FC = () => { }; if (isLoading) { - return ; + return ( +
+
+ + + + +
+ +
+ ); } return ( diff --git a/src/main/java/om/community/supportsystem/CommunitySupportSystemApplication.java b/src/main/java/om/community/supportsystem/CommunitySupportSystemApplication.java index 2c05ef0..5e75348 100644 --- a/src/main/java/om/community/supportsystem/CommunitySupportSystemApplication.java +++ b/src/main/java/om/community/supportsystem/CommunitySupportSystemApplication.java @@ -2,8 +2,10 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.cache.annotation.EnableCaching; @SpringBootApplication +@EnableCaching public class CommunitySupportSystemApplication { public static void main(String[] args) { diff --git a/src/main/java/om/community/supportsystem/config/DataInitializer.java b/src/main/java/om/community/supportsystem/config/DataInitializer.java index 1a5d1f4..68459c2 100644 --- a/src/main/java/om/community/supportsystem/config/DataInitializer.java +++ b/src/main/java/om/community/supportsystem/config/DataInitializer.java @@ -8,9 +8,13 @@ import org.springframework.boot.CommandLineRunner; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Component; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; @Component public class DataInitializer implements CommandLineRunner { + private static final Logger log = LoggerFactory.getLogger(DataInitializer.class); + @Autowired private LocationService locationService; @@ -38,30 +42,30 @@ public void run(String... args) throws Exception { if (initializeData) { initializeBasicData(); } else { - System.out.println("🚫 Data initialization disabled by configuration"); + log.info("🚫 Data initialization disabled by configuration"); } } public void initializeBasicData() { // Force initialization regardless of existing data - System.out.println("🚀 Force initializing system data..."); + log.info("🚀 Force initializing system data..."); initializeLocations(); initializeSkills(); initializeAdminUser(); - System.out.println("✅ System data initialized successfully!"); - System.out.println("📊 Data loaded:"); - System.out.println(" - Locations: " + locationRepository.count()); - System.out.println(" - Skills: " + skillRepository.count()); - System.out.println(" - Admin user: darkosee23@gmail.com"); + log.info("✅ System data initialized successfully!"); + log.info("📊 Data loaded:"); + log.info(" - Locations: " + locationRepository.count()); + log.info(" - Skills: " + skillRepository.count()); + log.info(" - Admin user: darkosee23@gmail.com"); } private void initializeLocations() { - System.out.println("Creating Rwanda administrative locations..."); + log.info("Creating Rwanda administrative locations..."); // Check if locations already exist if (locationRepository.count() > 0) { - System.out.println("⚠️ Locations already exist - skipping creation"); + log.info("⚠️ Locations already exist - skipping creation"); return; } @@ -113,7 +117,7 @@ private void createLocationIfNotExists(String province, String district, String } private void initializeSkills() { - System.out.println("Creating comprehensive skill categories..."); + log.info("Creating comprehensive skill categories..."); // Force creation of all skills (will skip duplicates) createSkillIfNotExists("Programming", "Software development, coding, and web development"); @@ -181,31 +185,31 @@ private void initializeSkills() { createSkillIfNotExists("Shoe Repair", "Cobbler services, shoe restoration, and leather repair"); createSkillIfNotExists("Watch Repair", "Timepiece repair, battery replacement, and watch maintenance"); - System.out.println("✅ Skills initialization completed. Total skills: " + skillRepository.count()); + log.info("✅ Skills initialization completed. Total skills: " + skillRepository.count()); } private void createSkillIfNotExists(String name, String description) { if (!skillRepository.existsBySkillName(name)) { skillService.createSkill(new Skill(name, description)); - System.out.println("➕ Created skill: " + name); + log.info("➕ Created skill: " + name); } else { - System.out.println("⚠️ Skill already exists: " + name); + log.info("⚠️ Skill already exists: " + name); } } private void initializeAdminUser() { - System.out.println("Creating system administrator account..."); + log.info("Creating system administrator account..."); // Force creation of admin user if (userRepository.existsByEmail("darkosee23@gmail.com")) { - System.out.println("⚠️ Admin user already exists - updating if needed"); + log.info("⚠️ Admin user already exists - updating if needed"); // Update existing admin user User existingAdmin = userRepository.findByEmail("darkosee23@gmail.com").orElse(null); if (existingAdmin != null && existingAdmin.getRole() != UserRole.ADMIN) { existingAdmin.setRole(UserRole.ADMIN); existingAdmin.setName("admin"); userRepository.save(existingAdmin); - System.out.println("✅ Updated existing user to admin role"); + log.info("✅ Updated existing user to admin role"); } return; } @@ -230,9 +234,9 @@ private void initializeAdminUser() { admin.setVillage("Kabeza"); userRepository.save(admin); - System.out.println("✅ Admin user created successfully!"); - System.out.println(" - Email: darkosee23@gmail.com"); - System.out.println(" - Password: admin123"); - System.out.println(" - Role: ADMIN"); + log.info("✅ Admin user created successfully!"); + log.info(" - Email: darkosee23@gmail.com"); + log.info(" - Password: admin123"); + log.info(" - Role: ADMIN"); } } \ No newline at end of file diff --git a/src/main/java/om/community/supportsystem/config/DatabaseConfig.java b/src/main/java/om/community/supportsystem/config/DatabaseConfig.java index 5541f0d..2023f10 100644 --- a/src/main/java/om/community/supportsystem/config/DatabaseConfig.java +++ b/src/main/java/om/community/supportsystem/config/DatabaseConfig.java @@ -5,6 +5,8 @@ import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Primary; import org.springframework.context.annotation.Profile; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import javax.sql.DataSource; import java.net.URI; @@ -13,6 +15,8 @@ @Configuration @Profile("fly") public class DatabaseConfig { + private static final Logger log = LoggerFactory.getLogger(DatabaseConfig.class); + @Bean @Primary @@ -23,7 +27,7 @@ public DataSource dataSource() { throw new RuntimeException("DATABASE_URL environment variable is not set"); } - System.out.println("🔍 Original DATABASE_URL: " + databaseUrl); + log.info("🔍 Original DATABASE_URL: " + databaseUrl); try { // Handle both postgresql:// and postgres:// schemes @@ -53,12 +57,12 @@ public DataSource dataSource() { String username = credentials[0]; String password = credentials[1]; - System.out.println("✅ Parsed database connection:"); - System.out.println(" Host: " + host); - System.out.println(" Port: " + port); - System.out.println(" Database: " + database); - System.out.println(" Username: " + username); - System.out.println(" JDBC URL: " + jdbcUrl); + log.info("✅ Parsed database connection:"); + log.info(" Host: " + host); + log.info(" Port: " + port); + log.info(" Database: " + database); + log.info(" Username: " + username); + log.info(" JDBC URL: " + jdbcUrl); return DataSourceBuilder.create() .url(jdbcUrl) diff --git a/src/main/java/om/community/supportsystem/config/RateLimitFilter.java b/src/main/java/om/community/supportsystem/config/RateLimitFilter.java new file mode 100644 index 0000000..3ce681a --- /dev/null +++ b/src/main/java/om/community/supportsystem/config/RateLimitFilter.java @@ -0,0 +1,82 @@ +package om.community.supportsystem.config; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; +import java.time.Instant; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; + +/** + * Simple in-memory rate limiter for sensitive auth endpoints. + * Allows MAX_REQUESTS per IP per WINDOW_SECONDS before returning 429. + * Entries are evicted lazily once the window expires. + */ +@Component +public class RateLimitFilter extends OncePerRequestFilter { + + private static final Logger log = LoggerFactory.getLogger(RateLimitFilter.class); + + private static final int MAX_REQUESTS = 10; + private static final long WINDOW_SECONDS = 60; + + private static final Set RATE_LIMITED_PATHS = Set.of( + "/api/auth/login", + "/api/auth/register", + "/api/auth/forgot-password" + ); + + // key = ip + path, value = [count, windowStartEpochSeconds] + private final Map buckets = new ConcurrentHashMap<>(); + + @Override + protected boolean shouldNotFilter(HttpServletRequest request) { + String path = request.getRequestURI(); + return RATE_LIMITED_PATHS.stream().noneMatch(path::startsWith); + } + + @Override + protected void doFilterInternal(HttpServletRequest request, + HttpServletResponse response, + FilterChain chain) throws ServletException, IOException { + String ip = resolveClientIp(request); + String key = ip + "|" + request.getRequestURI(); + + long now = Instant.now().getEpochSecond(); + long[] bucket = buckets.compute(key, (k, existing) -> { + if (existing == null || now - existing[1] >= WINDOW_SECONDS) { + return new long[]{1, now}; + } + existing[0]++; + return existing; + }); + + long count = bucket[0]; + if (count > MAX_REQUESTS) { + log.warn("Rate limit exceeded for IP={} path={} count={}", ip, request.getRequestURI(), count); + response.setStatus(HttpStatus.TOO_MANY_REQUESTS.value()); + response.setContentType("application/json"); + response.getWriter().write("{\"error\":\"Too many requests. Please try again later.\",\"status\":429}"); + return; + } + + chain.doFilter(request, response); + } + + private String resolveClientIp(HttpServletRequest request) { + String forwarded = request.getHeader("X-Forwarded-For"); + if (forwarded != null && !forwarded.isBlank()) { + return forwarded.split(",")[0].trim(); + } + return request.getRemoteAddr(); + } +} \ No newline at end of file diff --git a/src/main/java/om/community/supportsystem/controller/AdminInitController.java b/src/main/java/om/community/supportsystem/controller/AdminInitController.java index 4c7c411..c17bdd6 100644 --- a/src/main/java/om/community/supportsystem/controller/AdminInitController.java +++ b/src/main/java/om/community/supportsystem/controller/AdminInitController.java @@ -7,6 +7,9 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; +import org.springframework.context.annotation.Profile; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.responses.ApiResponse; @@ -14,11 +17,14 @@ import java.util.Map; +@Profile("dev") @RestController @RequestMapping("/api/admin-init") @Tag(name = "🔧 Admin Initialization", description = "Manual data initialization for production environment") @CrossOrigin(origins = {"http://localhost:3001", "http://localhost:5173", "https://community-support-system.vercel.app"}, allowCredentials = "true") public class AdminInitController { + private static final Logger log = LoggerFactory.getLogger(AdminInitController.class); + @Autowired private DataInitializer dataInitializer; @@ -65,7 +71,7 @@ public ResponseEntity getDatabaseStatus() { @PostMapping("/initialize") public ResponseEntity initializeDatabase() { try { - System.out.println("🔧 Manual database initialization requested..."); + log.info("🔧 Manual database initialization requested..."); // Force run the data initializer dataInitializer.run(); @@ -89,7 +95,7 @@ public ResponseEntity initializeDatabase() { ) )); } catch (Exception e) { - e.printStackTrace(); + log.error("Unexpected error", e); return ResponseEntity.internalServerError().body(Map.of( "error", "Failed to initialize database: " + e.getMessage() )); diff --git a/src/main/java/om/community/supportsystem/controller/AnalyticsController.java b/src/main/java/om/community/supportsystem/controller/AnalyticsController.java index d88d1a8..d2dbdfb 100644 --- a/src/main/java/om/community/supportsystem/controller/AnalyticsController.java +++ b/src/main/java/om/community/supportsystem/controller/AnalyticsController.java @@ -7,6 +7,8 @@ import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import io.swagger.v3.oas.annotations.responses.ApiResponse; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import java.util.*; import java.util.stream.Collectors; @@ -16,6 +18,8 @@ @CrossOrigin(origins = {"http://localhost:3000", "http://localhost:3001", "http://localhost:3002", "http://localhost:3003", "https://community-support-system.vercel.app"}) @Tag(name = "📊 Analytics", description = "Dashboard statistics and business intelligence endpoints") public class AnalyticsController { + private static final Logger log = LoggerFactory.getLogger(AnalyticsController.class); + @Autowired private UserRepository userRepository; @@ -138,8 +142,8 @@ public ResponseEntity getCitizenStats(@PathVariable Long userId) { return ResponseEntity.ok(stats); } catch (Exception e) { - System.err.println("❌ Error getting citizen stats for userId " + userId + ": " + e.getMessage()); - e.printStackTrace(); + log.error(String.valueOf("❌ Error getting citizen stats for userId " + userId + ": " + e.getMessage())); + log.error("Unexpected error", e); return ResponseEntity.status(500).body(Map.of( "error", "Failed to fetch citizen statistics", "message", e.getMessage() != null ? e.getMessage() : "Unknown error" @@ -181,8 +185,8 @@ public ResponseEntity getVolunteerStats(@PathVariable Long userId) { return ResponseEntity.ok(stats); } catch (Exception e) { - System.err.println("❌ Error getting volunteer stats for userId " + userId + ": " + e.getMessage()); - e.printStackTrace(); + log.error(String.valueOf("❌ Error getting volunteer stats for userId " + userId + ": " + e.getMessage())); + log.error("Unexpected error", e); return ResponseEntity.status(500).body(Map.of( "error", "Failed to fetch volunteer statistics", "message", e.getMessage() != null ? e.getMessage() : "Unknown error" diff --git a/src/main/java/om/community/supportsystem/controller/AssignmentController.java b/src/main/java/om/community/supportsystem/controller/AssignmentController.java index 124bdea..a4ccf1d 100644 --- a/src/main/java/om/community/supportsystem/controller/AssignmentController.java +++ b/src/main/java/om/community/supportsystem/controller/AssignmentController.java @@ -10,6 +10,8 @@ import org.springframework.data.domain.Sort; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; @@ -25,6 +27,8 @@ @Tag(name = "🤝 Assignments", description = "Assignment management APIs - volunteer task assignments, completion tracking, and statistics") @CrossOrigin(origins = {"http://localhost:3001", "http://localhost:5173", "https://community-support-system.vercel.app"}, allowCredentials = "true") public class AssignmentController { + private static final Logger log = LoggerFactory.getLogger(AssignmentController.class); + @Autowired private AssignmentService assignmentService; @@ -38,17 +42,17 @@ public class AssignmentController { @PostMapping public ResponseEntity createAssignment(@RequestBody Assignment assignment) { try { - System.out.println("🔄 Creating assignment for request: " + + log.info("🔄 Creating assignment for request: " + (assignment.getRequest() != null ? assignment.getRequest().getRequestId() : "null") + ", volunteer: " + (assignment.getVolunteer() != null ? assignment.getVolunteer().getUserId() : "null")); Assignment createdAssignment = assignmentService.createAssignment(assignment); - System.out.println("✅ Assignment created successfully with ID: " + createdAssignment.getAssignmentId()); + log.info("✅ Assignment created successfully with ID: " + createdAssignment.getAssignmentId()); return ResponseEntity.ok(createdAssignment); } catch (RuntimeException e) { - System.err.println("❌ Failed to create assignment: " + e.getMessage()); - e.printStackTrace(); + log.error(String.valueOf("❌ Failed to create assignment: " + e.getMessage())); + log.error("Unexpected error", e); return ResponseEntity.badRequest().body(java.util.Map.of("error", e.getMessage())); } } @@ -74,7 +78,7 @@ public ResponseEntity> getAllAssignments() { public ResponseEntity getAssignmentById( @Parameter(description = "Assignment ID", required = true) @PathVariable Long id) { try { - System.out.println("🔍 Fetching assignment by ID: " + id); + log.info("🔍 Fetching assignment by ID: " + id); Optional assignmentOpt = assignmentService.getAssignmentById(id); if (assignmentOpt.isEmpty()) { @@ -139,11 +143,11 @@ public ResponseEntity getAssignmentById( map.put("volunteer", volunteerMap); } - System.out.println("✅ Assignment " + id + " retrieved successfully"); + log.info("✅ Assignment " + id + " retrieved successfully"); return ResponseEntity.ok(map); } catch (Exception e) { - System.err.println("❌ Failed to fetch assignment " + id + ": " + e.getMessage()); - e.printStackTrace(); + log.error(String.valueOf("❌ Failed to fetch assignment " + id + ": " + e.getMessage())); + log.error("Unexpected error", e); return ResponseEntity.internalServerError().body(java.util.Map.of("error", e.getMessage())); } } @@ -157,9 +161,9 @@ public ResponseEntity getAssignmentById( public ResponseEntity getAssignmentsByVolunteerId( @Parameter(description = "Volunteer user ID", required = true) @PathVariable Long volunteerId) { try { - System.out.println("🔍 Fetching assignments for volunteer ID: " + volunteerId); + log.info("🔍 Fetching assignments for volunteer ID: " + volunteerId); List assignments = assignmentService.getAssignmentsByVolunteerId(volunteerId); - System.out.println("✅ Found " + assignments.size() + " assignments for volunteer " + volunteerId); + log.info("✅ Found " + assignments.size() + " assignments for volunteer " + volunteerId); // Create simplified response to avoid serialization issues List> simpleAssignments = assignments.stream() @@ -225,8 +229,8 @@ public ResponseEntity getAssignmentsByVolunteerId( return ResponseEntity.ok(simpleAssignments); } catch (Exception e) { - System.err.println("❌ Failed to fetch assignments for volunteer " + volunteerId + ": " + e.getMessage()); - e.printStackTrace(); + log.error(String.valueOf("❌ Failed to fetch assignments for volunteer " + volunteerId + ": " + e.getMessage())); + log.error("Unexpected error", e); return ResponseEntity.internalServerError().body(java.util.Map.of("error", e.getMessage())); } } diff --git a/src/main/java/om/community/supportsystem/controller/AuthController.java b/src/main/java/om/community/supportsystem/controller/AuthController.java index 964b006..b01ecd6 100644 --- a/src/main/java/om/community/supportsystem/controller/AuthController.java +++ b/src/main/java/om/community/supportsystem/controller/AuthController.java @@ -7,6 +7,8 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; @@ -31,6 +33,8 @@ }, allowCredentials = "true") @Tag(name = "🔐 Authentication", description = "User authentication, registration, password reset, and 2FA management") public class AuthController { + private static final Logger log = LoggerFactory.getLogger(AuthController.class); + @Autowired private AuthService authService; @@ -71,13 +75,13 @@ public ResponseEntity login(@Valid @RequestBody LoginRequest request) { public ResponseEntity forgotPassword(@RequestBody Map request) { try { String email = request.get("email"); - System.out.println("🔄 Password reset request received for: " + email); + log.info("🔄 Password reset request received for: " + email); authService.requestPasswordReset(email); - System.out.println("✅ Password reset process completed for: " + email); + log.info("✅ Password reset process completed for: " + email); return ResponseEntity.ok(Map.of("message", "Password reset email sent")); } catch (Exception e) { - System.err.println("❌ Password reset failed: " + e.getMessage()); - e.printStackTrace(); + log.error(String.valueOf("❌ Password reset failed: " + e.getMessage())); + log.error("Unexpected error", e); // Don't reveal if email exists for security, but log the actual error return ResponseEntity.ok(Map.of("message", "If the email exists, a reset link has been sent")); } diff --git a/src/main/java/om/community/supportsystem/controller/AuthTestController.java b/src/main/java/om/community/supportsystem/controller/AuthTestController.java index e865af2..87b37aa 100644 --- a/src/main/java/om/community/supportsystem/controller/AuthTestController.java +++ b/src/main/java/om/community/supportsystem/controller/AuthTestController.java @@ -6,10 +6,12 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; +import org.springframework.context.annotation.Profile; import java.util.Map; import java.util.Optional; +@Profile("dev") @RestController @RequestMapping("/api/test/auth") public class AuthTestController { diff --git a/src/main/java/om/community/supportsystem/controller/CategoryController.java b/src/main/java/om/community/supportsystem/controller/CategoryController.java index 6ccfa2b..e93c420 100644 --- a/src/main/java/om/community/supportsystem/controller/CategoryController.java +++ b/src/main/java/om/community/supportsystem/controller/CategoryController.java @@ -3,6 +3,8 @@ import om.community.supportsystem.model.RequestCategory; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.responses.ApiResponse; @@ -17,6 +19,8 @@ @RequestMapping("/api/categories") @Tag(name = "📂 Request Categories", description = "APIs for managing request categories - 8 predefined categories for help requests") public class CategoryController { + private static final Logger log = LoggerFactory.getLogger(CategoryController.class); + @Operation( summary = "Get all request categories", @@ -38,8 +42,8 @@ public ResponseEntity> getAllCategories() { .collect(Collectors.toList()); return ResponseEntity.ok(categories); } catch (Exception e) { - System.err.println("Error in getAllCategories: " + e.getMessage()); - e.printStackTrace(); + log.error(String.valueOf("Error in getAllCategories: " + e.getMessage())); + log.error("Unexpected error", e); // Return simple string array as fallback List fallbackCategories = Arrays.stream(RequestCategory.values()) .map(category -> category.name().replace("_", " ")) diff --git a/src/main/java/om/community/supportsystem/controller/EnvironmentTestController.java b/src/main/java/om/community/supportsystem/controller/EnvironmentTestController.java index c6a1e38..6c5b74a 100644 --- a/src/main/java/om/community/supportsystem/controller/EnvironmentTestController.java +++ b/src/main/java/om/community/supportsystem/controller/EnvironmentTestController.java @@ -5,9 +5,11 @@ import org.springframework.beans.factory.annotation.Value; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; +import org.springframework.context.annotation.Profile; import java.util.Map; +@Profile("dev") @RestController @RequestMapping("/api/test/environment") public class EnvironmentTestController { diff --git a/src/main/java/om/community/supportsystem/controller/ManualInitController.java b/src/main/java/om/community/supportsystem/controller/ManualInitController.java index bf83a0e..4ca19da 100644 --- a/src/main/java/om/community/supportsystem/controller/ManualInitController.java +++ b/src/main/java/om/community/supportsystem/controller/ManualInitController.java @@ -4,7 +4,9 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; +import org.springframework.context.annotation.Profile; +@Profile("dev") @RestController @RequestMapping("/api/manual-init") @CrossOrigin(origins = {"http://localhost:3001", "http://localhost:5173", "https://community-support-system.vercel.app"}, allowCredentials = "true") diff --git a/src/main/java/om/community/supportsystem/controller/RequestController.java b/src/main/java/om/community/supportsystem/controller/RequestController.java index 3324539..67f8e6b 100644 --- a/src/main/java/om/community/supportsystem/controller/RequestController.java +++ b/src/main/java/om/community/supportsystem/controller/RequestController.java @@ -11,6 +11,8 @@ import org.springframework.data.domain.Sort; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; @@ -18,6 +20,7 @@ import io.swagger.v3.oas.annotations.responses.ApiResponses; import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; import java.util.List; import java.util.stream.Collectors; @@ -26,6 +29,8 @@ @Tag(name = "📝 Requests", description = "Request management APIs - CRUD operations, status updates, and location-based filtering") @CrossOrigin(origins = {"http://localhost:3001", "http://localhost:5173", "https://community-support-system.vercel.app"}, allowCredentials = "true") public class RequestController { + private static final Logger log = LoggerFactory.getLogger(RequestController.class); + @Autowired private RequestService requestService; @@ -37,7 +42,7 @@ public class RequestController { @ApiResponse(responseCode = "400", description = "Invalid request data") }) @PostMapping - public ResponseEntity createRequest(@RequestBody Request request) { + public ResponseEntity createRequest(@Valid @RequestBody Request request) { Request createdRequest = requestService.createRequest(request); return ResponseEntity.ok(createdRequest); } @@ -54,8 +59,8 @@ public ResponseEntity getAllRequests() { .collect(Collectors.toList()); return ResponseEntity.ok(requestDTOs); } catch (Exception e) { - System.err.println("Error fetching all requests: " + e.getMessage()); - e.printStackTrace(); + log.error(String.valueOf("Error fetching all requests: " + e.getMessage())); + log.error("Unexpected error", e); return ResponseEntity.internalServerError().body( java.util.Map.of("error", "Failed to fetch requests: " + e.getMessage()) ); @@ -93,8 +98,8 @@ public ResponseEntity getRequestsByCitizenId( List requests = requestService.getRequestsByCitizenId(citizenId); return ResponseEntity.ok(requests); } catch (Exception e) { - System.err.println("Error fetching requests for citizen " + citizenId + ": " + e.getMessage()); - e.printStackTrace(); + log.error(String.valueOf("Error fetching requests for citizen " + citizenId + ": " + e.getMessage())); + log.error("Unexpected error", e); return ResponseEntity.internalServerError().body( java.util.Map.of("error", "Failed to fetch requests: " + e.getMessage()) ); @@ -109,8 +114,8 @@ public ResponseEntity getPendingRequests() { List requests = requestService.getPendingRequests(); return ResponseEntity.ok(requests); } catch (Exception e) { - System.err.println("Error fetching pending requests: " + e.getMessage()); - e.printStackTrace(); + log.error(String.valueOf("Error fetching pending requests: " + e.getMessage())); + log.error("Unexpected error", e); return ResponseEntity.internalServerError().body( java.util.Map.of("error", "Failed to fetch pending requests: " + e.getMessage()) ); @@ -123,8 +128,8 @@ public ResponseEntity getRequestsByProvince(@PathVariable String province) { List requests = requestService.getRequestsByProvince(province); return ResponseEntity.ok(requests); } catch (Exception e) { - System.err.println("Error fetching requests for province " + province + ": " + e.getMessage()); - e.printStackTrace(); + log.error(String.valueOf("Error fetching requests for province " + province + ": " + e.getMessage())); + log.error("Unexpected error", e); return ResponseEntity.internalServerError().body( java.util.Map.of("error", "Failed to fetch requests for province: " + e.getMessage()) ); @@ -144,8 +149,8 @@ public ResponseEntity getPendingRequestsByProvince( List requests = requestService.getPendingRequestsByProvince(province); return ResponseEntity.ok(requests); } catch (Exception e) { - System.err.println("Error fetching pending requests for province " + province + ": " + e.getMessage()); - e.printStackTrace(); + log.error(String.valueOf("Error fetching pending requests for province " + province + ": " + e.getMessage())); + log.error("Unexpected error", e); return ResponseEntity.internalServerError().body( java.util.Map.of("error", "Failed to fetch pending requests for province: " + e.getMessage()) ); @@ -199,7 +204,7 @@ public ResponseEntity> searchRequestsByTitle(@PathVariable String // Update @PutMapping("/{id}") - public ResponseEntity updateRequest(@PathVariable Long id, @RequestBody Request requestDetails) { + public ResponseEntity updateRequest(@PathVariable Long id, @Valid @RequestBody Request requestDetails) { try { Request updatedRequest = requestService.updateRequest(id, requestDetails); return ResponseEntity.ok(updatedRequest); diff --git a/src/main/java/om/community/supportsystem/controller/SendGridTestController.java b/src/main/java/om/community/supportsystem/controller/SendGridTestController.java index eacefd2..1027474 100644 --- a/src/main/java/om/community/supportsystem/controller/SendGridTestController.java +++ b/src/main/java/om/community/supportsystem/controller/SendGridTestController.java @@ -5,9 +5,11 @@ import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; +import org.springframework.context.annotation.Profile; import java.util.Map; +@Profile("dev") @RestController @RequestMapping("/api/test") @ConditionalOnProperty(name = "sendgrid.enabled", havingValue = "true") diff --git a/src/main/java/om/community/supportsystem/controller/SystemSettingsController.java b/src/main/java/om/community/supportsystem/controller/SystemSettingsController.java index cae6cca..e25ebec 100644 --- a/src/main/java/om/community/supportsystem/controller/SystemSettingsController.java +++ b/src/main/java/om/community/supportsystem/controller/SystemSettingsController.java @@ -4,12 +4,14 @@ import om.community.supportsystem.service.SystemSettingsService; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.web.bind.annotation.*; import java.util.List; import java.util.Map; @RestController @RequestMapping("/api/system/settings") +@PreAuthorize("hasRole('ADMIN')") @CrossOrigin(origins = {"http://localhost:3000", "http://localhost:3001", "http://localhost:3002", "http://localhost:3003", "https://community-support-system.vercel.app"}) public class SystemSettingsController { diff --git a/src/main/java/om/community/supportsystem/controller/TestAssignmentController.java b/src/main/java/om/community/supportsystem/controller/TestAssignmentController.java index 48e2052..6a8c7a5 100644 --- a/src/main/java/om/community/supportsystem/controller/TestAssignmentController.java +++ b/src/main/java/om/community/supportsystem/controller/TestAssignmentController.java @@ -5,6 +5,9 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; +import org.springframework.context.annotation.Profile; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.responses.ApiResponse; @@ -13,11 +16,14 @@ import java.util.List; import java.util.Map; +@Profile("dev") @RestController @RequestMapping("/api/test-assignments") @Tag(name = "🧪 Test Assignments", description = "Testing endpoints for debugging assignment functionality") @CrossOrigin(origins = {"http://localhost:3001", "http://localhost:5173", "https://community-support-system.vercel.app"}, allowCredentials = "true") public class TestAssignmentController { + private static final Logger log = LoggerFactory.getLogger(TestAssignmentController.class); + @Autowired private AssignmentRepository assignmentRepository; @@ -45,7 +51,7 @@ public ResponseEntity getAssignmentCount() { @GetMapping("/volunteer/{volunteerId}/simple") public ResponseEntity getSimpleAssignmentsByVolunteer(@PathVariable Long volunteerId) { try { - System.out.println("🔍 Testing simple assignment retrieval for volunteer: " + volunteerId); + log.info("🔍 Testing simple assignment retrieval for volunteer: " + volunteerId); List assignments = assignmentRepository.findByVolunteerUserId(volunteerId); @@ -66,7 +72,7 @@ public ResponseEntity getSimpleAssignmentsByVolunteer(@PathVariable Long volu "volunteerId", volunteerId )); } catch (Exception e) { - e.printStackTrace(); + log.error("Unexpected error", e); return ResponseEntity.internalServerError().body(Map.of( "error", "Failed to get simple assignments: " + e.getMessage(), "volunteerId", volunteerId @@ -96,7 +102,7 @@ public ResponseEntity getAllAssignments() { "count", simpleAssignments.size() )); } catch (Exception e) { - e.printStackTrace(); + log.error("Unexpected error", e); return ResponseEntity.internalServerError().body(Map.of( "error", "Failed to get all assignments: " + e.getMessage() )); diff --git a/src/main/java/om/community/supportsystem/controller/TestController.java b/src/main/java/om/community/supportsystem/controller/TestController.java index c144920..41e0ad2 100644 --- a/src/main/java/om/community/supportsystem/controller/TestController.java +++ b/src/main/java/om/community/supportsystem/controller/TestController.java @@ -6,11 +6,13 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; +import org.springframework.context.annotation.Profile; import java.util.Map; import java.util.Optional; import java.util.UUID; +@Profile("dev") @RestController @RequestMapping("/api/test") @CrossOrigin(origins = { diff --git a/src/main/java/om/community/supportsystem/controller/TestEmailController.java b/src/main/java/om/community/supportsystem/controller/TestEmailController.java index e76c713..8e120be 100644 --- a/src/main/java/om/community/supportsystem/controller/TestEmailController.java +++ b/src/main/java/om/community/supportsystem/controller/TestEmailController.java @@ -4,6 +4,9 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; +import org.springframework.context.annotation.Profile; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.responses.ApiResponse; @@ -11,11 +14,14 @@ import java.util.Map; +@Profile("dev") @RestController @RequestMapping("/api/test-email") @Tag(name = "📧 Test Email", description = "Testing endpoints for debugging email functionality") @CrossOrigin(origins = {"http://localhost:3001", "http://localhost:5173", "https://community-support-system.vercel.app"}, allowCredentials = "true") public class TestEmailController { + private static final Logger log = LoggerFactory.getLogger(TestEmailController.class); + @Autowired private EmailService emailService; @@ -28,7 +34,7 @@ public ResponseEntity testPasswordResetEmail(@RequestBody Map String email = request.get("email"); String testToken = "test-token-123"; - System.out.println("🧪 Testing password reset email to: " + email); + log.info("🧪 Testing password reset email to: " + email); emailService.sendPasswordResetEmail(email, testToken); return ResponseEntity.ok(Map.of( @@ -37,8 +43,8 @@ public ResponseEntity testPasswordResetEmail(@RequestBody Map "status", "success" )); } catch (Exception e) { - System.err.println("❌ Test email failed: " + e.getMessage()); - e.printStackTrace(); + log.error(String.valueOf("❌ Test email failed: " + e.getMessage())); + log.error("Unexpected error", e); return ResponseEntity.internalServerError().body(Map.of( "error", "Failed to send test email: " + e.getMessage(), "status", "failed" @@ -54,7 +60,7 @@ public ResponseEntity testVerificationEmail(@RequestBody Map String email = request.get("email"); String testToken = "test-verification-token-123"; - System.out.println("🧪 Testing verification email to: " + email); + log.info("🧪 Testing verification email to: " + email); emailService.sendEmailVerification(email, testToken); return ResponseEntity.ok(Map.of( @@ -63,8 +69,8 @@ public ResponseEntity testVerificationEmail(@RequestBody Map "status", "success" )); } catch (Exception e) { - System.err.println("❌ Test verification email failed: " + e.getMessage()); - e.printStackTrace(); + log.error(String.valueOf("❌ Test verification email failed: " + e.getMessage())); + log.error("Unexpected error", e); return ResponseEntity.internalServerError().body(Map.of( "error", "Failed to send test verification email: " + e.getMessage(), "status", "failed" diff --git a/src/main/java/om/community/supportsystem/controller/TestPasswordResetController.java b/src/main/java/om/community/supportsystem/controller/TestPasswordResetController.java index b2a1ae0..43630e6 100644 --- a/src/main/java/om/community/supportsystem/controller/TestPasswordResetController.java +++ b/src/main/java/om/community/supportsystem/controller/TestPasswordResetController.java @@ -5,6 +5,7 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; +import org.springframework.context.annotation.Profile; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.responses.ApiResponse; @@ -13,6 +14,7 @@ import java.util.Map; import java.util.Optional; +@Profile("dev") @RestController @RequestMapping("/api/test-password-reset") @Tag(name = "🔧 Test Password Reset", description = "Testing endpoints for password reset when email is unavailable") diff --git a/src/main/java/om/community/supportsystem/controller/TestRequestController.java b/src/main/java/om/community/supportsystem/controller/TestRequestController.java index c8ccd95..a648da3 100644 --- a/src/main/java/om/community/supportsystem/controller/TestRequestController.java +++ b/src/main/java/om/community/supportsystem/controller/TestRequestController.java @@ -6,6 +6,9 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; +import org.springframework.context.annotation.Profile; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.responses.ApiResponse; @@ -14,11 +17,14 @@ import java.util.List; import java.util.Map; +@Profile("dev") @RestController @RequestMapping("/api/test-requests") @Tag(name = "🧪 Test Requests", description = "Testing endpoints for debugging request functionality") @CrossOrigin(origins = {"http://localhost:3001", "http://localhost:5173", "https://community-support-system.vercel.app"}, allowCredentials = "true") public class TestRequestController { + private static final Logger log = LoggerFactory.getLogger(TestRequestController.class); + @Autowired private RequestRepository requestRepository; @@ -69,7 +75,7 @@ public ResponseEntity getSimpleRequests() { "count", simpleRequests.size() )); } catch (Exception e) { - e.printStackTrace(); + log.error("Unexpected error", e); return ResponseEntity.internalServerError().body(Map.of( "error", "Failed to get requests: " + e.getMessage() )); diff --git a/src/main/java/om/community/supportsystem/model/Request.java b/src/main/java/om/community/supportsystem/model/Request.java index 1760956..632dc8d 100644 --- a/src/main/java/om/community/supportsystem/model/Request.java +++ b/src/main/java/om/community/supportsystem/model/Request.java @@ -3,6 +3,8 @@ import com.fasterxml.jackson.annotation.JsonIgnore; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import jakarta.persistence.*; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; import java.time.LocalDateTime; import java.util.List; @@ -13,9 +15,13 @@ public class Request { @GeneratedValue(strategy = GenerationType.IDENTITY) private Long requestId; + @NotBlank(message = "Title is required") + @Size(min = 5, max = 200, message = "Title must be between 5 and 200 characters") @Column(nullable = false) private String title; - + + @NotBlank(message = "Description is required") + @Size(min = 10, max = 1000, message = "Description must be between 10 and 1000 characters") @Column(nullable = false, length = 1000) private String description; diff --git a/src/main/java/om/community/supportsystem/service/AnalyticsService.java b/src/main/java/om/community/supportsystem/service/AnalyticsService.java index eb7b214..1f85d15 100644 --- a/src/main/java/om/community/supportsystem/service/AnalyticsService.java +++ b/src/main/java/om/community/supportsystem/service/AnalyticsService.java @@ -4,6 +4,7 @@ import om.community.supportsystem.model.UserRole; import om.community.supportsystem.repository.*; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.cache.annotation.Cacheable; import org.springframework.stereotype.Service; import java.time.LocalDateTime; @@ -28,6 +29,7 @@ public class AnalyticsService { @Autowired private NotificationRepository notificationRepository; + @Cacheable(value = "analytics", key = "'dashboard'") public Map getAnalyticsDashboard() { Map analytics = new HashMap<>(); diff --git a/src/main/java/om/community/supportsystem/service/AssignmentService.java b/src/main/java/om/community/supportsystem/service/AssignmentService.java index 71249cd..c1be384 100644 --- a/src/main/java/om/community/supportsystem/service/AssignmentService.java +++ b/src/main/java/om/community/supportsystem/service/AssignmentService.java @@ -11,6 +11,8 @@ import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Service; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import java.time.LocalDateTime; import java.util.List; @@ -18,6 +20,8 @@ @Service public class AssignmentService { + private static final Logger log = LoggerFactory.getLogger(AssignmentService.class); + @Autowired private AssignmentRepository assignmentRepository; @@ -30,7 +34,7 @@ public class AssignmentService { // Create public Assignment createAssignment(Assignment assignment) { - System.out.println("🔄 Creating assignment..."); + log.info("🔄 Creating assignment..."); // Validate input if (assignment.getRequest() == null || assignment.getRequest().getRequestId() == null) { @@ -67,9 +71,9 @@ public Assignment createAssignment(Assignment assignment) { request.setStatus(RequestStatus.ACCEPTED); requestRepository.save(request); - System.out.println("✅ Assignment validation passed, saving..."); + log.info("✅ Assignment validation passed, saving..."); Assignment savedAssignment = assignmentRepository.save(assignment); - System.out.println("✅ Assignment saved with ID: " + savedAssignment.getAssignmentId()); + log.info("✅ Assignment saved with ID: " + savedAssignment.getAssignmentId()); return savedAssignment; } diff --git a/src/main/java/om/community/supportsystem/service/AuthService.java b/src/main/java/om/community/supportsystem/service/AuthService.java index 4022b61..8529853 100644 --- a/src/main/java/om/community/supportsystem/service/AuthService.java +++ b/src/main/java/om/community/supportsystem/service/AuthService.java @@ -10,6 +10,8 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import java.time.LocalDateTime; import java.util.Optional; @@ -18,6 +20,8 @@ @Service public class AuthService { + private static final Logger log = LoggerFactory.getLogger(AuthService.class); + @Autowired private UserRepository userRepository; @@ -80,12 +84,12 @@ public AuthResponse register(RegisterRequest request) { // Send verification email - this is critical, don't continue if it fails try { - System.out.println("🔄 Sending verification email to: " + user.getEmail()); + log.info("🔄 Sending verification email to: " + user.getEmail()); emailService.sendEmailVerification(user.getEmail(), user.getEmailVerificationToken()); - System.out.println("✅ Verification email sent successfully"); + log.info("✅ Verification email sent successfully"); } catch (Exception e) { - System.err.println("❌ Failed to send verification email: " + e.getMessage()); - e.printStackTrace(); + log.error(String.valueOf("❌ Failed to send verification email: " + e.getMessage())); + log.error("Unexpected error", e); // Don't fail registration, but log the issue } @@ -130,12 +134,12 @@ public AuthResponse login(LoginRequest request) { userRepository.save(user); try { - System.out.println("🔄 Sending login OTP to: " + user.getEmail()); + log.info("🔄 Sending login OTP to: " + user.getEmail()); emailService.sendLoginOTP(user.getEmail(), code); - System.out.println("✅ Login OTP sent successfully to: " + user.getEmail()); + log.info("✅ Login OTP sent successfully to: " + user.getEmail()); } catch (Exception e) { - System.err.println("❌ Failed to send login OTP: " + e.getMessage()); - e.printStackTrace(); + log.error(String.valueOf("❌ Failed to send login OTP: " + e.getMessage())); + log.error("Unexpected error", e); throw new RuntimeException("Failed to send verification code. Please try again."); } @@ -159,7 +163,7 @@ public AuthResponse login(LoginRequest request) { if (!user.isEmailVerified()) { user.setEmailVerified(true); user.setEmailVerificationToken(null); - System.out.println("✅ Email automatically verified for user: " + user.getEmail()); + log.info("✅ Email automatically verified for user: " + user.getEmail()); } user.setTwoFactorSecret(null); // Clear OTP code @@ -175,21 +179,21 @@ public AuthResponse login(LoginRequest request) { } public void requestPasswordReset(String email) { - System.out.println("🔄 Password reset requested for email: " + email); + log.info("🔄 Password reset requested for email: " + email); // Check if user exists in database Optional userOpt = userRepository.findByEmail(email); if (userOpt.isEmpty()) { - System.out.println("⚠️ Email not found in database: " + email); + log.info("⚠️ Email not found in database: " + email); throw new RuntimeException("Email address not found. Please check your email or register first."); } User user = userOpt.get(); - System.out.println("✅ User found: " + user.getUserId() + " - " + user.getName()); + log.info("✅ User found: " + user.getUserId() + " - " + user.getName()); // Check if account is locked if (user.isAccountLocked()) { - System.out.println("⚠️ Account is locked for user: " + email); + log.info("⚠️ Account is locked for user: " + email); throw new RuntimeException("Account is locked. Please contact support."); } @@ -197,20 +201,20 @@ public void requestPasswordReset(String email) { user.setPasswordResetToken(resetToken); user.setPasswordResetTokenExpiry(LocalDateTime.now().plusHours(1)); - System.out.println("💾 Saving reset token for user: " + user.getUserId()); - System.out.println("🔑 Reset token: " + resetToken); + log.info("💾 Saving reset token for user: " + user.getUserId()); + log.info("🔑 Reset token: " + resetToken); userRepository.save(user); - System.out.println("✅ Reset token saved successfully"); + log.info("✅ Reset token saved successfully"); try { - System.out.println("📧 Attempting to send password reset email to: " + email); - System.out.println("📧 EmailService instance: " + (emailService != null ? "Available" : "NULL")); + log.info("📧 Attempting to send password reset email to: " + email); + log.info("📧 EmailService instance: " + (emailService != null ? "Available" : "NULL")); emailService.sendPasswordResetEmail(email, resetToken); - System.out.println("✅ Password reset email sent successfully"); + log.info("✅ Password reset email sent successfully"); } catch (Exception e) { - System.err.println("❌ Failed to send password reset email: " + e.getMessage()); - e.printStackTrace(); - System.err.println("🔗 Reset URL: https://community-support-system.vercel.app/reset-password?token=" + resetToken); + log.error(String.valueOf("❌ Failed to send password reset email: " + e.getMessage())); + log.error("Unexpected error", e); + log.error(String.valueOf("🔗 Reset URL: https://community-support-system.vercel.app/reset-password?token=" + resetToken)); // Token is saved in DB, so even if email fails, manual reset is possible via logs throw new RuntimeException("Failed to send password reset email. Please try again later."); } @@ -264,7 +268,7 @@ public String[] enableTwoFactor(Long userId) { userRepository.save(user); - System.out.println("✅ 2FA enabled for user: " + user.getEmail()); + log.info("✅ 2FA enabled for user: " + user.getEmail()); return backupCodes; } @@ -309,9 +313,9 @@ public void resendEmailVerification(String email) { // Send verification email try { emailService.sendEmailVerification(email, newToken); - System.out.println("✅ Verification email resent to: " + email); + log.info("✅ Verification email resent to: " + email); } catch (Exception e) { - System.err.println("❌ Failed to resend verification email: " + e.getMessage()); + log.error(String.valueOf("❌ Failed to resend verification email: " + e.getMessage())); throw new RuntimeException("Failed to send verification email. Please try again later."); } } diff --git a/src/main/java/om/community/supportsystem/service/EmailService.java b/src/main/java/om/community/supportsystem/service/EmailService.java index 281eff8..746f771 100644 --- a/src/main/java/om/community/supportsystem/service/EmailService.java +++ b/src/main/java/om/community/supportsystem/service/EmailService.java @@ -5,9 +5,13 @@ import org.springframework.mail.SimpleMailMessage; import org.springframework.mail.javamail.JavaMailSender; import org.springframework.stereotype.Service; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; @Service public class EmailService { + private static final Logger log = LoggerFactory.getLogger(EmailService.class); + @Autowired(required = false) private JavaMailSender mailSender; @@ -20,60 +24,60 @@ public class EmailService { public void sendPasswordResetEmail(String toEmail, String resetToken) { try { - System.out.println("🔄 Attempting to send password reset email to: " + toEmail); + log.info("🔄 Attempting to send password reset email to: " + toEmail); if (sendGridEmailService != null) { - System.out.println("📧 Using SendGrid for password reset email delivery"); + log.info("📧 Using SendGrid for password reset email delivery"); sendGridEmailService.sendPasswordResetEmail(toEmail, "User", resetToken); - System.out.println("✅ Password reset email sent successfully via SendGrid to: " + toEmail); + log.info("✅ Password reset email sent successfully via SendGrid to: " + toEmail); return; } // Check if SMTP is available if (mailSender != null && fromEmail != null && !fromEmail.isEmpty()) { - System.out.println("📧 Using SMTP for email delivery"); + log.info("📧 Using SMTP for email delivery"); sendPasswordResetEmailSMTP(toEmail, resetToken); return; } // Email service unavailable - log token for manual use - System.err.println("⚠️ Email service unavailable. Logging reset token for manual use:"); - System.err.println("🔑 MANUAL RESET TOKEN for " + toEmail + ": " + resetToken); - System.err.println("🔗 MANUAL RESET URL: " + getFrontendUrl() + "/reset-password?token=" + resetToken); + log.error("⚠️ Email service unavailable. Logging reset token for manual use:"); + log.error(String.valueOf("🔑 MANUAL RESET TOKEN for " + toEmail + ": " + resetToken)); + log.error(String.valueOf("🔗 MANUAL RESET URL: " + getFrontendUrl()) + "/reset-password?token=" + resetToken); throw new RuntimeException("No email service available to send password reset email"); } catch (Exception e) { - System.err.println("❌ Failed to send password reset email to " + toEmail + ": " + e.getMessage()); - System.err.println("🔗 MANUAL RESET URL: " + getFrontendUrl() + "/reset-password?token=" + resetToken); + log.error(String.valueOf("❌ Failed to send password reset email to " + toEmail + ": " + e.getMessage())); + log.error(String.valueOf("🔗 MANUAL RESET URL: " + getFrontendUrl()) + "/reset-password?token=" + resetToken); throw new RuntimeException("Failed to send password reset email: " + e.getMessage(), e); } } public void sendEmailVerification(String toEmail, String verificationToken) { try { - System.out.println("🔄 Sending verification email to: " + toEmail); + log.info("🔄 Sending verification email to: " + toEmail); if (sendGridEmailService != null) { - System.out.println("📧 Using SendGrid for email delivery"); + log.info("📧 Using SendGrid for email delivery"); sendGridEmailService.sendVerificationEmail(toEmail, "User", verificationToken); - System.out.println("✅ Verification email sent successfully"); + log.info("✅ Verification email sent successfully"); return; } // Check if SMTP is available if (mailSender != null && fromEmail != null && !fromEmail.isEmpty()) { - System.out.println("📧 Using SMTP for email delivery"); + log.info("📧 Using SMTP for email delivery"); sendEmailVerificationSMTP(toEmail, verificationToken); return; } // Email service unavailable - log token for manual use - System.err.println("⚠️ Email service unavailable. Logging verification token for manual use:"); - System.err.println("🔑 MANUAL VERIFICATION TOKEN for " + toEmail + ": " + verificationToken); - System.err.println("🔗 MANUAL VERIFICATION URL: " + getFrontendUrl() + "/verify-email?token=" + verificationToken); + log.error("⚠️ Email service unavailable. Logging verification token for manual use:"); + log.error(String.valueOf("🔑 MANUAL VERIFICATION TOKEN for " + toEmail + ": " + verificationToken)); + log.error(String.valueOf("🔗 MANUAL VERIFICATION URL: " + getFrontendUrl()) + "/verify-email?token=" + verificationToken); } catch (Exception e) { - System.err.println("❌ Failed to send verification email to " + toEmail + ": " + e.getMessage()); + log.error(String.valueOf("❌ Failed to send verification email to " + toEmail + ": " + e.getMessage())); // Don't throw exception - allow registration to continue } } @@ -98,7 +102,7 @@ private void sendPasswordResetEmailSMTP(String toEmail, String resetToken) { "If you didn't request this reset, please ignore this email."); mailSender.send(message); - System.out.println("✅ Password reset email sent successfully to: " + toEmail); + log.info("✅ Password reset email sent successfully to: " + toEmail); } private void sendEmailVerificationSMTP(String toEmail, String verificationToken) { @@ -120,7 +124,7 @@ private void sendEmailVerificationSMTP(String toEmail, String verificationToken) "If you didn't create an account, please ignore this email."); mailSender.send(message); - System.out.println("✅ Verification email sent successfully to: " + toEmail); + log.info("✅ Verification email sent successfully to: " + toEmail); } private String getFrontendUrl() { @@ -159,25 +163,25 @@ public void sendTwoFactorCode(String toEmail, String code) { "\n\nIf you didn't request this code, please ignore this email."); mailSender.send(message); - System.out.println("2FA code sent to: " + toEmail); + log.info("2FA code sent to: " + toEmail); return; } // Email service unavailable - System.err.println("⚠️ Email service unavailable. 2FA code for " + toEmail + ": " + code); + log.error(String.valueOf("⚠️ Email service unavailable. 2FA code for " + toEmail + ": " + code)); } catch (Exception e) { - System.err.println("Failed to send 2FA code: " + e.getMessage()); - System.err.println("🔑 MANUAL 2FA CODE for " + toEmail + ": " + code); + log.error(String.valueOf("Failed to send 2FA code: " + e.getMessage())); + log.error(String.valueOf("🔑 MANUAL 2FA CODE for " + toEmail + ": " + code)); } } public void sendLoginOTP(String toEmail, String code) { try { - System.out.println("🔄 Attempting to send login OTP to: " + toEmail); + log.info("🔄 Attempting to send login OTP to: " + toEmail); if (sendGridEmailService != null) { - System.out.println("📧 Using SendGrid for OTP delivery"); + log.info("📧 Using SendGrid for OTP delivery"); String subject = "Login Verification Code - Community Support System"; String content = String.format( "
" + @@ -195,7 +199,7 @@ public void sendLoginOTP(String toEmail, String code) { code ); sendGridEmailService.sendEmail(toEmail, subject, content); - System.out.println("✅ Login OTP sent successfully via SendGrid to: " + toEmail); + log.info("✅ Login OTP sent successfully via SendGrid to: " + toEmail); return; } @@ -210,16 +214,16 @@ public void sendLoginOTP(String toEmail, String code) { "\n\nIf you didn't attempt to log in, please ignore this email."); mailSender.send(message); - System.out.println("Login OTP sent to: " + toEmail); + log.info("Login OTP sent to: " + toEmail); return; } // Email service unavailable - System.err.println("⚠️ Email service unavailable. Login OTP for " + toEmail + ": " + code); + log.error(String.valueOf("⚠️ Email service unavailable. Login OTP for " + toEmail + ": " + code)); } catch (Exception e) { - System.err.println("Failed to send login OTP: " + e.getMessage()); - System.err.println("🔑 MANUAL LOGIN OTP for " + toEmail + ": " + code); + log.error(String.valueOf("Failed to send login OTP: " + e.getMessage())); + log.error(String.valueOf("🔑 MANUAL LOGIN OTP for " + toEmail + ": " + code)); } } } \ No newline at end of file diff --git a/src/main/java/om/community/supportsystem/service/LeaderboardService.java b/src/main/java/om/community/supportsystem/service/LeaderboardService.java index 332c949..e05f626 100644 --- a/src/main/java/om/community/supportsystem/service/LeaderboardService.java +++ b/src/main/java/om/community/supportsystem/service/LeaderboardService.java @@ -3,6 +3,7 @@ import om.community.supportsystem.repository.AssignmentRepository; import om.community.supportsystem.repository.UserRepository; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.cache.annotation.Cacheable; import org.springframework.stereotype.Service; import java.util.*; import java.util.stream.Collectors; @@ -16,6 +17,7 @@ public class LeaderboardService { @Autowired private AssignmentRepository assignmentRepository; + @Cacheable(value = "leaderboard", key = "#limit") public List> getTopVolunteers(int limit) { return userRepository.findAll().stream() .filter(user -> "VOLUNTEER".equals(user.getRole())) diff --git a/src/main/java/om/community/supportsystem/service/NotificationService.java b/src/main/java/om/community/supportsystem/service/NotificationService.java index 72ee6fa..2cc41dc 100644 --- a/src/main/java/om/community/supportsystem/service/NotificationService.java +++ b/src/main/java/om/community/supportsystem/service/NotificationService.java @@ -7,6 +7,8 @@ import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Service; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import java.time.LocalDateTime; import java.util.List; @@ -14,6 +16,8 @@ @Service public class NotificationService { + private static final Logger log = LoggerFactory.getLogger(NotificationService.class); + @Autowired private NotificationRepository notificationRepository; @@ -170,6 +174,6 @@ public void notifyAllVolunteersAboutNewRequest(om.community.supportsystem.model. notificationRepository.save(notification); }); - System.out.println("✅ Notified " + volunteers.size() + " volunteers about new request: " + request.getTitle()); + log.info("✅ Notified " + volunteers.size() + " volunteers about new request: " + request.getTitle()); } } \ No newline at end of file diff --git a/src/main/java/om/community/supportsystem/service/SendGridEmailService.java b/src/main/java/om/community/supportsystem/service/SendGridEmailService.java index 0942c50..c770192 100644 --- a/src/main/java/om/community/supportsystem/service/SendGridEmailService.java +++ b/src/main/java/om/community/supportsystem/service/SendGridEmailService.java @@ -6,11 +6,15 @@ import com.sendgrid.helpers.mail.objects.Email; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import java.io.IOException; @Service public class SendGridEmailService { + private static final Logger log = LoggerFactory.getLogger(SendGridEmailService.class); + @Value("${sendgrid.api.key}") private String apiKey; @@ -56,12 +60,12 @@ public void sendEmail(String toEmail, String subject, String htmlContent) throws ", Body: " + response.getBody()); } - System.out.println("✅ Email sent successfully to: " + toEmail); - System.out.println("📧 Subject: " + subject); - System.out.println("📊 Status Code: " + response.getStatusCode()); + log.info("✅ Email sent successfully to: " + toEmail); + log.info("📧 Subject: " + subject); + log.info("📊 Status Code: " + response.getStatusCode()); } catch (IOException ex) { - System.err.println("❌ Error sending email: " + ex.getMessage()); + log.error(String.valueOf("❌ Error sending email: " + ex.getMessage())); throw ex; } } diff --git a/src/main/java/om/community/supportsystem/service/SkillService.java b/src/main/java/om/community/supportsystem/service/SkillService.java index 164cb41..75669f4 100644 --- a/src/main/java/om/community/supportsystem/service/SkillService.java +++ b/src/main/java/om/community/supportsystem/service/SkillService.java @@ -3,6 +3,8 @@ import om.community.supportsystem.model.Skill; import om.community.supportsystem.repository.SkillRepository; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.cache.annotation.CacheEvict; +import org.springframework.cache.annotation.Cacheable; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Service; @@ -17,14 +19,16 @@ public class SkillService { private SkillRepository skillRepository; // Create + @CacheEvict(value = "skills", allEntries = true) public Skill createSkill(Skill skill) { if (skillRepository.existsBySkillName(skill.getSkillName())) { throw new RuntimeException("Skill with name " + skill.getSkillName() + " already exists"); } return skillRepository.save(skill); } - + // Read + @Cacheable(value = "skills", key = "'all'") public List getAllSkills() { return skillRepository.findAll(); } @@ -62,6 +66,7 @@ public List getSkillsWithNoUsers() { } // Update + @CacheEvict(value = "skills", allEntries = true) public Skill updateSkill(Long id, Skill skillDetails) { return skillRepository.findById(id) .map(skill -> { @@ -73,6 +78,7 @@ public Skill updateSkill(Long id, Skill skillDetails) { } // Delete + @CacheEvict(value = "skills", allEntries = true) public void deleteSkill(Long id) { Skill skill = skillRepository.findById(id) .orElseThrow(() -> new RuntimeException("Skill not found with id: " + id)); diff --git a/src/main/java/om/community/supportsystem/util/JwtUtil.java b/src/main/java/om/community/supportsystem/util/JwtUtil.java index 738f315..658be33 100644 --- a/src/main/java/om/community/supportsystem/util/JwtUtil.java +++ b/src/main/java/om/community/supportsystem/util/JwtUtil.java @@ -2,6 +2,9 @@ import io.jsonwebtoken.*; import io.jsonwebtoken.security.Keys; +import jakarta.annotation.PostConstruct; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Component; @@ -10,15 +13,32 @@ import java.util.HashMap; import java.util.Map; +import static java.nio.charset.StandardCharsets.UTF_8; + @Component public class JwtUtil { - + + private static final Logger log = LoggerFactory.getLogger(JwtUtil.class); + private static final int MIN_SECRET_BYTES = 32; + @Value("${jwt.secret:mySecretKey}") private String secret; - + @Value("${jwt.expiration:86400000}") // 24 hours private Long expiration; - + + @PostConstruct + public void validateSecret() { + int len = secret.getBytes(UTF_8).length; + if (len < MIN_SECRET_BYTES) { + throw new IllegalStateException( + "JWT secret is too weak (" + len + " bytes). " + + "Set the JWT_SECRET environment variable to a random string of at least " + + MIN_SECRET_BYTES + " characters."); + } + log.info("JWT secret validated ({} bytes)", len); + } + private SecretKey getSigningKey() { return Keys.hmacShaKeyFor(secret.getBytes()); }