Admin API refactor: claim-based resume CRUD & versioning

Major refactor of AdminController and related services to support full CRUD for candidate resume, projects, hobbies, skills, academics, experiences, certifications, and contact info, all using access token claims for candidate identity. Introduced AdminService and expanded IResumeRepository for granular entity management. Updated DTOs for upsert operations and date support. Improved API versioning (Asp.Versioning.Mvc), Swagger integration, and middleware setup. Added unit test project. Enhanced error handling, documentation, and mapping.
This commit is contained in:
Bangara Raju Kottedi 2026-02-15 05:27:35 +05:30
parent cd01c11be7
commit 5b7952877e
25 changed files with 1496 additions and 128 deletions

View File

@ -1,7 +1,7 @@
 
Microsoft Visual Studio Solution File, Format Version 12.00 Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 17 # Visual Studio Version 18
VisualStudioVersion = 17.9.34701.34 VisualStudioVersion = 18.1.11304.174
MinimumVisualStudioVersion = 10.0.40219.1 MinimumVisualStudioVersion = 10.0.40219.1
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "PortBlog.API", "PortBlog.API\PortBlog.API.csproj", "{2E50B5D7-56E2-4E89-8742-BB57FF4245F9}" Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "PortBlog.API", "PortBlog.API\PortBlog.API.csproj", "{2E50B5D7-56E2-4E89-8742-BB57FF4245F9}"
EndProject EndProject
@ -15,32 +15,90 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "KBR.Shared", "Shared\KBR.Sh
EndProject EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "KBR.Shared.Lite", "Shared\KBR.Share.Lite\KBR.Shared.Lite.csproj", "{F4B7078B-C59A-46B8-881A-C3CEE2634498}" Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "KBR.Shared.Lite", "Shared\KBR.Share.Lite\KBR.Shared.Lite.csproj", "{F4B7078B-C59A-46B8-881A-C3CEE2634498}"
EndProject EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PortBlog.Tests", "PortBlog.Tests\PortBlog.Tests.csproj", "{11106F82-FC17-497D-8747-CF1A84BFA7F8}"
EndProject
Global Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU Debug|Any CPU = Debug|Any CPU
Debug|x64 = Debug|x64
Debug|x86 = Debug|x86
Release|Any CPU = Release|Any CPU Release|Any CPU = Release|Any CPU
Release|x64 = Release|x64
Release|x86 = Release|x86
EndGlobalSection EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution GlobalSection(ProjectConfigurationPlatforms) = postSolution
{2E50B5D7-56E2-4E89-8742-BB57FF4245F9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {2E50B5D7-56E2-4E89-8742-BB57FF4245F9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{2E50B5D7-56E2-4E89-8742-BB57FF4245F9}.Debug|Any CPU.Build.0 = Debug|Any CPU {2E50B5D7-56E2-4E89-8742-BB57FF4245F9}.Debug|Any CPU.Build.0 = Debug|Any CPU
{2E50B5D7-56E2-4E89-8742-BB57FF4245F9}.Debug|x64.ActiveCfg = Debug|Any CPU
{2E50B5D7-56E2-4E89-8742-BB57FF4245F9}.Debug|x64.Build.0 = Debug|Any CPU
{2E50B5D7-56E2-4E89-8742-BB57FF4245F9}.Debug|x86.ActiveCfg = Debug|Any CPU
{2E50B5D7-56E2-4E89-8742-BB57FF4245F9}.Debug|x86.Build.0 = Debug|Any CPU
{2E50B5D7-56E2-4E89-8742-BB57FF4245F9}.Release|Any CPU.ActiveCfg = Release|Any CPU {2E50B5D7-56E2-4E89-8742-BB57FF4245F9}.Release|Any CPU.ActiveCfg = Release|Any CPU
{2E50B5D7-56E2-4E89-8742-BB57FF4245F9}.Release|Any CPU.Build.0 = Release|Any CPU {2E50B5D7-56E2-4E89-8742-BB57FF4245F9}.Release|Any CPU.Build.0 = Release|Any CPU
{2E50B5D7-56E2-4E89-8742-BB57FF4245F9}.Release|x64.ActiveCfg = Release|Any CPU
{2E50B5D7-56E2-4E89-8742-BB57FF4245F9}.Release|x64.Build.0 = Release|Any CPU
{2E50B5D7-56E2-4E89-8742-BB57FF4245F9}.Release|x86.ActiveCfg = Release|Any CPU
{2E50B5D7-56E2-4E89-8742-BB57FF4245F9}.Release|x86.Build.0 = Release|Any CPU
{26654BFD-EE9B-49BA-84BA-9156AC348076}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {26654BFD-EE9B-49BA-84BA-9156AC348076}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{26654BFD-EE9B-49BA-84BA-9156AC348076}.Debug|Any CPU.Build.0 = Debug|Any CPU {26654BFD-EE9B-49BA-84BA-9156AC348076}.Debug|Any CPU.Build.0 = Debug|Any CPU
{26654BFD-EE9B-49BA-84BA-9156AC348076}.Debug|x64.ActiveCfg = Debug|Any CPU
{26654BFD-EE9B-49BA-84BA-9156AC348076}.Debug|x64.Build.0 = Debug|Any CPU
{26654BFD-EE9B-49BA-84BA-9156AC348076}.Debug|x86.ActiveCfg = Debug|Any CPU
{26654BFD-EE9B-49BA-84BA-9156AC348076}.Debug|x86.Build.0 = Debug|Any CPU
{26654BFD-EE9B-49BA-84BA-9156AC348076}.Release|Any CPU.ActiveCfg = Release|Any CPU {26654BFD-EE9B-49BA-84BA-9156AC348076}.Release|Any CPU.ActiveCfg = Release|Any CPU
{26654BFD-EE9B-49BA-84BA-9156AC348076}.Release|Any CPU.Build.0 = Release|Any CPU {26654BFD-EE9B-49BA-84BA-9156AC348076}.Release|Any CPU.Build.0 = Release|Any CPU
{26654BFD-EE9B-49BA-84BA-9156AC348076}.Release|x64.ActiveCfg = Release|Any CPU
{26654BFD-EE9B-49BA-84BA-9156AC348076}.Release|x64.Build.0 = Release|Any CPU
{26654BFD-EE9B-49BA-84BA-9156AC348076}.Release|x86.ActiveCfg = Release|Any CPU
{26654BFD-EE9B-49BA-84BA-9156AC348076}.Release|x86.Build.0 = Release|Any CPU
{DA0B3995-A83A-4D7C-A964-ADFE1F58B1FA}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {DA0B3995-A83A-4D7C-A964-ADFE1F58B1FA}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{DA0B3995-A83A-4D7C-A964-ADFE1F58B1FA}.Debug|Any CPU.Build.0 = Debug|Any CPU {DA0B3995-A83A-4D7C-A964-ADFE1F58B1FA}.Debug|Any CPU.Build.0 = Debug|Any CPU
{DA0B3995-A83A-4D7C-A964-ADFE1F58B1FA}.Debug|x64.ActiveCfg = Debug|Any CPU
{DA0B3995-A83A-4D7C-A964-ADFE1F58B1FA}.Debug|x64.Build.0 = Debug|Any CPU
{DA0B3995-A83A-4D7C-A964-ADFE1F58B1FA}.Debug|x86.ActiveCfg = Debug|Any CPU
{DA0B3995-A83A-4D7C-A964-ADFE1F58B1FA}.Debug|x86.Build.0 = Debug|Any CPU
{DA0B3995-A83A-4D7C-A964-ADFE1F58B1FA}.Release|Any CPU.ActiveCfg = Release|Any CPU {DA0B3995-A83A-4D7C-A964-ADFE1F58B1FA}.Release|Any CPU.ActiveCfg = Release|Any CPU
{DA0B3995-A83A-4D7C-A964-ADFE1F58B1FA}.Release|Any CPU.Build.0 = Release|Any CPU {DA0B3995-A83A-4D7C-A964-ADFE1F58B1FA}.Release|Any CPU.Build.0 = Release|Any CPU
{DA0B3995-A83A-4D7C-A964-ADFE1F58B1FA}.Release|x64.ActiveCfg = Release|Any CPU
{DA0B3995-A83A-4D7C-A964-ADFE1F58B1FA}.Release|x64.Build.0 = Release|Any CPU
{DA0B3995-A83A-4D7C-A964-ADFE1F58B1FA}.Release|x86.ActiveCfg = Release|Any CPU
{DA0B3995-A83A-4D7C-A964-ADFE1F58B1FA}.Release|x86.Build.0 = Release|Any CPU
{1C0F86E4-A791-4150-B9B3-B761925681DD}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {1C0F86E4-A791-4150-B9B3-B761925681DD}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{1C0F86E4-A791-4150-B9B3-B761925681DD}.Debug|Any CPU.Build.0 = Debug|Any CPU {1C0F86E4-A791-4150-B9B3-B761925681DD}.Debug|Any CPU.Build.0 = Debug|Any CPU
{1C0F86E4-A791-4150-B9B3-B761925681DD}.Debug|x64.ActiveCfg = Debug|Any CPU
{1C0F86E4-A791-4150-B9B3-B761925681DD}.Debug|x64.Build.0 = Debug|Any CPU
{1C0F86E4-A791-4150-B9B3-B761925681DD}.Debug|x86.ActiveCfg = Debug|Any CPU
{1C0F86E4-A791-4150-B9B3-B761925681DD}.Debug|x86.Build.0 = Debug|Any CPU
{1C0F86E4-A791-4150-B9B3-B761925681DD}.Release|Any CPU.ActiveCfg = Release|Any CPU {1C0F86E4-A791-4150-B9B3-B761925681DD}.Release|Any CPU.ActiveCfg = Release|Any CPU
{1C0F86E4-A791-4150-B9B3-B761925681DD}.Release|Any CPU.Build.0 = Release|Any CPU {1C0F86E4-A791-4150-B9B3-B761925681DD}.Release|Any CPU.Build.0 = Release|Any CPU
{1C0F86E4-A791-4150-B9B3-B761925681DD}.Release|x64.ActiveCfg = Release|Any CPU
{1C0F86E4-A791-4150-B9B3-B761925681DD}.Release|x64.Build.0 = Release|Any CPU
{1C0F86E4-A791-4150-B9B3-B761925681DD}.Release|x86.ActiveCfg = Release|Any CPU
{1C0F86E4-A791-4150-B9B3-B761925681DD}.Release|x86.Build.0 = Release|Any CPU
{F4B7078B-C59A-46B8-881A-C3CEE2634498}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {F4B7078B-C59A-46B8-881A-C3CEE2634498}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{F4B7078B-C59A-46B8-881A-C3CEE2634498}.Debug|Any CPU.Build.0 = Debug|Any CPU {F4B7078B-C59A-46B8-881A-C3CEE2634498}.Debug|Any CPU.Build.0 = Debug|Any CPU
{F4B7078B-C59A-46B8-881A-C3CEE2634498}.Debug|x64.ActiveCfg = Debug|Any CPU
{F4B7078B-C59A-46B8-881A-C3CEE2634498}.Debug|x64.Build.0 = Debug|Any CPU
{F4B7078B-C59A-46B8-881A-C3CEE2634498}.Debug|x86.ActiveCfg = Debug|Any CPU
{F4B7078B-C59A-46B8-881A-C3CEE2634498}.Debug|x86.Build.0 = Debug|Any CPU
{F4B7078B-C59A-46B8-881A-C3CEE2634498}.Release|Any CPU.ActiveCfg = Release|Any CPU {F4B7078B-C59A-46B8-881A-C3CEE2634498}.Release|Any CPU.ActiveCfg = Release|Any CPU
{F4B7078B-C59A-46B8-881A-C3CEE2634498}.Release|Any CPU.Build.0 = Release|Any CPU {F4B7078B-C59A-46B8-881A-C3CEE2634498}.Release|Any CPU.Build.0 = Release|Any CPU
{F4B7078B-C59A-46B8-881A-C3CEE2634498}.Release|x64.ActiveCfg = Release|Any CPU
{F4B7078B-C59A-46B8-881A-C3CEE2634498}.Release|x64.Build.0 = Release|Any CPU
{F4B7078B-C59A-46B8-881A-C3CEE2634498}.Release|x86.ActiveCfg = Release|Any CPU
{F4B7078B-C59A-46B8-881A-C3CEE2634498}.Release|x86.Build.0 = Release|Any CPU
{11106F82-FC17-497D-8747-CF1A84BFA7F8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{11106F82-FC17-497D-8747-CF1A84BFA7F8}.Debug|Any CPU.Build.0 = Debug|Any CPU
{11106F82-FC17-497D-8747-CF1A84BFA7F8}.Debug|x64.ActiveCfg = Debug|Any CPU
{11106F82-FC17-497D-8747-CF1A84BFA7F8}.Debug|x64.Build.0 = Debug|Any CPU
{11106F82-FC17-497D-8747-CF1A84BFA7F8}.Debug|x86.ActiveCfg = Debug|Any CPU
{11106F82-FC17-497D-8747-CF1A84BFA7F8}.Debug|x86.Build.0 = Debug|Any CPU
{11106F82-FC17-497D-8747-CF1A84BFA7F8}.Release|Any CPU.ActiveCfg = Release|Any CPU
{11106F82-FC17-497D-8747-CF1A84BFA7F8}.Release|Any CPU.Build.0 = Release|Any CPU
{11106F82-FC17-497D-8747-CF1A84BFA7F8}.Release|x64.ActiveCfg = Release|Any CPU
{11106F82-FC17-497D-8747-CF1A84BFA7F8}.Release|x64.Build.0 = Release|Any CPU
{11106F82-FC17-497D-8747-CF1A84BFA7F8}.Release|x86.ActiveCfg = Release|Any CPU
{11106F82-FC17-497D-8747-CF1A84BFA7F8}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection EndGlobalSection
GlobalSection(SolutionProperties) = preSolution GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE HideSolutionNode = FALSE

View File

@ -1,150 +1,420 @@
using Asp.Versioning; using Asp.Versioning;
using AutoMapper;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using PortBlog.API.Models; using PortBlog.API.Models;
using PortBlog.API.Repositories.Contracts; using PortBlog.API.Services.Contracts;
namespace PortBlog.API.Controllers namespace PortBlog.API.Controllers
{ {
/// <summary> /// <summary>
/// Controller for administrative actions related to candidates and their resumes. /// Controller for administrative actions related to the logged-in candidate and their resume.
/// All endpoints derive the candidate identity from the access token claims.
/// </summary> /// </summary>
[Route("api/v{version:apiVersion}/admin")]
[ApiController] [ApiController]
[ApiVersion(1)] [ApiVersion("1.0")]
[Route("api/v{version:apiVersion}/admin")]
[Authorize] [Authorize]
public class AdminController(ILogger<CvController> logger, ICandidateRepository candidateRepository, IResumeRepository resumeRepository, IMapper mapper) : Controller public class AdminController(ILogger<CvController> logger, IAdminService adminService) : Controller
{ {
/// <summary> /// <summary>
/// Get hobbies of the candidate by candidateid /// Get hobbies of the logged-in candidate.
/// </summary> /// </summary>
/// <param name="candidateId">The id of the candidate whose hobbies to get</param> /// <returns>Hobbies and about details of the candidate</returns>
/// <returns>Hobbies of the candidate</returns>
/// <response code="200">Returns the requested hobbies of the candidate</response> /// <response code="200">Returns the requested hobbies of the candidate</response>
[HttpGet("GetHobbies/{candidateId}")] [HttpGet("GetHobbies")]
[ProducesResponseType(StatusCodes.Status404NotFound)] [ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status500InternalServerError)] [ProducesResponseType(StatusCodes.Status500InternalServerError)]
[ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status200OK)]
public async Task<ActionResult<AboutDto>> GetHobbies(int candidateId) public async Task<ActionResult<AboutDto>> GetHobbies()
{ {
try try
{ {
if (!await candidateRepository.CandidateExistAsync(candidateId)) var candidateId = adminService.GetCandidateIdFromClaims(User);
{ var aboutDetails = await adminService.GetHobbiesAsync(candidateId);
logger.LogInformation("Candidate with id {CandidateId} wasn't found when fetching about details.", candidateId); return Ok(aboutDetails);
return NotFound();
} }
catch (UnauthorizedAccessException ex)
var aboutDetails = await resumeRepository.GetHobbiesAsync(candidateId); {
logger.LogInformation(ex, "Unauthorized access when fetching hobbies.");
return Ok(mapper.Map<AboutDto>(aboutDetails)); return Unauthorized(ex.Message);
} }
catch (Exception ex) catch (Exception ex)
{ {
logger.LogCritical(ex, "Exception while getting about details for the candidate with id {CandidateId}.", candidateId); logger.LogCritical(ex, "Exception while getting about details.");
return StatusCode(500, "A problem happened while handling your request."); return StatusCode(500, "A problem happened while handling your request.");
} }
} }
/// <summary> /// <summary>
/// Get Candidate details with social links by candidateid /// Get contact details (candidate with social links) for the logged-in candidate.
/// </summary> /// </summary>
/// <param name="candidateId">The id of the candidate whose detials to get with social links</param>
/// <returns>Candidate details with social links</returns> /// <returns>Candidate details with social links</returns>
/// <response code="200">Returns the requested candidate details with social links</response> /// <response code="200">Returns the requested candidate details with social links</response>
[HttpGet("GetCandidateWithSocialLinks/{candidateId}")] [HttpGet("GetCandidateWithSocialLinks")]
[ProducesResponseType(StatusCodes.Status404NotFound)] [ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status500InternalServerError)] [ProducesResponseType(StatusCodes.Status500InternalServerError)]
[ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status200OK)]
public async Task<ActionResult<CandidateSocialLinksDto>> GetContact(int candidateId) public async Task<ActionResult<CandidateSocialLinksDto>> GetContact()
{ {
try try
{ {
if (!await candidateRepository.CandidateExistAsync(candidateId)) var candidateId = adminService.GetCandidateIdFromClaims(User);
{ var contact = await adminService.GetContactAsync(candidateId);
logger.LogInformation("Candidate with id {CandidateId} wasn't found when fetching candidate with social links.", candidateId); return Ok(contact);
return NotFound();
} }
catch (UnauthorizedAccessException ex)
var contact = await resumeRepository.GetCandidateWithSocialLinksAsync(candidateId); {
logger.LogInformation(ex, "Unauthorized access when fetching contact.");
return Ok(mapper.Map<CandidateSocialLinksDto>(contact)); return Unauthorized(ex.Message);
} }
catch (Exception ex) catch (Exception ex)
{ {
logger.LogCritical(ex, "Exception while getting contact for the candidate with social links with id {CandidateId}.", candidateId); logger.LogCritical(ex, "Exception while getting contact details.");
return StatusCode(500, "A problem happened while handling your request."); return StatusCode(500, "A problem happened while handling your request.");
} }
} }
/// <summary> /// <summary>
/// Get Candidate resume by candidateid /// Get resume for the logged-in candidate.
/// </summary> /// </summary>
/// <param name="candidateId">The id of the candidate whose resume to get</param>
/// <returns>Candidate resume</returns> /// <returns>Candidate resume</returns>
/// <response code="200">Returns the requested candidate resume</response> /// <response code="200">Returns the requested candidate resume</response>
[HttpGet("GetResume/{candidateId}")] [HttpGet("GetResume")]
[ProducesResponseType(StatusCodes.Status404NotFound)] [ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status500InternalServerError)] [ProducesResponseType(StatusCodes.Status500InternalServerError)]
[ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status200OK)]
public async Task<ActionResult<ResumeDto>> GetResume(int candidateId) public async Task<ActionResult<ResumeDto>> GetResume()
{ {
try try
{ {
if (!await candidateRepository.CandidateExistAsync(candidateId)) var candidateId = adminService.GetCandidateIdFromClaims(User);
{ var resume = await adminService.GetResumeAsync(candidateId);
logger.LogInformation("Candidate with id {CandidateId} wasn't found when fetching resume.", candidateId); return Ok(resume);
return NotFound();
} }
catch (UnauthorizedAccessException ex)
var resume = await resumeRepository.GetResumeAsync(candidateId); {
logger.LogInformation(ex, "Unauthorized access when fetching resume.");
return Ok(mapper.Map<ResumeDto>(resume)); return Unauthorized(ex.Message);
} }
catch (Exception ex) catch (Exception ex)
{ {
logger.LogCritical(ex, "Exception while getting resume for the candidate with id {CandidateId}.", candidateId); logger.LogCritical(ex, "Exception while getting resume.");
return StatusCode(500, "A problem happened while handling your request."); return StatusCode(500, "A problem happened while handling your request.");
} }
} }
/// <summary> /// <summary>
/// Get Candidate projects by candidateid /// Get projects for the logged-in candidate.
/// </summary> /// </summary>
/// <param name="candidateId">The id of the candidate whose projects to get</param>
/// <returns>Candidate projects</returns> /// <returns>Candidate projects</returns>
/// <response code="200">Returns the requested candidate projects</response> /// <response code="200">Returns the requested candidate projects</response>
[HttpGet("GetProjects/{candidateId}")] [HttpGet("GetProjects")]
[ProducesResponseType(StatusCodes.Status404NotFound)] [ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status500InternalServerError)] [ProducesResponseType(StatusCodes.Status500InternalServerError)]
[ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status200OK)]
public async Task<ActionResult<ProjectsDto>> GetProjects(int candidateId) public async Task<ActionResult<ProjectsDto>> GetProjects()
{ {
try try
{ {
if (!await candidateRepository.CandidateExistAsync(candidateId)) var candidateId = adminService.GetCandidateIdFromClaims(User);
{ var projects = await adminService.GetProjectsAsync(candidateId);
logger.LogInformation("Candidate with id {CandidateId} wasn't found when fetching projects.", candidateId); return Ok(projects);
return NotFound();
} }
catch (UnauthorizedAccessException ex)
var projects = await resumeRepository.GetProjectsAsync(candidateId); {
logger.LogInformation(ex, "Unauthorized access when fetching projects.");
return Ok(mapper.Map<ProjectsDto>(projects)); return Unauthorized(ex.Message);
} }
catch (Exception ex) catch (Exception ex)
{ {
logger.LogCritical(ex, "Exception while getting projects for the candidate with id {CandidateId}.", candidateId); logger.LogCritical(ex, "Exception while getting projects.");
return StatusCode(500, "A problem happened while handling your request.");
}
}
/// <summary>
/// Create or update a project for the logged-in candidate's resume.
/// </summary>
[HttpPost("UpsertProject")]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status500InternalServerError)]
[ProducesResponseType(StatusCodes.Status200OK)]
public async Task<ActionResult<ProjectDto>> UpsertProject([FromBody] ProjectDto projectDto)
{
try
{
if (!ModelState.IsValid)
{
return BadRequest(ModelState);
}
var candidateId = adminService.GetCandidateIdFromClaims(User);
var result = await adminService.UpsertProjectAsync(candidateId, projectDto);
return Ok(result);
}
catch (UnauthorizedAccessException ex)
{
logger.LogInformation(ex, "Unauthorized access when upserting project.");
return Unauthorized(ex.Message);
}
catch (Exception ex)
{
logger.LogCritical(ex, "Exception while upserting project.");
return StatusCode(500, "A problem happened while handling your request.");
}
}
/// <summary>
/// Delete a project from the logged-in candidate's resume.
/// </summary>
/// <param name="projectId">The id of the project to delete</param>
[HttpDelete("DeleteProject/{projectId}")]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status500InternalServerError)]
[ProducesResponseType(StatusCodes.Status200OK)]
public async Task<ActionResult> DeleteProject(int projectId)
{
try
{
var candidateId = adminService.GetCandidateIdFromClaims(User);
var deleted = await adminService.DeleteProjectAsync(candidateId, projectId);
if (!deleted)
{
return NotFound();
}
return Ok();
}
catch (UnauthorizedAccessException ex)
{
logger.LogInformation(ex, "Unauthorized access when deleting project.");
return Unauthorized(ex.Message);
}
catch (KeyNotFoundException ex)
{
logger.LogInformation(ex, "Resume not found when deleting project.");
return NotFound();
}
catch (Exception ex)
{
logger.LogCritical(ex, "Exception while deleting project {ProjectId}.", projectId);
return StatusCode(500, "A problem happened while handling your request.");
}
}
/// <summary>
/// Create, update, or remove hobbies and update the about section for the logged-in candidate.
/// Hobbies present in the list are added or updated; hobbies not in the list are removed.
/// </summary>
[HttpPost("UpsertHobbies")]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status500InternalServerError)]
[ProducesResponseType(StatusCodes.Status200OK)]
public async Task<ActionResult<AboutDto>> UpsertHobbies([FromBody] AboutDto aboutDto)
{
try
{
if (!ModelState.IsValid)
{
return BadRequest(ModelState);
}
var candidateId = adminService.GetCandidateIdFromClaims(User);
var result = await adminService.UpsertHobbiesAsync(candidateId, aboutDto);
return Ok(result);
}
catch (UnauthorizedAccessException ex)
{
logger.LogInformation(ex, "Unauthorized access when upserting hobbies.");
return Unauthorized(ex.Message);
}
catch (KeyNotFoundException ex)
{
logger.LogInformation(ex, "Resume not found when upserting hobbies.");
return NotFound();
}
catch (Exception ex)
{
logger.LogCritical(ex, "Exception while upserting hobbies.");
return StatusCode(500, "A problem happened while handling your request.");
}
}
/// <summary>
/// Create, update, or remove skills for the logged-in candidate's resume.
/// Skills present in the list are added or updated; skills not in the list are removed.
/// </summary>
[HttpPost("UpsertSkills")]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status500InternalServerError)]
[ProducesResponseType(StatusCodes.Status200OK)]
public async Task<ActionResult<IEnumerable<SkillDto>>> UpsertSkills([FromBody] IEnumerable<SkillDto> skillDtos)
{
try
{
if (!ModelState.IsValid)
{
return BadRequest(ModelState);
}
var candidateId = adminService.GetCandidateIdFromClaims(User);
var result = await adminService.UpsertSkillsAsync(candidateId, skillDtos);
return Ok(result);
}
catch (UnauthorizedAccessException ex)
{
logger.LogInformation(ex, "Unauthorized access when upserting skills.");
return Unauthorized(ex.Message);
}
catch (Exception ex)
{
logger.LogCritical(ex, "Exception while upserting skills.");
return StatusCode(500, "A problem happened while handling your request.");
}
}
/// <summary>
/// Create, update, or remove academics for the logged-in candidate's resume.
/// Academics present in the list are added or updated; academics not in the list are removed.
/// </summary>
[HttpPost("UpsertAcademics")]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status500InternalServerError)]
[ProducesResponseType(StatusCodes.Status200OK)]
public async Task<ActionResult<IEnumerable<AcademicDto>>> UpsertAcademics([FromBody] IEnumerable<AcademicDto> academicDtos)
{
try
{
if (!ModelState.IsValid)
{
return BadRequest(ModelState);
}
var candidateId = adminService.GetCandidateIdFromClaims(User);
var result = await adminService.UpsertAcademicsAsync(candidateId, academicDtos);
return Ok(result);
}
catch (UnauthorizedAccessException ex)
{
logger.LogInformation(ex, "Unauthorized access when upserting academics.");
return Unauthorized(ex.Message);
}
catch (Exception ex)
{
logger.LogCritical(ex, "Exception while upserting academics.");
return StatusCode(500, "A problem happened while handling your request.");
}
}
/// <summary>
/// Create, update, or remove experiences for the logged-in candidate's resume.
/// Experiences present in the list are added or updated; experiences not in the list are removed.
/// </summary>
[HttpPost("UpsertExperiences")]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status500InternalServerError)]
[ProducesResponseType(StatusCodes.Status200OK)]
public async Task<ActionResult<IEnumerable<ExperienceDto>>> UpsertExperiences([FromBody] IEnumerable<ExperienceDto> experienceDtos)
{
try
{
if (!ModelState.IsValid)
{
return BadRequest(ModelState);
}
var candidateId = adminService.GetCandidateIdFromClaims(User);
var result = await adminService.UpsertExperiencesAsync(candidateId, experienceDtos);
return Ok(result);
}
catch (UnauthorizedAccessException ex)
{
logger.LogInformation(ex, "Unauthorized access when upserting experiences.");
return Unauthorized(ex.Message);
}
catch (Exception ex)
{
logger.LogCritical(ex, "Exception while upserting experiences.");
return StatusCode(500, "A problem happened while handling your request.");
}
}
/// <summary>
/// Create, update, or remove certifications for the logged-in candidate's resume.
/// Certifications present in the list are added or updated; certifications not in the list are removed.
/// </summary>
[HttpPost("UpsertCertifications")]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status500InternalServerError)]
[ProducesResponseType(StatusCodes.Status200OK)]
public async Task<ActionResult<IEnumerable<CertificationDto>>> UpsertCertifications([FromBody] IEnumerable<CertificationDto> certificationDtos)
{
try
{
if (!ModelState.IsValid)
{
return BadRequest(ModelState);
}
var candidateId = adminService.GetCandidateIdFromClaims(User);
var result = await adminService.UpsertCertificationsAsync(candidateId, certificationDtos);
return Ok(result);
}
catch (UnauthorizedAccessException ex)
{
logger.LogInformation(ex, "Unauthorized access when upserting certifications.");
return Unauthorized(ex.Message);
}
catch (Exception ex)
{
logger.LogCritical(ex, "Exception while upserting certifications.");
return StatusCode(500, "A problem happened while handling your request.");
}
}
/// <summary>
/// Create or update contact information (candidate with social links) for the logged-in candidate.
/// </summary>
[HttpPost("UpsertContact")]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status500InternalServerError)]
[ProducesResponseType(StatusCodes.Status200OK)]
public async Task<ActionResult<CandidateSocialLinksDto>> UpsertContact([FromBody] CandidateSocialLinksDto contactDto)
{
try
{
if (!ModelState.IsValid)
{
return BadRequest(ModelState);
}
var candidateId = adminService.GetCandidateIdFromClaims(User);
var result = await adminService.UpsertContactAsync(candidateId, contactDto);
return Ok(result);
}
catch (UnauthorizedAccessException ex)
{
logger.LogInformation(ex, "Unauthorized access when upserting contact.");
return Unauthorized(ex.Message);
}
catch (KeyNotFoundException ex)
{
logger.LogInformation(ex, "Resume not found when upserting contact.");
return NotFound();
}
catch (Exception ex)
{
logger.LogCritical(ex, "Exception while upserting contact.");
return StatusCode(500, "A problem happened while handling your request."); return StatusCode(500, "A problem happened while handling your request.");
} }
} }

View File

@ -12,9 +12,9 @@ namespace PortBlog.API.Controllers
/// <summary> /// <summary>
/// Controller for handling authentication-related operations. /// Controller for handling authentication-related operations.
/// </summary> /// </summary>
[Route("api/v{version:apiVersion}/auth")]
[ApiController] [ApiController]
[ApiVersion(1)] [ApiVersion("1.0")]
[Route("api/v{version:apiVersion}/auth")]
public class AuthController(IAuthService authService, IMailService mailService, ICandidateRepository candidateRepository, ILogger<AuthController> logger, IMapper mapper, IConfiguration configuration, ITemplateService templateService, IAppDistributedCache cache) : ControllerBase public class AuthController(IAuthService authService, IMailService mailService, ICandidateRepository candidateRepository, ILogger<AuthController> logger, IMapper mapper, IConfiguration configuration, ITemplateService templateService, IAppDistributedCache cache) : ControllerBase
{ {
/// <summary> /// <summary>

View File

@ -10,7 +10,7 @@ namespace PortBlog.API.Controllers
{ {
[Route("blog/api/v{version:apiVersion}/posts")] [Route("blog/api/v{version:apiVersion}/posts")]
[ApiController] [ApiController]
[ApiVersion(1)] [ApiVersion("1.0")]
public class BlogController : ControllerBase public class BlogController : ControllerBase
{ {
private readonly ILogger<BlogController> _logger; private readonly ILogger<BlogController> _logger;

View File

@ -7,9 +7,9 @@ using PortBlog.API.Services.Contracts;
namespace PortBlog.API.Controllers namespace PortBlog.API.Controllers
{ {
[Route("api/v{versions:apiVersion}/cv")]
[ApiController] [ApiController]
[ApiVersion(1)] [ApiVersion("1.0")]
[Route("api/v{version:apiVersion}/cv")]
public class CvController : ControllerBase public class CvController : ControllerBase
{ {
private readonly ILogger<CvController> _logger; private readonly ILogger<CvController> _logger;
@ -37,7 +37,7 @@ namespace PortBlog.API.Controllers
/// <response code="200">Returns the requested cv of the candidate</response> /// <response code="200">Returns the requested cv of the candidate</response>
[HttpGet("{candidateId}")] [HttpGet("{candidateId}")]
[Obsolete] [Obsolete]
[ApiVersion(0.1, Deprecated = true)] [ApiVersion("0.1", Deprecated = true)]
[ProducesResponseType(StatusCodes.Status404NotFound)] [ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status400BadRequest)] [ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status500InternalServerError)] [ProducesResponseType(StatusCodes.Status500InternalServerError)]

View File

@ -1,9 +1,11 @@
using Microsoft.AspNetCore.Mvc; using Asp.Versioning;
using Microsoft.AspNetCore.Mvc;
namespace PortBlog.API.Controllers namespace PortBlog.API.Controllers
{ {
[Route("api/blog/post")] [ApiVersionNeutral]
[ApiController] [ApiController]
[Route("api/v{version:apiVersion}/blog/post")]
public class PostController : ControllerBase public class PostController : ControllerBase
{ {
private readonly ILogger<PostController> _logger; private readonly ILogger<PostController> _logger;

View File

@ -0,0 +1,28 @@
using Asp.Versioning.ApiExplorer;
using Microsoft.Extensions.Options;
using Microsoft.OpenApi.Models;
using Swashbuckle.AspNetCore.SwaggerGen;
public class ConfigureSwaggerOptions : IConfigureOptions<SwaggerGenOptions>
{
private readonly IApiVersionDescriptionProvider _provider;
public ConfigureSwaggerOptions(IApiVersionDescriptionProvider provider)
{
_provider = provider;
}
public void Configure(SwaggerGenOptions options)
{
foreach (var description in _provider.ApiVersionDescriptions)
{
options.SwaggerDoc(
description.GroupName,
new OpenApiInfo
{
Title = "PortBlog API",
Version = description.GroupName
});
}
}
}

View File

@ -21,6 +21,7 @@ namespace PortBlog.API.Extensions
services.AddTransient<IMailService, MailService>(); services.AddTransient<IMailService, MailService>();
services.AddTransient<IAuthService, AuthService>(); services.AddTransient<IAuthService, AuthService>();
services.AddTransient<ITemplateService, TemplateService>(); services.AddTransient<ITemplateService, TemplateService>();
services.AddScoped<IAdminService, AdminService>();
return services; return services;
} }
} }

View File

@ -2,7 +2,7 @@
{ {
public class AcademicDto public class AcademicDto
{ {
public int AcademicId { get; set; } public int? AcademicId { get; set; }
public string Institution { get; set; } = string.Empty; public string Institution { get; set; } = string.Empty;

View File

@ -2,7 +2,7 @@
{ {
public class CertificationDto public class CertificationDto
{ {
public int CertificationId { get; set; } public int? CertificationId { get; set; }
public string CertificationName { get; set; } = string.Empty; public string CertificationName { get; set; } = string.Empty;

View File

@ -2,7 +2,7 @@
{ {
public class ExperienceDetailsDto public class ExperienceDetailsDto
{ {
public int Id { get; set; } public int? Id { get; set; }
public string Details { get; set; } = string.Empty; public string Details { get; set; } = string.Empty;

View File

@ -4,7 +4,7 @@ namespace PortBlog.API.Models
{ {
public class ExperienceDto public class ExperienceDto
{ {
public int ExperienceId { get; set; } public int? ExperienceId { get; set; }
public string Title { get; set; } = string.Empty; public string Title { get; set; } = string.Empty;
@ -12,10 +12,31 @@ namespace PortBlog.API.Models
public string Company { get; set; } = string.Empty; public string Company { get; set; } = string.Empty;
public string? Location { get; set; }
/// <summary>
/// Full start date for write operations (e.g. "2020-01-01").
/// </summary>
public DateTime? StartDate { get; set; }
/// <summary>
/// Full end date for write operations. Null means "Present".
/// </summary>
public DateTime? EndDate { get; set; }
/// <summary>
/// Read-only display value: start year (e.g. "2020"). Mapped from entity on read.
/// </summary>
public string StartYear { get; set; } = string.Empty; public string StartYear { get; set; } = string.Empty;
/// <summary>
/// Read-only display value: end year (e.g. "2023" or "Present"). Mapped from entity on read.
/// </summary>
public string EndYear { get; set; } = string.Empty; public string EndYear { get; set; } = string.Empty;
/// <summary>
/// Read-only display value: formatted period (e.g. "Jan 2020 - Mar 2023"). Mapped from entity on read.
/// </summary>
public string Period { get; set; } = string.Empty; public string Period { get; set; } = string.Empty;
public ICollection<ExperienceDetailsDto> Details { get; set; } = new List<ExperienceDetailsDto>(); public ICollection<ExperienceDetailsDto> Details { get; set; } = new List<ExperienceDetailsDto>();

View File

@ -2,7 +2,7 @@
{ {
public class HobbyDto public class HobbyDto
{ {
public int HobbyId { get; set; } public int? HobbyId { get; set; }
public string Name { get; set; } = string.Empty; public string Name { get; set; } = string.Empty;

View File

@ -4,7 +4,7 @@ namespace PortBlog.API.Models
{ {
public class ProjectDto public class ProjectDto
{ {
public int ProjectId { get; set; } public int? ProjectId { get; set; }
public string Name { get; set; } = string.Empty; public string Name { get; set; } = string.Empty;

View File

@ -2,7 +2,7 @@
{ {
public class SkillDto public class SkillDto
{ {
public int SkillId { get; set; } public int? SkillId { get; set; }
public string Name { get; set; } = string.Empty; public string Name { get; set; } = string.Empty;

View File

@ -9,6 +9,7 @@
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Asp.Versioning.Mvc" Version="8.1.1" />
<PackageReference Include="Asp.Versioning.Mvc.ApiExplorer" Version="8.1.0" /> <PackageReference Include="Asp.Versioning.Mvc.ApiExplorer" Version="8.1.0" />
<PackageReference Include="AutoMapper" Version="15.0.1" /> <PackageReference Include="AutoMapper" Version="15.0.1" />
<PackageReference Include="BCrypt.Net-Next" Version="4.0.3" /> <PackageReference Include="BCrypt.Net-Next" Version="4.0.3" />

View File

@ -6,46 +6,90 @@
<members> <members>
<member name="T:PortBlog.API.Controllers.AdminController"> <member name="T:PortBlog.API.Controllers.AdminController">
<summary> <summary>
Controller for administrative actions related to candidates and their resumes. Controller for administrative actions related to the logged-in candidate and their resume.
All endpoints derive the candidate identity from the access token claims.
</summary> </summary>
</member> </member>
<member name="M:PortBlog.API.Controllers.AdminController.#ctor(Microsoft.Extensions.Logging.ILogger{PortBlog.API.Controllers.CvController},PortBlog.API.Repositories.Contracts.ICandidateRepository,PortBlog.API.Repositories.Contracts.IResumeRepository,AutoMapper.IMapper)"> <member name="M:PortBlog.API.Controllers.AdminController.#ctor(Microsoft.Extensions.Logging.ILogger{PortBlog.API.Controllers.CvController},PortBlog.API.Services.Contracts.IAdminService)">
<summary> <summary>
Controller for administrative actions related to candidates and their resumes. Controller for administrative actions related to the logged-in candidate and their resume.
All endpoints derive the candidate identity from the access token claims.
</summary> </summary>
</member> </member>
<member name="M:PortBlog.API.Controllers.AdminController.GetHobbies(System.Int32)"> <member name="M:PortBlog.API.Controllers.AdminController.GetHobbies">
<summary> <summary>
Get hobbies of the candidate by candidateid Get hobbies of the logged-in candidate.
</summary> </summary>
<param name="candidateId">The id of the candidate whose hobbies to get</param> <returns>Hobbies and about details of the candidate</returns>
<returns>Hobbies of the candidate</returns>
<response code="200">Returns the requested hobbies of the candidate</response> <response code="200">Returns the requested hobbies of the candidate</response>
</member> </member>
<member name="M:PortBlog.API.Controllers.AdminController.GetContact(System.Int32)"> <member name="M:PortBlog.API.Controllers.AdminController.GetContact">
<summary> <summary>
Get Candidate details with social links by candidateid Get contact details (candidate with social links) for the logged-in candidate.
</summary> </summary>
<param name="candidateId">The id of the candidate whose detials to get with social links</param>
<returns>Candidate details with social links</returns> <returns>Candidate details with social links</returns>
<response code="200">Returns the requested candidate details with social links</response> <response code="200">Returns the requested candidate details with social links</response>
</member> </member>
<member name="M:PortBlog.API.Controllers.AdminController.GetResume(System.Int32)"> <member name="M:PortBlog.API.Controllers.AdminController.GetResume">
<summary> <summary>
Get Candidate resume by candidateid Get resume for the logged-in candidate.
</summary> </summary>
<param name="candidateId">The id of the candidate whose resume to get</param>
<returns>Candidate resume</returns> <returns>Candidate resume</returns>
<response code="200">Returns the requested candidate resume</response> <response code="200">Returns the requested candidate resume</response>
</member> </member>
<member name="M:PortBlog.API.Controllers.AdminController.GetProjects(System.Int32)"> <member name="M:PortBlog.API.Controllers.AdminController.GetProjects">
<summary> <summary>
Get Candidate projects by candidateid Get projects for the logged-in candidate.
</summary> </summary>
<param name="candidateId">The id of the candidate whose projects to get</param>
<returns>Candidate projects</returns> <returns>Candidate projects</returns>
<response code="200">Returns the requested candidate projects</response> <response code="200">Returns the requested candidate projects</response>
</member> </member>
<member name="M:PortBlog.API.Controllers.AdminController.UpsertProject(PortBlog.API.Models.ProjectDto)">
<summary>
Create or update a project for the logged-in candidate's resume.
</summary>
</member>
<member name="M:PortBlog.API.Controllers.AdminController.DeleteProject(System.Int32)">
<summary>
Delete a project from the logged-in candidate's resume.
</summary>
<param name="projectId">The id of the project to delete</param>
</member>
<member name="M:PortBlog.API.Controllers.AdminController.UpsertHobbies(PortBlog.API.Models.AboutDto)">
<summary>
Create, update, or remove hobbies and update the about section for the logged-in candidate.
Hobbies present in the list are added or updated; hobbies not in the list are removed.
</summary>
</member>
<member name="M:PortBlog.API.Controllers.AdminController.UpsertSkills(System.Collections.Generic.IEnumerable{PortBlog.API.Models.SkillDto})">
<summary>
Create, update, or remove skills for the logged-in candidate's resume.
Skills present in the list are added or updated; skills not in the list are removed.
</summary>
</member>
<member name="M:PortBlog.API.Controllers.AdminController.UpsertAcademics(System.Collections.Generic.IEnumerable{PortBlog.API.Models.AcademicDto})">
<summary>
Create, update, or remove academics for the logged-in candidate's resume.
Academics present in the list are added or updated; academics not in the list are removed.
</summary>
</member>
<member name="M:PortBlog.API.Controllers.AdminController.UpsertExperiences(System.Collections.Generic.IEnumerable{PortBlog.API.Models.ExperienceDto})">
<summary>
Create, update, or remove experiences for the logged-in candidate's resume.
Experiences present in the list are added or updated; experiences not in the list are removed.
</summary>
</member>
<member name="M:PortBlog.API.Controllers.AdminController.UpsertCertifications(System.Collections.Generic.IEnumerable{PortBlog.API.Models.CertificationDto})">
<summary>
Create, update, or remove certifications for the logged-in candidate's resume.
Certifications present in the list are added or updated; certifications not in the list are removed.
</summary>
</member>
<member name="M:PortBlog.API.Controllers.AdminController.UpsertContact(PortBlog.API.Models.CandidateSocialLinksDto)">
<summary>
Create or update contact information (candidate with social links) for the logged-in candidate.
</summary>
</member>
<member name="T:PortBlog.API.Controllers.AuthController"> <member name="T:PortBlog.API.Controllers.AuthController">
<summary> <summary>
Controller for handling authentication-related operations. Controller for handling authentication-related operations.
@ -401,6 +445,31 @@
The project categories of all the projects The project categories of all the projects
</summary> </summary>
</member> </member>
<member name="P:PortBlog.API.Models.ExperienceDto.StartDate">
<summary>
Full start date for write operations (e.g. "2020-01-01").
</summary>
</member>
<member name="P:PortBlog.API.Models.ExperienceDto.EndDate">
<summary>
Full end date for write operations. Null means "Present".
</summary>
</member>
<member name="P:PortBlog.API.Models.ExperienceDto.StartYear">
<summary>
Read-only display value: start year (e.g. "2020"). Mapped from entity on read.
</summary>
</member>
<member name="P:PortBlog.API.Models.ExperienceDto.EndYear">
<summary>
Read-only display value: end year (e.g. "2023" or "Present"). Mapped from entity on read.
</summary>
</member>
<member name="P:PortBlog.API.Models.ExperienceDto.Period">
<summary>
Read-only display value: formatted period (e.g. "Jan 2020 - Mar 2023"). Mapped from entity on read.
</summary>
</member>
<member name="T:PortBlog.API.Models.ResumeDto"> <member name="T:PortBlog.API.Models.ResumeDto">
<summary> <summary>
CV details of the candidate CV details of the candidate
@ -421,6 +490,29 @@
The work experiences of the candidate The work experiences of the candidate
</summary> </summary>
</member> </member>
<member name="T:PortBlog.API.Repositories.ResumeRepository">
<summary>
Repository for managing resume-related data access and operations.
</summary>
</member>
<member name="M:PortBlog.API.Repositories.ResumeRepository.GetLatestResumeForCandidateAsync(System.Int32,System.Boolean)">
<summary>
Retrieves the latest resume for a candidate, optionally including related data.
</summary>
<param name="candidateId">The ID of the candidate.</param>
<param name="includeOtherData">If true, includes related data such as academics, experiences, social links, etc.</param>
<returns>The latest <see cref="T:PortBlog.API.Entities.Resume"/> for the candidate, or null if not found.</returns>
</member>
<member name="T:PortBlog.API.Services.AdminService">
<summary>
Service for administrative operations related to candidates, resumes, and their associated data.
</summary>
</member>
<member name="M:PortBlog.API.Services.AdminService.#ctor(PortBlog.API.Repositories.Contracts.ICandidateRepository,PortBlog.API.Repositories.Contracts.IResumeRepository,AutoMapper.IMapper)">
<summary>
Service for administrative operations related to candidates, resumes, and their associated data.
</summary>
</member>
<member name="T:PortBlog.API.Services.AuthService"> <member name="T:PortBlog.API.Services.AuthService">
<summary> <summary>
Provides authentication services such as OTP generation, validation, and JWT token management. Provides authentication services such as OTP generation, validation, and JWT token management.
@ -493,6 +585,11 @@
<param name="refreshToken">The refresh token to revoke.</param> <param name="refreshToken">The refresh token to revoke.</param>
<returns>A task that represents the asynchronous revoke operation.</returns> <returns>A task that represents the asynchronous revoke operation.</returns>
</member> </member>
<member name="T:PortBlog.API.Services.Contracts.IAdminService">
<summary>
Service for administrative operations related to candidates, resumes, and their associated data.
</summary>
</member>
<member name="T:PortBlog.API.Services.Contracts.IAuthService"> <member name="T:PortBlog.API.Services.Contracts.IAuthService">
<summary> <summary>
Provides authentication-related services such as OTP generation, token management, and refresh token handling. Provides authentication-related services such as OTP generation, token management, and refresh token handling.

View File

@ -35,6 +35,16 @@ namespace PortBlog.API.Profiles
( (
dest => dest.EndYear, dest => dest.EndYear,
opts => opts.MapFrom(src => src.EndDate != null ? src.EndDate.Value.Year.ToString() : "Present") opts => opts.MapFrom(src => src.EndDate != null ? src.EndDate.Value.Year.ToString() : "Present")
)
.ForMember
(
dest => dest.StartDate,
opts => opts.MapFrom(src => src.StartDate)
)
.ForMember
(
dest => dest.EndDate,
opts => opts.MapFrom(src => src.EndDate)
); );
CreateMap<ExperienceDetails, ExperienceDetailsDto>(); CreateMap<ExperienceDetails, ExperienceDetailsDto>();
CreateMap<Project, ProjectDto>() CreateMap<Project, ProjectDto>()

View File

@ -99,16 +99,18 @@ builder.Services.AddAutoMapper(cfg => cfg.AddMaps(AppDomain.CurrentDomain.GetAss
builder.Services.AddCache(builder.Configuration); builder.Services.AddCache(builder.Configuration);
// Registering API Versioning Specification services builder.Services
builder.Services.AddApiVersioning(setupAction => .AddApiVersioning(options =>
{ {
setupAction.ReportApiVersions = true; options.DefaultApiVersion = new ApiVersion(1, 0);
setupAction.AssumeDefaultVersionWhenUnspecified = true; options.AssumeDefaultVersionWhenUnspecified = true;
setupAction.DefaultApiVersion = new ApiVersion(1, 0); options.ReportApiVersions = true;
}).AddMvc() })
.AddApiExplorer(setupAction => .AddMvc() // IMPORTANT
.AddApiExplorer(options =>
{ {
setupAction.SubstituteApiVersionInUrl = true; options.GroupNameFormat = "'v'V";
options.SubstituteApiVersionInUrl = true;
}); });
builder.Services.AddSwaggerGen(c => builder.Services.AddSwaggerGen(c =>
@ -168,6 +170,8 @@ builder.Services.AddSwaggerGen(c =>
c.AddSecurityRequirement(requirement); c.AddSecurityRequirement(requirement);
}); });
builder.Services.ConfigureOptions<ConfigureSwaggerOptions>();
var jwtKey = builder.Configuration["Jwt:Key"]; var jwtKey = builder.Configuration["Jwt:Key"];
if (string.IsNullOrEmpty(jwtKey)) if (string.IsNullOrEmpty(jwtKey))
{ {
@ -196,8 +200,6 @@ builder.Services.AddAuthentication(options =>
var app = builder.Build(); var app = builder.Build();
app.UseCors();
if (!app.Environment.IsDevelopment()) if (!app.Environment.IsDevelopment())
{ {
app.UseExceptionHandler(); app.UseExceptionHandler();
@ -211,6 +213,12 @@ if (app.Environment.IsDevelopment())
{ {
// Get the IApiVersionDescriptionProvider from the app's service provider // Get the IApiVersionDescriptionProvider from the app's service provider
var apiVersionDescriptionProvider = app.Services.GetRequiredService<IApiVersionDescriptionProvider>(); var apiVersionDescriptionProvider = app.Services.GetRequiredService<IApiVersionDescriptionProvider>();
foreach (var d in apiVersionDescriptionProvider.ApiVersionDescriptions)
{
Console.WriteLine($"GroupName: {d.GroupName}");
}
foreach (var description in apiVersionDescriptionProvider.ApiVersionDescriptions) foreach (var description in apiVersionDescriptionProvider.ApiVersionDescriptions)
{ {
setupAction.SwaggerEndpoint( setupAction.SwaggerEndpoint(
@ -229,12 +237,16 @@ app.UseStaticFiles(new StaticFileOptions()
RequestPath = new PathString("/images") RequestPath = new PathString("/images")
}); });
app.UseRouting();
app.UseCors();
app.UseMiddleware<ApiKeyMiddleware>();
app.UseAuthentication(); app.UseAuthentication();
app.UseAuthorization(); app.UseAuthorization();
app.UseMiddleware<ApiKeyMiddleware>();
app.MapControllers(); app.MapControllers();
app.Run(); app.Run();

View File

@ -15,5 +15,72 @@ namespace PortBlog.API.Repositories.Contracts
Task<Resume?> GetProjectsAsync(int candidateId); Task<Resume?> GetProjectsAsync(int candidateId);
Task<Blog?> GetBlogAsync(int candidate); Task<Blog?> GetBlogAsync(int candidate);
// Added for admin write operations used by controllers
Task<Resume?> GetByIdAsync(int resumeId);
Task<Resume?> GetByIdWithCollectionsAsync(int resumeId);
Task<Project?> GetProjectAsync(int projectId);
Task<Hobby?> GetHobbyAsync(int hobbyId);
Task<IEnumerable<Hobby>> GetHobbiesByResumeIdAsync(int resumeId);
Task<Candidate?> GetCandidateByIdAsync(int candidateId);
void AddProject(Project project);
void UpdateProject(Project project);
void RemoveProject(Project project);
void AddHobby(Hobby hobby);
void UpdateHobby(Hobby hobby);
void RemoveHobby(Hobby hobby);
void AddSkill(Skill skill);
void UpdateSkill(Skill skill);
void RemoveSkill(Skill skill);
void AddAcademic(Academic academic);
void UpdateAcademic(Academic academic);
void RemoveAcademic(Academic academic);
void AddExperience(Experience experience);
void UpdateExperience(Experience experience);
void RemoveExperience(Experience experience);
void AddExperienceDetails(ExperienceDetails details);
void UpdateExperienceDetails(ExperienceDetails details);
void RemoveExperienceDetails(ExperienceDetails details);
void AddCertification(Certification certification);
void UpdateCertification(Certification certification);
void RemoveCertification(Certification certification);
void AddResume(Resume resume);
void UpdateResume(Resume resume);
void UpdateCandidate(Candidate candidate);
void AddSocialLink(SocialLinks socialLink);
void UpdateSocialLink(SocialLinks socialLink);
Task<bool> SaveChangesAsync();
} }
} }

View File

@ -1,11 +1,13 @@
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using PortBlog.API.DbContexts; using PortBlog.API.DbContexts;
using PortBlog.API.Entities; using PortBlog.API.Entities;
using PortBlog.API.Models;
using PortBlog.API.Repositories.Contracts; using PortBlog.API.Repositories.Contracts;
namespace PortBlog.API.Repositories namespace PortBlog.API.Repositories
{ {
/// <summary>
/// Repository for managing resume-related data access and operations.
/// </summary>
public class ResumeRepository : IResumeRepository public class ResumeRepository : IResumeRepository
{ {
private readonly CvBlogContext _cvBlogContext; private readonly CvBlogContext _cvBlogContext;
@ -14,6 +16,13 @@ namespace PortBlog.API.Repositories
{ {
_cvBlogContext = cvBlogContext; _cvBlogContext = cvBlogContext;
} }
/// <summary>
/// Retrieves the latest resume for a candidate, optionally including related data.
/// </summary>
/// <param name="candidateId">The ID of the candidate.</param>
/// <param name="includeOtherData">If true, includes related data such as academics, experiences, social links, etc.</param>
/// <returns>The latest <see cref="Resume"/> for the candidate, or null if not found.</returns>
public async Task<Resume?> GetLatestResumeForCandidateAsync(int candidateId, bool includeOtherData) public async Task<Resume?> GetLatestResumeForCandidateAsync(int candidateId, bool includeOtherData)
{ {
if (includeOtherData) if (includeOtherData)
@ -83,5 +92,180 @@ namespace PortBlog.API.Repositories
return result?.SocialLinks?.Blog; return result?.SocialLinks?.Blog;
} }
// New read helpers
public async Task<Resume?> GetByIdAsync(int resumeId)
{
return await _cvBlogContext.Resumes.FirstOrDefaultAsync(r => r.ResumeId == resumeId);
}
public async Task<Resume?> GetByIdWithCollectionsAsync(int resumeId)
{
return await _cvBlogContext.Resumes
.Include(r => r.Skills)
.Include(r => r.Academics)
.Include(r => r.Experiences)
.ThenInclude(e => e.Details)
.Include(r => r.Certifications)
.Include(r => r.Projects)
.FirstOrDefaultAsync(r => r.ResumeId == resumeId);
}
public async Task<Project?> GetProjectAsync(int projectId)
{
return await _cvBlogContext.Projects.FirstOrDefaultAsync(p => p.ProjectId == projectId);
}
public async Task<Hobby?> GetHobbyAsync(int hobbyId)
{
return await _cvBlogContext.Hobbies.FirstOrDefaultAsync(h => h.HobbyId == hobbyId);
}
public async Task<IEnumerable<Hobby>> GetHobbiesByResumeIdAsync(int resumeId)
{
return await _cvBlogContext.Hobbies.Where(h => h.ResumeId == resumeId).ToListAsync();
}
public async Task<Candidate?> GetCandidateByIdAsync(int candidateId)
{
return await _cvBlogContext.Candidates
.FirstOrDefaultAsync(c => c.CandidateId == candidateId);
}
// New write helpers
public void AddProject(Project project)
{
_cvBlogContext.Projects.Add(project);
}
public void UpdateProject(Project project)
{
_cvBlogContext.Projects.Update(project);
}
public void RemoveProject(Project project)
{
_cvBlogContext.Projects.Remove(project);
}
public void AddHobby(Hobby hobby)
{
_cvBlogContext.Hobbies.Add(hobby);
}
public void UpdateHobby(Hobby hobby)
{
_cvBlogContext.Hobbies.Update(hobby);
}
public void RemoveHobby(Hobby hobby)
{
_cvBlogContext.Hobbies.Remove(hobby);
}
public void AddSkill(Skill skill)
{
_cvBlogContext.Skills.Add(skill);
}
public void UpdateSkill(Skill skill)
{
_cvBlogContext.Skills.Update(skill);
}
public void RemoveSkill(Skill skill)
{
_cvBlogContext.Skills.Remove(skill);
}
public void AddAcademic(Academic academic)
{
_cvBlogContext.Academics.Add(academic);
}
public void UpdateAcademic(Academic academic)
{
_cvBlogContext.Academics.Update(academic);
}
public void RemoveAcademic(Academic academic)
{
_cvBlogContext.Academics.Remove(academic);
}
public void AddExperience(Experience experience)
{
_cvBlogContext.Experiences.Add(experience);
}
public void UpdateExperience(Experience experience)
{
_cvBlogContext.Experiences.Update(experience);
}
public void RemoveExperience(Experience experience)
{
_cvBlogContext.Experiences.Remove(experience);
}
public void AddExperienceDetails(ExperienceDetails details)
{
_cvBlogContext.ExperienceDetails.Add(details);
}
public void UpdateExperienceDetails(ExperienceDetails details)
{
_cvBlogContext.ExperienceDetails.Update(details);
}
public void RemoveExperienceDetails(ExperienceDetails details)
{
_cvBlogContext.ExperienceDetails.Remove(details);
}
public void AddCertification(Certification certification)
{
_cvBlogContext.Certifications.Add(certification);
}
public void UpdateCertification(Certification certification)
{
_cvBlogContext.Certifications.Update(certification);
}
public void RemoveCertification(Certification certification)
{
_cvBlogContext.Certifications.Remove(certification);
}
public void AddResume(Resume resume)
{
_cvBlogContext.Resumes.Add(resume);
}
public void UpdateResume(Resume resume)
{
_cvBlogContext.Resumes.Update(resume);
}
public void UpdateCandidate(Candidate candidate)
{
_cvBlogContext.Candidates.Update(candidate);
}
public void AddSocialLink(SocialLinks socialLink)
{
_cvBlogContext.SocialLinks.Add(socialLink);
}
public void UpdateSocialLink(SocialLinks socialLink)
{
_cvBlogContext.SocialLinks.Update(socialLink);
}
public async Task<bool> SaveChangesAsync()
{
return (await _cvBlogContext.SaveChangesAsync() >= 0);
}
} }
} }

View File

@ -0,0 +1,552 @@
using System.Security.Claims;
using AutoMapper;
using PortBlog.API.Entities;
using PortBlog.API.Models;
using PortBlog.API.Repositories.Contracts;
using PortBlog.API.Services.Contracts;
namespace PortBlog.API.Services
{
/// <summary>
/// Service for administrative operations related to candidates, resumes, and their associated data.
/// </summary>
public class AdminService(ICandidateRepository candidateRepository, IResumeRepository resumeRepository, IMapper mapper) : IAdminService
{
public int GetCandidateIdFromClaims(ClaimsPrincipal user)
{
var candidateIdClaim = user.FindFirst("CandidateId")?.Value;
if (string.IsNullOrEmpty(candidateIdClaim) || !int.TryParse(candidateIdClaim, out var candidateId))
{
throw new UnauthorizedAccessException("CandidateId claim is missing or invalid.");
}
return candidateId;
}
public async Task<AboutDto?> GetHobbiesAsync(int candidateId)
{
var aboutDetails = await resumeRepository.GetHobbiesAsync(candidateId);
return aboutDetails != null ? mapper.Map<AboutDto>(aboutDetails) : null;
}
public async Task<CandidateSocialLinksDto?> GetContactAsync(int candidateId)
{
var contact = await resumeRepository.GetCandidateWithSocialLinksAsync(candidateId);
return contact != null ? mapper.Map<CandidateSocialLinksDto>(contact) : null;
}
public async Task<ResumeDto?> GetResumeAsync(int candidateId)
{
var resume = await resumeRepository.GetResumeAsync(candidateId);
return resume != null ? mapper.Map<ResumeDto>(resume) : null;
}
public async Task<ProjectsDto?> GetProjectsAsync(int candidateId)
{
var projects = await resumeRepository.GetProjectsAsync(candidateId);
return projects != null ? mapper.Map<ProjectsDto>(projects) : null;
}
public async Task<ProjectDto> UpsertProjectAsync(int candidateId, ProjectDto projectDto)
{
var resumeWithCollections = await GetOrCreateResumeWithCollectionsAsync(candidateId);
Project? projectEntity = null;
if (projectDto.ProjectId > 0)
{
projectEntity = await resumeRepository.GetProjectAsync(projectDto.ProjectId.Value);
}
if (projectEntity == null)
{
projectEntity = new Project
{
Name = projectDto.Name,
Description = projectDto.Description,
Categories = projectDto.Categories ?? Array.Empty<string>(),
Roles = projectDto.Roles != null ? string.Join(",", projectDto.Roles) : null,
Responsibilities = projectDto.Responsibilities != null ? string.Join(",", projectDto.Responsibilities) : null,
TechnologiesUsed = projectDto.TechnologiesUsed != null ? string.Join(",", projectDto.TechnologiesUsed) : null,
Challenges = projectDto.Challenges,
LessonsLearned = projectDto.LessonsLearned,
Impact = projectDto.Impact,
StartDate = projectDto.StartDate == default ? null : projectDto.StartDate,
EndDate = projectDto.EndDate == default ? null : projectDto.EndDate,
ImagePath = projectDto.ImagePath,
Status = projectDto.Status,
ResumeId = resumeWithCollections.ResumeId
};
resumeRepository.AddProject(projectEntity);
}
else
{
projectEntity.Name = projectDto.Name;
projectEntity.Description = projectDto.Description;
projectEntity.Categories = projectDto.Categories ?? Array.Empty<string>();
projectEntity.Roles = projectDto.Roles != null ? string.Join(",", projectDto.Roles) : projectEntity.Roles;
projectEntity.Responsibilities = projectDto.Responsibilities != null ? string.Join(",", projectDto.Responsibilities) : projectEntity.Responsibilities;
projectEntity.TechnologiesUsed = projectDto.TechnologiesUsed != null ? string.Join(",", projectDto.TechnologiesUsed) : projectEntity.TechnologiesUsed;
projectEntity.Challenges = projectDto.Challenges;
projectEntity.LessonsLearned = projectDto.LessonsLearned;
projectEntity.Impact = projectDto.Impact;
projectEntity.StartDate = projectDto.StartDate == default ? projectEntity.StartDate : projectDto.StartDate;
projectEntity.EndDate = projectDto.EndDate == default ? projectEntity.EndDate : projectDto.EndDate;
projectEntity.ImagePath = projectDto.ImagePath;
projectEntity.Status = projectDto.Status;
resumeRepository.UpdateProject(projectEntity);
}
await resumeRepository.SaveChangesAsync();
return mapper.Map<ProjectDto>(projectEntity);
}
public async Task<bool> DeleteProjectAsync(int candidateId, int projectId)
{
var resume = await resumeRepository.GetLatestResumeForCandidateAsync(candidateId, includeOtherData: false)
?? throw new KeyNotFoundException($"Resume for candidate {candidateId} not found.");
var project = await resumeRepository.GetProjectAsync(projectId);
if (project == null || project.ResumeId != resume.ResumeId)
{
return false;
}
resumeRepository.RemoveProject(project);
await resumeRepository.SaveChangesAsync();
return true;
}
public async Task<AboutDto> UpsertHobbiesAsync(int candidateId, AboutDto aboutDto)
{
var resume = await resumeRepository.GetLatestResumeForCandidateAsync(candidateId, includeOtherData: false)
?? throw new KeyNotFoundException($"Resume for candidate {candidateId} not found.");
// Update About
if (!string.IsNullOrWhiteSpace(aboutDto.About))
{
resume.About = aboutDto.About;
resumeRepository.UpdateResume(resume);
}
// Upsert Hobbies
var existingHobbies = (await resumeRepository.GetHobbiesByResumeIdAsync(resume.ResumeId)).ToList();
var incomingIds = aboutDto.Hobbies.Where(h => h.HobbyId > 0).Select(h => h.HobbyId).ToHashSet();
foreach (var existing in existingHobbies)
{
if (!incomingIds.Contains(existing.HobbyId))
{
resumeRepository.RemoveHobby(existing);
}
}
var order = 1;
var resultEntities = new List<Hobby>();
foreach (var hobbyDto in aboutDto.Hobbies)
{
Hobby? hobbyEntity = null;
if (hobbyDto.HobbyId > 0)
{
hobbyEntity = existingHobbies.FirstOrDefault(h => h.HobbyId == hobbyDto.HobbyId);
}
if (hobbyEntity == null)
{
hobbyEntity = new Hobby
{
Name = hobbyDto.Name,
Description = hobbyDto.Description,
Icon = hobbyDto.Icon,
ResumeId = resume.ResumeId,
Order = order
};
resumeRepository.AddHobby(hobbyEntity);
}
else
{
hobbyEntity.Name = hobbyDto.Name;
hobbyEntity.Description = hobbyDto.Description;
hobbyEntity.Icon = hobbyDto.Icon;
hobbyEntity.Order = order;
resumeRepository.UpdateHobby(hobbyEntity);
}
resultEntities.Add(hobbyEntity);
order++;
}
await resumeRepository.SaveChangesAsync();
return new AboutDto
{
About = resume.About,
Hobbies = mapper.Map<ICollection<HobbyDto>>(resultEntities)
};
}
public async Task<CandidateSocialLinksDto> UpsertContactAsync(int candidateId, CandidateSocialLinksDto contactDto)
{
var resume = await resumeRepository.GetCandidateWithSocialLinksAsync(candidateId)
?? throw new KeyNotFoundException($"Resume with candidate id {candidateId} not found.");
if (!string.IsNullOrWhiteSpace(contactDto.Title))
{
resume.Title = contactDto.Title;
resumeRepository.UpdateResume(resume);
}
if (contactDto.Candidate != null && resume.Candidate != null)
{
resume.Candidate.FirstName = contactDto.Candidate.FirstName;
resume.Candidate.LastName = contactDto.Candidate.LastName;
resume.Candidate.Email = contactDto.Candidate.Email;
resume.Candidate.Phone = contactDto.Candidate.Phone;
resume.Candidate.Address = contactDto.Candidate.Address;
resumeRepository.UpdateCandidate(resume.Candidate);
}
if (contactDto.SocialLinks != null)
{
if (resume.SocialLinks == null)
{
resume.SocialLinks = new SocialLinks
{
ResumeId = resume.ResumeId,
GitHub = contactDto.SocialLinks.GitHub,
Linkedin = contactDto.SocialLinks.Linkedin,
Instagram = contactDto.SocialLinks.Instagram,
Facebook = contactDto.SocialLinks.Facebook,
Twitter = contactDto.SocialLinks.Twitter,
PersonalWebsite = contactDto.SocialLinks.PersonalWebsite,
BlogUrl = contactDto.SocialLinks.BlogUrl
};
resumeRepository.AddSocialLink(resume.SocialLinks);
}
else
{
resume.SocialLinks.GitHub = contactDto.SocialLinks.GitHub;
resume.SocialLinks.Linkedin = contactDto.SocialLinks.Linkedin;
resume.SocialLinks.Instagram = contactDto.SocialLinks.Instagram;
resume.SocialLinks.Facebook = contactDto.SocialLinks.Facebook;
resume.SocialLinks.Twitter = contactDto.SocialLinks.Twitter;
resume.SocialLinks.PersonalWebsite = contactDto.SocialLinks.PersonalWebsite;
resume.SocialLinks.BlogUrl = contactDto.SocialLinks.BlogUrl;
resumeRepository.UpdateSocialLink(resume.SocialLinks);
}
}
await resumeRepository.SaveChangesAsync();
var updatedContact = await resumeRepository.GetCandidateWithSocialLinksAsync(candidateId);
return mapper.Map<CandidateSocialLinksDto>(updatedContact);
}
public async Task<IEnumerable<SkillDto>> UpsertSkillsAsync(int candidateId, IEnumerable<SkillDto> skillDtos)
{
var resumeWithCollections = await GetOrCreateResumeWithCollectionsAsync(candidateId);
UpsertSkills(resumeWithCollections, skillDtos.ToList());
await resumeRepository.SaveChangesAsync();
var updatedResume = await resumeRepository.GetByIdWithCollectionsAsync(resumeWithCollections.ResumeId);
return mapper.Map<IEnumerable<SkillDto>>(updatedResume?.Skills ?? []);
}
public async Task<IEnumerable<AcademicDto>> UpsertAcademicsAsync(int candidateId, IEnumerable<AcademicDto> academicDtos)
{
var resumeWithCollections = await GetOrCreateResumeWithCollectionsAsync(candidateId);
UpsertAcademics(resumeWithCollections, academicDtos.ToList());
await resumeRepository.SaveChangesAsync();
var updatedResume = await resumeRepository.GetByIdWithCollectionsAsync(resumeWithCollections.ResumeId);
return mapper.Map<IEnumerable<AcademicDto>>(updatedResume?.Academics ?? []);
}
public async Task<IEnumerable<ExperienceDto>> UpsertExperiencesAsync(int candidateId, IEnumerable<ExperienceDto> experienceDtos)
{
var resumeWithCollections = await GetOrCreateResumeWithCollectionsAsync(candidateId);
UpsertExperiences(resumeWithCollections, experienceDtos.ToList());
await resumeRepository.SaveChangesAsync();
var updatedResume = await resumeRepository.GetByIdWithCollectionsAsync(resumeWithCollections.ResumeId);
return mapper.Map<IEnumerable<ExperienceDto>>(updatedResume?.Experiences ?? []);
}
public async Task<IEnumerable<CertificationDto>> UpsertCertificationsAsync(int candidateId, IEnumerable<CertificationDto> certificationDtos)
{
var resumeWithCollections = await GetOrCreateResumeWithCollectionsAsync(candidateId);
UpsertCertifications(resumeWithCollections, certificationDtos.ToList());
await resumeRepository.SaveChangesAsync();
var updatedResume = await resumeRepository.GetByIdWithCollectionsAsync(resumeWithCollections.ResumeId);
return mapper.Map<IEnumerable<CertificationDto>>(updatedResume?.Certifications ?? []);
}
#region Private Helpers
private async Task<Resume> GetOrCreateResumeWithCollectionsAsync(int candidateId)
{
var resume = await resumeRepository.GetLatestResumeForCandidateAsync(candidateId, includeOtherData: false);
if (resume == null)
{
resume = new Resume
{
CandidateId = candidateId,
About = string.Empty,
Order = 1
};
resumeRepository.AddResume(resume);
await resumeRepository.SaveChangesAsync();
}
return await resumeRepository.GetByIdWithCollectionsAsync(resume.ResumeId)
?? throw new InvalidOperationException("Failed to reload resume with collections.");
}
private void UpsertSkills(Resume resume, ICollection<SkillDto> skillDtos)
{
var existingSkills = resume.Skills.ToList();
var incomingIds = skillDtos.Where(s => s.SkillId > 0).Select(s => s.SkillId).ToHashSet();
foreach (var existing in existingSkills)
{
if (!incomingIds.Contains(existing.SkillId))
{
resumeRepository.RemoveSkill(existing);
}
}
foreach (var skillDto in skillDtos)
{
var skillEntity = skillDto.SkillId > 0
? existingSkills.FirstOrDefault(s => s.SkillId == skillDto.SkillId)
: null;
if (skillEntity == null)
{
resumeRepository.AddSkill(new Skill
{
Name = skillDto.Name,
Description = skillDto.Description,
ProficiencyLevel = skillDto.ProficiencyLevel,
ResumeId = resume.ResumeId
});
}
else
{
skillEntity.Name = skillDto.Name;
skillEntity.Description = skillDto.Description;
skillEntity.ProficiencyLevel = skillDto.ProficiencyLevel;
resumeRepository.UpdateSkill(skillEntity);
}
}
}
private void UpsertAcademics(Resume resume, ICollection<AcademicDto> academicDtos)
{
var existingAcademics = resume.Academics.ToList();
var incomingIds = academicDtos.Where(a => a.AcademicId > 0).Select(a => a.AcademicId).ToHashSet();
foreach (var existing in existingAcademics)
{
if (!incomingIds.Contains(existing.AcademicId))
{
resumeRepository.RemoveAcademic(existing);
}
}
foreach (var academicDto in academicDtos)
{
var academicEntity = academicDto.AcademicId > 0
? existingAcademics.FirstOrDefault(a => a.AcademicId == academicDto.AcademicId)
: null;
if (academicEntity == null)
{
resumeRepository.AddAcademic(new Academic
{
Institution = academicDto.Institution,
StartYear = academicDto.StartYear,
EndYear = academicDto.EndYear,
Degree = academicDto.Degree,
DegreeSpecialization = academicDto.DegreeSpecialization,
ResumeId = resume.ResumeId
});
}
else
{
academicEntity.Institution = academicDto.Institution;
academicEntity.StartYear = academicDto.StartYear;
academicEntity.EndYear = academicDto.EndYear;
academicEntity.Degree = academicDto.Degree;
academicEntity.DegreeSpecialization = academicDto.DegreeSpecialization;
resumeRepository.UpdateAcademic(academicEntity);
}
}
}
private void UpsertExperiences(Resume resume, ICollection<ExperienceDto> experienceDtos)
{
var existingExperiences = resume.Experiences.ToList();
var incomingIds = experienceDtos.Where(e => e.ExperienceId > 0).Select(e => e.ExperienceId).ToHashSet();
foreach (var existing in existingExperiences)
{
if (!incomingIds.Contains(existing.ExperienceId))
{
foreach (var detail in existing.Details.ToList())
{
resumeRepository.RemoveExperienceDetails(detail);
}
resumeRepository.RemoveExperience(existing);
}
}
foreach (var experienceDto in experienceDtos)
{
var experienceEntity = experienceDto.ExperienceId > 0
? existingExperiences.FirstOrDefault(e => e.ExperienceId == experienceDto.ExperienceId)
: null;
if (experienceEntity == null)
{
experienceEntity = new Experience
{
Title = experienceDto.Title,
Description = experienceDto.Description,
Company = experienceDto.Company,
Location = experienceDto.Location ?? string.Empty,
StartDate = experienceDto.StartDate ?? DateTime.UtcNow,
EndDate = experienceDto.EndDate,
ResumeId = resume.ResumeId
};
resumeRepository.AddExperience(experienceEntity);
var detailOrder = 1;
foreach (var detailDto in experienceDto.Details)
{
resumeRepository.AddExperienceDetails(new ExperienceDetails
{
Details = detailDto.Details,
Order = detailOrder++,
Experience = experienceEntity
});
}
}
else
{
experienceEntity.Title = experienceDto.Title;
experienceEntity.Description = experienceDto.Description;
experienceEntity.Company = experienceDto.Company;
if (!string.IsNullOrWhiteSpace(experienceDto.Location))
experienceEntity.Location = experienceDto.Location;
if (experienceDto.StartDate.HasValue)
experienceEntity.StartDate = experienceDto.StartDate.Value;
experienceEntity.EndDate = experienceDto.EndDate;
resumeRepository.UpdateExperience(experienceEntity);
UpsertExperienceDetails(experienceEntity, experienceDto.Details);
}
}
}
private void UpsertExperienceDetails(Experience experience, ICollection<ExperienceDetailsDto> detailDtos)
{
var existingDetails = experience.Details.ToList();
var incomingIds = detailDtos.Where(d => d.Id > 0).Select(d => d.Id).ToHashSet();
foreach (var existing in existingDetails)
{
if (!incomingIds.Contains(existing.Id))
{
resumeRepository.RemoveExperienceDetails(existing);
}
}
var order = 1;
foreach (var detailDto in detailDtos)
{
var detailEntity = detailDto.Id > 0
? existingDetails.FirstOrDefault(d => d.Id == detailDto.Id)
: null;
if (detailEntity == null)
{
resumeRepository.AddExperienceDetails(new ExperienceDetails
{
Details = detailDto.Details,
Order = order,
ExperienceId = experience.ExperienceId
});
}
else
{
detailEntity.Details = detailDto.Details;
detailEntity.Order = order;
resumeRepository.UpdateExperienceDetails(detailEntity);
}
order++;
}
}
private void UpsertCertifications(Resume resume, ICollection<CertificationDto> certificationDtos)
{
var existingCertifications = resume.Certifications.ToList();
var incomingIds = certificationDtos.Where(c => c.CertificationId > 0).Select(c => c.CertificationId).ToHashSet();
foreach (var existing in existingCertifications)
{
if (!incomingIds.Contains(existing.CertificationId))
{
resumeRepository.RemoveCertification(existing);
}
}
foreach (var certDto in certificationDtos)
{
var existingCert = certDto.CertificationId > 0
? existingCertifications.FirstOrDefault(c => c.CertificationId == certDto.CertificationId)
: null;
if (existingCert == null)
{
resumeRepository.AddCertification(new Certification
{
CertificationName = certDto.CertificationName,
IssuingOrganization = certDto.IssuingOrganization,
CertificationLink = certDto.CertificationLink,
IssueDate = certDto.IssueDate,
ExpiryDate = certDto.ExpiryDate,
ResumeId = resume.ResumeId
});
}
else
{
existingCert.CertificationName = certDto.CertificationName;
existingCert.IssuingOrganization = certDto.IssuingOrganization;
existingCert.CertificationLink = certDto.CertificationLink;
existingCert.IssueDate = certDto.IssueDate;
existingCert.ExpiryDate = certDto.ExpiryDate;
resumeRepository.UpdateCertification(existingCert);
}
}
}
#endregion
}
}

View File

@ -56,12 +56,19 @@ namespace PortBlog.API.Services
/// <returns>The generated JWT access token as a string.</returns> /// <returns>The generated JWT access token as a string.</returns>
public string GenerateAccessToken(string username) public string GenerateAccessToken(string username)
{ {
var claims = new[] var candidate = context.Candidates.FirstOrDefault(c => c.Email == username);
var claims = new List<Claim>
{ {
new Claim(ClaimTypes.Name, username), new Claim(ClaimTypes.Name, username),
new Claim(ClaimTypes.Role, "Admin") new Claim(ClaimTypes.Role, "Admin")
}; };
if (candidate != null)
{
claims.Add(new Claim("CandidateId", candidate.CandidateId.ToString()));
}
var jwtKey = configuration["Jwt:Key"]; var jwtKey = configuration["Jwt:Key"];
if (string.IsNullOrEmpty(jwtKey)) if (string.IsNullOrEmpty(jwtKey))
throw new InvalidOperationException("JWT key is not configured."); throw new InvalidOperationException("JWT key is not configured.");

View File

@ -0,0 +1,37 @@
using System.Security.Claims;
using PortBlog.API.Models;
namespace PortBlog.API.Services.Contracts
{
/// <summary>
/// Service for administrative operations related to candidates, resumes, and their associated data.
/// </summary>
public interface IAdminService
{
int GetCandidateIdFromClaims(ClaimsPrincipal user);
Task<AboutDto?> GetHobbiesAsync(int candidateId);
Task<CandidateSocialLinksDto?> GetContactAsync(int candidateId);
Task<ResumeDto?> GetResumeAsync(int candidateId);
Task<ProjectsDto?> GetProjectsAsync(int candidateId);
Task<ProjectDto> UpsertProjectAsync(int candidateId, ProjectDto projectDto);
Task<bool> DeleteProjectAsync(int candidateId, int projectId);
Task<AboutDto> UpsertHobbiesAsync(int candidateId, AboutDto aboutDto);
Task<IEnumerable<SkillDto>> UpsertSkillsAsync(int candidateId, IEnumerable<SkillDto> skillDtos);
Task<IEnumerable<AcademicDto>> UpsertAcademicsAsync(int candidateId, IEnumerable<AcademicDto> academicDtos);
Task<IEnumerable<ExperienceDto>> UpsertExperiencesAsync(int candidateId, IEnumerable<ExperienceDto> experienceDtos);
Task<IEnumerable<CertificationDto>> UpsertCertificationsAsync(int candidateId, IEnumerable<CertificationDto> certificationDtos);
Task<CandidateSocialLinksDto> UpsertContactAsync(int candidateId, CandidateSocialLinksDto contactDto);
}
}

View File

@ -0,0 +1,21 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="coverlet.collector" Version="6.0.4" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
</Project>