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