diff --git a/PortBlog.API.sln b/PortBlog.API.sln
index cbbed79..6d7cdb6 100644
--- a/PortBlog.API.sln
+++ b/PortBlog.API.sln
@@ -1,7 +1,7 @@
Microsoft Visual Studio Solution File, Format Version 12.00
-# Visual Studio Version 17
-VisualStudioVersion = 17.9.34701.34
+# Visual Studio Version 18
+VisualStudioVersion = 18.1.11304.174
MinimumVisualStudioVersion = 10.0.40219.1
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "PortBlog.API", "PortBlog.API\PortBlog.API.csproj", "{2E50B5D7-56E2-4E89-8742-BB57FF4245F9}"
EndProject
@@ -15,32 +15,90 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "KBR.Shared", "Shared\KBR.Sh
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "KBR.Shared.Lite", "Shared\KBR.Share.Lite\KBR.Shared.Lite.csproj", "{F4B7078B-C59A-46B8-881A-C3CEE2634498}"
EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PortBlog.Tests", "PortBlog.Tests\PortBlog.Tests.csproj", "{11106F82-FC17-497D-8747-CF1A84BFA7F8}"
+EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
+ Debug|x64 = Debug|x64
+ Debug|x86 = Debug|x86
Release|Any CPU = Release|Any CPU
+ Release|x64 = Release|x64
+ Release|x86 = Release|x86
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{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|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.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.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.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.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.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.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.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.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.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
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
diff --git a/PortBlog.API/Controllers/AdminController.cs b/PortBlog.API/Controllers/AdminController.cs
index b6cb275..1ef275f 100644
--- a/PortBlog.API/Controllers/AdminController.cs
+++ b/PortBlog.API/Controllers/AdminController.cs
@@ -1,150 +1,420 @@
using Asp.Versioning;
-using AutoMapper;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using PortBlog.API.Models;
-using PortBlog.API.Repositories.Contracts;
+using PortBlog.API.Services.Contracts;
namespace PortBlog.API.Controllers
{
///
- /// 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.
///
- [Route("api/v{version:apiVersion}/admin")]
[ApiController]
- [ApiVersion(1)]
+ [ApiVersion("1.0")]
+ [Route("api/v{version:apiVersion}/admin")]
[Authorize]
- public class AdminController(ILogger logger, ICandidateRepository candidateRepository, IResumeRepository resumeRepository, IMapper mapper) : Controller
+ public class AdminController(ILogger logger, IAdminService adminService) : Controller
{
///
- /// Get hobbies of the candidate by candidateid
+ /// Get hobbies of the logged-in candidate.
///
- /// The id of the candidate whose hobbies to get
- /// Hobbies of the candidate
+ /// Hobbies and about details of the candidate
/// Returns the requested hobbies of the candidate
- [HttpGet("GetHobbies/{candidateId}")]
- [ProducesResponseType(StatusCodes.Status404NotFound)]
- [ProducesResponseType(StatusCodes.Status400BadRequest)]
+ [HttpGet("GetHobbies")]
+ [ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status500InternalServerError)]
[ProducesResponseType(StatusCodes.Status200OK)]
- public async Task> GetHobbies(int candidateId)
+ public async Task> GetHobbies()
{
try
{
- if (!await candidateRepository.CandidateExistAsync(candidateId))
- {
- logger.LogInformation("Candidate with id {CandidateId} wasn't found when fetching about details.", candidateId);
- return NotFound();
- }
-
- var aboutDetails = await resumeRepository.GetHobbiesAsync(candidateId);
-
- return Ok(mapper.Map(aboutDetails));
-
+ var candidateId = adminService.GetCandidateIdFromClaims(User);
+ var aboutDetails = await adminService.GetHobbiesAsync(candidateId);
+ return Ok(aboutDetails);
+ }
+ catch (UnauthorizedAccessException ex)
+ {
+ logger.LogInformation(ex, "Unauthorized access when fetching hobbies.");
+ return Unauthorized(ex.Message);
}
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.");
}
}
///
- /// Get Candidate details with social links by candidateid
+ /// Get contact details (candidate with social links) for the logged-in candidate.
///
- /// The id of the candidate whose detials to get with social links
- /// Candidate details with sociallinks
+ /// Candidate details with social links
/// Returns the requested candidate details with social links
- [HttpGet("GetCandidateWithSocialLinks/{candidateId}")]
- [ProducesResponseType(StatusCodes.Status404NotFound)]
- [ProducesResponseType(StatusCodes.Status400BadRequest)]
+ [HttpGet("GetCandidateWithSocialLinks")]
+ [ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status500InternalServerError)]
[ProducesResponseType(StatusCodes.Status200OK)]
- public async Task> GetContact(int candidateId)
+ public async Task> GetContact()
{
try
{
- if (!await candidateRepository.CandidateExistAsync(candidateId))
- {
- logger.LogInformation("Candidate with id {CandidateId} wasn't found when fetching candidate with social links.", candidateId);
- return NotFound();
- }
-
- var contact = await resumeRepository.GetCandidateWithSocialLinksAsync(candidateId);
-
- return Ok(mapper.Map(contact));
-
+ var candidateId = adminService.GetCandidateIdFromClaims(User);
+ var contact = await adminService.GetContactAsync(candidateId);
+ return Ok(contact);
+ }
+ catch (UnauthorizedAccessException ex)
+ {
+ logger.LogInformation(ex, "Unauthorized access when fetching contact.");
+ return Unauthorized(ex.Message);
}
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.");
}
}
///
- /// Get Candidate resume by candidateid
+ /// Get resume for the logged-in candidate.
///
- /// The id of the candidate whose resume to get
/// Candidate resume
/// Returns the requested candidate resume
- [HttpGet("GetResume/{candidateId}")]
- [ProducesResponseType(StatusCodes.Status404NotFound)]
- [ProducesResponseType(StatusCodes.Status400BadRequest)]
+ [HttpGet("GetResume")]
+ [ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status500InternalServerError)]
[ProducesResponseType(StatusCodes.Status200OK)]
- public async Task> GetResume(int candidateId)
+ public async Task> GetResume()
{
try
{
- if (!await candidateRepository.CandidateExistAsync(candidateId))
- {
- logger.LogInformation("Candidate with id {CandidateId} wasn't found when fetching resume.", candidateId);
- return NotFound();
- }
-
- var resume = await resumeRepository.GetResumeAsync(candidateId);
-
- return Ok(mapper.Map(resume));
-
+ var candidateId = adminService.GetCandidateIdFromClaims(User);
+ var resume = await adminService.GetResumeAsync(candidateId);
+ return Ok(resume);
+ }
+ catch (UnauthorizedAccessException ex)
+ {
+ logger.LogInformation(ex, "Unauthorized access when fetching resume.");
+ return Unauthorized(ex.Message);
}
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.");
}
}
///
- /// Get Candidate projects by candidateid
+ /// Get projects for the logged-in candidate.
///
- /// The id of the candidate whose projects to get
/// Candidate projects
/// Returns the requested candidate projects
- [HttpGet("GetProjects/{candidateId}")]
- [ProducesResponseType(StatusCodes.Status404NotFound)]
- [ProducesResponseType(StatusCodes.Status400BadRequest)]
+ [HttpGet("GetProjects")]
+ [ProducesResponseType(StatusCodes.Status401Unauthorized)]
[ProducesResponseType(StatusCodes.Status500InternalServerError)]
[ProducesResponseType(StatusCodes.Status200OK)]
- public async Task> GetProjects(int candidateId)
+ public async Task> GetProjects()
{
try
{
- if (!await candidateRepository.CandidateExistAsync(candidateId))
- {
- logger.LogInformation("Candidate with id {CandidateId} wasn't found when fetching projects.", candidateId);
- return NotFound();
- }
-
- var projects = await resumeRepository.GetProjectsAsync(candidateId);
-
- return Ok(mapper.Map(projects));
-
+ var candidateId = adminService.GetCandidateIdFromClaims(User);
+ var projects = await adminService.GetProjectsAsync(candidateId);
+ return Ok(projects);
+ }
+ catch (UnauthorizedAccessException ex)
+ {
+ logger.LogInformation(ex, "Unauthorized access when fetching projects.");
+ return Unauthorized(ex.Message);
}
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.");
+ }
+ }
+
+ ///
+ /// Create or update a project for the logged-in candidate's resume.
+ ///
+ [HttpPost("UpsertProject")]
+ [ProducesResponseType(StatusCodes.Status400BadRequest)]
+ [ProducesResponseType(StatusCodes.Status401Unauthorized)]
+ [ProducesResponseType(StatusCodes.Status500InternalServerError)]
+ [ProducesResponseType(StatusCodes.Status200OK)]
+ public async Task> 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.");
+ }
+ }
+
+ ///
+ /// Delete a project from the logged-in candidate's resume.
+ ///
+ /// The id of the project to delete
+ [HttpDelete("DeleteProject/{projectId}")]
+ [ProducesResponseType(StatusCodes.Status401Unauthorized)]
+ [ProducesResponseType(StatusCodes.Status404NotFound)]
+ [ProducesResponseType(StatusCodes.Status500InternalServerError)]
+ [ProducesResponseType(StatusCodes.Status200OK)]
+ public async Task 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.");
+ }
+ }
+
+ ///
+ /// 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.
+ ///
+ [HttpPost("UpsertHobbies")]
+ [ProducesResponseType(StatusCodes.Status400BadRequest)]
+ [ProducesResponseType(StatusCodes.Status401Unauthorized)]
+ [ProducesResponseType(StatusCodes.Status404NotFound)]
+ [ProducesResponseType(StatusCodes.Status500InternalServerError)]
+ [ProducesResponseType(StatusCodes.Status200OK)]
+ public async Task> 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.");
+ }
+ }
+
+ ///
+ /// 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.
+ ///
+ [HttpPost("UpsertSkills")]
+ [ProducesResponseType(StatusCodes.Status400BadRequest)]
+ [ProducesResponseType(StatusCodes.Status401Unauthorized)]
+ [ProducesResponseType(StatusCodes.Status500InternalServerError)]
+ [ProducesResponseType(StatusCodes.Status200OK)]
+ public async Task>> UpsertSkills([FromBody] IEnumerable 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.");
+ }
+ }
+
+ ///
+ /// 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.
+ ///
+ [HttpPost("UpsertAcademics")]
+ [ProducesResponseType(StatusCodes.Status400BadRequest)]
+ [ProducesResponseType(StatusCodes.Status401Unauthorized)]
+ [ProducesResponseType(StatusCodes.Status500InternalServerError)]
+ [ProducesResponseType(StatusCodes.Status200OK)]
+ public async Task>> UpsertAcademics([FromBody] IEnumerable 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.");
+ }
+ }
+
+ ///
+ /// 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.
+ ///
+ [HttpPost("UpsertExperiences")]
+ [ProducesResponseType(StatusCodes.Status400BadRequest)]
+ [ProducesResponseType(StatusCodes.Status401Unauthorized)]
+ [ProducesResponseType(StatusCodes.Status500InternalServerError)]
+ [ProducesResponseType(StatusCodes.Status200OK)]
+ public async Task>> UpsertExperiences([FromBody] IEnumerable 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.");
+ }
+ }
+
+ ///
+ /// 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.
+ ///
+ [HttpPost("UpsertCertifications")]
+ [ProducesResponseType(StatusCodes.Status400BadRequest)]
+ [ProducesResponseType(StatusCodes.Status401Unauthorized)]
+ [ProducesResponseType(StatusCodes.Status500InternalServerError)]
+ [ProducesResponseType(StatusCodes.Status200OK)]
+ public async Task>> UpsertCertifications([FromBody] IEnumerable 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.");
+ }
+ }
+
+ ///
+ /// Create or update contact information (candidate with social links) for the logged-in candidate.
+ ///
+ [HttpPost("UpsertContact")]
+ [ProducesResponseType(StatusCodes.Status400BadRequest)]
+ [ProducesResponseType(StatusCodes.Status401Unauthorized)]
+ [ProducesResponseType(StatusCodes.Status404NotFound)]
+ [ProducesResponseType(StatusCodes.Status500InternalServerError)]
+ [ProducesResponseType(StatusCodes.Status200OK)]
+ public async Task> 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.");
}
}
diff --git a/PortBlog.API/Controllers/AuthController.cs b/PortBlog.API/Controllers/AuthController.cs
index d1bf7dd..4b599f6 100644
--- a/PortBlog.API/Controllers/AuthController.cs
+++ b/PortBlog.API/Controllers/AuthController.cs
@@ -12,9 +12,9 @@ namespace PortBlog.API.Controllers
///
/// Controller for handling authentication-related operations.
///
- [Route("api/v{version:apiVersion}/auth")]
[ApiController]
- [ApiVersion(1)]
+ [ApiVersion("1.0")]
+ [Route("api/v{version:apiVersion}/auth")]
public class AuthController(IAuthService authService, IMailService mailService, ICandidateRepository candidateRepository, ILogger logger, IMapper mapper, IConfiguration configuration, ITemplateService templateService, IAppDistributedCache cache) : ControllerBase
{
///
diff --git a/PortBlog.API/Controllers/BlogController.cs b/PortBlog.API/Controllers/BlogController.cs
index ecc4ef5..b8319ef 100644
--- a/PortBlog.API/Controllers/BlogController.cs
+++ b/PortBlog.API/Controllers/BlogController.cs
@@ -10,7 +10,7 @@ namespace PortBlog.API.Controllers
{
[Route("blog/api/v{version:apiVersion}/posts")]
[ApiController]
- [ApiVersion(1)]
+ [ApiVersion("1.0")]
public class BlogController : ControllerBase
{
private readonly ILogger _logger;
diff --git a/PortBlog.API/Controllers/CvController.cs b/PortBlog.API/Controllers/CvController.cs
index da59e1d..f39e309 100644
--- a/PortBlog.API/Controllers/CvController.cs
+++ b/PortBlog.API/Controllers/CvController.cs
@@ -7,9 +7,9 @@ using PortBlog.API.Services.Contracts;
namespace PortBlog.API.Controllers
{
- [Route("api/v{versions:apiVersion}/cv")]
[ApiController]
- [ApiVersion(1)]
+ [ApiVersion("1.0")]
+ [Route("api/v{version:apiVersion}/cv")]
public class CvController : ControllerBase
{
private readonly ILogger _logger;
@@ -37,7 +37,7 @@ namespace PortBlog.API.Controllers
/// Returns the requested cv of the candidate
[HttpGet("{candidateId}")]
[Obsolete]
- [ApiVersion(0.1, Deprecated = true)]
+ [ApiVersion("0.1", Deprecated = true)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status500InternalServerError)]
diff --git a/PortBlog.API/Controllers/PostController.cs b/PortBlog.API/Controllers/PostController.cs
index bce5fd9..dda284b 100644
--- a/PortBlog.API/Controllers/PostController.cs
+++ b/PortBlog.API/Controllers/PostController.cs
@@ -1,9 +1,11 @@
-using Microsoft.AspNetCore.Mvc;
+using Asp.Versioning;
+using Microsoft.AspNetCore.Mvc;
namespace PortBlog.API.Controllers
{
- [Route("api/blog/post")]
+ [ApiVersionNeutral]
[ApiController]
+ [Route("api/v{version:apiVersion}/blog/post")]
public class PostController : ControllerBase
{
private readonly ILogger _logger;
diff --git a/PortBlog.API/Extensions/ConfigureSwaggerOptions.cs b/PortBlog.API/Extensions/ConfigureSwaggerOptions.cs
new file mode 100644
index 0000000..6e6a928
--- /dev/null
+++ b/PortBlog.API/Extensions/ConfigureSwaggerOptions.cs
@@ -0,0 +1,28 @@
+using Asp.Versioning.ApiExplorer;
+using Microsoft.Extensions.Options;
+using Microsoft.OpenApi.Models;
+using Swashbuckle.AspNetCore.SwaggerGen;
+
+public class ConfigureSwaggerOptions : IConfigureOptions
+{
+ 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
+ });
+ }
+ }
+}
diff --git a/PortBlog.API/Extensions/ServiceExtensions.cs b/PortBlog.API/Extensions/ServiceExtensions.cs
index 39b1282..4c98ba0 100644
--- a/PortBlog.API/Extensions/ServiceExtensions.cs
+++ b/PortBlog.API/Extensions/ServiceExtensions.cs
@@ -21,6 +21,7 @@ namespace PortBlog.API.Extensions
services.AddTransient();
services.AddTransient();
services.AddTransient();
+ services.AddScoped();
return services;
}
}
diff --git a/PortBlog.API/Models/AcademicDto.cs b/PortBlog.API/Models/AcademicDto.cs
index f2386fd..893fd0f 100644
--- a/PortBlog.API/Models/AcademicDto.cs
+++ b/PortBlog.API/Models/AcademicDto.cs
@@ -2,7 +2,7 @@
{
public class AcademicDto
{
- public int AcademicId { get; set; }
+ public int? AcademicId { get; set; }
public string Institution { get; set; } = string.Empty;
diff --git a/PortBlog.API/Models/CertificationDto.cs b/PortBlog.API/Models/CertificationDto.cs
index 8447ca0..650f68c 100644
--- a/PortBlog.API/Models/CertificationDto.cs
+++ b/PortBlog.API/Models/CertificationDto.cs
@@ -2,7 +2,7 @@
{
public class CertificationDto
{
- public int CertificationId { get; set; }
+ public int? CertificationId { get; set; }
public string CertificationName { get; set; } = string.Empty;
diff --git a/PortBlog.API/Models/ExperienceDetailsDto.cs b/PortBlog.API/Models/ExperienceDetailsDto.cs
index 034a67d..ec82888 100644
--- a/PortBlog.API/Models/ExperienceDetailsDto.cs
+++ b/PortBlog.API/Models/ExperienceDetailsDto.cs
@@ -2,7 +2,7 @@
{
public class ExperienceDetailsDto
{
- public int Id { get; set; }
+ public int? Id { get; set; }
public string Details { get; set; } = string.Empty;
diff --git a/PortBlog.API/Models/ExperienceDto.cs b/PortBlog.API/Models/ExperienceDto.cs
index cb9dc9f..ad6fe8e 100644
--- a/PortBlog.API/Models/ExperienceDto.cs
+++ b/PortBlog.API/Models/ExperienceDto.cs
@@ -4,7 +4,7 @@ namespace PortBlog.API.Models
{
public class ExperienceDto
{
- public int ExperienceId { get; set; }
+ public int? ExperienceId { get; set; }
public string Title { get; set; } = string.Empty;
@@ -12,10 +12,31 @@ namespace PortBlog.API.Models
public string Company { get; set; } = string.Empty;
+ public string? Location { get; set; }
+
+ ///
+ /// Full start date for write operations (e.g. "2020-01-01").
+ ///
+ public DateTime? StartDate { get; set; }
+
+ ///
+ /// Full end date for write operations. Null means "Present".
+ ///
+ public DateTime? EndDate { get; set; }
+
+ ///
+ /// Read-only display value: start year (e.g. "2020"). Mapped from entity on read.
+ ///
public string StartYear { get; set; } = string.Empty;
+ ///
+ /// Read-only display value: end year (e.g. "2023" or "Present"). Mapped from entity on read.
+ ///
public string EndYear { get; set; } = string.Empty;
+ ///
+ /// Read-only display value: formatted period (e.g. "Jan 2020 - Mar 2023"). Mapped from entity on read.
+ ///
public string Period { get; set; } = string.Empty;
public ICollection Details { get; set; } = new List();
diff --git a/PortBlog.API/Models/HobbyDto.cs b/PortBlog.API/Models/HobbyDto.cs
index 22d91ea..4116eb6 100644
--- a/PortBlog.API/Models/HobbyDto.cs
+++ b/PortBlog.API/Models/HobbyDto.cs
@@ -2,7 +2,7 @@
{
public class HobbyDto
{
- public int HobbyId { get; set; }
+ public int? HobbyId { get; set; }
public string Name { get; set; } = string.Empty;
diff --git a/PortBlog.API/Models/ProjectDto.cs b/PortBlog.API/Models/ProjectDto.cs
index be20506..8de39a2 100644
--- a/PortBlog.API/Models/ProjectDto.cs
+++ b/PortBlog.API/Models/ProjectDto.cs
@@ -4,7 +4,7 @@ namespace PortBlog.API.Models
{
public class ProjectDto
{
- public int ProjectId { get; set; }
+ public int? ProjectId { get; set; }
public string Name { get; set; } = string.Empty;
diff --git a/PortBlog.API/Models/SkillDto.cs b/PortBlog.API/Models/SkillDto.cs
index eb5581a..c83d3e3 100644
--- a/PortBlog.API/Models/SkillDto.cs
+++ b/PortBlog.API/Models/SkillDto.cs
@@ -2,7 +2,7 @@
{
public class SkillDto
{
- public int SkillId { get; set; }
+ public int? SkillId { get; set; }
public string Name { get; set; } = string.Empty;
diff --git a/PortBlog.API/PortBlog.API.csproj b/PortBlog.API/PortBlog.API.csproj
index 303fa5a..aabb708 100644
--- a/PortBlog.API/PortBlog.API.csproj
+++ b/PortBlog.API/PortBlog.API.csproj
@@ -9,6 +9,7 @@
+
diff --git a/PortBlog.API/PortBlog.API.xml b/PortBlog.API/PortBlog.API.xml
index 274a32b..9fd26c2 100644
--- a/PortBlog.API/PortBlog.API.xml
+++ b/PortBlog.API/PortBlog.API.xml
@@ -6,46 +6,90 @@
- 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.
-
+
- 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.
-
+
- Get hobbies of the candidate by candidateid
+ Get hobbies of the logged-in candidate.
- The id of the candidate whose hobbies to get
- Hobbies of the candidate
+ Hobbies and about details of the candidate
Returns the requested hobbies of the candidate
-
+
- Get Candidate details with social links by candidateid
+ Get contact details (candidate with social links) for the logged-in candidate.
- The id of the candidate whose detials to get with social links
- Candidate details with sociallinks
+ Candidate details with social links
Returns the requested candidate details with social links
-
+
- Get Candidate resume by candidateid
+ Get resume for the logged-in candidate.
- The id of the candidate whose resume to get
Candidate resume
Returns the requested candidate resume
-
+
- Get Candidate projects by candidateid
+ Get projects for the logged-in candidate.
- The id of the candidate whose projects to get
Candidate projects
Returns the requested candidate projects
+
+
+ Create or update a project for the logged-in candidate's resume.
+
+
+
+
+ Delete a project from the logged-in candidate's resume.
+
+ The id of the project to delete
+
+
+
+ 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.
+
+
+
+
+ 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.
+
+
+
+
+ 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.
+
+
+
+
+ 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.
+
+
+
+
+ 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.
+
+
+
+
+ Create or update contact information (candidate with social links) for the logged-in candidate.
+
+
Controller for handling authentication-related operations.
@@ -401,6 +445,31 @@
The project categories of all the projects
+
+
+ Full start date for write operations (e.g. "2020-01-01").
+
+
+
+
+ Full end date for write operations. Null means "Present".
+
+
+
+
+ Read-only display value: start year (e.g. "2020"). Mapped from entity on read.
+
+
+
+
+ Read-only display value: end year (e.g. "2023" or "Present"). Mapped from entity on read.
+
+
+
+
+ Read-only display value: formatted period (e.g. "Jan 2020 - Mar 2023"). Mapped from entity on read.
+
+
CV details of the candidate
@@ -421,6 +490,29 @@
The work experiences of the candidate
+
+
+ Repository for managing resume-related data access and operations.
+
+
+
+
+ Retrieves the latest resume for a candidate, optionally including related data.
+
+ The ID of the candidate.
+ If true, includes related data such as academics, experiences, social links, etc.
+ The latest for the candidate, or null if not found.
+
+
+
+ Service for administrative operations related to candidates, resumes, and their associated data.
+
+
+
+
+ Service for administrative operations related to candidates, resumes, and their associated data.
+
+
Provides authentication services such as OTP generation, validation, and JWT token management.
@@ -493,6 +585,11 @@
The refresh token to revoke.
A task that represents the asynchronous revoke operation.
+
+
+ Service for administrative operations related to candidates, resumes, and their associated data.
+
+
Provides authentication-related services such as OTP generation, token management, and refresh token handling.
diff --git a/PortBlog.API/Profiles/ResumeProfile.cs b/PortBlog.API/Profiles/ResumeProfile.cs
index a3ebc8d..975e1b2 100644
--- a/PortBlog.API/Profiles/ResumeProfile.cs
+++ b/PortBlog.API/Profiles/ResumeProfile.cs
@@ -35,6 +35,16 @@ namespace PortBlog.API.Profiles
(
dest => dest.EndYear,
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();
CreateMap()
diff --git a/PortBlog.API/Program.cs b/PortBlog.API/Program.cs
index 9d9e7d0..1389589 100644
--- a/PortBlog.API/Program.cs
+++ b/PortBlog.API/Program.cs
@@ -99,17 +99,19 @@ builder.Services.AddAutoMapper(cfg => cfg.AddMaps(AppDomain.CurrentDomain.GetAss
builder.Services.AddCache(builder.Configuration);
-// Registering API Versioning Specification services
-builder.Services.AddApiVersioning(setupAction =>
-{
- setupAction.ReportApiVersions = true;
- setupAction.AssumeDefaultVersionWhenUnspecified = true;
- setupAction.DefaultApiVersion = new ApiVersion(1, 0);
-}).AddMvc()
-.AddApiExplorer(setupAction =>
-{
- setupAction.SubstituteApiVersionInUrl = true;
-});
+builder.Services
+ .AddApiVersioning(options =>
+ {
+ options.DefaultApiVersion = new ApiVersion(1, 0);
+ options.AssumeDefaultVersionWhenUnspecified = true;
+ options.ReportApiVersions = true;
+ })
+ .AddMvc() // IMPORTANT
+ .AddApiExplorer(options =>
+ {
+ options.GroupNameFormat = "'v'V";
+ options.SubstituteApiVersionInUrl = true;
+ });
builder.Services.AddSwaggerGen(c =>
{
@@ -168,6 +170,8 @@ builder.Services.AddSwaggerGen(c =>
c.AddSecurityRequirement(requirement);
});
+builder.Services.ConfigureOptions();
+
var jwtKey = builder.Configuration["Jwt:Key"];
if (string.IsNullOrEmpty(jwtKey))
{
@@ -196,8 +200,6 @@ builder.Services.AddAuthentication(options =>
var app = builder.Build();
-app.UseCors();
-
if (!app.Environment.IsDevelopment())
{
app.UseExceptionHandler();
@@ -211,6 +213,12 @@ if (app.Environment.IsDevelopment())
{
// Get the IApiVersionDescriptionProvider from the app's service provider
var apiVersionDescriptionProvider = app.Services.GetRequiredService();
+
+ foreach (var d in apiVersionDescriptionProvider.ApiVersionDescriptions)
+ {
+ Console.WriteLine($"GroupName: {d.GroupName}");
+ }
+
foreach (var description in apiVersionDescriptionProvider.ApiVersionDescriptions)
{
setupAction.SwaggerEndpoint(
@@ -229,12 +237,16 @@ app.UseStaticFiles(new StaticFileOptions()
RequestPath = new PathString("/images")
});
+app.UseRouting();
+
+app.UseCors();
+
+app.UseMiddleware();
+
app.UseAuthentication();
app.UseAuthorization();
-app.UseMiddleware();
-
app.MapControllers();
app.Run();
diff --git a/PortBlog.API/Repositories/Contracts/IResumeRepository.cs b/PortBlog.API/Repositories/Contracts/IResumeRepository.cs
index c221769..9bd11d0 100644
--- a/PortBlog.API/Repositories/Contracts/IResumeRepository.cs
+++ b/PortBlog.API/Repositories/Contracts/IResumeRepository.cs
@@ -15,5 +15,72 @@ namespace PortBlog.API.Repositories.Contracts
Task GetProjectsAsync(int candidateId);
Task GetBlogAsync(int candidate);
+
+ // Added for admin write operations used by controllers
+ Task GetByIdAsync(int resumeId);
+
+ Task GetByIdWithCollectionsAsync(int resumeId);
+
+ Task GetProjectAsync(int projectId);
+
+ Task GetHobbyAsync(int hobbyId);
+
+ Task> GetHobbiesByResumeIdAsync(int resumeId);
+
+ Task 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 SaveChangesAsync();
}
}
diff --git a/PortBlog.API/Repositories/ResumeRepository.cs b/PortBlog.API/Repositories/ResumeRepository.cs
index 534ba9b..ea4368f 100644
--- a/PortBlog.API/Repositories/ResumeRepository.cs
+++ b/PortBlog.API/Repositories/ResumeRepository.cs
@@ -1,11 +1,13 @@
using Microsoft.EntityFrameworkCore;
using PortBlog.API.DbContexts;
using PortBlog.API.Entities;
-using PortBlog.API.Models;
using PortBlog.API.Repositories.Contracts;
namespace PortBlog.API.Repositories
{
+ ///
+ /// Repository for managing resume-related data access and operations.
+ ///
public class ResumeRepository : IResumeRepository
{
private readonly CvBlogContext _cvBlogContext;
@@ -14,9 +16,16 @@ namespace PortBlog.API.Repositories
{
_cvBlogContext = cvBlogContext;
}
+
+ ///
+ /// Retrieves the latest resume for a candidate, optionally including related data.
+ ///
+ /// The ID of the candidate.
+ /// If true, includes related data such as academics, experiences, social links, etc.
+ /// The latest for the candidate, or null if not found.
public async Task GetLatestResumeForCandidateAsync(int candidateId, bool includeOtherData)
{
- if(includeOtherData)
+ if (includeOtherData)
{
return await _cvBlogContext.Resumes
.Include(r => r.Candidate)
@@ -83,5 +92,180 @@ namespace PortBlog.API.Repositories
return result?.SocialLinks?.Blog;
}
+
+ // New read helpers
+ public async Task GetByIdAsync(int resumeId)
+ {
+ return await _cvBlogContext.Resumes.FirstOrDefaultAsync(r => r.ResumeId == resumeId);
+ }
+
+ public async Task 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 GetProjectAsync(int projectId)
+ {
+ return await _cvBlogContext.Projects.FirstOrDefaultAsync(p => p.ProjectId == projectId);
+ }
+
+ public async Task GetHobbyAsync(int hobbyId)
+ {
+ return await _cvBlogContext.Hobbies.FirstOrDefaultAsync(h => h.HobbyId == hobbyId);
+ }
+
+ public async Task> GetHobbiesByResumeIdAsync(int resumeId)
+ {
+ return await _cvBlogContext.Hobbies.Where(h => h.ResumeId == resumeId).ToListAsync();
+ }
+
+ public async Task 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 SaveChangesAsync()
+ {
+ return (await _cvBlogContext.SaveChangesAsync() >= 0);
+ }
}
}
diff --git a/PortBlog.API/Services/AdminService.cs b/PortBlog.API/Services/AdminService.cs
new file mode 100644
index 0000000..3add174
--- /dev/null
+++ b/PortBlog.API/Services/AdminService.cs
@@ -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
+{
+ ///
+ /// Service for administrative operations related to candidates, resumes, and their associated data.
+ ///
+ 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 GetHobbiesAsync(int candidateId)
+ {
+ var aboutDetails = await resumeRepository.GetHobbiesAsync(candidateId);
+ return aboutDetails != null ? mapper.Map(aboutDetails) : null;
+ }
+
+ public async Task GetContactAsync(int candidateId)
+ {
+ var contact = await resumeRepository.GetCandidateWithSocialLinksAsync(candidateId);
+ return contact != null ? mapper.Map(contact) : null;
+ }
+
+ public async Task GetResumeAsync(int candidateId)
+ {
+ var resume = await resumeRepository.GetResumeAsync(candidateId);
+ return resume != null ? mapper.Map(resume) : null;
+ }
+
+ public async Task GetProjectsAsync(int candidateId)
+ {
+ var projects = await resumeRepository.GetProjectsAsync(candidateId);
+ return projects != null ? mapper.Map(projects) : null;
+ }
+
+ public async Task 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(),
+ 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();
+ 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(projectEntity);
+ }
+
+ public async Task 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 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();
+
+ 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>(resultEntities)
+ };
+ }
+
+ public async Task 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(updatedContact);
+ }
+
+ public async Task> UpsertSkillsAsync(int candidateId, IEnumerable skillDtos)
+ {
+ var resumeWithCollections = await GetOrCreateResumeWithCollectionsAsync(candidateId);
+
+ UpsertSkills(resumeWithCollections, skillDtos.ToList());
+
+ await resumeRepository.SaveChangesAsync();
+
+ var updatedResume = await resumeRepository.GetByIdWithCollectionsAsync(resumeWithCollections.ResumeId);
+ return mapper.Map>(updatedResume?.Skills ?? []);
+ }
+
+ public async Task> UpsertAcademicsAsync(int candidateId, IEnumerable academicDtos)
+ {
+ var resumeWithCollections = await GetOrCreateResumeWithCollectionsAsync(candidateId);
+
+ UpsertAcademics(resumeWithCollections, academicDtos.ToList());
+
+ await resumeRepository.SaveChangesAsync();
+
+ var updatedResume = await resumeRepository.GetByIdWithCollectionsAsync(resumeWithCollections.ResumeId);
+ return mapper.Map>(updatedResume?.Academics ?? []);
+ }
+
+ public async Task> UpsertExperiencesAsync(int candidateId, IEnumerable experienceDtos)
+ {
+ var resumeWithCollections = await GetOrCreateResumeWithCollectionsAsync(candidateId);
+
+ UpsertExperiences(resumeWithCollections, experienceDtos.ToList());
+
+ await resumeRepository.SaveChangesAsync();
+
+ var updatedResume = await resumeRepository.GetByIdWithCollectionsAsync(resumeWithCollections.ResumeId);
+ return mapper.Map>(updatedResume?.Experiences ?? []);
+ }
+
+ public async Task> UpsertCertificationsAsync(int candidateId, IEnumerable certificationDtos)
+ {
+ var resumeWithCollections = await GetOrCreateResumeWithCollectionsAsync(candidateId);
+
+ UpsertCertifications(resumeWithCollections, certificationDtos.ToList());
+
+ await resumeRepository.SaveChangesAsync();
+
+ var updatedResume = await resumeRepository.GetByIdWithCollectionsAsync(resumeWithCollections.ResumeId);
+ return mapper.Map>(updatedResume?.Certifications ?? []);
+ }
+
+ #region Private Helpers
+
+ private async Task 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 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 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 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 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 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
+ }
+}
diff --git a/PortBlog.API/Services/AuthService.cs b/PortBlog.API/Services/AuthService.cs
index ae97e58..1ae4899 100644
--- a/PortBlog.API/Services/AuthService.cs
+++ b/PortBlog.API/Services/AuthService.cs
@@ -56,12 +56,19 @@ namespace PortBlog.API.Services
/// The generated JWT access token as a string.
public string GenerateAccessToken(string username)
{
- var claims = new[]
+ var candidate = context.Candidates.FirstOrDefault(c => c.Email == username);
+
+ var claims = new List
{
new Claim(ClaimTypes.Name, username),
new Claim(ClaimTypes.Role, "Admin")
};
+ if (candidate != null)
+ {
+ claims.Add(new Claim("CandidateId", candidate.CandidateId.ToString()));
+ }
+
var jwtKey = configuration["Jwt:Key"];
if (string.IsNullOrEmpty(jwtKey))
throw new InvalidOperationException("JWT key is not configured.");
diff --git a/PortBlog.API/Services/Contracts/IAdminService.cs b/PortBlog.API/Services/Contracts/IAdminService.cs
new file mode 100644
index 0000000..4f585c3
--- /dev/null
+++ b/PortBlog.API/Services/Contracts/IAdminService.cs
@@ -0,0 +1,37 @@
+using System.Security.Claims;
+using PortBlog.API.Models;
+
+namespace PortBlog.API.Services.Contracts
+{
+ ///
+ /// Service for administrative operations related to candidates, resumes, and their associated data.
+ ///
+ public interface IAdminService
+ {
+ int GetCandidateIdFromClaims(ClaimsPrincipal user);
+
+ Task GetHobbiesAsync(int candidateId);
+
+ Task GetContactAsync(int candidateId);
+
+ Task GetResumeAsync(int candidateId);
+
+ Task GetProjectsAsync(int candidateId);
+
+ Task UpsertProjectAsync(int candidateId, ProjectDto projectDto);
+
+ Task DeleteProjectAsync(int candidateId, int projectId);
+
+ Task UpsertHobbiesAsync(int candidateId, AboutDto aboutDto);
+
+ Task> UpsertSkillsAsync(int candidateId, IEnumerable skillDtos);
+
+ Task> UpsertAcademicsAsync(int candidateId, IEnumerable academicDtos);
+
+ Task> UpsertExperiencesAsync(int candidateId, IEnumerable experienceDtos);
+
+ Task> UpsertCertificationsAsync(int candidateId, IEnumerable certificationDtos);
+
+ Task UpsertContactAsync(int candidateId, CandidateSocialLinksDto contactDto);
+ }
+}
diff --git a/PortBlog.Tests/PortBlog.Tests.csproj b/PortBlog.Tests/PortBlog.Tests.csproj
new file mode 100644
index 0000000..db296aa
--- /dev/null
+++ b/PortBlog.Tests/PortBlog.Tests.csproj
@@ -0,0 +1,21 @@
+
+
+
+ net10.0
+ enable
+ enable
+ false
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file