dev #4

Merged
rajukottedi merged 7 commits from dev into prod 2026-02-16 10:28:59 +05:30
25 changed files with 1496 additions and 128 deletions
Showing only changes of commit 5b7952877e - Show all commits

View File

@ -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

View File

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

View File

@ -12,9 +12,9 @@ namespace PortBlog.API.Controllers
/// <summary>
/// Controller for handling authentication-related operations.
/// </summary>
[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<AuthController> logger, IMapper mapper, IConfiguration configuration, ITemplateService templateService, IAppDistributedCache cache) : ControllerBase
{
/// <summary>

View File

@ -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<BlogController> _logger;

View File

@ -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<CvController> _logger;
@ -37,7 +37,7 @@ namespace PortBlog.API.Controllers
/// <response code="200">Returns the requested cv of the candidate</response>
[HttpGet("{candidateId}")]
[Obsolete]
[ApiVersion(0.1, Deprecated = true)]
[ApiVersion("0.1", Deprecated = true)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status500InternalServerError)]

View File

@ -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<PostController> _logger;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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; }
/// <summary>
/// Full start date for write operations (e.g. "2020-01-01").
/// </summary>
public DateTime? StartDate { get; set; }
/// <summary>
/// Full end date for write operations. Null means "Present".
/// </summary>
public DateTime? EndDate { get; set; }
/// <summary>
/// Read-only display value: start year (e.g. "2020"). Mapped from entity on read.
/// </summary>
public string StartYear { get; set; } = string.Empty;
/// <summary>
/// Read-only display value: end year (e.g. "2023" or "Present"). Mapped from entity on read.
/// </summary>
public string EndYear { get; set; } = string.Empty;
/// <summary>
/// Read-only display value: formatted period (e.g. "Jan 2020 - Mar 2023"). Mapped from entity on read.
/// </summary>
public string Period { get; set; } = string.Empty;
public ICollection<ExperienceDetailsDto> Details { get; set; } = new List<ExperienceDetailsDto>();

View File

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

View File

@ -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;

View File

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

View File

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

View File

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

View File

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

View File

@ -99,16 +99,18 @@ builder.Services.AddAutoMapper(cfg => cfg.AddMaps(AppDomain.CurrentDomain.GetAss
builder.Services.AddCache(builder.Configuration);
// Registering API Versioning Specification services
builder.Services.AddApiVersioning(setupAction =>
builder.Services
.AddApiVersioning(options =>
{
setupAction.ReportApiVersions = true;
setupAction.AssumeDefaultVersionWhenUnspecified = true;
setupAction.DefaultApiVersion = new ApiVersion(1, 0);
}).AddMvc()
.AddApiExplorer(setupAction =>
options.DefaultApiVersion = new ApiVersion(1, 0);
options.AssumeDefaultVersionWhenUnspecified = true;
options.ReportApiVersions = true;
})
.AddMvc() // IMPORTANT
.AddApiExplorer(options =>
{
setupAction.SubstituteApiVersionInUrl = true;
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<ConfigureSwaggerOptions>();
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<IApiVersionDescriptionProvider>();
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<ApiKeyMiddleware>();
app.UseAuthentication();
app.UseAuthorization();
app.UseMiddleware<ApiKeyMiddleware>();
app.MapControllers();
app.Run();

View File

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

View File

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

View File

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

View File

@ -56,12 +56,19 @@ namespace PortBlog.API.Services
/// <returns>The generated JWT access token as a string.</returns>
public string GenerateAccessToken(string username)
{
var claims = new[]
var candidate = context.Candidates.FirstOrDefault(c => c.Email == username);
var claims = new List<Claim>
{
new Claim(ClaimTypes.Name, username),
new Claim(ClaimTypes.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.");

View File

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

View File

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