Enterprise-level Certification Tracking Platform
Built with Spring Boot 4 · PostgreSQL · JWT · SendGrid · Docker
⭐ If you find this project useful, please consider giving it a star!
Quick Start · API Reference · Architecture · Project Structure · Docker
- Project Overview
- Tech Stack
- Architecture & Working Flow
- Database Schema
- Project Structure
- Prerequisites
- Quick Start (Local Setup)
- Environment Variables Reference
- API Reference
- Security Model
- Email & Notification System
- Scheduled Jobs
- Exception Handling
- Docker Deployment
- Production Deployment (Render)
- Testing
- Common Issues & Troubleshooting
- Contributing
CertifyMe is a full-stack certification tracking and lifecycle management platform designed for academic and corporate environments. The backend is a stateless RESTful API that serves two user roles:
🚀 Live at scale — currently powering 4,000+ student users and 10+ faculty/admin accounts, managing thousands of certification records with automated expiry tracking and renewal workflows.
| 👨🎓 Students | 👨🏫 Faculty / Admins | 🏅 Certifications Tracked | ⏰ Daily Reminders |
|---|---|---|---|
| 4,000+ | 10+ | Thousands | Automated at 09:00 AM |
| Role | Description |
|---|---|
| STUDENT | Registers, views, and manages their own certifications; submits renewal requests |
| ADMIN | Manages all users and certifications; approves/rejects renewals; sends reminders; exports reports |
- 🔐 JWT-based stateless authentication with BCrypt password hashing
- 📧 OTP-based 2-Factor Authentication for Admin login via SendGrid
- 🏅 Full certification CRUD with status tracking (Active / Expiring Soon / Expired)
- 🔄 Renewal workflow — Student requests, Admin approves/rejects with remarks
- 🔔 In-app notification system with read/unread states
- ⏰ Automated daily scheduler — sends expiry reminders for certifications expiring within 30 days
- 📊 Dashboard statistics — total, active, expiring, expired, pending renewal counts
- 📥 Excel export for certifications (per-user or global for Admin)
- 🛡️ Global exception handling with structured JSON error responses
- 🐳 Dockerized with multi-stage build for production
| Category | Technology | Version | Purpose |
|---|---|---|---|
| Language | Java | 17 (LTS) | Core programming language |
| Framework | Spring Boot | 4.0.5 | Application framework |
| Security | Spring Security | (managed) | Authentication & authorization |
| JWT | JJWT (io.jsonwebtoken) | 0.12.6 | Token generation & validation |
| Database | PostgreSQL | (Supabase) | Primary relational database |
| ORM | Spring Data JPA / Hibernate | (managed) | Database abstraction & ORM |
| DB Driver | PostgreSQL Driver | (managed) | JDBC connectivity |
| SendGrid Java SDK | 4.10.3 | Transactional email (OTP & notifications) | |
| Excel | Apache POI (poi-ooxml) | 5.2.5 | Certification report generation |
| Validation | Spring Validation (Hibernate Validator) | (managed) | Request body validation |
| Build Tool | Apache Maven | 3.9.6 | Dependency management & build |
| Boilerplate | Lombok | 1.18.44 | Reduces boilerplate (getters, builders, logs) |
| Containerization | Docker | (latest) | Containerized deployments |
| Hosting | Render | — | Cloud PaaS deployment |
| Cloud DB | Supabase (PostgreSQL) | — | Managed database with connection pooler |
┌──────────────────────────────────────────────────────────────────┐
│ FRONTEND (React) │
│ http://localhost:5173 | Vercel Production │
└─────────────────────────┬────────────────────────────────────────┘
│ HTTPS (Axios + JWT Bearer Token)
▼
┌──────────────────────────────────────────────────────────────────┐
│ SPRING BOOT BACKEND (Port 8080) │
│ │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ Spring Security Filter Chain │ │
│ │ ┌──────────────────────────────────────────────────┐ │ │
│ │ │ JwtAuthenticationFilter │ │ │
│ │ │ 1. Extract Bearer token from Authorization header│ │ │
│ │ │ 2. Validate & decode JWT via JwtService │ │ │
│ │ │ 3. Load UserDetails from DB │ │ │
│ │ │ 4. Set SecurityContextHolder │ │ │
│ │ └────────────────────┬─────────────────────────────┘ │ │
│ └───────────────────────┼─────────────────────────────────┘ │
│ │ │
│ ┌───────────────────────▼──────────────────────────────────┐ │
│ │ REST Controllers (Layer 1) │ │
│ │ AuthController │ CertificationController │ │
│ │ UserController │ NotificationController │ │
│ │ StudentNotificationController │ │
│ └───────────────────────┬──────────────────────────────────┘ │
│ │ │
│ ┌───────────────────────▼──────────────────────────────────┐ │
│ │ Service Layer (Layer 2) │ │
│ │ AuthService │ CertificationService │ NotificationService │ │
│ │ UserService │ EmailService │ │
│ └───────────────────────┬──────────────────────────────────┘ │
│ │ │
│ ┌───────────────────────▼──────────────────────────────────┐ │
│ │ Repository Layer (Layer 3) │ │
│ │ Spring Data JPA Repositories │ │
│ └───────────────────────┬──────────────────────────────────┘ │
└─────────────────────────┬────────────────────────────────────────┘
│
▼
┌──────────────────────────────────────────────────────────────────┐
│ PostgreSQL on Supabase (Connection Pooler) │
│ aws-1-ap-southeast-1.pooler.supabase.com:5432 │
└──────────────────────────────────────────────────────────────────┘
│
┌───────────────┴───────────────┐
▼ ▼
┌─────────────┐ ┌────────────────┐
│ SendGrid │ │ ReminderScheduler │
│ Email API │ │ (Cron @09:00 AM) │
└─────────────┘ └────────────────────┘
Below is the step-by-step lifecycle of a request — using "Student adds a Certification" as the example:
Step 1: FRONTEND
User fills form → handleSubmit() validates → certificationApi.create(userId, data)
→ Axios picks up JWT from localStorage → Attaches "Authorization: Bearer <token>"
→ POST https://api.certifyme.com/api/certifications/user/{userId}
Step 2: SPRING SECURITY FILTER (JwtAuthenticationFilter)
→ Intercepts every request
→ Extracts token from "Authorization" header
→ JwtService.validateToken() → decodes claims → extracts email
→ CustomUserDetailsService.loadUserByUsername(email) → fetches User from DB
→ Sets Authentication in SecurityContextHolder
→ Request proceeds to the Controller
Step 3: CONTROLLER (CertificationController)
→ @PostMapping("/user/{userId}") receives request
→ @Valid validates @RequestBody CertificationRequestDTO
(blank checks, date format, required fields)
→ Delegates to CertificationService.saveCertification(userId, request)
Step 4: SERVICE (CertificationService)
→ Fetches User entity from UserRepository by userId
→ CertificationMapper.toEntity(request) converts DTO → Entity
→ Sets user on certification entity
→ CertificationRepository.save(cert) persists to PostgreSQL
→ NotificationService.createNotification(...) fires an in-app notification
→ CertificationMapper.toResponseDTO(cert) converts Entity → DTO
Step 5: RESPONSE
→ Controller returns ResponseEntity<CertificationResponseDTO> with HTTP 200
→ Frontend Axios receives response → updates state → shows success toast
[Admin Login Attempt]
│
▼
POST /api/auth/login (email + password, no OTP)
│
├──► AuthenticationManager validates credentials
│
├──► User role = ADMIN → OTP required
│
├──► OtpUtils.generateOTP() → hash with SHA-256 → store in DB
│
├──► emailService.sendOtpEmail(email, firstName, otp)
│ └─ SendGrid API → delivers OTP to email
│
└──► Response: { otpRequired: true, remainingValiditySeconds: 120 }
[OTP Submission]
│
POST /api/auth/login (email + password + otp)
│
├──► Validate OTP hash match (SHA-256 comparison)
├──► Check OTP not expired (2-minute window)
├──► Check attempt count < 3 (else lock 5 minutes)
│
└──► Valid → Clear OTP → JwtService.generateToken(user)
→ Response: { token: "eyJ...", user: {...} }
POST /api/auth/login (email + password)
│
├──► AuthenticationManager validates credentials
├──► Role = STUDENT → No OTP required
└──► JwtService.generateToken(user) → Response: { token, user }
STUDENT BACKEND ADMIN
│ │ │
│── PUT /certifications │ │
│ /{id}/renewal │ │
│ { action: "REQUEST" }─►│ │
│ │── cert.renewalStatus = PENDING│
│ │── notify student │
│◄── 200 OK (PENDING) │ │
│ │ │
│ │◄─── GET /certifications/renewals
│ │ (Admin sees pending list) │
│ │ │
│ │◄─── PUT /certifications/{id}/renewal
│ │ { action: "APPROVE" } │
│ │── cert.renewalStatus = APPROVED
│ │── notify student │
│◄── notification: Approved │ │
The application uses PostgreSQL with JPA/Hibernate managing the DDL (spring.jpa.hibernate.ddl-auto=update).
┌─────────────────────┐ ┌──────────────────────────┐
│ users │ │ certification │
├─────────────────────┤ ├──────────────────────────┤
│ id (PK) │──────┐ │ id (PK) │
│ first_name │ └─►│ user_id (FK → users.id) │
│ middle_name │ │ title │
│ last_name │ │ issuer │
│ email (UNIQUE) │ │ credential_id │
│ password (BCrypt) │ │ url │
│ role (ADMIN/STUDENT)│ │ remarks │
│ age │ │ renewal_status │
│ gender │ │ issue_date │
│ country │ │ expiry_date │
│ student_id │ │ requested_on │
│ verification_code │ │ approved_on │
│ otp_created_at │ │ rejected_on │
│ otp_attempts │ │ last_reminder_sent │
│ otp_locked_until │ │ created_at │
│ created_at │ └──────────────────────────┘
└─────────────────────┘
│
│ One-to-Many
▼
┌─────────────────────┐ ┌──────────────────────────┐
│ notification │ │ password_reset_token │
├─────────────────────┤ ├──────────────────────────┤
│ id (PK) │ │ id (PK) │
│ user_id (FK) │ │ email │
│ certification_id │ │ otp │
│ title │ │ expires_at │
│ message │ │ is_used │
│ type │ └──────────────────────────┘
│ is_read │
│ created_at │
└─────────────────────┘
| Enum | Values | Usage |
|---|---|---|
Role |
STUDENT, ADMIN |
User role-based access control |
RenewalStatus |
NONE, PENDING, APPROVED, REJECTED |
Certification renewal workflow state |
CertificationStatus |
ACTIVE, EXPIRING_SOON, EXPIRED |
Computed at runtime from expiryDate (not persisted) |
RenewalAction |
REQUEST, APPROVE, REJECT |
Actions allowed in the renewal PUT endpoint |
certifyme/
├── src/
│ ├── main/
│ │ ├── java/com/certifyme/app/
│ │ │ │
│ │ │ ├── CertifymeApplication.java # Spring Boot entry point
│ │ │ │
│ │ │ ├── config/ # (Reserved for future config beans)
│ │ │ │
│ │ │ ├── controller/ # REST Controllers — Layer 1
│ │ │ │ ├── AuthController.java # /api/auth/** — Login, register, OTP, password
│ │ │ │ ├── CertificationController.java # /api/certifications/** — CRUD, export, stats
│ │ │ │ ├── NotificationController.java # /api/notifications/** — Admin notification ops
│ │ │ │ ├── StudentNotificationController.java # /api/student/** — Student's own notifications
│ │ │ │ ├── UserController.java # /api/users/** — User management (Admin)
│ │ │ │ └── HomeController.java # / — Health check endpoint
│ │ │ │
│ │ │ ├── dto/ # Data Transfer Objects (API contracts)
│ │ │ │ ├── LoginRequestDTO.java
│ │ │ │ ├── RegisterRequestDTO.java
│ │ │ │ ├── AuthResponseDTO.java
│ │ │ │ ├── CertificationRequestDTO.java
│ │ │ │ ├── CertificationResponseDTO.java
│ │ │ │ ├── CertificationRenewalDTO.java
│ │ │ │ ├── DashboardStatsDTO.java
│ │ │ │ ├── NotificationResponseDTO.java
│ │ │ │ ├── PagedResponseDTO.java # Generic paginated wrapper
│ │ │ │ ├── UserResponseDTO.java
│ │ │ │ ├── ApiErrorResponseDTO.java # Standardized error response shape
│ │ │ │ ├── ForgotPasswordRequestDTO.java
│ │ │ │ ├── VerifyOtpRequestDTO.java
│ │ │ │ ├── ResetPasswordRequestDTO.java
│ │ │ │ ├── ChangePasswordRequestDTO.java
│ │ │ │ ├── ResendOtpRequestDTO.java
│ │ │ │ └── RenewalRequestDTO.java
│ │ │ │
│ │ │ ├── exception/ # Error handling
│ │ │ │ ├── GlobalExceptionHandler.java # @RestControllerAdvice — all exception mapping
│ │ │ │ ├── ResourceNotFoundException.java # → 404
│ │ │ │ ├── BadRequestException.java # → 400
│ │ │ │ ├── UnauthorizedException.java # → 401
│ │ │ │ └── DuplicateResourceException.java # → 409
│ │ │ │
│ │ │ ├── mapper/ # Entity ↔ DTO conversion
│ │ │ │ ├── CertificationMapper.java
│ │ │ │ ├── NotificationMapper.java
│ │ │ │ └── UserMapper.java
│ │ │ │
│ │ │ ├── model/ # JPA Entities (database tables)
│ │ │ │ ├── User.java # users table; implements UserDetails
│ │ │ │ ├── Certification.java # certification table
│ │ │ │ ├── Notification.java # notification table
│ │ │ │ ├── PasswordResetToken.java # password_reset_token table
│ │ │ │ ├── Role.java # Enum: STUDENT, ADMIN
│ │ │ │ ├── RenewalStatus.java # Enum: NONE, PENDING, APPROVED, REJECTED
│ │ │ │ ├── RenewalAction.java # Enum: REQUEST, APPROVE, REJECT
│ │ │ │ ├── CertificationStatus.java # Enum: ACTIVE, EXPIRING_SOON, EXPIRED (computed)
│ │ │ │ └── RenewalStatusConverter.java # JPA AttributeConverter for RenewalStatus
│ │ │ │
│ │ │ ├── repository/ # Spring Data JPA Repositories — Layer 3
│ │ │ │ ├── UserRepository.java
│ │ │ │ ├── CertificationRepository.java # Custom JPQL for reminders, expiry queries
│ │ │ │ ├── NotificationRepository.java
│ │ │ │ └── PasswordResetTokenRepository.java
│ │ │ │
│ │ │ ├── scheduler/ # Background scheduled tasks
│ │ │ │ └── ReminderScheduler.java # Cron: sends expiry reminders at 09:00 AM daily
│ │ │ │
│ │ │ ├── security/ # JWT & Spring Security
│ │ │ │ ├── SecurityConfig.java # Filter chain, CORS, endpoint rules
│ │ │ │ ├── JwtService.java # Token generation, validation, claims extraction
│ │ │ │ ├── JwtAuthenticationFilter.java # Per-request token interceptor
│ │ │ │ └── CustomUserDetailsService.java # Loads User by email from DB
│ │ │ │
│ │ │ ├── service/ # Business Logic — Layer 2
│ │ │ │ ├── AuthService.java # Register, login, OTP, password management
│ │ │ │ ├── CertificationService.java # CRUD, renewal workflow, stats, Excel export
│ │ │ │ ├── NotificationService.java # Create, read, mark, delete notifications
│ │ │ │ ├── UserService.java # User fetch operations
│ │ │ │ ├── EmailService.java # SendGrid integration (OTP, expiry, renewal emails)
│ │ │ │ └── DataMigrationComponent.java # One-time data migration utility
│ │ │ │
│ │ │ └── util/ # Reusable utility classes
│ │ │ ├── DateUtil.java # Computes CertificationStatus from expiryDate
│ │ │ ├── ExcelExportUtil.java # Apache POI — generates .xlsx reports
│ │ │ └── OtpUtils.java # OTP generation and SHA-256 hashing
│ │ │
│ │ └── resources/
│ │ └── application.properties # All config (reads from .env / system env)
│ │
│ └── test/ # Unit & integration tests
│
├── .env # Local environment variables (DO NOT COMMIT)
├── .gitignore
├── Dockerfile # Multi-stage Docker build
├── mvnw / mvnw.cmd # Maven wrapper scripts
└── pom.xml # Project dependencies & build config
Before running this project locally, ensure the following are installed:
| Tool | Minimum Version | Download |
|---|---|---|
| Java JDK | 17 (LTS) | Adoptium / Oracle |
| Maven | 3.9+ (or use mvnw) |
maven.apache.org |
| PostgreSQL | 14+ (or use Supabase) | postgresql.org |
| Git | Any | git-scm.com |
| Docker (optional) | 24+ | docker.com |
Tip: You do not need a local PostgreSQL installation if you use the provided Supabase connection details. The
.envalready points to the cloud database.
Follow these steps exactly to get the backend running on your machine in under 5 minutes.
git clone <your-backend-repo-url>
cd certifymeThe application reads all secrets from a .env file in the project root. Create it if it doesn't exist:
# Windows (PowerShell)
Copy-Item .env.example .env # if example file exists
# Or create manually — see the section below for all variablesFill in the .env with the following values (see Environment Variables Reference):
# ============================================================
# CertifyMe — Environment Variables
# DO NOT commit this file with real secrets!
# ============================================================
# Database (Supabase PostgreSQL)
SPRING_DATASOURCE_URL=jdbc:postgresql://aws-1-ap-southeast-1.pooler.supabase.com:5432/postgres?sslmode=require
SPRING_DATASOURCE_USERNAME=your_supabase_username
SPRING_DATASOURCE_PASSWORD=your_supabase_password
# Server
PORT=8080
SPRING_PROFILES_ACTIVE=prod
# JWT Secret (64-char hex string)
JWT_SECRET=404E635266556A586E3272357538782F413F4428472B4B6250645367566B5970
# SendGrid
SENDGRID_API_KEY=SG.your_sendgrid_api_key_here
APP_MAIL_FROM=verify.certifyme@gmail.com
⚠️ Security Warning: The.envfile is already listed in.gitignore. Never push real secrets to source control.
java -version
# Expected: openjdk version "17.x.x" or higherIf Java 17 is not your default, set JAVA_HOME:
# Windows (PowerShell)
$env:JAVA_HOME = "C:\Program Files\Eclipse Adoptium\jdk-17.x.x"
$env:PATH = "$env:JAVA_HOME\bin;$env:PATH"# Using Maven Wrapper (no Maven installation required)
./mvnw clean install -DskipTests
# Windows
.\mvnw.cmd clean install -DskipTestsThis will:
- Download all dependencies from Maven Central
- Compile the Java source files
- Package the application into a JAR at
target/certifyme-0.0.1-SNAPSHOT.jar
# Using Maven Wrapper
./mvnw spring-boot:run
# OR run the JAR directly
java -jar target/certifyme-0.0.1-SNAPSHOT.jar
# Windows
.\mvnw.cmd spring-boot:run# Check health endpoint
curl http://localhost:8080/
# Or test the API
curl http://localhost:8080/api/auth/test
# Expected response: "Backend is running correctly!"You should see in the console:
. ____ _ __ _ _
/\\ / ___'_ __ _ _(_)_ __ __ _ \ \ \ \
( ( )\___ | '_ | '_| | '_ \/ _` | \ \ \ \
\\/ ___)| |_)| | | | | || (_| | ) ) ) )
' |____| .__|_| |_|_| |_\__, | / / / /
=========|_|==============|___/=/_/_/_/
:: Spring Boot :: (v4.0.5)
...
Started CertifymeApplication in X.XXX seconds (process running for X.XXX)
All environment variables are loaded from .env at startup (via spring.config.import=optional:file:.env[.properties]). On cloud deployments (Render), these are injected as system environment variables directly.
| Variable | Required | Default | Description |
|---|---|---|---|
SPRING_DATASOURCE_URL |
✅ Yes | — | Full JDBC connection string for PostgreSQL |
SPRING_DATASOURCE_USERNAME |
✅ Yes | — | Database username |
SPRING_DATASOURCE_PASSWORD |
✅ Yes | — | Database password |
PORT |
⚪ Optional | 8080 |
HTTP port the server listens on |
SPRING_PROFILES_ACTIVE |
⚪ Optional | — | Active Spring profile (prod, dev) |
JWT_SECRET |
✅ Yes | fallback (dev only) | 64-char hex string for JWT signing (HS256) |
SENDGRID_API_KEY |
✅ Yes | — | SendGrid API key starting with SG. |
APP_MAIL_FROM |
✅ Yes | — | Sender email address for transactional emails |
JWT Token Expiry: Tokens are valid for 24 hours (
app.jwt.expiration-ms=86400000).
OTP Validity: Admin OTPs expire in 2 minutes. Password reset OTPs expire in 10 minutes.
Base URL (Local): http://localhost:8080
Base URL (Production): https://certifyme-backend.onrender.com
Authentication: All endpoints except
/api/auth/**and/api/public/**require the HTTP header:Authorization: Bearer <your-jwt-token>
| Method | Endpoint | Auth Required | Description |
|---|---|---|---|
POST |
/api/auth/register |
❌ | Register a new user (Student or Admin) |
POST |
/api/auth/login |
❌ | Login — returns JWT or triggers Admin OTP flow |
POST |
/api/auth/resend-otp |
❌ | Resend Admin OTP (30s cooldown enforced) |
POST |
/api/auth/forgot-password |
❌ | Initiate password reset — sends OTP via email |
POST |
/api/auth/verify-otp |
❌ | Verify reset OTP — returns session JWT |
POST |
/api/auth/verify-reset-otp |
❌ | Alias for verify-otp |
POST |
/api/auth/reset-password |
❌ | Reset password using email + OTP + newPassword |
POST |
/api/auth/change-password |
✅ JWT | Change password (authenticated user, current password required) |
GET |
/api/auth/test |
❌ | Health/smoke test endpoint |
// Request Body
{
"firstName": "John",
"lastName": "Doe",
"email": "john.doe@example.com",
"password": "SecurePass123!",
"role": "STUDENT",
"age": 22,
"gender": "Male",
"country": "India",
"studentId": "STU2024001"
}
// Response 200
{
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
"user": {
"id": 1,
"firstName": "John",
"lastName": "Doe",
"email": "john.doe@example.com",
"role": "STUDENT"
}
}// Request
{ "email": "john.doe@example.com", "password": "SecurePass123!" }
// Response 200
{ "token": "eyJ...", "user": { ... } }// Request (no OTP field)
{ "email": "admin@certifyme.com", "password": "AdminPass!" }
// Response 200 — OTP sent to admin's email
{
"otpRequired": true,
"remainingValiditySeconds": 120,
"resendCooldownSeconds": 30
}// Request
{ "email": "admin@certifyme.com", "password": "AdminPass!", "otp": "483921" }
// Response 200
{ "token": "eyJ...", "user": { "id": 2, "role": "ADMIN", ... } }All endpoints require a valid JWT (Authorization: Bearer <token>).
| Method | Endpoint | Roles | Description |
|---|---|---|---|
POST |
/api/certifications/user/{userId} |
ALL | Add a new certification for a user |
GET |
/api/certifications/user/{userId} |
ALL | Get all certifications for a user (list) |
GET |
/api/certifications/user/{userId}/paged |
ALL | Paginated certifications for a user |
GET |
/api/certifications |
ADMIN | Get all certifications (paginated) |
GET |
/api/certifications/all |
ADMIN | Get all certifications (list, backward compat.) |
PUT |
/api/certifications/{id} |
ALL | Update a certification by ID |
DELETE |
/api/certifications/{id} |
ALL | Delete a certification by ID |
PUT |
/api/certifications/{id}/renewal |
STUDENT/ADMIN | Request / Approve / Reject renewal |
PUT |
/api/certifications/{id}/remind |
ADMIN | Manually send reminder for a certification |
GET |
/api/certifications/expiring |
ALL | Get certifications expiring within 30 days |
GET |
/api/certifications/expired |
ALL | Get all expired certifications |
GET |
/api/certifications/renewals |
ADMIN | Get all pending renewal requests |
GET |
/api/certifications/stats/{userId} |
ALL | Get dashboard stats for a user |
GET |
/api/certifications/export |
ALL | Export all certifications as .xlsx |
GET |
/api/certifications/export/{userId} |
ALL | Export a specific user's certifications as .xlsx |
// Request Body
{
"title": "AWS Certified Solutions Architect",
"issuer": "Amazon Web Services",
"credentialId": "AWS-SAA-12345",
"url": "https://aws.amazon.com/verification/12345",
"issueDate": "2024-01-15T00:00:00",
"expiryDate": "2027-01-15T00:00:00",
"remarks": "Passed with score 890/1000"
}
// Response 200
{
"id": 42,
"title": "AWS Certified Solutions Architect",
"issuer": "Amazon Web Services",
"credentialId": "AWS-SAA-12345",
"certificationStatus": "ACTIVE",
"renewalStatus": "NONE",
"issueDate": "2024-01-15T00:00:00",
"expiryDate": "2027-01-15T00:00:00",
"createdAt": "2026-07-14T13:00:00"
}// Request Body (Student)
{ "action": "REQUEST" }
// Request Body (Admin — Approve)
{ "action": "APPROVE" }
// Request Body (Admin — Reject)
{ "action": "REJECT", "remarks": "Certificate document not uploaded" }// Response 200
{
"totalCertifications": 12,
"activeCertifications": 8,
"expiringSoon": 2,
"expiredCertifications": 2,
"pendingRenewals": 1,
"completionPercentage": 66.67
}| Method | Endpoint | Roles | Description |
|---|---|---|---|
GET |
/api/users/{id} |
ALL | Get a user by ID |
GET |
/api/users |
ADMIN | Get all users |
GET |
/api/users/students |
ADMIN | Get all students (paginated) |
| Method | Endpoint | Roles | Description |
|---|---|---|---|
GET |
/api/notifications/user/{userId} |
ALL | Get paginated notifications for a user |
GET |
/api/notifications/user/{userId}/unread |
ALL | Get unread notification count |
PUT |
/api/notifications/{id}/read |
ALL | Mark a single notification as read |
PUT |
/api/notifications/user/{userId}/read-all |
ALL | Mark all notifications as read |
DELETE |
/api/notifications/{id} |
ALL | Delete a notification |
| Method | Endpoint | Description |
|---|---|---|
GET |
/api/student/notifications |
Get student's own notifications (extracts userId from JWT) |
GET |
/api/student/notifications/unread-count |
Get student's unread count (from JWT) |
All paginated endpoints return the following wrapper:
{
"content": [ { ... }, { ... } ],
"pageNumber": 0,
"pageSize": 20,
"totalElements": 47,
"totalPages": 3,
"last": false
}All errors return a consistent ApiErrorResponseDTO:
{
"timestamp": "2026-07-14T13:00:00",
"status": 401,
"error": "Unauthorized",
"message": "Invalid OTP",
"path": "/api/auth/login"
}| HTTP Status | Exception | Trigger |
|---|---|---|
400 |
BadRequestException |
Invalid request data |
400 |
MethodArgumentNotValidException |
Bean Validation failure |
401 |
UnauthorizedException |
Invalid credentials, expired/invalid JWT |
404 |
ResourceNotFoundException |
Entity not found |
405 |
HttpRequestMethodNotSupportedException |
Wrong HTTP method |
409 |
DuplicateResourceException |
Email already in use |
503 |
EmailDeliveryException |
SendGrid API failure |
500 |
Exception |
Unhandled server error |
- Algorithm: HMAC-SHA256 (HS256)
- Expiry: 24 hours (
86400000ms) - Secret: 64-character hex string (set via
JWT_SECRETenv var) - Storage: Frontend stores in
localStorage; sent viaAuthorization: Bearerheader
The backend explicitly allows the following origins:
http://localhost:* (all local development ports)
https://certifyme-*.vercel.app (Vercel preview deployments)
https://certifyme*.vercel.app (Vercel production deployment)
Allowed methods: GET, POST, PUT, DELETE, OPTIONS, PATCH
- Passwords are hashed with BCrypt before storage
- OTPs are hashed with SHA-256 before storage (plaintext never persisted)
- Admin OTP attempts are tracked and locked for 5 minutes after 3 failed attempts
- Admin OTPs have a 30-second resend cooldown
| Path Pattern | Rule |
|---|---|
/ |
Public |
/api/auth/** |
Public (except /api/auth/change-password) |
/api/auth/change-password |
Authenticated |
/api/public/** |
Public |
All other /api/** |
Authenticated (JWT required) |
hasRole('ADMIN') endpoints |
Requires ROLE_ADMIN in JWT claims |
CertifyMe uses the official SendGrid Java SDK (not SMTP) for transactional emails.
| Email Type | Trigger | Recipient |
|---|---|---|
| Admin OTP | Admin login attempt | Admin |
| Password Reset OTP | /api/auth/forgot-password |
Any user |
| Renewal Requested | Student submits renewal | Student (confirmation) |
| Renewal Approved | Admin approves | Student |
| Renewal Rejected | Admin rejects | Student |
| Expiry Reminder | Scheduler (09:00 AM daily) | User whose cert expires in ≤30 days |
| Type | Trigger |
|---|---|
SYSTEM |
Certification added or updated |
RENEWAL_UPDATE |
Renewal status changed (Requested / Approved / Rejected) |
REMINDER |
Expiry reminder from scheduler or manual admin action |
The ReminderScheduler runs background tasks via Spring's @Scheduled annotation.
| Job | Cron Expression | Description |
|---|---|---|
sendExpiryReminders() |
0 0 9 * * * (09:00 AM daily) |
Finds all certifications expiring within 30 days that haven't been reminded today; creates in-app notifications; updates last_reminder_sent |
cleanupOldNotifications() |
0 0 0 * * * (12:00 AM daily) |
Intended for cleanup of read notifications older than 90 days |
The project includes a production-ready multi-stage Dockerfile.
docker build -t certifyme-backend:latest .docker run -d \
--name certifyme-backend \
-p 8080:8080 \
-e SPRING_DATASOURCE_URL="jdbc:postgresql://..." \
-e SPRING_DATASOURCE_USERNAME="your_user" \
-e SPRING_DATASOURCE_PASSWORD="your_password" \
-e JWT_SECRET="your_jwt_secret" \
-e SENDGRID_API_KEY="SG.your_key" \
-e APP_MAIL_FROM="verify.certifyme@gmail.com" \
certifyme-backend:latestCreate a docker-compose.yml:
version: '3.8'
services:
db:
image: postgres:16-alpine
environment:
POSTGRES_DB: certifyme
POSTGRES_USER: certifyme_user
POSTGRES_PASSWORD: certifyme_pass
ports:
- "5432:5432"
volumes:
- pgdata:/var/lib/postgresql/data
backend:
build: .
ports:
- "8080:8080"
environment:
SPRING_DATASOURCE_URL: jdbc:postgresql://db:5432/certifyme
SPRING_DATASOURCE_USERNAME: certifyme_user
SPRING_DATASOURCE_PASSWORD: certifyme_pass
JWT_SECRET: 404E635266556A586E3272357538782F413F4428472B4B6250645367566B5970
SENDGRID_API_KEY: SG.your_key_here
APP_MAIL_FROM: verify.certifyme@gmail.com
depends_on:
- db
volumes:
pgdata:docker-compose up --build# Stage 1: Build — uses Maven + JDK to compile and package
FROM maven:3.9.6-eclipse-temurin-17 AS build
WORKDIR /app
COPY . .
RUN mvn clean package -DskipTests
# Stage 2: Runtime — uses lightweight JDK Alpine image
FROM eclipse-temurin:17-jdk-alpine
WORKDIR /app
COPY --from=build /app/target/*.jar app.jar
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "app.jar"]The multi-stage build ensures the final image only contains the runtime, not the build tools — resulting in a significantly smaller image (~200 MB vs ~700 MB).
The backend is deployed on Render as a Docker Web Service.
- Create a Web Service on render.com
- Connect your GitHub repository
- Set Environment to
Docker - Set the Start Command (Render handles it automatically via
ENTRYPOINTin Dockerfile) - Add all environment variables in Render's Environment tab:
SPRING_DATASOURCE_URL = jdbc:postgresql://...
SPRING_DATASOURCE_USERNAME = postgres.xxx
SPRING_DATASOURCE_PASSWORD = ***
JWT_SECRET = 404E635...
SENDGRID_API_KEY = SG.xxx
APP_MAIL_FROM = verify.certifyme@gmail.com
PORT = 8080
- Click Deploy
Render auto-detects the service is healthy when / returns HTTP 200.
# Run all tests
./mvnw test
# Windows
.\mvnw.cmd testNote: The JVM flag
-XX:+EnableDynamicAgentLoading -Xshare:offis pre-configured inmaven-surefire-pluginfor compatibility with Java 17.
# 1. Register a student
curl -X POST http://localhost:8080/api/auth/register \
-H "Content-Type: application/json" \
-d '{
"firstName": "Jane",
"lastName": "Smith",
"email": "jane.smith@test.com",
"password": "TestPass123!",
"role": "STUDENT"
}'
# 2. Login and capture token
TOKEN=$(curl -s -X POST http://localhost:8080/api/auth/login \
-H "Content-Type: application/json" \
-d '{"email": "jane.smith@test.com", "password": "TestPass123!"}' \
| python -c "import sys, json; print(json.load(sys.stdin)['token'])")
# 3. Add a certification (replace {userId} with actual ID)
curl -X POST http://localhost:8080/api/certifications/user/1 \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $TOKEN" \
-d '{
"title": "Google Cloud Associate",
"issuer": "Google",
"issueDate": "2024-06-01T00:00:00",
"expiryDate": "2026-06-01T00:00:00"
}'
# 4. Get dashboard stats
curl http://localhost:8080/api/certifications/stats/1 \
-H "Authorization: Bearer $TOKEN"
# 5. Export certifications as Excel
curl -O -J http://localhost:8080/api/certifications/export/1 \
-H "Authorization: Bearer $TOKEN"Cause: Database is unreachable.
Fix:
- Verify
SPRING_DATASOURCE_URL,USERNAME, andPASSWORDare set correctly in.env - Ensure
sslmode=requireis in the URL when using Supabase - Check Supabase is active and the Connection Pooler URL is used (not direct connection)
Cause: JWT_SECRET changed between sessions, or is different between environments.
Fix: Make sure JWT_SECRET is identical in your .env and production environment. Existing tokens from a previous secret are now invalid — users must log in again.
Cause: SENDGRID_API_KEY is invalid, expired, or missing.
Fix: Verify the API key at app.sendgrid.com. Ensure APP_MAIL_FROM matches a verified Sender Identity in SendGrid.
Fix:
# Windows — find and kill the process on port 8080
netstat -ano | findstr :8080
taskkill /PID <PID> /FCause: Application compiled with Java 17 but running with a lower version.
Fix: Set JAVA_HOME to JDK 17 and ensure java -version shows 17.x.
Cause: Frontend origin not in the allowed list.
Fix: Add your frontend's URL to corsConfigurationSource() in SecurityConfig.java, or use http://localhost:* for local development.
- Fork the repository
- Create a branch:
git checkout -b feature/your-feature-name - Make changes following the existing package structure and layered architecture
- Test your changes:
./mvnw test - Commit:
git commit -m "feat: add <description>" - Push:
git push origin feature/your-feature-name - Open a Pull Request with a clear description
- Follow the Controller → Service → Repository layered architecture
- Use DTOs for all API request/response bodies — never expose entities directly
- Use Lombok annotations (
@Slf4j,@Builder,@Getter,@Setter) appropriately - Add
@Validon all@RequestBodyparameters in controllers - Throw specific domain exceptions (
ResourceNotFoundException,UnauthorizedException, etc.) — never genericRuntimeException - Log meaningful messages using
log.info()/log.error()at service level
CertifyMe was designed, developed, and deployed end-to-end as a solo project — from database schema and Spring Boot architecture, to JWT security, SendGrid email integration, Docker containerization, and production deployment on Render.
| Platform | Link |
|---|---|
| 🌐 Portfolio | meetlalith.vercel.app |
| linkedin.com/in/lalith-aditya-singuparapu | |
| 🐙 GitHub | github.com/lalithdev |
| lalithadityasinguparapu@gmail.com |
Have a question, found a bug, or want to suggest a feature?
| Channel | Where |
|---|---|
| 🐛 Bug Reports / Feature Requests | Open a GitHub Issue |
| 📧 Direct Email | lalithadityasinguparapu@gmail.com |
| 💼 Professional Enquiries |
If you encounter a bug or unexpected API behaviour:
- Check the Troubleshooting section first
- Search existing issues to avoid duplicates
- Open a new issue and include:
- Steps to reproduce the problem
- Endpoint that is failing (method + path)
- Request body / headers used (redact any real tokens)
- Response received vs expected
- Environment (local / Render / Docker)
If CertifyMe helped you or you find it impressive, consider giving it a star — it means a lot!
This project is licensed under the MIT License.
MIT License
Copyright (c) 2026 Lalith Aditya S (LalithDev)
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.