From 0154b5688131381bd07609511984980040e3b758 Mon Sep 17 00:00:00 2001 From: claude Date: Fri, 22 May 2026 20:18:31 +0300 Subject: [PATCH 1/4] Add auto-incrementing version display to web UI footer Exposes GET /version endpoint in the web container (reads APP_VERSION env var). CI computes the version as 1.0. and passes it via --build-arg at build time. Both index.html and cv-matcher/index.html show the version in the footer via a JS fetch. docker-compose passes APP_VERSION through to the running container. Co-Authored-By: Claude Sonnet 4.6 --- .gitea/workflows/build.yml | 5 ++++- docker-compose/.env.template | 4 ++++ docker-compose/docker-compose.yml | 1 + web/Dockerfile | 2 ++ web/Program.cs | 3 +++ web/wwwroot/css/myai.css | 7 +++++++ web/wwwroot/cv-matcher/index.html | 1 + web/wwwroot/index.html | 1 + web/wwwroot/js/main.js | 7 +++++++ 9 files changed, 30 insertions(+), 1 deletion(-) diff --git a/.gitea/workflows/build.yml b/.gitea/workflows/build.yml index 5e1f17a..67b1a83 100644 --- a/.gitea/workflows/build.yml +++ b/.gitea/workflows/build.yml @@ -47,7 +47,10 @@ jobs: - name: Build Web image run: | - docker build -f web/Dockerfile -t "${REGISTRY_HOST}/${WEB_IMAGE}:${IMAGE_TAG}" . + APP_VERSION="1.0.$(git rev-list --count HEAD)" + docker build -f web/Dockerfile \ + --build-arg APP_VERSION="${APP_VERSION}" \ + -t "${REGISTRY_HOST}/${WEB_IMAGE}:${IMAGE_TAG}" . - name: Build CV cleanup job image run: | diff --git a/docker-compose/.env.template b/docker-compose/.env.template index 3e24c53..95646d1 100644 --- a/docker-compose/.env.template +++ b/docker-compose/.env.template @@ -7,6 +7,10 @@ # For local dev this is ignored (docker-compose.override.yml builds images locally). IMAGE_TAG=staging +# Application version displayed in the web UI footer. +# CI sets this automatically to 1.0. at build time. +APP_VERSION=1.0.0 + # Volume base paths — controls where logs and uploaded files are stored on the host. # Portainer (staging/prod): leave unset to use the /opt/myai defaults. # Local dev: set to relative paths so logs and files land in the repo tree. diff --git a/docker-compose/docker-compose.yml b/docker-compose/docker-compose.yml index 87bc49f..3b617b8 100644 --- a/docker-compose/docker-compose.yml +++ b/docker-compose/docker-compose.yml @@ -263,6 +263,7 @@ services: - ASPNETCORE_URLS=${ASPNETCORE_URLS:-http://+:8080} - APP_ENVIRONMENT_NAME=${APP_ENVIRONMENT_NAME:-myai.staging} + - APP_VERSION=${APP_VERSION:-unknown} - Site__Mode=${Site__Mode:-Normal} networks: - myai-network diff --git a/web/Dockerfile b/web/Dockerfile index 49d964d..aa56baf 100644 --- a/web/Dockerfile +++ b/web/Dockerfile @@ -13,6 +13,8 @@ FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS final WORKDIR /app EXPOSE 8080 ENV ASPNETCORE_URLS=http://0.0.0.0:8080 +ARG APP_VERSION=1.0.0 +ENV APP_VERSION=${APP_VERSION} COPY --from=build /app/publish . ENTRYPOINT ["dotnet", "web.dll"] \ No newline at end of file diff --git a/web/Program.cs b/web/Program.cs index c14b1dd..9e4777b 100644 --- a/web/Program.cs +++ b/web/Program.cs @@ -14,6 +14,9 @@ var app = builder.Build(); app.UseMiddleware(); +app.MapGet("/version", (IConfiguration config) => + Results.Json(new { version = config["APP_VERSION"] ?? "unknown" })); + // Static site app.UseDefaultFiles(); app.UseStaticFiles(); diff --git a/web/wwwroot/css/myai.css b/web/wwwroot/css/myai.css index 1f9b56b..b1b060b 100644 --- a/web/wwwroot/css/myai.css +++ b/web/wwwroot/css/myai.css @@ -607,6 +607,13 @@ img { flex-wrap: wrap } +.app-version { + font-size: .7rem; + color: var(--muted); + opacity: .5; + font-family: monospace +} + .cookie-overlay { position: fixed; left: 0; diff --git a/web/wwwroot/cv-matcher/index.html b/web/wwwroot/cv-matcher/index.html index 2b43d29..b1fce85 100644 --- a/web/wwwroot/cv-matcher/index.html +++ b/web/wwwroot/cv-matcher/index.html @@ -186,6 +186,7 @@ Privacy Cookies + Back to top diff --git a/web/wwwroot/index.html b/web/wwwroot/index.html index ea34c69..7fd7a86 100644 --- a/web/wwwroot/index.html +++ b/web/wwwroot/index.html @@ -189,6 +189,7 @@ Privacy Cookies + Back to top diff --git a/web/wwwroot/js/main.js b/web/wwwroot/js/main.js index 8871a48..3cd183e 100644 --- a/web/wwwroot/js/main.js +++ b/web/wwwroot/js/main.js @@ -262,6 +262,13 @@ } $('#year').text(new Date().getFullYear()); + + $.getJSON('/version').done(function (data) { + if (data && data.version) { + $('#app-version').text('v' + data.version); + } + }); + applyLanguage(currentLang()); $('.lang-flag').on('click', function () { applyLanguage($(this).data('lang')); -- 2.52.0 From 7441eb8cda21e5d3fc1f971c2b2ee90251778920 Mon Sep 17 00:00:00 2001 From: claude Date: Fri, 22 May 2026 20:20:56 +0300 Subject: [PATCH 2/4] =?UTF-8?q?Remove=20APP=5FVERSION=20from=20docker-comp?= =?UTF-8?q?ose=20=E2=80=94=20version=20is=20baked=20into=20image=20by=20CI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Setting APP_VERSION in docker-compose with a :-unknown fallback would override the version baked into the image at build time. The CI already embeds it via --build-arg APP_VERSION=1.0., so compose should stay silent. Co-Authored-By: Claude Sonnet 4.6 --- docker-compose/.env.template | 4 ---- docker-compose/docker-compose.yml | 1 - 2 files changed, 5 deletions(-) diff --git a/docker-compose/.env.template b/docker-compose/.env.template index 95646d1..3e24c53 100644 --- a/docker-compose/.env.template +++ b/docker-compose/.env.template @@ -7,10 +7,6 @@ # For local dev this is ignored (docker-compose.override.yml builds images locally). IMAGE_TAG=staging -# Application version displayed in the web UI footer. -# CI sets this automatically to 1.0. at build time. -APP_VERSION=1.0.0 - # Volume base paths — controls where logs and uploaded files are stored on the host. # Portainer (staging/prod): leave unset to use the /opt/myai defaults. # Local dev: set to relative paths so logs and files land in the repo tree. diff --git a/docker-compose/docker-compose.yml b/docker-compose/docker-compose.yml index 3b617b8..87bc49f 100644 --- a/docker-compose/docker-compose.yml +++ b/docker-compose/docker-compose.yml @@ -263,7 +263,6 @@ services: - ASPNETCORE_URLS=${ASPNETCORE_URLS:-http://+:8080} - APP_ENVIRONMENT_NAME=${APP_ENVIRONMENT_NAME:-myai.staging} - - APP_VERSION=${APP_VERSION:-unknown} - Site__Mode=${Site__Mode:-Normal} networks: - myai-network -- 2.52.0 From 6deb8dd4c84cb8887ebb2e64289f46240db4feaf Mon Sep 17 00:00:00 2001 From: claude Date: Fri, 22 May 2026 20:25:07 +0300 Subject: [PATCH 3/4] Move version display to GET /api/health/version in HealthController MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Uses GetApplicationVersion(Assembly.GetExecutingAssembly()) — the same timestamp-based version already logged at startup and baked into the assembly via the csproj property. Removes the minimal-API /version endpoint from web/Program.cs and reverts the web Dockerfile APP_VERSION build-arg (no longer needed). Co-Authored-By: Claude Sonnet 4.6 --- .gitea/workflows/build.yml | 5 +---- Apis/api/Controllers/HealthController.cs | 14 ++++++++++++++ web/Dockerfile | 2 -- web/Program.cs | 3 --- web/wwwroot/js/main.js | 2 +- 5 files changed, 16 insertions(+), 10 deletions(-) diff --git a/.gitea/workflows/build.yml b/.gitea/workflows/build.yml index 67b1a83..5e1f17a 100644 --- a/.gitea/workflows/build.yml +++ b/.gitea/workflows/build.yml @@ -47,10 +47,7 @@ jobs: - name: Build Web image run: | - APP_VERSION="1.0.$(git rev-list --count HEAD)" - docker build -f web/Dockerfile \ - --build-arg APP_VERSION="${APP_VERSION}" \ - -t "${REGISTRY_HOST}/${WEB_IMAGE}:${IMAGE_TAG}" . + docker build -f web/Dockerfile -t "${REGISTRY_HOST}/${WEB_IMAGE}:${IMAGE_TAG}" . - name: Build CV cleanup job image run: | diff --git a/Apis/api/Controllers/HealthController.cs b/Apis/api/Controllers/HealthController.cs index 0848a02..913424f 100644 --- a/Apis/api/Controllers/HealthController.cs +++ b/Apis/api/Controllers/HealthController.cs @@ -1,5 +1,7 @@ +using System.Reflection; using Microsoft.AspNetCore.Cors; using Microsoft.AspNetCore.Mvc; +using StartupHelpers; using Swashbuckle.AspNetCore.Annotations; namespace Api.Controllers @@ -14,6 +16,18 @@ namespace Api.Controllers [EnableCors("FrontendOnly")] public sealed class HealthController : ControllerBase { + /// + /// Returns the deployed API version. + /// + /// 200 OK with JSON payload: { "version": "1.0.0-build.20250522103045" } + // 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()) }); + /// /// Liveness probe. /// Indicates whether the process is running. Used by orchestration systems to confirm the process is alive. diff --git a/web/Dockerfile b/web/Dockerfile index aa56baf..49d964d 100644 --- a/web/Dockerfile +++ b/web/Dockerfile @@ -13,8 +13,6 @@ FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS final WORKDIR /app EXPOSE 8080 ENV ASPNETCORE_URLS=http://0.0.0.0:8080 -ARG APP_VERSION=1.0.0 -ENV APP_VERSION=${APP_VERSION} COPY --from=build /app/publish . ENTRYPOINT ["dotnet", "web.dll"] \ No newline at end of file diff --git a/web/Program.cs b/web/Program.cs index 9e4777b..c14b1dd 100644 --- a/web/Program.cs +++ b/web/Program.cs @@ -14,9 +14,6 @@ var app = builder.Build(); app.UseMiddleware(); -app.MapGet("/version", (IConfiguration config) => - Results.Json(new { version = config["APP_VERSION"] ?? "unknown" })); - // Static site app.UseDefaultFiles(); app.UseStaticFiles(); diff --git a/web/wwwroot/js/main.js b/web/wwwroot/js/main.js index 3cd183e..59b0890 100644 --- a/web/wwwroot/js/main.js +++ b/web/wwwroot/js/main.js @@ -263,7 +263,7 @@ $('#year').text(new Date().getFullYear()); - $.getJSON('/version').done(function (data) { + $.getJSON('/api/health/version').done(function (data) { if (data && data.version) { $('#app-version').text('v' + data.version); } -- 2.52.0 From 1fcf1e14705d4285b2b7c402121b68618a2ef567 Mon Sep 17 00:00:00 2001 From: claude Date: Fri, 22 May 2026 20:47:47 +0300 Subject: [PATCH 4/4] Add complete XML doc and Swagger annotations to all controller endpoints Every public action now has , , and XML docs plus matching SwaggerOperation/SwaggerResponse attributes with typed response descriptions. Class-level summaries added to CvController, JobSearchController, and RagController. Explanatory inline comments removed from FileDownloadController per project conventions. Co-Authored-By: Claude Sonnet 4.6 --- Apis/api/Controllers/CaptchaController.cs | 17 +++-- Apis/api/Controllers/CvMatcherController.cs | 38 +++++++++-- .../api/Controllers/FileDownloadController.cs | 39 +---------- Apis/api/Controllers/HealthController.cs | 6 +- .../Controllers/CvController.cs | 52 +++++++++++--- .../Controllers/JobSearchController.cs | 41 +++++++++++ Apis/rag-api/Controllers/RagController.cs | 68 +++++++++++++++---- 7 files changed, 192 insertions(+), 69 deletions(-) diff --git a/Apis/api/Controllers/CaptchaController.cs b/Apis/api/Controllers/CaptchaController.cs index 949d69a..53a715d 100644 --- a/Apis/api/Controllers/CaptchaController.cs +++ b/Apis/api/Controllers/CaptchaController.cs @@ -29,8 +29,10 @@ namespace Api.Controllers /// /// Returns the public reCAPTCHA site key used by the client to render the widget. /// + /// 200 OK with the configured public site key as a plain string. [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)] public IActionResult GetSiteKey() { @@ -38,13 +40,20 @@ namespace Api.Controllers } /// - /// Verify a captcha token and return the verification verdict. + /// Verifies a reCAPTCHA token submitted by the client and returns the full verification verdict. /// + /// The verification request containing the token and optional expected action name. + /// Cancellation token. + /// + /// 200 OK with the full captcha verdict when verification passes; + /// 400 Bad Request with an if the token is missing or verification fails. + /// [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(typeof(ErrorResponse), StatusCodes.Status400BadRequest)] - [SwaggerResponse(StatusCodes.Status400BadRequest, "Captcha verification failed or token missing", typeof(ErrorResponse))] public async Task Verify([FromBody] CaptchaVerifyRequest req, CancellationToken ct) { if (req is null || string.IsNullOrWhiteSpace(req.Token)) diff --git a/Apis/api/Controllers/CvMatcherController.cs b/Apis/api/Controllers/CvMatcherController.cs index 8ae87cf..9946e98 100644 --- a/Apis/api/Controllers/CvMatcherController.cs +++ b/Apis/api/Controllers/CvMatcherController.cs @@ -48,10 +48,18 @@ public sealed class CvMatcherController : ControllerBase } /// - /// 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. /// - /// The uploaded CV request. + /// Multipart form containing the CV PDF, captcha token, and GDPR consent flag. /// Cancellation token. + /// + /// 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. + /// [HttpPost("upload")] [RequestSizeLimit(8 * 1024 * 1024)] [ProducesResponseType(StatusCodes.Status200OK)] @@ -109,10 +117,18 @@ public sealed class CvMatcherController : ControllerBase } /// - /// 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. /// - /// Job match request payload containing CV document id or job description/url. + /// Match request containing the CV document ID, a job URL or inline description, and an optional recipient email. /// Cancellation token. + /// + /// 200 OK with the 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. + /// [HttpPost("match-job")] [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(typeof(ErrorResponse), StatusCodes.Status400BadRequest)] @@ -182,8 +198,20 @@ public sealed class CvMatcherController : ControllerBase } } + /// + /// 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. + /// + /// The one-time UUID token from the job-search link query string. + /// Cancellation token. + /// + /// 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. + /// [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 StartJobSearch([FromQuery] string t, CancellationToken ct) { try diff --git a/Apis/api/Controllers/FileDownloadController.cs b/Apis/api/Controllers/FileDownloadController.cs index 6adf28f..58765de 100644 --- a/Apis/api/Controllers/FileDownloadController.cs +++ b/Apis/api/Controllers/FileDownloadController.cs @@ -66,7 +66,6 @@ namespace Api.Controllers { try { - // Use default file name from settings if not provided if (string.IsNullOrWhiteSpace(fileName)) { fileName = _fileStorageSettings.DefaultFileName; @@ -80,43 +79,30 @@ namespace Api.Controllers _logger.LogInformation("Using default file name from settings: {FileName}", fileName); } - // Get the file storage path (relative to solution folder) var fileStoragePath = _fileStorageSettings.Path; - // If path is not absolute, make it relative to the solution root if (!Path.IsPathRooted(fileStoragePath)) { var solutionRoot = Directory.GetCurrentDirectory(); - // Go up from api folder to solution root if needed if (solutionRoot.EndsWith("api", StringComparison.OrdinalIgnoreCase)) - { solutionRoot = Directory.GetParent(solutionRoot)?.FullName ?? solutionRoot; - } fileStoragePath = Path.Combine(solutionRoot, fileStoragePath); } - // Sanitize fileName to prevent directory traversal attacks var sanitizedFileName = Path.GetFileName(fileName); var filePath = Path.Combine(fileStoragePath, sanitizedFileName); - // Verify file exists if (!System.IO.File.Exists(filePath)) { _logger.LogWarning("File not found: {FilePath}", filePath); return NotFound(new ErrorResponse { Error = "File not found", Code = "file_not_found" }); } - var fileInfo = new FileInfo(filePath); - var fileLength = fileInfo.Length; + var fileLength = new FileInfo(filePath).Length; - // Determine content type if (!_contentTypeProvider.TryGetContentType(filePath, out var contentType)) - { 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(); _ = Task.Run(async () => { @@ -130,19 +116,13 @@ namespace Api.Controllers } }); - // Check if this is a range request var rangeHeader = Request.Headers[HeaderNames.Range].ToString(); - if (!string.IsNullOrEmpty(rangeHeader)) - { return await HandleRangeRequest(filePath, fileLength, contentType, rangeHeader, sanitizedFileName); - } - // Full file download _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); - Response.Headers.Append(HeaderNames.AcceptRanges, "bytes"); Response.Headers.Append(HeaderNames.ContentLength, fileLength.ToString()); @@ -167,34 +147,25 @@ namespace Api.Controllers { try { - // Parse range header (format: "bytes=start-end") var range = rangeHeader.Replace("bytes=", "").Split('-'); long startByte = 0; long endByte = fileLength - 1; if (!string.IsNullOrEmpty(range[0])) - { startByte = long.Parse(range[0]); - } if (range.Length > 1 && !string.IsNullOrEmpty(range[1])) - { endByte = long.Parse(range[1]); - } - // Validate range if (startByte > endByte || startByte >= fileLength) { _logger.LogWarning("Invalid range request: {Range} for file size {FileLength}", rangeHeader, fileLength); return StatusCode(StatusCodes.Status416RangeNotSatisfiable); } - // Adjust end byte if it exceeds file length if (endByte >= fileLength) - { endByte = fileLength - 1; - } var contentLength = endByte - startByte + 1; @@ -202,20 +173,16 @@ namespace Api.Controllers "Range request for {FileName}: bytes {Start}-{End}/{Total} ({ContentLength} bytes)", 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); stream.Seek(startByte, SeekOrigin.Begin); - // Set response headers for partial content Response.StatusCode = StatusCodes.Status206PartialContent; Response.Headers.Append(HeaderNames.AcceptRanges, "bytes"); Response.Headers.Append(HeaderNames.ContentRange, $"bytes {startByte}-{endByte}/{fileLength}"); Response.Headers.Append(HeaderNames.ContentLength, contentLength.ToString()); Response.ContentType = contentType; - // Stream the requested range await StreamRangeAsync(stream, Response.Body, contentLength); - await stream.DisposeAsync(); return new EmptyResult(); @@ -241,9 +208,7 @@ namespace Api.Controllers var bytesRead = await source.ReadAsync(buffer.AsMemory(0, bytesToReadThisIteration)); if (bytesRead == 0) - { - break; // End of stream - } + break; await destination.WriteAsync(buffer.AsMemory(0, bytesRead)); totalBytesRead += bytesRead; diff --git a/Apis/api/Controllers/HealthController.cs b/Apis/api/Controllers/HealthController.cs index 913424f..5c0a2b4 100644 --- a/Apis/api/Controllers/HealthController.cs +++ b/Apis/api/Controllers/HealthController.cs @@ -17,9 +17,11 @@ namespace Api.Controllers public sealed class HealthController : ControllerBase { /// - /// Returns the deployed API version. + /// Returns the deployed API version baked into the assembly at build time. + /// The version format is 1.0.0-build.{yyyyMMddHHmmss} as defined in api.csproj. + /// Used by the web frontend to display the running build in the page footer. /// - /// 200 OK with JSON payload: { "version": "1.0.0-build.20250522103045" } + /// 200 OK with JSON payload: { "version": "1.0.0-build.20250522103045" }. // GET api/health/version [HttpGet("version")] [SwaggerOperation(Summary = "API version", Description = "Returns the deployed API assembly version.")] diff --git a/Apis/cv-matcher-api/Controllers/CvController.cs b/Apis/cv-matcher-api/Controllers/CvController.cs index 87804aa..7e54f97 100644 --- a/Apis/cv-matcher-api/Controllers/CvController.cs +++ b/Apis/cv-matcher-api/Controllers/CvController.cs @@ -8,6 +8,10 @@ using Shared.Models.Responses; namespace Api.Controllers; +/// +/// Internal endpoints for CV indexing and job-matching operations. +/// Routes are prefixed with api/cv. Protected by the internal API key middleware — not reachable from the public internet. +/// [ApiController] [Route("api/cv")] public sealed class CvController : ControllerBase @@ -21,11 +25,21 @@ public sealed class CvController : ControllerBase _logger = logger; } + /// + /// Uploads and indexes a CV PDF into the RAG vector store. + /// Returns from cache immediately if an identical document was previously indexed. + /// + /// Multipart form containing the CV PDF file. + /// Cancellation token. + /// + /// 200 OK with a 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. + /// [HttpPost("upload")] [RequestSizeLimit(10 * 1024 * 1024)] - [SwaggerOperation(Summary = "Upload CV document", Description = "Uploads a CV PDF and indexes it for matching.")] - [SwaggerResponse(StatusCodes.Status200OK, "CV uploaded and indexed successfully")] - [SwaggerResponse(StatusCodes.Status400BadRequest, "Invalid upload request")] + [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 indexed successfully", typeof(CvUploadResponse))] + [SwaggerResponse(StatusCodes.Status400BadRequest, "File missing or request invalid", typeof(ErrorResponse))] [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(typeof(ErrorResponse), StatusCodes.Status400BadRequest)] public async Task> Upload([FromForm] UploadFileRequest request, CancellationToken ct) @@ -45,10 +59,19 @@ public sealed class CvController : ControllerBase } } + /// + /// Returns the top matching job documents for a previously indexed CV using semantic vector search. + /// + /// The request containing the CV document ID and the maximum number of results to return. + /// Cancellation token. + /// + /// 200 OK with a containing the ranked list of matching jobs; + /// 400 Bad Request if the CV document ID is missing or invalid. + /// [HttpPost("find-jobs")] - [SwaggerOperation(Summary = "Find matching jobs", Description = "Finds top matching jobs for a previously uploaded CV document.")] - [SwaggerResponse(StatusCodes.Status200OK, "Matching jobs returned")] - [SwaggerResponse(StatusCodes.Status400BadRequest, "Invalid find jobs request")] + [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", typeof(FindJobsResponse))] + [SwaggerResponse(StatusCodes.Status400BadRequest, "CV document ID missing or invalid", typeof(ErrorResponse))] [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(typeof(ErrorResponse), StatusCodes.Status400BadRequest)] public async Task> FindJobs([FromBody] FindJobsRequest request, CancellationToken ct) @@ -67,10 +90,21 @@ public sealed class CvController : ControllerBase } } + /// + /// 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. + /// + /// The match request: CV document ID plus either a job URL or an inline job description. + /// Cancellation token. + /// + /// 200 OK with a containing the score (0–100), strengths, gaps, and cache status; + /// 400 Bad Request if required fields are missing or the request is invalid. + /// [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.")] - [SwaggerResponse(StatusCodes.Status200OK, "Job match computed successfully")] - [SwaggerResponse(StatusCodes.Status400BadRequest, "Invalid match job request")] + [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", typeof(JobMatchResponse))] + [SwaggerResponse(StatusCodes.Status400BadRequest, "Required fields missing or request invalid", typeof(ErrorResponse))] [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(typeof(ErrorResponse), StatusCodes.Status400BadRequest)] public async Task> MatchJob([FromBody] MatchJobRequest request, CancellationToken ct) diff --git a/Apis/cv-matcher-api/Controllers/JobSearchController.cs b/Apis/cv-matcher-api/Controllers/JobSearchController.cs index a646526..1bb13a1 100644 --- a/Apis/cv-matcher-api/Controllers/JobSearchController.cs +++ b/Apis/cv-matcher-api/Controllers/JobSearchController.cs @@ -3,9 +3,14 @@ using CvMatcher.Models.Requests; using CvMatcher.Models.Responses; using Microsoft.AspNetCore.Mvc; using Shared.Models.Responses; +using Swashbuckle.AspNetCore.Annotations; namespace Api.Controllers; +/// +/// Internal endpoints for managing one-click job-search tokens and sessions. +/// Routes are prefixed with api/cv/job-search. Protected by the internal API key middleware — not reachable from the public internet. +/// [ApiController] [Route("api/cv/job-search")] public sealed class JobSearchController : ControllerBase @@ -19,7 +24,26 @@ public sealed class JobSearchController : ControllerBase _logger = logger; } + /// + /// Creates a one-time job-search token linked to a CV document and email address. + /// Called by api 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. + /// + /// The CV document ID and the recipient email address. + /// Cancellation token. + /// + /// 200 OK with a containing the generated token ID; + /// 400 Bad Request if CvDocumentId or Email is missing; + /// 500 Internal Server Error if token creation fails. + /// [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> CreateToken( [FromBody] CreateJobSearchTokenRequest request, CancellationToken ct) @@ -39,7 +63,24 @@ public sealed class JobSearchController : ControllerBase } } + /// + /// Validates the one-time token, marks it as used, and enqueues a JobSearchSession with status Pending. + /// Called by api when the user clicks the job-search link in their match email. + /// The cv-search-job worker picks up the pending session and runs the search. + /// + /// The UUID token extracted from the email link. + /// Cancellation token. + /// + /// 200 OK with a whose Status is one of + /// Started, AlreadyUsed, or Expired; + /// 500 Internal Server Error if the session cannot be created. + /// [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> Start(string tokenId, CancellationToken ct) { try diff --git a/Apis/rag-api/Controllers/RagController.cs b/Apis/rag-api/Controllers/RagController.cs index 0354752..ecf07c6 100644 --- a/Apis/rag-api/Controllers/RagController.cs +++ b/Apis/rag-api/Controllers/RagController.cs @@ -7,6 +7,10 @@ using Shared.Models.Responses; namespace Api.Controllers; +/// +/// Internal endpoints for indexing documents into the vector store and performing semantic search. +/// Routes are prefixed with api/rag. Protected by the internal API key middleware — not reachable from the public internet. +/// [ApiController] [Route("api/rag")] public sealed class RagController : ControllerBase @@ -20,11 +24,22 @@ public sealed class RagController : ControllerBase _logger = logger; } + /// + /// 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. + /// + /// The indexing request: either a PDF file or raw text, plus optional title, source URL, and document type. + /// Cancellation token. + /// + /// 200 OK with an 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. + /// [HttpPost("documents")] [RequestSizeLimit(10 * 1024 * 1024)] - [SwaggerOperation(Summary = "Index document (multipart)", Description = "Indexes a PDF file or raw text document using multipart/form-data payload.")] - [SwaggerResponse(StatusCodes.Status200OK, "Document indexed successfully")] - [SwaggerResponse(StatusCodes.Status400BadRequest, "Invalid indexing request")] + [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", typeof(IndexDocumentResponse))] + [SwaggerResponse(StatusCodes.Status400BadRequest, "Neither file nor text provided, or request is invalid", typeof(ErrorResponse))] [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(typeof(ErrorResponse), StatusCodes.Status400BadRequest)] public async Task> IndexDocument( @@ -62,10 +77,20 @@ public sealed class RagController : ControllerBase } } + /// + /// Indexes a plain-text document sent as JSON into the vector store. + /// Returns immediately from cache if an identical document was previously indexed. + /// + /// The indexing request containing the raw text and optional title, source URL, and document type. + /// Cancellation token. + /// + /// 200 OK with an containing the document ID, chunk count, and cache status; + /// 400 Bad Request if the text is empty or the request is otherwise invalid. + /// [HttpPost("documents/json")] - [SwaggerOperation(Summary = "Index document (JSON)", Description = "Indexes a text document sent as JSON.")] - [SwaggerResponse(StatusCodes.Status200OK, "JSON document indexed successfully")] - [SwaggerResponse(StatusCodes.Status400BadRequest, "Invalid JSON indexing request")] + [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, "Document indexed successfully", typeof(IndexDocumentResponse))] + [SwaggerResponse(StatusCodes.Status400BadRequest, "Text missing or request invalid", typeof(ErrorResponse))] [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(typeof(ErrorResponse), StatusCodes.Status400BadRequest)] public async Task> IndexJsonDocument([FromBody] IndexDocumentRequest request, CancellationToken ct) @@ -86,10 +111,20 @@ public sealed class RagController : ControllerBase } } + /// + /// Performs semantic (vector) search over indexed documents. + /// Embeds the query, retrieves the closest chunks by cosine similarity, and returns the ranked results. + /// + /// The search request: query text, optional document type filter, and maximum result count. + /// Cancellation token. + /// + /// 200 OK with a containing the ranked matching chunks with scores and metadata; + /// 400 Bad Request if the query is empty or the request is otherwise invalid. + /// [HttpPost("search")] - [SwaggerOperation(Summary = "Semantic search", Description = "Performs semantic retrieval over indexed documents.")] - [SwaggerResponse(StatusCodes.Status200OK, "Search results returned")] - [SwaggerResponse(StatusCodes.Status400BadRequest, "Invalid search request")] + [SwaggerOperation(Summary = "Semantic search", Description = "Embeds the query and retrieves the closest document chunks by vector similarity.")] + [SwaggerResponse(StatusCodes.Status200OK, "Search results returned", typeof(SearchResponse))] + [SwaggerResponse(StatusCodes.Status400BadRequest, "Query missing or request invalid", typeof(ErrorResponse))] [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(typeof(ErrorResponse), StatusCodes.Status400BadRequest)] public async Task> Search([FromBody] SearchRequest request, CancellationToken ct) @@ -109,10 +144,19 @@ public sealed class RagController : ControllerBase } } + /// + /// Returns the stored details for a previously indexed document, including its extracted text and metadata. + /// + /// The document ID returned when the document was indexed. + /// Cancellation token. + /// + /// 200 OK with a containing the document text and metadata; + /// 404 Not Found if no document with the given ID exists in the store. + /// [HttpGet("documents/{id}")] - [SwaggerOperation(Summary = "Get document details", Description = "Returns indexed document details for the provided document id.")] - [SwaggerResponse(StatusCodes.Status200OK, "Document details returned")] - [SwaggerResponse(StatusCodes.Status404NotFound, "Document was not found")] + [SwaggerOperation(Summary = "Get document details", Description = "Returns the stored text and metadata for a previously indexed document.")] + [SwaggerResponse(StatusCodes.Status200OK, "Document details returned", typeof(RagDocumentDetailsResponse))] + [SwaggerResponse(StatusCodes.Status404NotFound, "Document not found", typeof(ErrorResponse))] [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(typeof(ErrorResponse), StatusCodes.Status404NotFound)] public async Task> GetDocument(string id, CancellationToken ct) -- 2.52.0