Mobility Pro

Job board built with ASP.NET Core: companies publish positions with their requirements, and each offer gets a compatibility score against the candidate's profile and a driving distance.

Overview

Mobility Pro is a web application that connects companies and candidates, written in C# with ASP.NET Core 8. I built it in two months and it was graded 20/20. Companies describe their sites and publish offers with quantified requirements. Members enter their skills and the positions they have held. The application crosses the two: each offer gets a compatibility score and a driving distance from the candidate's address, which are used to sort and filter the list. An admin panel oversees users, companies and reference data.

A model built around skills

The schema revolves around TRequirementType, the skill reference table. An offer (TJobPost) carries TJobPostRequirement entries: an expected level from 0 to 10 and a number of years of experience. A member declares TUserRequirement entries: a self-assessment on the same scale, a start date and an optional end date. Around them, I modelled companies (TCompany), their sites (TSite), job titles (TPoste), contract types and work history (TExerce). The UserCompanieAssociations table links a user to one or more companies.

A demo member's skills: a self-assessment out of 10 and a period, either open (« jusqu'à ce jour ») or closed.

Data goes through Entity Framework Core on SQL Server. Program.cs registers two contexts on the same connection string: AuthDbContext, which inherits from IdentityDbContext, and WebAppContext for the business data. I renamed the Identity user and role tables to T_Users and T_Roles to follow the convention of the rest of the schema.

Identity, roles and pages per profile

Authentication relies on ASP.NET Identity, and ApplicationUser extends IdentityUser with the name, address, date of birth and geographic coordinates. The personal fields carry [PersonalData], which includes them in the data export offered by Identity. Two-factor authentication goes through the Identity pages: Login.cshtml.cs redirects to LoginWith2fa when the account requires it.

The Identity 2FA page with an authenticator app enabled, on a demo account that has both the Admin and Membre roles: the menu shows Administrator alongside Métiers Exerces and Mes Compétences.

On startup, Program.cs creates the Admin, Manager, Enterprise and Membre roles, then an administrator account if none exists. Registration assigns Membre. On the Razor Pages side, a member manages their skills (UserComp) and the positions they have held (ListExerce). An Enterprise account manages the members and sites of its company (ManageCompany) and publishes offers (AddJobPost). From ViewPosts, it opens ManageRequirements to set an offer's requirements, and the page refuses the same skill type twice. The admin CRUD controllers come from MVC scaffolding and are protected by [Authorize(Roles = "Admin")]. A GitHub Actions workflow restores and builds the project on .NET 8 on every push and pull request to master.

Ten points per requirement

CalculateCompatibilityScore, in TJobPostsController, compares each requirement of the offer with the member's matching skill. Five points measure the level gap, five more the experience, pro rata when it falls short. A missing skill scores zero. The total is turned into a percentage.

foreach (var requirement in jobPost.TJobPostRequirements)
{
    var userRequirement = userRequirements.FirstOrDefault(ur => ur.RequirementTypeId == requirement.RequirementTypeId);
    if (userRequirement == null) continue;
    double score = 5 - Math.Abs(userRequirement.PersonnalEvaluation - requirement.Evaluation);
    int years = CalculateYearsOfExperience(userRequirement.StartDate, userRequirement.EndDate);
    if (requirement.YearsOfExperience > 0)
        score += years >= requirement.YearsOfExperience ? 5 : (double)years / requirement.YearsOfExperience.Value * 5;
    totalScore += score;
}
return totalRequirements > 0 ? totalScore / (totalRequirements * 10) * 100 : 0;

I declared CompatibilityScore as [NotMapped]: it depends on the signed-in member and does not belong in the database.

The offers seen by a demo member, sorted by descending compatibility above 80%, with an empty distance because the demo sites were not geocoded (no Geoapify key).

Distance by road, not as the crow flies

When a user saves their address, the Manage/Index profile page calls the Geoapify geocoding API and stores latitude and longitude. If the address becomes incomplete, the coordinates are cleared. Sites created from ManageCompany are geocoded the same way.

For the offer list, CalculateDistancesAsync sends a single request to the Geoapify Route Matrix API in drive mode: one source, the member, and one target per located offer. The List action then filters by skill, city and position, applies minimum and maximum bounds on distance and compatibility, sorts on those two criteria and paginates the result with PagedResult<T>.

What I would redo today

The first point is access control. ManageCompany and ViewPosts check company membership when the page loads, but their POST handlers trust the CompanyId received in the request. AddJobPost and ManageRequirements do not check it at all, and ManageRequirements only requires the Membre role: only the ViewPosts view restricts the button leading to it to Enterprise accounts. OnPostRemoveExerceAsync deletes an experience by its id alone, and AdminController.Delete deletes a user on a plain GET request, with no anti-CSRF validation. Yet DeleteRequirement shows the right practice: it filters on UserId. I would centralise these checks in a resource-based authorization handler.

The requirements of a Démo Logiciels offer, opened with an account that only has the Membre role and does not belong to the company: the page loads with its add and delete buttons.

Secrets also ended up in the repository: the Geoapify key in the configuration and in a page, the credentials of the initial administrator account in Program.cs. I would move them to user-secrets and environment variables.

On performance, List loads every offer and then filters in memory, recomputes the distances of all offers on each display, and reloads the member's skills for every offer. The score also penalises a candidate stronger than required, since it measures an absolute gap, and its level component turns negative when that gap exceeds five. A requirement that asks for no experience is capped at five points out of ten.

Finally, the workflow runs dotnet test although the solution contains no test project, and most of the code arrives in a single commit. I would split the work into smaller commits and write tests around the score, the part best suited to it.