# AppliCare

URL: https://sola.ysz.life/projects/applicare

> Every job application in one place

- Type: Personal project
- Period: Dec 2024 - Feb 2025
- Live: https://applicare.app
- Source: https://github.com/lyfe691/AppliCare
- Stack: React (Vite), Spring Boot, Ant Design, MongoDB, Java, JavaScript, Docker, SEO

AppliCare is a job-application tracker I built end to end, from the React interface to the Java API and the database behind it. The API ties every request to its user through a signed token, so each account only ever sees its own applications and tasks.

## A job-application tracker with a Spring Boot API

[AppliCare](https://applicare.app) keeps track of job applications: where each one stands, the tasks that go with it, and how the search is going overall. I built both halves between December 2024 and February 2025, a React client on Ant Design and a Spring Boot API that stores its data in MongoDB.

![The Manage page: New Application opens a form, Lakeside Systems, Frontend Developer, Zürich and a salary are typed in, Create adds the row, and the table scrolls down to it](https://sola.ysz.life/projects/applicare/add.webp)
*Adding an application, on a demo account.*
[Video](https://sola.ysz.life/projects/applicare/add.mp4)

### Applications

Every application lives in one table on the Manage page, with its company, position, status and the date it was sent. Adding one takes the company, the title and a status; the salary, a contact, the link to the posting and notes are there when you have them. The search box filters as you type, and the columns sort and filter.

An application moves from applied through interviews to an offer, and ends accepted or rejected. The status column is a dropdown, and a pick saves right away, with its colour following the stage.

![The Manage table: the status of Clearwater Labs is changed from Pending to Interviewing and the status of Evergreen Energy from Offer to Accepted, each confirmed by a message at the top](https://sola.ysz.life/projects/applicare/status.webp)
*Each status saves as soon as it changes.*
[Video](https://sola.ysz.life/projects/applicare/status.mp4)

### The dashboard

The dashboard sums up the search in four numbers:

| Number       | Counts                                           |
| ------------ | ------------------------------------------------ |
| Applications | Every application sent                           |
| In progress  | The ones in screening or interviews              |
| Success rate | Accepted applications, as a share of all of them |
| Open         | Everything that isn't accepted or rejected yet   |

Below them, a chart counts applications over time and a breakdown shows the share in each status. The Tasks tab sits next to the overview.

![The dashboard: switching to the Tasks tab and back to Overview, where the applications-over-time chart draws in beside the status breakdown, and a tooltip follows the pointer along the chart](https://sola.ysz.life/projects/applicare/dashboard.webp)
*The overview and the Tasks tab.*
[Video](https://sola.ysz.life/projects/applicare/dashboard.mp4)

### Tasks

A task has a title, a short description, a deadline and a priority of high, medium or low, and it can be linked to one of the applications, whose company then shows as a tag. The list is sorted by deadline, so the next thing due is always at the top. Ticking a task crosses it out.

![The Tasks tab: tasks such as Send references and Prepare system design answers, each with a due date, a tag for the related company, a priority badge and buttons to edit and delete](https://sola.ysz.life/projects/applicare/tasks.webp)
*Tasks, the next deadline first.*

### How it's built

The client is React on Vite with Ant Design components, and it talks to the API through one Axios instance. The API is Spring Boot with Spring Security and Spring Data MongoDB, built into a Docker image. Accounts, applications and tasks are three MongoDB collections, and every application and task carries the id of the user it belongs to.

#### A status has its own endpoint

On the server the status is a plain string on the `JobApplication` document, and the list of stages lives in the client, so adding a stage is a change to the frontend alone. Changing a status goes through its own endpoint, apart from editing the application:

| Request                                            | Sent by             | Changes                                    |
| -------------------------------------------------- | ------------------- | ------------------------------------------ |
| `PATCH /api/applications/{id}/status?status=OFFER` | The status dropdown | The status and `lastUpdated`, nothing else |
| `PUT /api/applications/{id}`                       | The edit form       | The whole application                      |

The dashboard reads the same field in groups. Accepted and Rejected are final, so every other stage counts as open. Screening and Interviewing count as in progress, and the success rate is accepted applications over all of them. The browser works all of this out in a `useMemo` over the list the API returns, and a second one turns the applied dates into the running total behind the chart.

#### Signing in with a token

Login checks the password against its BCrypt hash and returns a JSON Web Token, a signed string the client sends back with every request. AppliCare's token is signed with HS256, carries the username as its subject and the user's id as a `userId` claim, and expires after a configured time. The client keeps it in `localStorage`, and an Axios interceptor adds it to each request as `Authorization: Bearer …`.

The security configuration is stateless: `/api/auth/**` is open, and every other route needs an authenticated request. A filter that runs once per request, ahead of Spring's own username-and-password filter, is what makes a request authenticated:

`filter/JwtAuthenticationFilter.java`

```java
public class JwtAuthenticationFilter extends OncePerRequestFilter {
    private final JwtUtil jwtUtil;
    public JwtAuthenticationFilter(JwtUtil jwtUtil) {
        this.jwtUtil = jwtUtil;
    }
    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
            throws ServletException, IOException {
        String authHeader = request.getHeader("Authorization");
        if (authHeader != null && authHeader.startsWith("Bearer ")) {
            String token = authHeader.substring(7);
            if (jwtUtil.validateToken(token)) {
                String username = jwtUtil.getUsernameFromToken(token);
                UsernamePasswordAuthenticationToken authentication =
                    new UsernamePasswordAuthenticationToken(username, null, new ArrayList<>());
                SecurityContextHolder.getContext().setAuthentication(authentication);
            }
        }
        filterChain.doFilter(request, response);
    }
}
```

The filter never rejects a request itself. A missing or invalid token leaves the security context empty, and Spring Security turns the request away further down the chain. `validateToken` parses the token with the signing key, which checks the signature and the expiry date in one call, so a forged token and an expired one fail the same way. On the client, the Axios response interceptor treats a 401 or 403 as an expired session: it clears the stored user and sends the browser to the login page, so session expiry is handled in one place for every page.

#### Every read and write belongs to its owner

The token says who is calling, and the services decide what that caller may touch. Controllers read the `userId` claim from the same header and pass it down, and no service takes an owner from the request body. Creating an application stamps the caller's id on it. Reading one, updating it, deleting it and changing its status all load the stored document first and compare its owner with the caller:

`service/JobApplicationService.java`

```java
public JobApplication updateApplication(String id, JobApplication application, String userId) {
    JobApplication existingApp = jobApplicationRepository.findById(id)
        .orElseThrow(() -> new RuntimeException("Application not found"));
    if (!existingApp.getUserId().equals(userId)) {
        throw new RuntimeException("Not authorized to update this application");
    }
    application.setId(existingApp.getId());
    application.setUserId(userId);
    application.setAppliedDate(existingApp.getAppliedDate());
    application.setLastUpdated(LocalDateTime.now());
    return jobApplicationRepository.save(application);
}
```

An update also copies the id and the original applied date from the stored document, so a request body can neither move an application to another user nor change when it was sent. Lists are queried by the caller's id, and editing, completing and deleting a task are checked the same way. Deleting an account removes the user's tasks and applications before the user record itself.

![The AppliCare login screen: a card with fields for a username or email address and a password, and links to register or reset the password](https://sola.ysz.life/projects/applicare/login.webp)
*Signing in.*

![The dashboard on a phone: the four numbers in a two-by-two grid above the applications-over-time chart](https://sola.ysz.life/projects/applicare/dashboard-mobile.webp)
*The dashboard.*

![The Manage page on a phone: the search box, the New Application button and the table of companies and positions](https://sola.ysz.life/projects/applicare/manage-mobile.webp)
*The Manage page.*
