A full-stack web app for tracking job applications. Track your applications' progress, change statuses, add notes.
Built in Java 25 with Spring Boot 4, Spring Security, Spring Data JPA, JWT authentication (HttpOnly cookies), Flyway migration, Docker, PostgreSQL, and an Angular 21 frontend.
- Project Overview
- Features
- Architecture & Packages
- Tech Stack & Requirements
- Environment Configuration
- Local Setup
- Running the App
- Database
- API Endpoints
- Application Status Flow
- Exception Handling & Responses
- Security Notes
- Developer Notes
- License
JAT (Job Application Tracker) is a full-stack web app for managing your job search from a single dashboard.
- The backend exposes a secured REST API (JWT in HttpOnly cookies) built with Spring Boot 4 and PostgreSQL, with schema migrations managed by Flyway.
- The frontend is an Angular 21 SPA that consumes the API, featuring a PrimeNG UI, protected routes (auth/guest guards), OTP email verification, and automatic token handling via HTTP interceptors.
- User registration, login, JWT access/refresh token flow via HttpOnly SameSite cookies
- Email OTP verification on registration (via Resend)
- Full CRUD for job applications
- Archive / unarchive applications
- Status lifecycle management (
PENDING → SCREENING → INTERVIEW → OFFER / GHOSTED / REJECTED) - Company, location, position level, and contract type tracking per application
- Salary range (lowest / highest) per application
- Job posting URL per application
- Notes field for custom comments on each application
- Application and closing date tracking
- Filter applications by status, position, contract type, company name, or date range; toggle archived visibility
- Role-based access control (each user sees only their own applications)
- Global exception handling with consistent JSON error responses
- Actuator endpoints exposed:
health,info,metrics
com.wardiusz.jat
├── config/ # SecurityConfig, DatabaseConfig
├── controller/ # AuthController, JobController, UserController
├── dto/ # JobDTO, JobFilter, UserDTO
│ ├── request/ # LoginRequest, RegisterRequest, OtpRequest, CreateJobRequest, ...
│ └── response/
├── entity/ # Job, User, RefreshToken, OtpToken
├── enums/ # JobStatus, JobPosition, JobContract, UserType
├── exception/ # GlobalExceptionHandler, ErrorResponse, ResourceNotFoundException
├── mapper/ # JobMapper, UserMapper
├── repository/ # JobRepository, UserRepository, RefreshTokenRepository, OtpRepository
├── security/ # JwtTokenProvider, JwtAuthFilter, JwtAuthEntryPoint, CookieUtil
└── service/ # AuthService, JobService, OtpTokenService, RefreshTokenService, UserService
└── implementation/
Frontend structure (frontend/src/app):
app
├── core/
│ ├── guards/ # authGuard, guestGuard
│ ├── interceptors/ # jwt.interceptor (attaches cookies)
│ ├── models/ # auth.model, job.model
│ └── services/ # auth.service, job.service
└── features/
├── auth/ # login, register, otp-verify
├── dashboard/ # dashboard, job-table, job-modal, filter-bar, stat-card
└── settings/
| Layer | Technology |
|---|---|
| Language | Java 25 |
| Framework | Spring Boot 4.0.3 |
| Security | Spring Security 6.x + JWT (jjwt 0.12.6) |
| Build tool | Maven 3.x (Maven Wrapper included) |
| Database | PostgreSQL |
| Migrations | Flyway |
| ORM | Spring Data JPA / Hibernate |
| API style | REST |
| Resend (resend-java) | |
| Testing | JUnit 5, Spring Boot Test (H2 in-memory) |
| Frontend | Angular 21 |
| HTTP | Angular HttpClient + JWT Interceptor |
| Reactivity | RxJS / Observables |
Minimum requirements: Java 17, Node.js 20+, Docker (for the database)
Secrets are stored locally in src/main/resources/env.properties, which is loaded via spring.config.import: optional:env.properties.
Create this file before running the app:
DB_SECRET=your-base64-or-plain-jwt-secret-minimum-32-chars
DB_URL=jdbc:postgresql://localhost:5432/postgres-jat-db
DB_USERNAME=postgres
DB_PASSWORD=postgres
MAIL_ADDRESS=noreply@yourdomain.com
RESEND_SECRET=re_your_resend_api_keyThe COOKIE_SECURE flag defaults to false in development. Set COOKIE_SECURE=true in production (requires HTTPS).
-
Clone the repository:
git clone https://github.com/Wardiusz/JobApplicationTracker.git cd JobApplicationTracker -
Create the local config:
A template is provided. Copy it and fill in your values:
cp src/main/resources/templates/application.yml \ src/main/resources/application.yml # Then create env.properties as described above -
Start PostgreSQL with Docker:
docker compose -f docker/docker-compose.yaml up -d
This starts a
postgres:latestcontainer namedjat-postgreswith:- Database:
postgres-jat-db - User:
postgres/ Password:postgres - Port:
5432
Flyway will automatically create and migrate the schema on first startup.
- Database:
-
Build the backend:
./mvnw clean package -DskipTests # Windows: mvnw.cmd clean package -DskipTests -
Install frontend dependencies:
cd frontend npm install
./mvnw spring-boot:runThe API will be available at: http://localhost:8080/api/v1
cd frontend
ng serve
# or: npm startThe Angular dev server will be available at: http://localhost:4200
cd frontend
npm run build
# Output: frontend/dist/frontend/browser/Serve the built dist/ folder via Nginx or let Spring Boot serve it as static content.
- Schema is managed by Flyway — migrations run automatically on startup.
ddl-autois set tovalidate(Flyway is the source of truth, not Hibernate).- In-memory H2 is used for tests (
application-test.properties). - Main entities:
users,jobs,refresh_tokens,otp_tokens
Tokens are transmitted via HttpOnly cookies (access_token, refresh_token), not Authorization headers. All requests to protected endpoints must include cookies (the Angular interceptor handles this automatically).
# Register (sends OTP email)
POST /api/v1/auth/register
Content-Type: application/json
{
"email": "jan@example.com",
"password": "strongpassword"
}# Verify OTP after registration
POST /api/v1/auth/otp-verify
Content-Type: application/json
{
"email": "jan@example.com",
"otp": "123456"
}# Resend OTP
POST /api/v1/auth/otp-resend
Content-Type: application/json
{
"email": "jan@example.com"
}# Login — sets access_token and refresh_token cookies
POST /api/v1/auth/login
Content-Type: application/json
{
"username": "jan@example.com",
"password": "strongpassword"
}# Refresh access token — reads refresh_token cookie, sets new access_token cookie
POST /api/v1/auth/refresh# Logout — clears both cookies and deletes refresh token from DB
POST /api/v1/auth/logoutAll job endpoints require an authenticated session (valid access_token cookie).
# List all jobs (supports filtering via query params)
GET /api/v1/jobs
GET /api/v1/jobs?status=INTERVIEW
GET /api/v1/jobs?position=JUNIOR&contract=B2B
GET /api/v1/jobs?companyName=Acme&dateFrom=2026-01-01&dateTo=2026-06-01
GET /api/v1/jobs?includeArchived=trueFilter parameters:
| Parameter | Type | Values |
|---|---|---|
status |
enum | PENDING, SCREENING, INTERVIEW, OFFER, GHOSTED, REJECTED |
position |
enum | INTERN, JUNIOR, MID, SENIOR |
contract |
enum | B2B, UOP, UOZ, UOD, INTERN |
companyName |
string | partial or full company name |
dateFrom |
date | YYYY-MM-DD |
dateTo |
date | YYYY-MM-DD |
includeArchived |
boolean | true / false (default: false) |
# Create a new job application
POST /api/v1/jobs
Content-Type: application/json
{
"company": "Acme Corp",
"location": "Warsaw",
"status": "PENDING",
"position": "JUNIOR",
"contract": "B2B",
"url": "https://example.com/job/123",
"dateApplied": "2026-04-01",
"dateClosing": "2026-05-01",
"salaryLowest": 8000,
"salaryHighest": 12000,
"notes": "Applied via LinkedIn"
}# Update a job application
PUT /api/v1/jobs/{id}/update
Content-Type: application/json
{
"company": "Acme Corp",
"location": "Warsaw",
"status": "INTERVIEW",
"position": "JUNIOR",
"contract": "B2B",
"url": "https://example.com/job/123",
"dateApplied": "2026-04-01",
"notes": "Phone screen scheduled"
}# Update notes only
PATCH /api/v1/jobs/{id}/notes
Content-Type: text/plain
Phone screen moved to next week# Archive a job
PATCH /api/v1/jobs/{id}/archive# Unarchive a job
PATCH /api/v1/jobs/{id}/unarchive# Delete a job
DELETE /api/v1/jobs/{id}/deletePENDING → SCREENING → INTERVIEW → OFFER
↘ ↘
GHOSTED REJECTED
| Status | Meaning |
|---|---|
PENDING |
Application submitted, awaiting response |
SCREENING |
Initial HR screening / phone call |
INTERVIEW |
Technical or on-site interview stage |
OFFER |
Offer received |
GHOSTED |
No response after follow-up attempts |
REJECTED |
Formally rejected |
All errors return a consistent JSON body:
{
"status": 404,
"timestamp": "2026-04-01T12:00:00",
"message": "Resource not found with id: 42",
"error": "Not Found"
}Common HTTP response codes:
| Code | Meaning |
|---|---|
| 200 | Success |
| 201 | Resource created |
| 204 | Success, no content (delete, archive, logout) |
| 400 | Validation or bad request |
| 401 | Unauthorized (missing/invalid token) |
| 403 | Forbidden (resource belongs to another user) |
| 404 | Resource not found |
| 408 | OTP expired or invalid |
| 500 | Internal server error |
- JWT tokens are stored in HttpOnly, SameSite cookies — they are not accessible via JavaScript.
- Access token expiry: 15 minutes (
900000 ms). Refresh token expiry: 24 hours (86400000 ms). - Each user can only access their own applications — enforced at the service layer.
- For production:
- Set
COOKIE_SECURE=true(requires HTTPS / TLS termination via Nginx) - Store all secrets in environment variables or a secrets manager (Vault, AWS Secrets Manager)
- Use a strong, randomly generated
DB_SECRET(256+ bits for HS256) - Configure CORS to only allow your frontend origin
- Consider rate-limiting the
/authendpoints to prevent brute-force attacks
- Set
- Add Swagger/OpenAPI — use
springdoc-openapifor auto-generated API docs - Add pagination — wrap list endpoints
- Add statistics endpoint —
GET /api/v1/application/statsreturning counts by status Add email OTP verification — verify accountsWrite integration and unit tests — cover auth flow, application CRUD, status transitions
This project is licensed under the MIT License.