A backend-only REST API, built with Spring Boot, for tracking internship applications end to end — from logging a new application through to interview reminders, CV storage, and AI-powered gap analysis against job descriptions.
Every user's data is fully isolated (JWT-based auth, ownership checks on every request), interview reminders go out automatically by email, uploaded CVs are stored persistently via Cloudinary, and each application can be analyzed against its own job description using Gemini — comparing the applicant's actual CV, not a fixed skill list. Built as a hands-on exercise in layered Spring Boot architecture: Controller → Service → Repository, with caching, scheduling, file storage, and an external AI integration each handled as their own concern.
┌──────────────────┐
│ JWT Filter │
│ (auth on every │
│ request) │
└────────┬─────────┘
│
Client ───HTTP───▶ Controller ─┼─▶ Service ───▶ Repository ───▶ PostgreSQL
(Swagger/ (REST API) │ (business (Spring Data (Neon)
Postman) │ logic) JPA)
│ │
┌─────────┴──┐ ┌──┴───────────────┐
│ Cache │ │ GeminiService │
│ (dashboard │ │ (AI gap analysis │
│ stats, │ │ via Gemini API) │
│ per-user │ └──────────────────┘
│ key) │
└────────────┘
┌────────────────────┐
│ Scheduler │
│ (runs every 5 min, │
│ independent of │
│ HTTP requests) │
└─────────┬──────────┘
│
▼
Repository ──▶ PostgreSQL
│
▼
EmailService ──▶ Gmail SMTP
Every incoming request passes through the JWT Filter before reaching a controller, except for /api/auth/register and /api/auth/login. The Scheduler runs independently of any client request, on its own timer, and talks directly to the repository and mail layer. AI requests flow through their own dedicated GeminiService, kept separate from the rest of the business logic in ApplicationService.
The API is deployed and publicly accessible:
Note: The free Render instance spins down after 15 minutes of inactivity. The first request after idling may take a few minutes (up to ~3 minutes) to respond while it wakes up.
- Java 17
- Spring Boot 3
- Spring Data JPA
- Spring Security + JWT
- PostgreSQL
- Redis (caching, via Spring Cache abstraction — Simple Cache locally, Redis in Codespaces)
- Spring Mail + Spring Scheduler (automated interview reminder emails)
- Cloudinary (persistent storage for uploaded CV files)
- Google Gemini API (AI-powered job fit / gap analysis)
- JUnit 5 + Mockito (unit tests for core business logic)
- Docker (containerized deployment on Render)
- Swagger / OpenAPI
- Lombok
The application is deployed as a Docker container on Render, connected to a Neon PostgreSQL database (both free tier). Every push to main triggers the GitHub Actions test suite; Render then auto-deploys from main via its own build hook.
- Ensure PostgreSQL is running on your local machine.
- Create a database and update the credentials in
src/main/resources/application.properties:
spring.datasource.url=jdbc:postgresql://localhost:5432/interntrack
spring.datasource.username=your_username
spring.datasource.password=your_password- Set the following environment variables:
MAIL_USERNAME/MAIL_PASSWORD— for email notifications (see Interview Reminders)CLOUDINARY_CLOUD_NAME/CLOUDINARY_API_KEY/CLOUDINARY_API_SECRET— for CV file storage (see File Upload)GEMINI_API_KEY— for AI gap analysis (see AI Gap Analyzer)
- Run the application using Maven:
mvn spring-boot:run- The API will be available at
http://localhost:8080. - Swagger UI:
http://localhost:8080/swagger-ui.html
- Go to the repository on GitHub.
- Add
MAIL_USERNAME,MAIL_PASSWORD,CLOUDINARY_CLOUD_NAME,CLOUDINARY_API_KEY,CLOUDINARY_API_SECRET, andGEMINI_API_KEYas Codespaces repository secrets so they're injected automatically. - Click Code > Codespaces > Create codespace on main.
- The container automatically installs Java 17, PostgreSQL, and Redis.
- Run the application with the
codespacesprofile to enable Redis-backed caching:
./mvnw spring-boot:run -Dspring-boot.run.profiles=codespaces- Open the forwarded port 8080 URL and append
/swagger-ui.html.
| Method | Path | Description |
|---|---|---|
POST |
/api/auth/register |
Registers a new user. |
POST |
/api/auth/login |
Authenticates a user and returns a JWT token. |
DELETE |
/api/auth/delete-account |
Deletes the authenticated user's account and all their applications. Requires password confirmation. (requires token) |
POST |
/api/applications |
Creates a new internship application, owned by the authenticated user. (requires token) |
GET |
/api/applications |
Retrieves all applications belonging to the authenticated user. (requires token) |
GET |
/api/applications/{id} |
Retrieves a specific application by ID, if it belongs to the authenticated user. (requires token) |
PUT |
/api/applications/{id} |
Updates an existing application, if it belongs to the authenticated user. (requires token) |
DELETE |
/api/applications/{id} |
Deletes an application, if it belongs to the authenticated user. (requires token) |
GET |
/api/applications/dashboard |
Returns total application count and a status breakdown, scoped to the authenticated user. (requires token) |
POST |
/api/applications/{id}/upload-cv |
Uploads a PDF CV for the given application, replacing any previously uploaded file. (requires token) |
GET |
/api/applications/{id}/download-cv |
Downloads the CV file attached to the given application. (requires token) |
DELETE |
/api/applications/{id}/delete-cv |
Deletes the CV file attached to the given application. (requires token) |
POST |
/api/applications/{id}/analyze |
Runs (or returns a cached) AI gap analysis comparing the job description against the uploaded CV. (requires token) |
{
"companyName": "Google",
"position": "Backend Developer Intern",
"status": "Applied",
"appliedDate": "2026-07-17",
"interviewDate": "2026-07-25",
"notes": "Referral used.",
"jobDescription": "We are looking for a Backend Developer Intern with experience in Java, Spring Boot, and PostgreSQL..."
}interviewDate and jobDescription are optional — leave them out (or set to null) until an interview is scheduled or you're ready to run an analysis.
{
"id": 1,
"companyName": "Google",
"position": "Backend Developer Intern",
"status": "Applied",
"appliedDate": "2026-07-17",
"interviewDate": "2026-07-25",
"notes": "Referral used.",
"jobDescription": "We are looking for a Backend Developer Intern with experience in Java, Spring Boot, and PostgreSQL...",
"aiInsight": null,
"aiInsightGeneratedAt": null
}The /api/applications/dashboard endpoint returns the total number of applications and a dynamic breakdown by status (grouped by whatever status values actually exist in the database, rather than a fixed set), scoped to the authenticated user.
{
"total": 2,
"statusCounts": {
"Applied": 2
}
}Results are cached to avoid recomputing the breakdown on every request. The cache is invalidated automatically whenever an application is created, updated, or deleted. The application uses Spring's Cache abstraction, so the same @Cacheable/@CacheEvict code works with two different backends depending on the environment:
- Local development: in-memory Simple Cache (no external dependency required).
- GitHub Codespaces: Redis, running in the container.
Since dashboard data is scoped per user, the cache key is tied to the authenticated username — each user gets their own cached breakdown, and one user's dashboard is never served to another.
A scheduled job runs every 5 minutes and checks for applications with an interviewDate set to today or tomorrow. For each match, it sends a reminder email — via Gmail SMTP using JavaMailSender — to the application owner's registered email address.
To avoid sending duplicate reminders, each application tracks a lastReminderSentDate. This is only updated after a reminder email is confirmed sent successfully; if the email fails (e.g. an SMTP error), the send is retried on the next scheduled run instead of being silently skipped.
Mail credentials are never hardcoded or committed to the repository. They're read from environment variables:
spring.mail.username=${MAIL_USERNAME}
spring.mail.password=${MAIL_PASSWORD}MAIL_PASSWORD should be a Gmail App Password, generated separately from your main account password, so it can be revoked independently if ever exposed.
Note: On the deployed Render instance, outbound SMTP connections on port 587 are currently blocked by Render's free-tier network restrictions, so reminder emails are not delivered in production. The feature works correctly when run locally or in Codespaces with
MAIL_USERNAME/MAIL_PASSWORDset.
Each application can have one PDF CV attached to it, uploaded via POST /api/applications/{id}/upload-cv.
- Only PDF files are accepted; anything else returns a
400 Bad Request. - Uploading a new file for an application that already has one automatically replaces the old file — there's no separate "update" endpoint, just upload again.
DELETE /api/applications/{id}/delete-cvremoves the file without uploading a replacement.- Deleting an application also deletes its attached CV file.
- All of the above respect ownership: you can only upload, download, or delete a CV for an application that belongs to you.
Files are stored on Cloudinary rather than local disk, so they persist across redeploys — including on Render's free tier, where the container's local filesystem is wiped on every redeploy. Credentials are read from environment variables and never committed to the repository:
cloudinary.cloud-name=${CLOUDINARY_CLOUD_NAME}
cloudinary.api-key=${CLOUDINARY_API_KEY}
cloudinary.api-secret=${CLOUDINARY_API_SECRET}POST /api/applications/{id}/analyze compares the jobDescription text stored on that specific application against the CV actually uploaded for that same application — sent together to the Gemini API (gemini-3.8-flash) using its multimodal input, so the model reads the PDF directly rather than relying on a fixed, hardcoded skill list. The response highlights the 2–3 most relevant gaps between the two and explains why each one matters for that specific role, then stores the result on the application as aiInsight, with a timestamp in aiInsightGeneratedAt.
- Isolated per application: each application has its own
jobDescriptionandcvFilePath, so analyzing application #1 never touches application #2's data, even if they share the same CV. - Cached, not re-run needlessly: if
aiInsightis already populated, calling/analyzeagain returns the cached result immediately instead of calling Gemini again. - Automatically invalidated —
aiInsightis cleared and a fresh analysis will be generated next time/analyzeis called — whenever:- the
jobDescriptionis updated, - a new CV is uploaded, or
- the CV is deleted.
- the
- Fails safely: if the Gemini API is unavailable or returns an error, the request returns a
503 Service Unavailablewith a clear message — it never crashes the request or corrupts existing data.
Requires a GEMINI_API_KEY (free, from Google AI Studio, no billing needed):
gemini.api.key=${GEMINI_API_KEY}Core business logic in ApplicationService is covered by unit tests (JUnit 5 + Mockito), focused on the rules that matter most for correctness and safety:
- Ownership isolation — fetching an application that belongs to another user correctly results in a
404, never leaking data across accounts. - AI insight invalidation — updating the job description, uploading a new CV, or deleting a CV clears any existing
aiInsight, while an unrelated update leaves a valid analysis untouched. - Gap analyzer caching — a cached
aiInsightis returned without calling the Gemini API again; a missing one triggers a real call. - Input validation for
/analyze— missing job description or missing CV both fail with a clear400, before any external API call is attempted.
Run the tests with:
mvn testInteractive API documentation is available via Swagger UI at /swagger-ui.html. All protected endpoints can be tested directly from the browser after authorizing with a JWT token (obtained from /api/auth/login) via the Authorize button.
Requests with missing or invalid fields (companyName, position, status, appliedDate) are handled by a centralized GlobalExceptionHandler. It intercepts validation failures and returns a structured 400 Bad Request response detailing each failing field, instead of a generic server error.
{
"timestamp": "2026-07-28T14:30:15.12345",
"status": 400,
"errors": {
"companyName": "Company name cannot be blank",
"appliedDate": "Applied date cannot be in the future"
}
}Requests to non-existent resources (e.g., GET /api/applications/999) — or to a resource that belongs to a different user — return a 404 Not Found with a similarly structured error message.
The API uses JWT (JSON Web Token) based authentication. All endpoints under /api/applications require a valid token; only registration and login are publicly accessible.
POST /api/auth/register
{
"username": "your_username",
"email": "your_email@example.com",
"password": "your_password"
}POST /api/auth/login
{
"username": "your_username",
"email": "your_email@example.com",
"password": "your_password"
}Returns a JWT token on success:
{
"token": "eyJhbGciOiJIUzM4NCJ9..."
}Include the token in the Authorization header for all protected endpoints:
Authorization: Bearer <your_token>Requests without a valid token return a 401 Unauthorized response:
{
"timestamp": "2026-08-07T11:27:30.787292700",
"status": 401,
"message": "Authentication required. Please provide a valid token."
}DELETE /api/auth/delete-account
Authorization: Bearer <your_token>
{
"password": "your_password"
}Permanently deletes the authenticated user's account along with all of their application records. The account to delete is always derived from the JWT token, never from the request body — so a valid token for one user can never be used to delete another user's account. Requires the correct current password in the request body; an incorrect password returns 401 Unauthorized.
- Production email delivery: Render's free tier blocks outbound SMTP on port 587, so interview reminder emails are not sent when running on the deployed instance. This works correctly locally or in Codespaces.
- AI provider availability: Gemini's free tier occasionally returns a temporary
503under high demand on Google's side; the API surfaces this as a503rather than retrying automatically, so an occasional manual retry may be needed.

