Merge pull request 'feat: version display in web UI footer' (#11) from main into staging
Build and Push Docker Images Staging / build (push) Successful in 2m14s
Build and Push Docker Images Staging / build (push) Successful in 2m14s
Reviewed-on: #11
This commit was merged in pull request #11.
This commit is contained in:
@@ -29,8 +29,10 @@ namespace Api.Controllers
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Returns the public reCAPTCHA site key used by the client to render the widget.
|
/// Returns the public reCAPTCHA site key used by the client to render the widget.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <returns>200 OK with the configured public site key as a plain string.</returns>
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
[SwaggerOperation(Summary = "Get captcha site key")]
|
[SwaggerOperation(Summary = "Get captcha public key", Description = "Returns the public reCAPTCHA site key required by the frontend to render the challenge widget.")]
|
||||||
|
[SwaggerResponse(StatusCodes.Status200OK, "Public site key returned")]
|
||||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||||
public IActionResult GetSiteKey()
|
public IActionResult GetSiteKey()
|
||||||
{
|
{
|
||||||
@@ -38,13 +40,20 @@ namespace Api.Controllers
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Verify a captcha token and return the verification verdict.
|
/// Verifies a reCAPTCHA token submitted by the client and returns the full verification verdict.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <param name="req">The verification request containing the token and optional expected action name.</param>
|
||||||
|
/// <param name="ct">Cancellation token.</param>
|
||||||
|
/// <returns>
|
||||||
|
/// 200 OK with the full captcha verdict when verification passes;
|
||||||
|
/// 400 Bad Request with an <see cref="ErrorResponse"/> if the token is missing or verification fails.
|
||||||
|
/// </returns>
|
||||||
[HttpPost("verify")]
|
[HttpPost("verify")]
|
||||||
[SwaggerOperation(Summary = "Verify captcha token")]
|
[SwaggerOperation(Summary = "Verify captcha token", Description = "Verifies a reCAPTCHA token and returns the provider verdict including the score.")]
|
||||||
|
[SwaggerResponse(StatusCodes.Status200OK, "Token verified successfully")]
|
||||||
|
[SwaggerResponse(StatusCodes.Status400BadRequest, "Token missing or verification failed", typeof(ErrorResponse))]
|
||||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||||
[ProducesResponseType(typeof(ErrorResponse), StatusCodes.Status400BadRequest)]
|
[ProducesResponseType(typeof(ErrorResponse), StatusCodes.Status400BadRequest)]
|
||||||
[SwaggerResponse(StatusCodes.Status400BadRequest, "Captcha verification failed or token missing", typeof(ErrorResponse))]
|
|
||||||
public async Task<IActionResult> Verify([FromBody] CaptchaVerifyRequest req, CancellationToken ct)
|
public async Task<IActionResult> Verify([FromBody] CaptchaVerifyRequest req, CancellationToken ct)
|
||||||
{
|
{
|
||||||
if (req is null || string.IsNullOrWhiteSpace(req.Token))
|
if (req is null || string.IsNullOrWhiteSpace(req.Token))
|
||||||
|
|||||||
@@ -48,10 +48,18 @@ public sealed class CvMatcherController : ControllerBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Upload a CV PDF to the cv-matcher-api.
|
/// Proxies a CV PDF upload to the internal cv-matcher-api for indexing.
|
||||||
|
/// Validates the reCAPTCHA token and GDPR consent before forwarding.
|
||||||
|
/// Caches the uploaded file locally so it can be attached to the match result email.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="request">The uploaded CV request.</param>
|
/// <param name="request">Multipart form containing the CV PDF, captcha token, and GDPR consent flag.</param>
|
||||||
/// <param name="ct">Cancellation token.</param>
|
/// <param name="ct">Cancellation token.</param>
|
||||||
|
/// <returns>
|
||||||
|
/// 200 OK with the document ID and cache status from cv-matcher-api;
|
||||||
|
/// 400 Bad Request if the file is missing or captcha verification fails;
|
||||||
|
/// 499 if the client cancelled the request;
|
||||||
|
/// 502 Bad Gateway if the upstream cv-matcher-api call fails.
|
||||||
|
/// </returns>
|
||||||
[HttpPost("upload")]
|
[HttpPost("upload")]
|
||||||
[RequestSizeLimit(8 * 1024 * 1024)]
|
[RequestSizeLimit(8 * 1024 * 1024)]
|
||||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||||
@@ -109,10 +117,18 @@ public sealed class CvMatcherController : ControllerBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Proxy a job matching request to the cv-matcher-api.
|
/// Proxies a CV-to-job match request to the internal cv-matcher-api.
|
||||||
|
/// Validates the reCAPTCHA token, then forwards the request and emails the scored result to the user.
|
||||||
|
/// When an email is provided, also creates a one-time job-search token and appends the search link to the email.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="request">Job match request payload containing CV document id or job description/url.</param>
|
/// <param name="request">Match request containing the CV document ID, a job URL or inline description, and an optional recipient email.</param>
|
||||||
/// <param name="ct">Cancellation token.</param>
|
/// <param name="ct">Cancellation token.</param>
|
||||||
|
/// <returns>
|
||||||
|
/// 200 OK with the <see cref="JobMatchResponse"/> score, strengths, and gaps;
|
||||||
|
/// 400 Bad Request if captcha verification fails;
|
||||||
|
/// 499 if the client cancelled the request;
|
||||||
|
/// 502 Bad Gateway if the upstream cv-matcher-api call fails.
|
||||||
|
/// </returns>
|
||||||
[HttpPost("match-job")]
|
[HttpPost("match-job")]
|
||||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||||
[ProducesResponseType(typeof(ErrorResponse), StatusCodes.Status400BadRequest)]
|
[ProducesResponseType(typeof(ErrorResponse), StatusCodes.Status400BadRequest)]
|
||||||
@@ -182,8 +198,20 @@ public sealed class CvMatcherController : ControllerBase
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Validates a one-time job-search token and kicks off the background job search.
|
||||||
|
/// Returns a self-contained HTML page intended to be opened directly in the browser via the link in the match email.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="t">The one-time UUID token from the job-search link query string.</param>
|
||||||
|
/// <param name="ct">Cancellation token.</param>
|
||||||
|
/// <returns>
|
||||||
|
/// 200 OK with an HTML page indicating whether the search was started, the token was already used, expired, or invalid.
|
||||||
|
/// Always returns 200 — error states are communicated via the HTML page content, not the HTTP status code.
|
||||||
|
/// </returns>
|
||||||
[HttpGet("job-search/start")]
|
[HttpGet("job-search/start")]
|
||||||
[SwaggerOperation(Summary = "Start job search", Description = "Validates the one-time token and starts the background job search. Returns a simple HTML confirmation page.")]
|
[SwaggerOperation(Summary = "Start job search", Description = "Validates the one-time token and starts the background job search. Returns a self-contained HTML confirmation page.")]
|
||||||
|
[SwaggerResponse(StatusCodes.Status200OK, "HTML page returned for all token states (started, already used, expired, invalid)")]
|
||||||
|
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||||
public async Task<IActionResult> StartJobSearch([FromQuery] string t, CancellationToken ct)
|
public async Task<IActionResult> StartJobSearch([FromQuery] string t, CancellationToken ct)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
|
|||||||
@@ -66,7 +66,6 @@ namespace Api.Controllers
|
|||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
// Use default file name from settings if not provided
|
|
||||||
if (string.IsNullOrWhiteSpace(fileName))
|
if (string.IsNullOrWhiteSpace(fileName))
|
||||||
{
|
{
|
||||||
fileName = _fileStorageSettings.DefaultFileName;
|
fileName = _fileStorageSettings.DefaultFileName;
|
||||||
@@ -80,43 +79,30 @@ namespace Api.Controllers
|
|||||||
_logger.LogInformation("Using default file name from settings: {FileName}", fileName);
|
_logger.LogInformation("Using default file name from settings: {FileName}", fileName);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get the file storage path (relative to solution folder)
|
|
||||||
var fileStoragePath = _fileStorageSettings.Path;
|
var fileStoragePath = _fileStorageSettings.Path;
|
||||||
|
|
||||||
// If path is not absolute, make it relative to the solution root
|
|
||||||
if (!Path.IsPathRooted(fileStoragePath))
|
if (!Path.IsPathRooted(fileStoragePath))
|
||||||
{
|
{
|
||||||
var solutionRoot = Directory.GetCurrentDirectory();
|
var solutionRoot = Directory.GetCurrentDirectory();
|
||||||
// Go up from api folder to solution root if needed
|
|
||||||
if (solutionRoot.EndsWith("api", StringComparison.OrdinalIgnoreCase))
|
if (solutionRoot.EndsWith("api", StringComparison.OrdinalIgnoreCase))
|
||||||
{
|
|
||||||
solutionRoot = Directory.GetParent(solutionRoot)?.FullName ?? solutionRoot;
|
solutionRoot = Directory.GetParent(solutionRoot)?.FullName ?? solutionRoot;
|
||||||
}
|
|
||||||
fileStoragePath = Path.Combine(solutionRoot, fileStoragePath);
|
fileStoragePath = Path.Combine(solutionRoot, fileStoragePath);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sanitize fileName to prevent directory traversal attacks
|
|
||||||
var sanitizedFileName = Path.GetFileName(fileName);
|
var sanitizedFileName = Path.GetFileName(fileName);
|
||||||
var filePath = Path.Combine(fileStoragePath, sanitizedFileName);
|
var filePath = Path.Combine(fileStoragePath, sanitizedFileName);
|
||||||
|
|
||||||
// Verify file exists
|
|
||||||
if (!System.IO.File.Exists(filePath))
|
if (!System.IO.File.Exists(filePath))
|
||||||
{
|
{
|
||||||
_logger.LogWarning("File not found: {FilePath}", filePath);
|
_logger.LogWarning("File not found: {FilePath}", filePath);
|
||||||
return NotFound(new ErrorResponse { Error = "File not found", Code = "file_not_found" });
|
return NotFound(new ErrorResponse { Error = "File not found", Code = "file_not_found" });
|
||||||
}
|
}
|
||||||
|
|
||||||
var fileInfo = new FileInfo(filePath);
|
var fileLength = new FileInfo(filePath).Length;
|
||||||
var fileLength = fileInfo.Length;
|
|
||||||
|
|
||||||
// Determine content type
|
|
||||||
if (!_contentTypeProvider.TryGetContentType(filePath, out var contentType))
|
if (!_contentTypeProvider.TryGetContentType(filePath, out var contentType))
|
||||||
{
|
|
||||||
contentType = "application/octet-stream";
|
contentType = "application/octet-stream";
|
||||||
}
|
|
||||||
|
|
||||||
// Send email notification asynchronously (fire and forget with error handling)
|
|
||||||
// This is done before streaming to ensure notification is sent for both full and range downloads
|
|
||||||
var userIp = HttpContext.Connection.RemoteIpAddress?.ToString();
|
var userIp = HttpContext.Connection.RemoteIpAddress?.ToString();
|
||||||
_ = Task.Run(async () =>
|
_ = Task.Run(async () =>
|
||||||
{
|
{
|
||||||
@@ -130,19 +116,13 @@ namespace Api.Controllers
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Check if this is a range request
|
|
||||||
var rangeHeader = Request.Headers[HeaderNames.Range].ToString();
|
var rangeHeader = Request.Headers[HeaderNames.Range].ToString();
|
||||||
|
|
||||||
if (!string.IsNullOrEmpty(rangeHeader))
|
if (!string.IsNullOrEmpty(rangeHeader))
|
||||||
{
|
|
||||||
return await HandleRangeRequest(filePath, fileLength, contentType, rangeHeader, sanitizedFileName);
|
return await HandleRangeRequest(filePath, fileLength, contentType, rangeHeader, sanitizedFileName);
|
||||||
}
|
|
||||||
|
|
||||||
// Full file download
|
|
||||||
_logger.LogInformation("Starting full file download: {FileName} ({FileSize} bytes)", sanitizedFileName, fileLength);
|
_logger.LogInformation("Starting full file download: {FileName} ({FileSize} bytes)", sanitizedFileName, fileLength);
|
||||||
|
|
||||||
var stream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.Read, BufferSize, useAsync: true);
|
var stream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.Read, BufferSize, useAsync: true);
|
||||||
|
|
||||||
Response.Headers.Append(HeaderNames.AcceptRanges, "bytes");
|
Response.Headers.Append(HeaderNames.AcceptRanges, "bytes");
|
||||||
Response.Headers.Append(HeaderNames.ContentLength, fileLength.ToString());
|
Response.Headers.Append(HeaderNames.ContentLength, fileLength.ToString());
|
||||||
|
|
||||||
@@ -167,34 +147,25 @@ namespace Api.Controllers
|
|||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
// Parse range header (format: "bytes=start-end")
|
|
||||||
var range = rangeHeader.Replace("bytes=", "").Split('-');
|
var range = rangeHeader.Replace("bytes=", "").Split('-');
|
||||||
|
|
||||||
long startByte = 0;
|
long startByte = 0;
|
||||||
long endByte = fileLength - 1;
|
long endByte = fileLength - 1;
|
||||||
|
|
||||||
if (!string.IsNullOrEmpty(range[0]))
|
if (!string.IsNullOrEmpty(range[0]))
|
||||||
{
|
|
||||||
startByte = long.Parse(range[0]);
|
startByte = long.Parse(range[0]);
|
||||||
}
|
|
||||||
|
|
||||||
if (range.Length > 1 && !string.IsNullOrEmpty(range[1]))
|
if (range.Length > 1 && !string.IsNullOrEmpty(range[1]))
|
||||||
{
|
|
||||||
endByte = long.Parse(range[1]);
|
endByte = long.Parse(range[1]);
|
||||||
}
|
|
||||||
|
|
||||||
// Validate range
|
|
||||||
if (startByte > endByte || startByte >= fileLength)
|
if (startByte > endByte || startByte >= fileLength)
|
||||||
{
|
{
|
||||||
_logger.LogWarning("Invalid range request: {Range} for file size {FileLength}", rangeHeader, fileLength);
|
_logger.LogWarning("Invalid range request: {Range} for file size {FileLength}", rangeHeader, fileLength);
|
||||||
return StatusCode(StatusCodes.Status416RangeNotSatisfiable);
|
return StatusCode(StatusCodes.Status416RangeNotSatisfiable);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Adjust end byte if it exceeds file length
|
|
||||||
if (endByte >= fileLength)
|
if (endByte >= fileLength)
|
||||||
{
|
|
||||||
endByte = fileLength - 1;
|
endByte = fileLength - 1;
|
||||||
}
|
|
||||||
|
|
||||||
var contentLength = endByte - startByte + 1;
|
var contentLength = endByte - startByte + 1;
|
||||||
|
|
||||||
@@ -202,20 +173,16 @@ namespace Api.Controllers
|
|||||||
"Range request for {FileName}: bytes {Start}-{End}/{Total} ({ContentLength} bytes)",
|
"Range request for {FileName}: bytes {Start}-{End}/{Total} ({ContentLength} bytes)",
|
||||||
fileName, startByte, endByte, fileLength, contentLength);
|
fileName, startByte, endByte, fileLength, contentLength);
|
||||||
|
|
||||||
// Open file stream and seek to start position
|
|
||||||
var stream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.Read, BufferSize, useAsync: true);
|
var stream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.Read, BufferSize, useAsync: true);
|
||||||
stream.Seek(startByte, SeekOrigin.Begin);
|
stream.Seek(startByte, SeekOrigin.Begin);
|
||||||
|
|
||||||
// Set response headers for partial content
|
|
||||||
Response.StatusCode = StatusCodes.Status206PartialContent;
|
Response.StatusCode = StatusCodes.Status206PartialContent;
|
||||||
Response.Headers.Append(HeaderNames.AcceptRanges, "bytes");
|
Response.Headers.Append(HeaderNames.AcceptRanges, "bytes");
|
||||||
Response.Headers.Append(HeaderNames.ContentRange, $"bytes {startByte}-{endByte}/{fileLength}");
|
Response.Headers.Append(HeaderNames.ContentRange, $"bytes {startByte}-{endByte}/{fileLength}");
|
||||||
Response.Headers.Append(HeaderNames.ContentLength, contentLength.ToString());
|
Response.Headers.Append(HeaderNames.ContentLength, contentLength.ToString());
|
||||||
Response.ContentType = contentType;
|
Response.ContentType = contentType;
|
||||||
|
|
||||||
// Stream the requested range
|
|
||||||
await StreamRangeAsync(stream, Response.Body, contentLength);
|
await StreamRangeAsync(stream, Response.Body, contentLength);
|
||||||
|
|
||||||
await stream.DisposeAsync();
|
await stream.DisposeAsync();
|
||||||
|
|
||||||
return new EmptyResult();
|
return new EmptyResult();
|
||||||
@@ -241,9 +208,7 @@ namespace Api.Controllers
|
|||||||
var bytesRead = await source.ReadAsync(buffer.AsMemory(0, bytesToReadThisIteration));
|
var bytesRead = await source.ReadAsync(buffer.AsMemory(0, bytesToReadThisIteration));
|
||||||
|
|
||||||
if (bytesRead == 0)
|
if (bytesRead == 0)
|
||||||
{
|
break;
|
||||||
break; // End of stream
|
|
||||||
}
|
|
||||||
|
|
||||||
await destination.WriteAsync(buffer.AsMemory(0, bytesRead));
|
await destination.WriteAsync(buffer.AsMemory(0, bytesRead));
|
||||||
totalBytesRead += bytesRead;
|
totalBytesRead += bytesRead;
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
|
using System.Reflection;
|
||||||
using Microsoft.AspNetCore.Cors;
|
using Microsoft.AspNetCore.Cors;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using StartupHelpers;
|
||||||
using Swashbuckle.AspNetCore.Annotations;
|
using Swashbuckle.AspNetCore.Annotations;
|
||||||
|
|
||||||
namespace Api.Controllers
|
namespace Api.Controllers
|
||||||
@@ -14,6 +16,20 @@ namespace Api.Controllers
|
|||||||
[EnableCors("FrontendOnly")]
|
[EnableCors("FrontendOnly")]
|
||||||
public sealed class HealthController : ControllerBase
|
public sealed class HealthController : ControllerBase
|
||||||
{
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Returns the deployed API version baked into the assembly at build time.
|
||||||
|
/// The version format is <c>1.0.0-build.{yyyyMMddHHmmss}</c> as defined in <c>api.csproj</c>.
|
||||||
|
/// Used by the web frontend to display the running build in the page footer.
|
||||||
|
/// </summary>
|
||||||
|
/// <returns>200 OK with JSON payload: <c>{ "version": "1.0.0-build.20250522103045" }</c>.</returns>
|
||||||
|
// GET api/health/version
|
||||||
|
[HttpGet("version")]
|
||||||
|
[SwaggerOperation(Summary = "API version", Description = "Returns the deployed API assembly version.")]
|
||||||
|
[SwaggerResponse(StatusCodes.Status200OK, "Version returned")]
|
||||||
|
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||||
|
public IActionResult Version() =>
|
||||||
|
Ok(new { version = StartupExtensions.GetApplicationVersion(Assembly.GetExecutingAssembly()) });
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Liveness probe.
|
/// Liveness probe.
|
||||||
/// Indicates whether the process is running. Used by orchestration systems to confirm the process is alive.
|
/// Indicates whether the process is running. Used by orchestration systems to confirm the process is alive.
|
||||||
|
|||||||
@@ -8,6 +8,10 @@ using Shared.Models.Responses;
|
|||||||
|
|
||||||
namespace Api.Controllers;
|
namespace Api.Controllers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Internal endpoints for CV indexing and job-matching operations.
|
||||||
|
/// Routes are prefixed with <c>api/cv</c>. Protected by the internal API key middleware — not reachable from the public internet.
|
||||||
|
/// </summary>
|
||||||
[ApiController]
|
[ApiController]
|
||||||
[Route("api/cv")]
|
[Route("api/cv")]
|
||||||
public sealed class CvController : ControllerBase
|
public sealed class CvController : ControllerBase
|
||||||
@@ -21,11 +25,21 @@ public sealed class CvController : ControllerBase
|
|||||||
_logger = logger;
|
_logger = logger;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Uploads and indexes a CV PDF into the RAG vector store.
|
||||||
|
/// Returns from cache immediately if an identical document was previously indexed.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="request">Multipart form containing the CV PDF file.</param>
|
||||||
|
/// <param name="ct">Cancellation token.</param>
|
||||||
|
/// <returns>
|
||||||
|
/// 200 OK with a <see cref="CvUploadResponse"/> containing the document ID and whether it was a cache hit;
|
||||||
|
/// 400 Bad Request if the file is missing or the request is otherwise invalid.
|
||||||
|
/// </returns>
|
||||||
[HttpPost("upload")]
|
[HttpPost("upload")]
|
||||||
[RequestSizeLimit(10 * 1024 * 1024)]
|
[RequestSizeLimit(10 * 1024 * 1024)]
|
||||||
[SwaggerOperation(Summary = "Upload CV document", Description = "Uploads a CV PDF and indexes it for matching.")]
|
[SwaggerOperation(Summary = "Upload CV document", Description = "Uploads a CV PDF and indexes it into the RAG vector store. Returns from cache if the same document was previously uploaded.")]
|
||||||
[SwaggerResponse(StatusCodes.Status200OK, "CV uploaded and indexed successfully")]
|
[SwaggerResponse(StatusCodes.Status200OK, "CV indexed successfully", typeof(CvUploadResponse))]
|
||||||
[SwaggerResponse(StatusCodes.Status400BadRequest, "Invalid upload request")]
|
[SwaggerResponse(StatusCodes.Status400BadRequest, "File missing or request invalid", typeof(ErrorResponse))]
|
||||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||||
[ProducesResponseType(typeof(ErrorResponse), StatusCodes.Status400BadRequest)]
|
[ProducesResponseType(typeof(ErrorResponse), StatusCodes.Status400BadRequest)]
|
||||||
public async Task<ActionResult<CvUploadResponse>> Upload([FromForm] UploadFileRequest request, CancellationToken ct)
|
public async Task<ActionResult<CvUploadResponse>> Upload([FromForm] UploadFileRequest request, CancellationToken ct)
|
||||||
@@ -45,10 +59,19 @@ public sealed class CvController : ControllerBase
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns the top matching job documents for a previously indexed CV using semantic vector search.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="request">The request containing the CV document ID and the maximum number of results to return.</param>
|
||||||
|
/// <param name="ct">Cancellation token.</param>
|
||||||
|
/// <returns>
|
||||||
|
/// 200 OK with a <see cref="FindJobsResponse"/> containing the ranked list of matching jobs;
|
||||||
|
/// 400 Bad Request if the CV document ID is missing or invalid.
|
||||||
|
/// </returns>
|
||||||
[HttpPost("find-jobs")]
|
[HttpPost("find-jobs")]
|
||||||
[SwaggerOperation(Summary = "Find matching jobs", Description = "Finds top matching jobs for a previously uploaded CV document.")]
|
[SwaggerOperation(Summary = "Find matching jobs", Description = "Performs semantic search over indexed job documents to find the best matches for a given CV.")]
|
||||||
[SwaggerResponse(StatusCodes.Status200OK, "Matching jobs returned")]
|
[SwaggerResponse(StatusCodes.Status200OK, "Matching jobs returned", typeof(FindJobsResponse))]
|
||||||
[SwaggerResponse(StatusCodes.Status400BadRequest, "Invalid find jobs request")]
|
[SwaggerResponse(StatusCodes.Status400BadRequest, "CV document ID missing or invalid", typeof(ErrorResponse))]
|
||||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||||
[ProducesResponseType(typeof(ErrorResponse), StatusCodes.Status400BadRequest)]
|
[ProducesResponseType(typeof(ErrorResponse), StatusCodes.Status400BadRequest)]
|
||||||
public async Task<ActionResult<FindJobsResponse>> FindJobs([FromBody] FindJobsRequest request, CancellationToken ct)
|
public async Task<ActionResult<FindJobsResponse>> FindJobs([FromBody] FindJobsRequest request, CancellationToken ct)
|
||||||
@@ -67,10 +90,21 @@ public sealed class CvController : ControllerBase
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Scores a CV against a single job using LLM analysis.
|
||||||
|
/// Fetches and extracts job text from the provided URL if no inline description is supplied,
|
||||||
|
/// then runs a deep semantic match and returns a score with strengths and gaps.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="request">The match request: CV document ID plus either a job URL or an inline job description.</param>
|
||||||
|
/// <param name="ct">Cancellation token.</param>
|
||||||
|
/// <returns>
|
||||||
|
/// 200 OK with a <see cref="JobMatchResponse"/> containing the score (0–100), strengths, gaps, and cache status;
|
||||||
|
/// 400 Bad Request if required fields are missing or the request is invalid.
|
||||||
|
/// </returns>
|
||||||
[HttpPost("match-job")]
|
[HttpPost("match-job")]
|
||||||
[SwaggerOperation(Summary = "Match CV to one job", Description = "Computes detailed match analysis between a CV and a single job description or URL.")]
|
[SwaggerOperation(Summary = "Match CV to one job", Description = "Scores a CV against a job URL or description using LLM analysis and returns a match score with strengths and gaps.")]
|
||||||
[SwaggerResponse(StatusCodes.Status200OK, "Job match computed successfully")]
|
[SwaggerResponse(StatusCodes.Status200OK, "Job match computed successfully", typeof(JobMatchResponse))]
|
||||||
[SwaggerResponse(StatusCodes.Status400BadRequest, "Invalid match job request")]
|
[SwaggerResponse(StatusCodes.Status400BadRequest, "Required fields missing or request invalid", typeof(ErrorResponse))]
|
||||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||||
[ProducesResponseType(typeof(ErrorResponse), StatusCodes.Status400BadRequest)]
|
[ProducesResponseType(typeof(ErrorResponse), StatusCodes.Status400BadRequest)]
|
||||||
public async Task<ActionResult<JobMatchResponse>> MatchJob([FromBody] MatchJobRequest request, CancellationToken ct)
|
public async Task<ActionResult<JobMatchResponse>> MatchJob([FromBody] MatchJobRequest request, CancellationToken ct)
|
||||||
|
|||||||
@@ -3,9 +3,14 @@ using CvMatcher.Models.Requests;
|
|||||||
using CvMatcher.Models.Responses;
|
using CvMatcher.Models.Responses;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Shared.Models.Responses;
|
using Shared.Models.Responses;
|
||||||
|
using Swashbuckle.AspNetCore.Annotations;
|
||||||
|
|
||||||
namespace Api.Controllers;
|
namespace Api.Controllers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Internal endpoints for managing one-click job-search tokens and sessions.
|
||||||
|
/// Routes are prefixed with <c>api/cv/job-search</c>. Protected by the internal API key middleware — not reachable from the public internet.
|
||||||
|
/// </summary>
|
||||||
[ApiController]
|
[ApiController]
|
||||||
[Route("api/cv/job-search")]
|
[Route("api/cv/job-search")]
|
||||||
public sealed class JobSearchController : ControllerBase
|
public sealed class JobSearchController : ControllerBase
|
||||||
@@ -19,7 +24,26 @@ public sealed class JobSearchController : ControllerBase
|
|||||||
_logger = logger;
|
_logger = logger;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates a one-time job-search token linked to a CV document and email address.
|
||||||
|
/// Called by <c>api</c> immediately after a successful CV match when an email is provided.
|
||||||
|
/// The token is embedded in the job-search link sent to the user's email.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="request">The CV document ID and the recipient email address.</param>
|
||||||
|
/// <param name="ct">Cancellation token.</param>
|
||||||
|
/// <returns>
|
||||||
|
/// 200 OK with a <see cref="CreateJobSearchTokenResponse"/> containing the generated token ID;
|
||||||
|
/// 400 Bad Request if <c>CvDocumentId</c> or <c>Email</c> is missing;
|
||||||
|
/// 500 Internal Server Error if token creation fails.
|
||||||
|
/// </returns>
|
||||||
[HttpPost("token")]
|
[HttpPost("token")]
|
||||||
|
[SwaggerOperation(Summary = "Create job search token", Description = "Creates a one-time token that lets the user start a background job search by clicking the link in their match email.")]
|
||||||
|
[SwaggerResponse(StatusCodes.Status200OK, "Token created successfully", typeof(CreateJobSearchTokenResponse))]
|
||||||
|
[SwaggerResponse(StatusCodes.Status400BadRequest, "CvDocumentId or Email missing", typeof(ErrorResponse))]
|
||||||
|
[SwaggerResponse(StatusCodes.Status500InternalServerError, "Token creation failed", typeof(ErrorResponse))]
|
||||||
|
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(typeof(ErrorResponse), StatusCodes.Status400BadRequest)]
|
||||||
|
[ProducesResponseType(typeof(ErrorResponse), StatusCodes.Status500InternalServerError)]
|
||||||
public async Task<ActionResult<CreateJobSearchTokenResponse>> CreateToken(
|
public async Task<ActionResult<CreateJobSearchTokenResponse>> CreateToken(
|
||||||
[FromBody] CreateJobSearchTokenRequest request,
|
[FromBody] CreateJobSearchTokenRequest request,
|
||||||
CancellationToken ct)
|
CancellationToken ct)
|
||||||
@@ -39,7 +63,24 @@ public sealed class JobSearchController : ControllerBase
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Validates the one-time token, marks it as used, and enqueues a <c>JobSearchSession</c> with status <c>Pending</c>.
|
||||||
|
/// Called by <c>api</c> when the user clicks the job-search link in their match email.
|
||||||
|
/// The <c>cv-search-job</c> worker picks up the pending session and runs the search.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="tokenId">The UUID token extracted from the email link.</param>
|
||||||
|
/// <param name="ct">Cancellation token.</param>
|
||||||
|
/// <returns>
|
||||||
|
/// 200 OK with a <see cref="StartJobSearchResponse"/> whose <c>Status</c> is one of
|
||||||
|
/// <c>Started</c>, <c>AlreadyUsed</c>, or <c>Expired</c>;
|
||||||
|
/// 500 Internal Server Error if the session cannot be created.
|
||||||
|
/// </returns>
|
||||||
[HttpPost("token/{tokenId}/start")]
|
[HttpPost("token/{tokenId}/start")]
|
||||||
|
[SwaggerOperation(Summary = "Start job search", Description = "Validates the one-time token and creates a Pending job search session for the cv-search-job worker to process.")]
|
||||||
|
[SwaggerResponse(StatusCodes.Status200OK, "Search status returned (Started, AlreadyUsed, or Expired)", typeof(StartJobSearchResponse))]
|
||||||
|
[SwaggerResponse(StatusCodes.Status500InternalServerError, "Session creation failed", typeof(ErrorResponse))]
|
||||||
|
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(typeof(ErrorResponse), StatusCodes.Status500InternalServerError)]
|
||||||
public async Task<ActionResult<StartJobSearchResponse>> Start(string tokenId, CancellationToken ct)
|
public async Task<ActionResult<StartJobSearchResponse>> Start(string tokenId, CancellationToken ct)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
|
|||||||
@@ -7,6 +7,10 @@ using Shared.Models.Responses;
|
|||||||
|
|
||||||
namespace Api.Controllers;
|
namespace Api.Controllers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Internal endpoints for indexing documents into the vector store and performing semantic search.
|
||||||
|
/// Routes are prefixed with <c>api/rag</c>. Protected by the internal API key middleware — not reachable from the public internet.
|
||||||
|
/// </summary>
|
||||||
[ApiController]
|
[ApiController]
|
||||||
[Route("api/rag")]
|
[Route("api/rag")]
|
||||||
public sealed class RagController : ControllerBase
|
public sealed class RagController : ControllerBase
|
||||||
@@ -20,11 +24,22 @@ public sealed class RagController : ControllerBase
|
|||||||
_logger = logger;
|
_logger = logger;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Indexes a PDF file or plain-text document into the vector store via multipart/form-data.
|
||||||
|
/// Chunks the content, generates embeddings, and stores them for semantic retrieval.
|
||||||
|
/// Returns immediately from cache if an identical document was previously indexed.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="request">The indexing request: either a PDF file or raw text, plus optional title, source URL, and document type.</param>
|
||||||
|
/// <param name="ct">Cancellation token.</param>
|
||||||
|
/// <returns>
|
||||||
|
/// 200 OK with an <see cref="IndexDocumentResponse"/> containing the document ID, chunk count, and cache status;
|
||||||
|
/// 400 Bad Request if neither a file nor text is provided, or the request is otherwise invalid.
|
||||||
|
/// </returns>
|
||||||
[HttpPost("documents")]
|
[HttpPost("documents")]
|
||||||
[RequestSizeLimit(10 * 1024 * 1024)]
|
[RequestSizeLimit(10 * 1024 * 1024)]
|
||||||
[SwaggerOperation(Summary = "Index document (multipart)", Description = "Indexes a PDF file or raw text document using multipart/form-data payload.")]
|
[SwaggerOperation(Summary = "Index document (multipart)", Description = "Indexes a PDF or plain-text document via multipart/form-data. Returns from cache if the same content was previously indexed.")]
|
||||||
[SwaggerResponse(StatusCodes.Status200OK, "Document indexed successfully")]
|
[SwaggerResponse(StatusCodes.Status200OK, "Document indexed successfully", typeof(IndexDocumentResponse))]
|
||||||
[SwaggerResponse(StatusCodes.Status400BadRequest, "Invalid indexing request")]
|
[SwaggerResponse(StatusCodes.Status400BadRequest, "Neither file nor text provided, or request is invalid", typeof(ErrorResponse))]
|
||||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||||
[ProducesResponseType(typeof(ErrorResponse), StatusCodes.Status400BadRequest)]
|
[ProducesResponseType(typeof(ErrorResponse), StatusCodes.Status400BadRequest)]
|
||||||
public async Task<ActionResult<IndexDocumentResponse>> IndexDocument(
|
public async Task<ActionResult<IndexDocumentResponse>> IndexDocument(
|
||||||
@@ -62,10 +77,20 @@ public sealed class RagController : ControllerBase
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Indexes a plain-text document sent as JSON into the vector store.
|
||||||
|
/// Returns immediately from cache if an identical document was previously indexed.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="request">The indexing request containing the raw text and optional title, source URL, and document type.</param>
|
||||||
|
/// <param name="ct">Cancellation token.</param>
|
||||||
|
/// <returns>
|
||||||
|
/// 200 OK with an <see cref="IndexDocumentResponse"/> containing the document ID, chunk count, and cache status;
|
||||||
|
/// 400 Bad Request if the text is empty or the request is otherwise invalid.
|
||||||
|
/// </returns>
|
||||||
[HttpPost("documents/json")]
|
[HttpPost("documents/json")]
|
||||||
[SwaggerOperation(Summary = "Index document (JSON)", Description = "Indexes a text document sent as JSON.")]
|
[SwaggerOperation(Summary = "Index document (JSON)", Description = "Indexes a plain-text document sent as JSON. Returns from cache if the same content was previously indexed.")]
|
||||||
[SwaggerResponse(StatusCodes.Status200OK, "JSON document indexed successfully")]
|
[SwaggerResponse(StatusCodes.Status200OK, "Document indexed successfully", typeof(IndexDocumentResponse))]
|
||||||
[SwaggerResponse(StatusCodes.Status400BadRequest, "Invalid JSON indexing request")]
|
[SwaggerResponse(StatusCodes.Status400BadRequest, "Text missing or request invalid", typeof(ErrorResponse))]
|
||||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||||
[ProducesResponseType(typeof(ErrorResponse), StatusCodes.Status400BadRequest)]
|
[ProducesResponseType(typeof(ErrorResponse), StatusCodes.Status400BadRequest)]
|
||||||
public async Task<ActionResult<IndexDocumentResponse>> IndexJsonDocument([FromBody] IndexDocumentRequest request, CancellationToken ct)
|
public async Task<ActionResult<IndexDocumentResponse>> IndexJsonDocument([FromBody] IndexDocumentRequest request, CancellationToken ct)
|
||||||
@@ -86,10 +111,20 @@ public sealed class RagController : ControllerBase
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Performs semantic (vector) search over indexed documents.
|
||||||
|
/// Embeds the query, retrieves the closest chunks by cosine similarity, and returns the ranked results.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="request">The search request: query text, optional document type filter, and maximum result count.</param>
|
||||||
|
/// <param name="ct">Cancellation token.</param>
|
||||||
|
/// <returns>
|
||||||
|
/// 200 OK with a <see cref="SearchResponse"/> containing the ranked matching chunks with scores and metadata;
|
||||||
|
/// 400 Bad Request if the query is empty or the request is otherwise invalid.
|
||||||
|
/// </returns>
|
||||||
[HttpPost("search")]
|
[HttpPost("search")]
|
||||||
[SwaggerOperation(Summary = "Semantic search", Description = "Performs semantic retrieval over indexed documents.")]
|
[SwaggerOperation(Summary = "Semantic search", Description = "Embeds the query and retrieves the closest document chunks by vector similarity.")]
|
||||||
[SwaggerResponse(StatusCodes.Status200OK, "Search results returned")]
|
[SwaggerResponse(StatusCodes.Status200OK, "Search results returned", typeof(SearchResponse))]
|
||||||
[SwaggerResponse(StatusCodes.Status400BadRequest, "Invalid search request")]
|
[SwaggerResponse(StatusCodes.Status400BadRequest, "Query missing or request invalid", typeof(ErrorResponse))]
|
||||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||||
[ProducesResponseType(typeof(ErrorResponse), StatusCodes.Status400BadRequest)]
|
[ProducesResponseType(typeof(ErrorResponse), StatusCodes.Status400BadRequest)]
|
||||||
public async Task<ActionResult<SearchResponse>> Search([FromBody] SearchRequest request, CancellationToken ct)
|
public async Task<ActionResult<SearchResponse>> Search([FromBody] SearchRequest request, CancellationToken ct)
|
||||||
@@ -109,10 +144,19 @@ public sealed class RagController : ControllerBase
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns the stored details for a previously indexed document, including its extracted text and metadata.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="id">The document ID returned when the document was indexed.</param>
|
||||||
|
/// <param name="ct">Cancellation token.</param>
|
||||||
|
/// <returns>
|
||||||
|
/// 200 OK with a <see cref="RagDocumentDetailsResponse"/> containing the document text and metadata;
|
||||||
|
/// 404 Not Found if no document with the given ID exists in the store.
|
||||||
|
/// </returns>
|
||||||
[HttpGet("documents/{id}")]
|
[HttpGet("documents/{id}")]
|
||||||
[SwaggerOperation(Summary = "Get document details", Description = "Returns indexed document details for the provided document id.")]
|
[SwaggerOperation(Summary = "Get document details", Description = "Returns the stored text and metadata for a previously indexed document.")]
|
||||||
[SwaggerResponse(StatusCodes.Status200OK, "Document details returned")]
|
[SwaggerResponse(StatusCodes.Status200OK, "Document details returned", typeof(RagDocumentDetailsResponse))]
|
||||||
[SwaggerResponse(StatusCodes.Status404NotFound, "Document was not found")]
|
[SwaggerResponse(StatusCodes.Status404NotFound, "Document not found", typeof(ErrorResponse))]
|
||||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||||
[ProducesResponseType(typeof(ErrorResponse), StatusCodes.Status404NotFound)]
|
[ProducesResponseType(typeof(ErrorResponse), StatusCodes.Status404NotFound)]
|
||||||
public async Task<ActionResult<RagDocumentDetailsResponse>> GetDocument(string id, CancellationToken ct)
|
public async Task<ActionResult<RagDocumentDetailsResponse>> GetDocument(string id, CancellationToken ct)
|
||||||
|
|||||||
@@ -607,6 +607,13 @@ img {
|
|||||||
flex-wrap: wrap
|
flex-wrap: wrap
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.app-version {
|
||||||
|
font-size: .7rem;
|
||||||
|
color: var(--muted);
|
||||||
|
opacity: .5;
|
||||||
|
font-family: monospace
|
||||||
|
}
|
||||||
|
|
||||||
.cookie-overlay {
|
.cookie-overlay {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
left: 0;
|
left: 0;
|
||||||
|
|||||||
@@ -186,6 +186,7 @@
|
|||||||
<a data-legal="privacy" href="/legal/privacy-en.html" target="_blank" data-i18n="legal.privacy">Privacy</a>
|
<a data-legal="privacy" href="/legal/privacy-en.html" target="_blank" data-i18n="legal.privacy">Privacy</a>
|
||||||
<a data-legal="cookies" href="/legal/cookies-en.html" target="_blank" data-i18n="legal.cookies">Cookies</a>
|
<a data-legal="cookies" href="/legal/cookies-en.html" target="_blank" data-i18n="legal.cookies">Cookies</a>
|
||||||
</div>
|
</div>
|
||||||
|
<span id="app-version" class="app-version"></span>
|
||||||
<a href="#top" class="back-to-top btn btn-dark btn-sm shadow" data-i18n="footer.top">Back to top</a>
|
<a href="#top" class="back-to-top btn btn-dark btn-sm shadow" data-i18n="footer.top">Back to top</a>
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
|
|||||||
@@ -189,6 +189,7 @@
|
|||||||
<a data-legal="privacy" href="/legal/privacy-en.html" target="_blank" data-i18n="legal.privacy">Privacy</a>
|
<a data-legal="privacy" href="/legal/privacy-en.html" target="_blank" data-i18n="legal.privacy">Privacy</a>
|
||||||
<a data-legal="cookies" href="/legal/cookies-en.html" target="_blank" data-i18n="legal.cookies">Cookies</a>
|
<a data-legal="cookies" href="/legal/cookies-en.html" target="_blank" data-i18n="legal.cookies">Cookies</a>
|
||||||
</div>
|
</div>
|
||||||
|
<span id="app-version" class="app-version"></span>
|
||||||
<a href="#top" class="back-to-top btn btn-dark btn-sm shadow" data-i18n="footer.top">Back to top</a>
|
<a href="#top" class="back-to-top btn btn-dark btn-sm shadow" data-i18n="footer.top">Back to top</a>
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
|
|||||||
@@ -262,6 +262,13 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
$('#year').text(new Date().getFullYear());
|
$('#year').text(new Date().getFullYear());
|
||||||
|
|
||||||
|
$.getJSON('/api/health/version').done(function (data) {
|
||||||
|
if (data && data.version) {
|
||||||
|
$('#app-version').text('v' + data.version);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
applyLanguage(currentLang());
|
applyLanguage(currentLang());
|
||||||
$('.lang-flag').on('click', function () {
|
$('.lang-flag').on('click', function () {
|
||||||
applyLanguage($(this).data('lang'));
|
applyLanguage($(this).data('lang'));
|
||||||
|
|||||||
Reference in New Issue
Block a user