diff --git a/.gitea/workflows/build.yml b/.gitea/workflows/build.yml index 9daa7a8..850a436 100644 --- a/.gitea/workflows/build.yml +++ b/.gitea/workflows/build.yml @@ -1,9 +1,9 @@ -name: Build and Push Docker Images Production +name: Build and Push Docker Images Staging on: push: branches: - - production + - staging env: GIT_HOST: git.easysoft.ro @@ -11,9 +11,12 @@ env: API_IMAGE: apps/myai-api CV_MATCHER_API_IMAGE: apps/myai-cv-matcher-api RAG_API_IMAGE: apps/myai-rag-api + EMAIL_API_IMAGE: apps/myai-email-api WEB_IMAGE: apps/myai-web CV_CLEANUP_JOB_IMAGE: apps/myai-cv-cleanup-job - IMAGE_TAG: production + CV_SEARCH_JOB_IMAGE: apps/myai-cv-search-job + PAGE_FETCHER_API_IMAGE: apps/myai-page-fetcher-api + IMAGE_TAG: staging jobs: build: @@ -44,6 +47,10 @@ jobs: run: | docker build -f Apis/rag-api/Dockerfile -t "${REGISTRY_HOST}/${RAG_API_IMAGE}:${IMAGE_TAG}" . + - name: Build Email API image + run: | + docker build -f Apis/email-api/Dockerfile -t "${REGISTRY_HOST}/${EMAIL_API_IMAGE}:${IMAGE_TAG}" . + - name: Build Web image run: | docker build -f web/Dockerfile -t "${REGISTRY_HOST}/${WEB_IMAGE}:${IMAGE_TAG}" . @@ -52,6 +59,14 @@ jobs: run: | docker build -f Jobs/cv-cleanup-job/Dockerfile -t "${REGISTRY_HOST}/${CV_CLEANUP_JOB_IMAGE}:${IMAGE_TAG}" . + - name: Build CV search job image + run: | + docker build -f Jobs/cv-search-job/Dockerfile -t "${REGISTRY_HOST}/${CV_SEARCH_JOB_IMAGE}:${IMAGE_TAG}" . + + - name: Build Page Fetcher API image + run: | + docker build -f Apis/page-fetcher-api/Dockerfile -t "${REGISTRY_HOST}/${PAGE_FETCHER_API_IMAGE}:${IMAGE_TAG}" . + - name: Push API image run: | docker push "${REGISTRY_HOST}/${API_IMAGE}:${IMAGE_TAG}" @@ -64,10 +79,22 @@ jobs: run: | docker push "${REGISTRY_HOST}/${RAG_API_IMAGE}:${IMAGE_TAG}" + - name: Push Email API image + run: | + docker push "${REGISTRY_HOST}/${EMAIL_API_IMAGE}:${IMAGE_TAG}" + - name: Push Web image run: | docker push "${REGISTRY_HOST}/${WEB_IMAGE}:${IMAGE_TAG}" - name: Push CV cleanup job image run: | - docker push "${REGISTRY_HOST}/${CV_CLEANUP_JOB_IMAGE}:${IMAGE_TAG}" \ No newline at end of file + docker push "${REGISTRY_HOST}/${CV_CLEANUP_JOB_IMAGE}:${IMAGE_TAG}" + + - name: Push CV search job image + run: | + docker push "${REGISTRY_HOST}/${CV_SEARCH_JOB_IMAGE}:${IMAGE_TAG}" + + - name: Push Page Fetcher API image + run: | + docker push "${REGISTRY_HOST}/${PAGE_FETCHER_API_IMAGE}:${IMAGE_TAG}" \ No newline at end of file diff --git a/.gitignore b/.gitignore index 4d72645..b4be5a0 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,9 @@ ## ## Get latest from https://github.com/github/gitignore/blob/master/VisualStudio.gitignore +# Claude Code session files +.claude/ + # Environment Variables - DO NOT COMMIT *.env .env diff --git a/Apis/api-models/Requests/JobMatchRequest.cs b/Apis/api-models/Requests/JobMatchRequest.cs index 9aec051..dbd5ae6 100644 --- a/Apis/api-models/Requests/JobMatchRequest.cs +++ b/Apis/api-models/Requests/JobMatchRequest.cs @@ -8,4 +8,8 @@ public sealed class JobMatchRequest public bool GdprConsent { get; set; } public string? Email { get; set; } public string? CaptchaToken { get; set; } + /// ISO 639-1 language code for the match result (e.g. "en", "ro"). Defaults to "en". + public string? Language { get; set; } + /// Client IP address — set by the api layer from the HTTP context before forwarding. Not supplied by the browser. + public string? ClientIpAddress { get; set; } } diff --git a/Apis/api-models/Requests/UploadCvRequest.cs b/Apis/api-models/Requests/UploadCvRequest.cs index 18bd5db..31349d0 100644 --- a/Apis/api-models/Requests/UploadCvRequest.cs +++ b/Apis/api-models/Requests/UploadCvRequest.cs @@ -1,4 +1,4 @@ -using Shared.Models.Requests; +using Common.Requests; namespace Models.Requests { diff --git a/Apis/api-models/Settings/JobSearchLinkSettings.cs b/Apis/api-models/Settings/JobSearchLinkSettings.cs new file mode 100644 index 0000000..50955d9 --- /dev/null +++ b/Apis/api-models/Settings/JobSearchLinkSettings.cs @@ -0,0 +1,6 @@ +namespace Models.Settings; + +public sealed class JobSearchLinkSettings +{ + public string BaseUrl { get; set; } = string.Empty; +} diff --git a/Apis/api-models/Settings/SmtpSettings.cs b/Apis/api-models/Settings/SmtpSettings.cs deleted file mode 100644 index d719a29..0000000 --- a/Apis/api-models/Settings/SmtpSettings.cs +++ /dev/null @@ -1,11 +0,0 @@ -namespace Models.Settings -{ - public class SmtpSettings - { - public string Host { get; set; } = ""; - public int Port { get; set; } = 587; - public string Username { get; set; } = ""; - public string Password { get; set; } = ""; - public bool UseStartTls { get; set; } = true; - } -} diff --git a/Apis/api-models/api-models.csproj b/Apis/api-models/api-models.csproj index f9c95a3..5e0613b 100644 --- a/Apis/api-models/api-models.csproj +++ b/Apis/api-models/api-models.csproj @@ -1,4 +1,4 @@ - + net10.0 @@ -8,11 +8,11 @@ - + - + diff --git a/Apis/api/CLAUDE.md b/Apis/api/CLAUDE.md new file mode 100644 index 0000000..2e56219 --- /dev/null +++ b/Apis/api/CLAUDE.md @@ -0,0 +1,45 @@ +# api — Public-Facing Proxy API + +Internal port 8080. The only service exposed to the internet. + +## Responsibilities + +- Validates reCAPTCHA on CV upload and match requests +- Proxies CV operations to `cv-matcher-api` via Refit (`ICvMatcherApi`, `IJobSearchApi`) +- Sends match result emails via SMTP (`SmtpEmailSender`) +- Includes a job search link in match emails when a `CvDocumentId` is present +- Serves the job-search-start page (`GET /api/cv-matcher/job-search/start?t=`) +- Enforces rate limiting (`cvMatcher` policy: 10 req / 10 min) +- Enforces CORS (allow list from `Cors__AllowedOrigins__*` env vars) +- Caches uploaded CV PDFs locally to `FileStorage:Path` for email attachment + +## Key routes + +| Method | Route | Description | +|--------|-------|-------------| +| POST | `/api/cv-matcher/upload` | Upload CV PDF, forward to cv-matcher-api | +| POST | `/api/cv-matcher/match` | Match CV+job, send email with job search link | +| GET | `/api/cv-matcher/job-search/start?t=` | One-click job search start; returns plain HTML | +| GET | `/api/health` | Health check | + +## Job search link flow + +1. After a successful match with an email, `CvMatcherController.MatchJob` calls `IJobSearchApi.CreateTokenAsync` +2. Builds link: `{JobSearch:BaseUrl}/api/cv-matcher/job-search/start?t={tokenId}` +3. Passes link to `SmtpEmailSender.BuildMatchEmailBody(result, jobSearchLink)` +4. When user clicks link → `GET /api/cv-matcher/job-search/start?t=` → proxies to `cv-matcher-api POST /api/cv/job-search/token/{tokenId}/start` +5. Returns styled HTML page (Started / AlreadyUsed / Expired / NotFound) + +## Settings + +| Section | Key env var | Notes | +|---------|-------------|-------| +| `CvMatcherApi` | `CvMatcherApi__BaseUrl`, `CvMatcherApi__InternalApiKey` | Shared by both Refit clients | +| `JobSearch` | `JobSearch__BaseUrl` | Base URL for link generation only (maps to `JobSearchLinkSettings.BaseUrl`) | +| `FileStorage` | `FileStorage__Path` | Directory for cached CV PDFs; shared volume with cv-search-job | +| `Smtp` | `Smtp__Host`, `Smtp__Username`, etc. | Used by SmtpEmailSender | +| `Captcha` | `Captcha__SecretKey` | reCAPTCHA v3 secret | + +## HTML page generation + +`CvMatcherController.HtmlPage(title, message)` uses `$$"""` raw string literal so CSS `{` / `}` are literal. Do not change to `$"""` — causes CS9006. diff --git a/Apis/api/Clients/Api/Contracts/IJobSearchApi.cs b/Apis/api/Clients/Api/Contracts/IJobSearchApi.cs new file mode 100644 index 0000000..05724bf --- /dev/null +++ b/Apis/api/Clients/Api/Contracts/IJobSearchApi.cs @@ -0,0 +1,14 @@ +using CvMatcher.Models.Requests; +using CvMatcher.Models.Responses; +using Refit; + +namespace Api.Clients.Api.Contracts; + +public interface IJobSearchApi +{ + [Post("/api/cv/job-search/token")] + Task CreateTokenAsync([Body] CreateJobSearchTokenRequest request, CancellationToken ct); + + [Post("/api/cv/job-search/token/{tokenId}/start")] + Task StartSearchAsync(string tokenId, [Body] StartJobSearchRequest request, CancellationToken ct); +} diff --git a/Apis/api/Controllers/CaptchaController.cs b/Apis/api/Controllers/CaptchaController.cs index 949d69a..40e3647 100644 --- a/Apis/api/Controllers/CaptchaController.cs +++ b/Apis/api/Controllers/CaptchaController.cs @@ -4,7 +4,7 @@ using Microsoft.Extensions.Options; using Models.Settings; using Swashbuckle.AspNetCore.Annotations; using Models.Requests; -using Shared.Models.Responses; +using Common.Responses; namespace Api.Controllers { @@ -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)) @@ -61,7 +70,7 @@ namespace Api.Controllers { Error = "Captcha verification failed.", Code = "captcha_verification_failed", - Score = verdict.Score + Detail = verdict.Score.HasValue ? $"Score: {verdict.Score:0.00}" : null }); } diff --git a/Apis/api/Controllers/ContactController.cs b/Apis/api/Controllers/ContactController.cs index 920b594..122d772 100644 --- a/Apis/api/Controllers/ContactController.cs +++ b/Apis/api/Controllers/ContactController.cs @@ -7,7 +7,7 @@ using Microsoft.Extensions.Options; using Models.Settings; using Models.Requests; using Swashbuckle.AspNetCore.Annotations; -using Shared.Models.Responses; +using Common.Responses; namespace Api.Controllers { @@ -115,7 +115,7 @@ namespace Api.Controllers catch (Exception ex) { _log.LogError(ex, "Subscription failed. ip={Ip} eMail={eMail}", userIp, req.Email); - return StatusCode(StatusCodes.Status500InternalServerError, new ErrorResponse { Error = "Failed.", Code = "subscription_failed" }); + return StatusCode(StatusCodes.Status500InternalServerError, new ErrorResponse { Error = "Could not process subscription.", Code = "subscription_failed" }); } } diff --git a/Apis/api/Controllers/CvMatcherController.cs b/Apis/api/Controllers/CvMatcherController.cs index 2488ae8..5a5f60d 100644 --- a/Apis/api/Controllers/CvMatcherController.cs +++ b/Apis/api/Controllers/CvMatcherController.cs @@ -1,13 +1,15 @@ using Api.Clients.Api.Contracts; +using CvMatcher.Models.Requests; +using CvMatcher.Models.Responses; using Models.Requests; using Models.Settings; using Api.Services.Contracts; -using Api.Services; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.RateLimiting; using Microsoft.Extensions.Options; using Swashbuckle.AspNetCore.Annotations; -using Shared.Models.Responses; +using Common.Responses; +using MyAi.Data.Services; namespace Api.Controllers; @@ -20,30 +22,47 @@ namespace Api.Controllers; public sealed class CvMatcherController : ControllerBase { private readonly ICvMatcherApi _cvApi; + private readonly IJobSearchApi _jobSearchApi; private readonly ICaptchaVerifier _captcha; private readonly FileStorageSettings _fileStorageSettings; + private readonly JobSearchLinkSettings _jobSearchLinkSettings; private readonly IEmailSender _emailSender; + private readonly ITemplateService _templates; private readonly ILogger _logger; public CvMatcherController( ICvMatcherApi cvApi, + IJobSearchApi jobSearchApi, ICaptchaVerifier captcha, IOptions fileStorageSettings, + IOptions jobSearchLinkSettings, IEmailSender emailSender, + ITemplateService templates, ILogger logger) { _cvApi = cvApi; + _jobSearchApi = jobSearchApi; _captcha = captcha; _fileStorageSettings = fileStorageSettings.Value; + _jobSearchLinkSettings = jobSearchLinkSettings.Value; _emailSender = emailSender; + _templates = templates; _logger = logger; } /// - /// 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)] @@ -93,6 +112,16 @@ public sealed class CvMatcherController : ControllerBase _logger.LogWarning("CV upload proxy request was cancelled by the client."); return StatusCode(499, new ErrorResponse { Error = "Request cancelled.", Code = "request_cancelled" }); } + catch (Refit.ApiException apiEx) when ((int)apiEx.StatusCode < 500) + { + // Forward upstream 4xx errors (e.g. "File is too large", "Only PDF files supported") + // so the browser can display the actionable message rather than a generic 502. + var body = await apiEx.GetContentAsAsync(); + _logger.LogWarning("Upstream cv-matcher-api returned {Status} during CV upload: {Error}", + (int)apiEx.StatusCode, body?.Error); + return StatusCode((int)apiEx.StatusCode, + body ?? new ErrorResponse { Error = apiEx.Message, Code = "upstream_error" }); + } catch (Exception ex) { _logger.LogError(ex, "CV upload proxy request failed."); @@ -101,10 +130,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)] @@ -126,6 +163,7 @@ public sealed class CvMatcherController : ControllerBase return BadRequest(new ErrorResponse { Error = "Captcha verification failed.", Code = "captcha_verification_failed" }); } + request.ClientIpAddress = userIp; _logger.LogInformation("Proxying job match request to cv-matcher-api. CvDocumentId={CvDocumentId}, HasJobUrl={HasJobUrl}, HasJobDescription={HasJobDescription}", request.CvDocumentId, !string.IsNullOrWhiteSpace(request.JobUrl), @@ -136,10 +174,32 @@ public sealed class CvMatcherController : ControllerBase ? request.JobUrl : "Manual job description"; + var language = NormalizeLanguage(request.Language); + + string? jobSearchLink = null; + if (!string.IsNullOrWhiteSpace(request.Email) && !string.IsNullOrWhiteSpace(request.CvDocumentId)) + { + try + { + var tokenResp = await _jobSearchApi.CreateTokenAsync( + new CreateJobSearchTokenRequest { CvDocumentId = request.CvDocumentId, Email = request.Email, Language = language, Keywords = res.Keywords, Location = res.Location, ClientIpAddress = userIp }, + ct); + if (!string.IsNullOrWhiteSpace(tokenResp.TokenId)) + { + var baseUrl = _jobSearchLinkSettings.BaseUrl.TrimEnd('/'); + jobSearchLink = $"{baseUrl}/api/cv-matcher/job-search/start?t={tokenResp.TokenId}"; + } + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Could not create job search token. Email link will be omitted."); + } + } + await _emailSender.SendMatchAsync( request.Email, - SmtpEmailSender.BuildMatchEmailSubject(res.Score, jobLabel), - SmtpEmailSender.BuildMatchEmailBody(request.CvDocumentId ?? "N/A", res, jobLabel), + _emailSender.BuildMatchEmailSubject(res.Score, jobLabel, language), + _emailSender.BuildMatchEmailBody(request.CvDocumentId ?? "N/A", res, jobLabel, language, jobSearchLink), attachmentPath, ct); @@ -150,6 +210,16 @@ public sealed class CvMatcherController : ControllerBase _logger.LogWarning("Job match proxy request was cancelled by the client."); return StatusCode(499, new ErrorResponse { Error = "Request cancelled.", Code = "request_cancelled" }); } + catch (Refit.ApiException apiEx) when ((int)apiEx.StatusCode < 500) + { + // Forward upstream 4xx errors (e.g. "Could not extract enough job text", + // "Invalid job URL") so the browser can display the actionable message. + var body = await apiEx.GetContentAsAsync(); + _logger.LogWarning("Upstream cv-matcher-api returned {Status} during job match: {Error}", + (int)apiEx.StatusCode, body?.Error); + return StatusCode((int)apiEx.StatusCode, + body ?? new ErrorResponse { Error = apiEx.Message, Code = "upstream_error" }); + } catch (Exception ex) { _logger.LogError(ex, "Job match proxy request failed."); @@ -157,6 +227,45 @@ 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 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 + { + var userIp = HttpContext.Connection.RemoteIpAddress?.ToString(); + var result = await _jobSearchApi.StartSearchAsync(t, new StartJobSearchRequest { ClientIpAddress = userIp }, ct); + var lang = "en"; + var (title, message) = result.Status switch + { + StartJobSearchStatus.Started => (_templates.Get("html.job-search.started.title", lang), _templates.Get("html.job-search.started.message", lang)), + StartJobSearchStatus.AlreadyUsed => (_templates.Get("html.job-search.already-used.title", lang), _templates.Get("html.job-search.already-used.message", lang)), + StartJobSearchStatus.Expired => (_templates.Get("html.job-search.expired.title", lang), _templates.Get("html.job-search.expired.message", lang)), + _ => (_templates.Get("html.job-search.invalid.title", lang), _templates.Get("html.job-search.invalid.message", lang)) + }; + return Content(_templates.Render("html.job-search.shell", "*", ("title", title), ("message", message)), "text/html"); + } + catch (Exception ex) + { + _logger.LogError(ex, "Job search start failed for token {Token}.", t); + var title = _templates.Get("html.job-search.error.title", "en"); + var message = _templates.Get("html.job-search.error.message", "en"); + return Content(_templates.Render("html.job-search.shell", "*", ("title", title), ("message", message)), "text/html"); + } + } + private async Task CacheUploadedCvAsync(IFormFile file, string documentId, CancellationToken ct) { try @@ -196,6 +305,9 @@ public sealed class CvMatcherController : ControllerBase return Path.Combine(GetFileStoragePath(), $"{safeId}.pdf"); } + private static string NormalizeLanguage(string? language) => + string.IsNullOrWhiteSpace(language) ? "en" : language.ToLowerInvariant().Split('-')[0].Trim(); + private string GetFileStoragePath() { var fileStoragePath = _fileStorageSettings.Path; diff --git a/Apis/api/Controllers/FileDownloadController.cs b/Apis/api/Controllers/FileDownloadController.cs index 6adf28f..1792d48 100644 --- a/Apis/api/Controllers/FileDownloadController.cs +++ b/Apis/api/Controllers/FileDownloadController.cs @@ -6,7 +6,8 @@ using Microsoft.AspNetCore.StaticFiles; using Microsoft.Extensions.Options; using Microsoft.Net.Http.Headers; using Swashbuckle.AspNetCore.Annotations; -using Shared.Models.Responses; +using Common.Responses; +using Microsoft.AspNetCore.RateLimiting; namespace Api.Controllers { @@ -17,42 +18,44 @@ namespace Api.Controllers [ApiController] [Route("api/[controller]")] [EnableCors("FrontendOnly")] + [EnableRateLimiting("download")] public sealed class FileDownloadController : ControllerBase { private readonly ILogger _logger; private readonly FileStorageSettings _fileStorageSettings; private readonly IContentTypeProvider _contentTypeProvider; private readonly IEmailSender _emailSender; - private const int BufferSize = 81920; // 80 KB buffer for optimal streaming performance + private readonly ICaptchaVerifier _captcha; + private const int BufferSize = 81920; public FileDownloadController( ILogger logger, IOptions fileStorageSettings, IContentTypeProvider contentTypeProvider, - IEmailSender emailSender) + IEmailSender emailSender, + ICaptchaVerifier captcha) { _logger = logger; _fileStorageSettings = fileStorageSettings.Value; _contentTypeProvider = contentTypeProvider; _emailSender = emailSender; + _captcha = captcha; } /// /// Downloads a file with support for resume (range requests) and chunked transfer. /// Supports HTTP 206 Partial Content for efficient downloads and resume capability. + /// Requires a valid reCAPTCHA v3 token on the initial (non-range) request. /// Sends email notification when download starts. /// - /// The name of the file to download (optional - uses default from settings if not provided) + /// The name of the file to download (optional - uses default from settings if not provided). + /// reCAPTCHA v3 token — required on the initial download request; omit on subsequent range requests. /// File stream with appropriate headers for resumable downloads - /// Full file content - /// Partial file content (range request) - /// File not found - /// Requested range not satisfiable [HttpGet("{fileName?}")] - [SwaggerOperation(Summary = "Download file", Description = "Downloads a file with support for full and ranged (resumable) transfers.")] + [SwaggerOperation(Summary = "Download file", Description = "Downloads a file. Requires a reCAPTCHA v3 token on the initial request. Range requests for resume do not require a token.")] [SwaggerResponse(StatusCodes.Status200OK, "Full file content returned")] [SwaggerResponse(StatusCodes.Status206PartialContent, "Partial file content returned for a range request")] - [SwaggerResponse(StatusCodes.Status400BadRequest, "No file name provided and no default configured")] + [SwaggerResponse(StatusCodes.Status400BadRequest, "Missing/invalid captcha token, no file name, or no default configured")] [SwaggerResponse(StatusCodes.Status404NotFound, "Requested file was not found")] [SwaggerResponse(StatusCodes.Status416RangeNotSatisfiable, "Requested byte range is invalid")] [SwaggerResponse(StatusCodes.Status500InternalServerError, "Unexpected server error while downloading")] @@ -62,11 +65,30 @@ namespace Api.Controllers [ProducesResponseType(typeof(ErrorResponse), StatusCodes.Status404NotFound)] [ProducesResponseType(StatusCodes.Status416RangeNotSatisfiable)] [ProducesResponseType(typeof(ErrorResponse), StatusCodes.Status500InternalServerError)] - public async Task DownloadFile(string? fileName = null) + public async Task DownloadFile(string? fileName = null, [FromQuery] string? captchaToken = null) { try { - // Use default file name from settings if not provided + var userIp = HttpContext.Connection.RemoteIpAddress?.ToString(); + + // Captcha required on the initial (full) download only — range requests are resume continuations. + var isRangeRequest = !string.IsNullOrEmpty(Request.Headers[HeaderNames.Range].ToString()); + if (!isRangeRequest) + { + if (string.IsNullOrWhiteSpace(captchaToken)) + { + _logger.LogWarning("Download attempt without captcha token from IP={IP}", HttpContext.Connection.RemoteIpAddress); + return BadRequest(new ErrorResponse { Error = "Captcha token is required.", Code = "captcha_token_missing" }); + } + + var verdict = await _captcha.VerifyAsync(captchaToken, userIp, "file_download", CancellationToken.None); + if (!verdict.Success) + { + _logger.LogWarning("Download blocked by captcha. IP={IP} Score={Score}", userIp, verdict.Score); + return BadRequest(new ErrorResponse { Error = "Captcha verification failed.", Code = "captcha_verification_failed" }); + } + } + if (string.IsNullOrWhiteSpace(fileName)) { fileName = _fileStorageSettings.DefaultFileName; @@ -80,44 +102,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 () => { try @@ -130,19 +138,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 +169,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 +195,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 +230,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 0848a02..5c0a2b4 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,20 @@ namespace Api.Controllers [EnableCors("FrontendOnly")] public sealed class HealthController : ControllerBase { + /// + /// 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" }. + // 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/Apis/api/Dockerfile b/Apis/api/Dockerfile index 3450b36..d0e29aa 100644 --- a/Apis/api/Dockerfile +++ b/Apis/api/Dockerfile @@ -2,18 +2,27 @@ FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build ARG BUILD_CONFIGURATION=Release WORKDIR /src +COPY Directory.Packages.props ./ COPY Apis/api/api.csproj Apis/api/ -COPY Apis/shared-models/shared-models.csproj Apis/shared-models/ COPY Apis/api-models/api-models.csproj Apis/api-models/ +COPY Apis/email-data/email-data.csproj Apis/email-data/ +COPY Apis/email-api-models/email-api-models.csproj Apis/email-api-models/ COPY Apis/cv-matcher-api-models/cv-matcher-api-models.csproj Apis/cv-matcher-api-models/ +COPY Apis/common/common.csproj Apis/common/ +COPY Apis/myai-data/myai-data.csproj Apis/myai-data/ +COPY Apis/shared-data/shared-data.csproj Apis/shared-data/ COPY Helpers/startup-helpers/startup-helpers.csproj Helpers/startup-helpers/ RUN dotnet restore Apis/api/api.csproj COPY Apis/api/ Apis/api/ -COPY Apis/shared-models/ Apis/shared-models/ COPY Apis/api-models/ Apis/api-models/ +COPY Apis/email-data/ Apis/email-data/ +COPY Apis/email-api-models/ Apis/email-api-models/ COPY Apis/cv-matcher-api-models/ Apis/cv-matcher-api-models/ +COPY Apis/common/ Apis/common/ +COPY Apis/myai-data/ Apis/myai-data/ +COPY Apis/shared-data/ Apis/shared-data/ COPY Helpers/startup-helpers/ Helpers/startup-helpers/ RUN dotnet publish Apis/api/api.csproj -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=false @@ -25,4 +34,4 @@ ENV ASPNETCORE_URLS=http://0.0.0.0:8080 COPY --from=build /app/publish . -ENTRYPOINT ["dotnet", "api.dll"] \ No newline at end of file +ENTRYPOINT ["dotnet", "api.dll"] diff --git a/Apis/api/Program.cs b/Apis/api/Program.cs index 78c6beb..286612d 100644 --- a/Apis/api/Program.cs +++ b/Apis/api/Program.cs @@ -1,9 +1,19 @@ using System.Reflection; using Api.Services; using Api.Services.Contracts; +using Email.Data; +using Email.Data.Repositories; +using Email.Data.Repositories.Contracts; +using Email.Data.Services; +using Email.Models.Clients; +using Email.Models.Settings; +using Microsoft.EntityFrameworkCore; using Models.Settings; +using MyAi.Data; +using MyAi.Data.Services; using Refit; using Serilog; +using Common.Settings; using StartupHelpers; StartupExtensions.LoadDotEnvFile(); @@ -25,30 +35,69 @@ try builder.Services.Configure(builder.Configuration.GetSection("Google")); builder.Services.Configure(builder.Configuration.GetSection("Contact")); builder.Services.Configure(builder.Configuration.GetSection("Subscribe")); - builder.Services.Configure(builder.Configuration.GetSection("Smtp")); builder.Services.Configure(builder.Configuration.GetSection("Captcha")); builder.Services.Configure(builder.Configuration.GetSection("FileStorage")); + builder.Services.Configure(builder.Configuration.GetSection("JobSearch")); + builder.Services.Configure(builder.Configuration.GetSection("EmailApi")); + + builder.Services.AddDbContext(options => + { + var connectionString = builder.Services.GetConfiguredDbConnectionString(builder.Configuration); + options.UseSqlServer(connectionString, sql => + { + sql.MigrationsAssembly("myai-data"); + sql.MigrationsHistoryTable(MyAiDbContext.MigrationTableName, MyAiDbContext.SchemaName); + }); + }); + builder.Services.AddSingleton(); + + builder.Services.AddDbContext(options => + { + var connectionString = builder.Services.GetConfiguredDbConnectionString(builder.Configuration); + options.UseSqlServer(connectionString, sql => + { + sql.MigrationsHistoryTable(EmailDbContext.MigrationTableName, EmailDbContext.SchemaName); + sql.MigrationsAssembly("email-data"); + }); + }); + + builder.Services.AddScoped(); + builder.Services.AddSingleton(); builder.Services.AddHttpClient(); - builder.Services.AddSingleton(); + builder.Services.AddSingleton(); builder.Services.AddSingleton(); - builder.Services.AddRefitClient() - .ConfigureHttpClient((sp, client) => - { - var config = sp.GetRequiredService(); - var baseUrl = config["CvMatcherApi:BaseUrl"] ?? string.Empty; - if (!string.IsNullOrWhiteSpace(baseUrl)) - { - client.BaseAddress = new Uri(baseUrl.TrimEnd('/') + "/"); - } + static void ConfigureEmailApiClient(IServiceProvider sp, HttpClient client) + { + var config = sp.GetRequiredService(); + var baseUrl = config["EmailApi:BaseUrl"] ?? string.Empty; + if (!string.IsNullOrWhiteSpace(baseUrl)) + client.BaseAddress = new Uri(baseUrl.TrimEnd('/') + "/"); + var key = config["EmailApi:InternalApiKey"]; + if (!string.IsNullOrWhiteSpace(key) && !client.DefaultRequestHeaders.Contains("X-Internal-Api-Key")) + client.DefaultRequestHeaders.Add("X-Internal-Api-Key", key); + } - var key = config["CvMatcherApi:InternalApiKey"]; - if (!string.IsNullOrWhiteSpace(key) && !client.DefaultRequestHeaders.Contains("X-Internal-Api-Key")) - { - client.DefaultRequestHeaders.Add("X-Internal-Api-Key", key); - } - }); + static void ConfigureCvMatcherApiClient(IServiceProvider sp, HttpClient client) + { + var config = sp.GetRequiredService(); + var baseUrl = config["CvMatcherApi:BaseUrl"] ?? string.Empty; + if (!string.IsNullOrWhiteSpace(baseUrl)) + client.BaseAddress = new Uri(baseUrl.TrimEnd('/') + "/"); + var key = config["CvMatcherApi:InternalApiKey"]; + if (!string.IsNullOrWhiteSpace(key) && !client.DefaultRequestHeaders.Contains("X-Internal-Api-Key")) + client.DefaultRequestHeaders.Add("X-Internal-Api-Key", key); + } + + builder.Services.AddRefitClient() + .ConfigureHttpClient(ConfigureEmailApiClient); + + builder.Services.AddRefitClient() + .ConfigureHttpClient(ConfigureCvMatcherApiClient); + + builder.Services.AddRefitClient() + .ConfigureHttpClient(ConfigureCvMatcherApiClient); builder.Services.AddSwaggerWithXmlComments(Assembly.GetExecutingAssembly(), "API"); builder.Services.ConfigureCaddyForwardedHeaders(); @@ -70,6 +119,13 @@ try app.UseRateLimiter(); app.MapControllers(); + Log.Information("Running EF Core migrations if any"); + using (var scope = app.Services.CreateScope()) + { + var db = scope.ServiceProvider.GetRequiredService(); + db.Database.Migrate(); + } + Log.Information("{Service} startup complete. Listening for requests...", ServiceName); app.Run(); } diff --git a/Apis/api/Services/Contracts/ICaptchaVerifier.cs b/Apis/api/Services/Contracts/ICaptchaVerifier.cs index a97754c..a549e9d 100644 --- a/Apis/api/Services/Contracts/ICaptchaVerifier.cs +++ b/Apis/api/Services/Contracts/ICaptchaVerifier.cs @@ -1,9 +1,21 @@ -using Api.Services.Contracts.Models; +using Api.Services.Contracts.Models; namespace Api.Services.Contracts { + /// + /// Verifies a reCAPTCHA token against the Google verification API. + /// public interface ICaptchaVerifier { + /// + /// Sends the token to the Google reCAPTCHA verification endpoint and + /// returns a verdict indicating success, score, and any failure reason. + /// + /// The reCAPTCHA token provided by the client. + /// Optional remote IP address passed to Google for additional risk analysis. + /// Optional action name to validate against the token's embedded action (v3 only). + /// Cancellation token. + /// A with the verification outcome. Task VerifyAsync(string token, string? userIp, string? expectedAction, CancellationToken ct); } } diff --git a/Apis/api/Services/Contracts/IEmailSender.cs b/Apis/api/Services/Contracts/IEmailSender.cs index bfae8d2..8fd7502 100644 --- a/Apis/api/Services/Contracts/IEmailSender.cs +++ b/Apis/api/Services/Contracts/IEmailSender.cs @@ -1,12 +1,65 @@ -using Models.Requests; +using CvMatcher.Models.Responses; +using Models.Requests; namespace Api.Services.Contracts { + /// + /// Abstraction for sending transactional emails from the public API. + /// public interface IEmailSender { + /// + /// Sends a contact-form message to the configured operator address. + /// + /// Contact request containing name, email, subject, and message. + /// Cancellation token. Task SendContactAsync(ContactRequest req, CancellationToken ct); + + /// + /// Notifies the configured operator address that a new email subscription was received. + /// + /// Subscription request containing the subscriber's email address. + /// Cancellation token. Task SendSubscribeAsync(SubscribeRequest req, CancellationToken ct); + + /// + /// Sends a background notification when a file download is initiated. + /// Does nothing when no notification address is configured. + /// + /// Name of the downloaded file. + /// Remote IP address of the downloader, or null if unavailable. + /// Cancellation token. Task SendFileDownloadNotificationAsync(string fileName, string? userIp, CancellationToken ct); + + /// + /// Sends a CV match results email to the user and the operator copy address. + /// + /// Primary recipient email address, or null to send only the operator copy. + /// Email subject line. + /// Pre-built HTML body fragment. + /// Full path to a CV PDF to attach, or null for no attachment. + /// Cancellation token. Task SendMatchAsync(string? explicitTo, string subject, string body, string? attachmentPath, CancellationToken ct); + + /// + /// Builds the localised subject line for a CV match email. + /// + /// Match score percentage (0–100). + /// Human-readable job title or label. + /// Two-letter language code (e.g. "en", "ro"). + /// Rendered subject string. + string BuildMatchEmailSubject(int score, string? jobLabel, string language); + + /// + /// Builds the full HTML body for a CV match email, including an optional job-search footer link. + /// + /// Identifier of the indexed CV document. + /// Structured match response from the CV matcher engine. + /// Human-readable job title or label. + /// Two-letter language code. + /// Optional one-click job-search URL to append as a footer CTA. + /// Number of days until the job-search link expires (shown in the footer copy). + /// Rendered HTML body string. + string BuildMatchEmailBody(string cvDocumentId, JobMatchResponse result, string? jobLabel, string language, string? jobSearchLink = null, int expiryDays = 7); } } diff --git a/Apis/api/Services/EmailApiEmailSender.cs b/Apis/api/Services/EmailApiEmailSender.cs new file mode 100644 index 0000000..86a416f --- /dev/null +++ b/Apis/api/Services/EmailApiEmailSender.cs @@ -0,0 +1,242 @@ +using Api.Services.Contracts; +using CvMatcher.Models.Responses; +using Email.Data.Services; +using Email.Models.Clients; +using Email.Models.Requests; +using Microsoft.Extensions.Options; +using Models.Requests; +using Models.Settings; +using System.Net; + +namespace Api.Services; + +/// +/// Implements by delegating all email dispatch to the internal email-api service via Refit. +/// +public sealed class EmailApiEmailSender : IEmailSender +{ + private readonly IEmailApiClient _emailApi; + private readonly ContactSettings _contact; + private readonly SubscribeSettings _subscribe; + private readonly FileStorageSettings _fileStorage; + private readonly IEmailTemplateService _emailTemplates; + private readonly ILogger _log; + + public EmailApiEmailSender( + IEmailApiClient emailApi, + IOptions contact, + IOptions subscribe, + IOptions fileStorage, + IEmailTemplateService emailTemplates, + ILogger log) + { + _emailApi = emailApi; + _contact = contact.Value; + _subscribe = subscribe.Value; + _fileStorage = fileStorage.Value; + _emailTemplates = emailTemplates; + _log = log; + } + + /// + public async Task SendContactAsync(ContactRequest req, CancellationToken ct) + { + if (string.IsNullOrWhiteSpace(_contact.ToEmail)) + { + _log.LogDebug("Contact email skipped - ToEmail not configured"); + throw new InvalidOperationException("Contact email recipient is not configured."); + } + + _log.LogInformation("Preparing contact email from {SenderEmail} to {RecipientEmail}", + req.Email, _contact.ToEmail); + + var htmlBody = $""" +

New Contact Message

+ + + + + + + + + + + + + +
Name{req.Name}
Email{req.Email}
Subject{req.Subject}
+

Message

+

{req.Message}

+ """; + + await _emailApi.SendAsync(new SendEmailRequest + { + To = [_contact.ToEmail], + ReplyTo = req.Email, + Subject = $"{_contact.SubjectPrefix} {req.Subject}".Trim(), + HtmlBody = htmlBody + }, ct); + + _log.LogInformation("Contact email sent successfully from {SenderEmail}", req.Email); + } + + /// + public async Task SendSubscribeAsync(SubscribeRequest req, CancellationToken ct) + { + if (string.IsNullOrWhiteSpace(_subscribe.ToEmail)) + { + _log.LogDebug("Subscription email skipped - ToEmail not configured"); + throw new InvalidOperationException("Subscription email recipient is not configured."); + } + + _log.LogInformation("Processing subscription request for {Email}", req.Email); + + var htmlBody = $""" +

New Subscription Request

+

A new user has subscribed:

+ + + + + +
Email{req.Email}
+ """; + + await _emailApi.SendAsync(new SendEmailRequest + { + To = [_subscribe.ToEmail], + ReplyTo = req.Email, + Subject = _subscribe.SubjectPrefix.Trim(), + HtmlBody = htmlBody + }, ct); + + _log.LogInformation("Subscription email sent successfully for {Email}", req.Email); + } + + /// + public async Task SendFileDownloadNotificationAsync(string fileName, string? userIp, CancellationToken ct) + { + if (string.IsNullOrWhiteSpace(_fileStorage.ToEmail)) + { + _log.LogDebug("File download notification skipped - ToEmail not configured"); + return; + } + + _log.LogInformation("Preparing file download notification for {FileName}", fileName); + + var htmlBody = $""" +

File Download Notification

+ + + + + + + + + + + + + +
File{fileName}
Downloaded at{DateTime.UtcNow:yyyy-MM-dd HH:mm:ss} UTC
IP Address{userIp ?? "Unknown"}
+ """; + + await _emailApi.SendAsync(new SendEmailRequest + { + To = [_fileStorage.ToEmail], + Subject = $"{_fileStorage.SubjectPrefix} {fileName}".Trim(), + HtmlBody = htmlBody + }, ct); + + _log.LogInformation("File download notification sent successfully for {FileName}", fileName); + } + + /// + public async Task SendMatchAsync(string? explicitTo, string subject, string body, string? attachmentPath, CancellationToken ct) + { + var operatorCopy = _emailTemplates.GetOperatorCopy("email.match.subject", "en"); + + var recipients = new List(); + if (!string.IsNullOrWhiteSpace(explicitTo)) + recipients.Add(explicitTo); + + if (!string.IsNullOrWhiteSpace(operatorCopy) && + !recipients.Any(x => string.Equals(x, operatorCopy, StringComparison.OrdinalIgnoreCase))) + recipients.Add(operatorCopy); + + if (recipients.Count == 0) + { + _log.LogDebug("Match email skipped - no recipients configured"); + return; + } + + string? relativeAttachment = null; + if (!string.IsNullOrWhiteSpace(attachmentPath)) + relativeAttachment = Path.GetFileName(attachmentPath); + + foreach (var recipient in recipients) + { + _log.LogInformation("Preparing CV match email to {RecipientEmail}", recipient); + + await _emailApi.SendAsync(new SendEmailRequest + { + To = [recipient], + Subject = subject, + HtmlBody = body, + AttachmentPath = relativeAttachment + }, ct); + + _log.LogInformation("CV match email sent successfully to {RecipientEmail}", recipient); + } + } + + /// + public string BuildMatchEmailBody(string cvDocumentId, JobMatchResponse result, string? jobLabel, string language, string? jobSearchLink = null, int expiryDays = 7) + { + // Build HTML lists for strengths, gaps, and recommendations + var strengths = result.Strengths?.Count > 0 + ? "
    " + + string.Join("", result.Strengths.Select(s => $"
  • {s}
  • ")) + "
" + : "

"; + + var gaps = result.Gaps?.Count > 0 + ? "
    " + + string.Join("", result.Gaps.Select(g => $"
  • {g}
  • ")) + "
" + : "

"; + + var recommendations = result.Recommendations?.Count > 0 + ? "
    " + + string.Join("", result.Recommendations.Select(r => $"
  • {r}
  • ")) + "
" + : "

"; + + // Render the HTML template with substituted values + // email.match.body is now stored as HTML in the database + var body = _emailTemplates.Render("email.match.body", language, + ("cvDocumentId", cvDocumentId), + ("jobLabel", jobLabel ?? "N/A"), + ("jobUrl", result.JobUrl ?? "N/A"), + ("score", result.Score.ToString()), + ("summary", WebUtility.HtmlEncode(result.Summary ?? string.Empty)), + ("strengths", strengths), + ("gaps", gaps), + ("recommendations", recommendations)); + + // Append the job search footer if link is provided + if (!string.IsNullOrWhiteSpace(jobSearchLink)) + { + body += _emailTemplates.Render("email.match.job-search-footer", language, + ("jobSearchLink", jobSearchLink), + ("expiryDays", expiryDays.ToString())); + } + + return body; + } + + /// + public string BuildMatchEmailSubject(int score, string? jobLabel, string language) => + _emailTemplates.Render("email.match.subject", language, + ("score", score.ToString()), + ("jobLabel", jobLabel ?? "Job")); +} diff --git a/Apis/api/Services/RecaptchaVerifier.cs b/Apis/api/Services/RecaptchaVerifier.cs index b5659be..517e2fb 100644 --- a/Apis/api/Services/RecaptchaVerifier.cs +++ b/Apis/api/Services/RecaptchaVerifier.cs @@ -5,6 +5,9 @@ using Models.Settings; namespace Api.Services { + /// + /// Verifies reCAPTCHA v2/v3 tokens by calling the Google site-verify API. + /// public sealed class RecaptchaVerifier : ICaptchaVerifier { private readonly HttpClient _http; @@ -18,6 +21,7 @@ namespace Api.Services _log = log; } + /// public async Task VerifyAsync(string token, string? userIp, string? expectedAction, CancellationToken ct) { _log.LogDebug("Verifying captcha token for IP {Ip}", userIp ?? "unknown"); diff --git a/Apis/api/Services/SmtpEmailSender.cs b/Apis/api/Services/SmtpEmailSender.cs deleted file mode 100644 index d854534..0000000 --- a/Apis/api/Services/SmtpEmailSender.cs +++ /dev/null @@ -1,239 +0,0 @@ -using Api.Services.Contracts; -using Microsoft.Extensions.Options; -using MailKit.Net.Smtp; -using MailKit.Security; -using MimeKit; -using Models.Settings; -using Models.Requests; -using CvMatcher.Models.Responses; - -namespace Api.Services -{ - public sealed class SmtpEmailSender : IEmailSender - { - private readonly SmtpSettings _smtp; - private readonly ContactSettings _contact; - private readonly SubscribeSettings _subscribe; - private readonly FileStorageSettings _fileStorage; - private readonly ILogger _log; - private readonly string _environmentName; - - public SmtpEmailSender(IOptions smtp, - IOptions contact, - IOptions subscribe, - IOptions fileStorage, - ILogger log) - { - _smtp = smtp.Value; - _contact = contact.Value; - _subscribe = subscribe.Value; - _fileStorage = fileStorage.Value; - _log = log; - // Use APP_ENVIRONMENT_NAME from environment variable (set in docker-compose) with fallback to "Development" - _environmentName = Environment.GetEnvironmentVariable("APP_ENVIRONMENT_NAME") ?? "Development"; - } - - public async Task SendContactAsync(ContactRequest req, CancellationToken ct) - { - // Throw error if ToEmail is not configured, since contact requests are important to process. - if (string.IsNullOrWhiteSpace(_contact.ToEmail)) - { - _log.LogDebug("Contact email skipped - ToEmail not configured"); - throw new InvalidOperationException("Contact email recipient is not configured."); - } - - _log.LogInformation("Preparing contact email from {SenderEmail} to {RecipientEmail}", - req.Email, _contact.ToEmail); - - var msg = new MimeMessage(); - msg.From.Add(MailboxAddress.Parse(_smtp.Username)); - msg.To.Add(MailboxAddress.Parse(_contact.ToEmail)); - msg.ReplyTo.Add(MailboxAddress.Parse(req.Email)); - msg.Subject = $"{_contact.SubjectPrefix} [{_environmentName}] {req.Subject}".Trim(); - - var body = - $@"New contact form submission: - - Name: {req.Name} - Email: {req.Email} - Subject: {req.Subject} - - Message: - {req.Message} - "; - - msg.Body = new TextPart("plain") { Text = body }; - - await SendEmailAsync(msg, "contact email", ct); - - _log.LogInformation("Contact email sent successfully from {SenderEmail}", req.Email); - } - - public async Task SendSubscribeAsync(SubscribeRequest req, CancellationToken ct) - { - // Throw error if ToEmail is not configured, since subscription requests are important to process. - if (string.IsNullOrWhiteSpace(_subscribe.ToEmail)) - { - _log.LogDebug("Subscription email skipped - ToEmail not configured"); - throw new InvalidOperationException("Subscription email recipient is not configured."); - } - - _log.LogInformation("Processing subscription request for {Email}", req.Email); - - var msg = new MimeMessage(); - msg.From.Add(MailboxAddress.Parse(_smtp.Username)); - msg.To.Add(MailboxAddress.Parse(_subscribe.ToEmail)); - msg.ReplyTo.Add(MailboxAddress.Parse(req.Email)); - msg.Subject = $"{_subscribe.SubjectPrefix} [{_environmentName}]".Trim(); - - var body = - $@"New subscription request: - - Email: {req.Email} - "; - - msg.Body = new TextPart("plain") { Text = body }; - - await SendEmailAsync(msg, "subscription email", ct); - - _log.LogInformation("Subscription email sent successfully for {Email}", req.Email); - } - - public async Task SendFileDownloadNotificationAsync(string fileName, string? userIp, CancellationToken ct) - { - // Skip sending if ToEmail is not configured - if (string.IsNullOrWhiteSpace(_fileStorage.ToEmail)) - { - _log.LogDebug("File download notification skipped - ToEmail not configured"); - return; - } - - _log.LogInformation("Preparing file download notification for {FileName}", fileName); - - var msg = new MimeMessage(); - msg.From.Add(MailboxAddress.Parse(_smtp.Username)); - msg.To.Add(MailboxAddress.Parse(_fileStorage.ToEmail)); - msg.Subject = $"{_fileStorage.SubjectPrefix} [{_environmentName}] {fileName}".Trim(); - - var body = - $@"File download notification: - - File: {fileName} - Downloaded at: {DateTime.UtcNow:yyyy-MM-dd HH:mm:ss} UTC - IP Address: {userIp ?? "Unknown"} - "; - - msg.Body = new TextPart("plain") { Text = body }; - - await SendEmailAsync(msg, "file download notification email", ct); - - _log.LogInformation("File download notification sent successfully for {FileName}", fileName); - } - - /// - /// Connects to the SMTP server and authenticates if credentials are configured. - /// - private async Task ConnectAndAuthenticateAsync(SmtpClient client, CancellationToken ct) - { - // If you're in enterprise environments, you may need to tweak certificate validation. - // Don't disable it casually. - var tls = _smtp.UseStartTls ? SecureSocketOptions.StartTls : SecureSocketOptions.Auto; - - _log.LogDebug("Connecting to SMTP server {Host}:{Port} with security={Security}", - _smtp.Host, _smtp.Port, tls); - - await client.ConnectAsync(_smtp.Host, _smtp.Port, tls, ct); - - if (!string.IsNullOrWhiteSpace(_smtp.Username)) - { - _log.LogDebug("Authenticating with SMTP server as {Username}", _smtp.Username); - await client.AuthenticateAsync(_smtp.Username, _smtp.Password, ct); - } - } - - /// - /// Sends an email message using SMTP. - /// - /// The email message to send. - /// Description of the message type for logging purposes. - /// Cancellation token. - private async Task SendEmailAsync(MimeMessage message, string messageType, CancellationToken ct) - { - using var client = new SmtpClient(); - - await ConnectAndAuthenticateAsync(client, ct); - - _log.LogDebug("Sending {MessageType} message", messageType); - await client.SendAsync(message, ct); - await client.DisconnectAsync(true, ct); - } - - public async Task SendMatchAsync(string? explicitTo, string subject, string body, string? attachmentPath, CancellationToken ct) - { - var recipients = new List(); - if (!string.IsNullOrWhiteSpace(explicitTo)) - { - recipients.Add(explicitTo); - } - - if (!string.IsNullOrWhiteSpace(_contact.ToEmail) && - !recipients.Any(x => string.Equals(x, _contact.ToEmail, StringComparison.OrdinalIgnoreCase))) - { - recipients.Add(_contact.ToEmail); - } - - if (recipients.Count == 0) - { - _log.LogDebug("Match email skipped - no recipients configured (user email and Contact:ToEmail missing)"); - return; - } - - foreach (var recipient in recipients) - { - _log.LogInformation("Preparing CV match email to {RecipientEmail}", recipient); - - var msg = new MimeMessage(); - msg.From.Add(MailboxAddress.Parse(_smtp.Username)); - msg.To.Add(MailboxAddress.Parse(recipient)); - msg.Subject = $"[{_environmentName}] {subject}".Trim(); - - var builder = new BodyBuilder - { - TextBody = body - }; - - if (!string.IsNullOrWhiteSpace(attachmentPath) && File.Exists(attachmentPath)) - { - builder.Attachments.Add(attachmentPath); - } - - msg.Body = builder.ToMessageBody(); - - await SendEmailAsync(msg, "cv match email", ct); - _log.LogInformation("CV match email sent successfully to {RecipientEmail}", recipient); - } - } - - public static string BuildMatchEmailBody(string cvDocumentId, JobMatchResponse result, string? jobLabel) => $@"CV Matcher result - -CV Document ID: {cvDocumentId} -Job: {jobLabel ?? "N/A"} -Job URL: {result.JobUrl ?? "N/A"} -Score: {result.Score}% - -Summary: -{result.Summary} - -Strengths: -- {string.Join("\n- ", result.Strengths)} - -Gaps: -- {string.Join("\n- ", result.Gaps)} - -Recommendations: -- {string.Join("\n- ", result.Recommendations)}"; - - public static string BuildMatchEmailSubject(int score, string? jobLabel) - => $"MyAi.ro CV Match: {score}% - {jobLabel ?? "Job"}"; - } -} diff --git a/Apis/api/api.csproj b/Apis/api/api.csproj index 282531a..b9953dc 100644 --- a/Apis/api/api.csproj +++ b/Apis/api/api.csproj @@ -16,18 +16,18 @@ - - - - - - - - - - - - + + + + + + + + + + + + @@ -36,9 +36,12 @@ + + - + + diff --git a/Apis/api/appsettings.json b/Apis/api/appsettings.json index 016f207..3d629cc 100644 --- a/Apis/api/appsettings.json +++ b/Apis/api/appsettings.json @@ -2,8 +2,7 @@ "Serilog": { "Using": [ "Serilog.Sinks.Console", - "Serilog.Sinks.File", - "Serilog.Sinks.Email" + "Serilog.Sinks.File" ], "MinimumLevel": { "Default": "Information", @@ -30,25 +29,6 @@ "retainedFileCountLimit": 30, "outputTemplate": "{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} [{Level:u3}] {SourceContext}: {Message:lj}{NewLine}{Exception}" } - }, - { - "Name": "Email", - "Args": { - "restrictedToMinimumLevel": "Error", - "fromEmail": "", - "toEmail": "", - "mailServer": "", - "networkCredential": { - "userName": "", - "password": "" - }, - "port": 587, - "enableSsl": true, - "emailSubject": "[mihes.ro API] Error Alert", - "outputTemplate": "{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} [{Level:u3}] {SourceContext}{NewLine}{Message:lj}{NewLine}{Exception}", - "batchPostingLimit": 10, - "period": "0.00:05:00" - } } ], "Enrich": [ @@ -110,6 +90,10 @@ "BaseUrl": "", "InternalApiKey": "" }, + "EmailApi": { + "BaseUrl": "", + "InternalApiKey": "" + }, "RateLimiting": { "Global": { "PermitLimit": 120, diff --git a/Apis/shared-models/Requests/UploadFileRequest.cs b/Apis/common/Requests/UploadFileRequest.cs similarity index 86% rename from Apis/shared-models/Requests/UploadFileRequest.cs rename to Apis/common/Requests/UploadFileRequest.cs index c9ed0b2..99a24dc 100644 --- a/Apis/shared-models/Requests/UploadFileRequest.cs +++ b/Apis/common/Requests/UploadFileRequest.cs @@ -1,7 +1,7 @@ using Microsoft.AspNetCore.Http; using System.ComponentModel.DataAnnotations; -namespace Shared.Models.Requests +namespace Common.Requests { public class UploadFileRequest { diff --git a/Apis/common/Responses/ErrorResponse.cs b/Apis/common/Responses/ErrorResponse.cs new file mode 100644 index 0000000..253a382 --- /dev/null +++ b/Apis/common/Responses/ErrorResponse.cs @@ -0,0 +1,16 @@ +namespace Common.Responses; + +/// +/// Standard error body returned by all API endpoints on 4xx and 5xx responses. +/// +public sealed class ErrorResponse +{ + /// Human-readable error message, safe to display directly to the end user for 4xx responses. + public string Error { get; init; } = string.Empty; + + /// Machine-readable error code for programmatic handling (e.g. "captcha_verification_failed"). + public string? Code { get; init; } + + /// Optional additional detail for debugging (not shown in UI). + public string? Detail { get; init; } +} diff --git a/Apis/shared-models/Settings/AiSettings.cs b/Apis/common/Settings/AiSettings.cs similarity index 73% rename from Apis/shared-models/Settings/AiSettings.cs rename to Apis/common/Settings/AiSettings.cs index 56ff34c..f6fef72 100644 --- a/Apis/shared-models/Settings/AiSettings.cs +++ b/Apis/common/Settings/AiSettings.cs @@ -1,4 +1,4 @@ -namespace Shared.Models.Settings +namespace Common.Settings { public class AiSettings { diff --git a/Apis/shared-models/Settings/DatabaseSettings.cs b/Apis/common/Settings/DatabaseSettings.cs similarity index 90% rename from Apis/shared-models/Settings/DatabaseSettings.cs rename to Apis/common/Settings/DatabaseSettings.cs index d5f89e9..4a3961a 100644 --- a/Apis/shared-models/Settings/DatabaseSettings.cs +++ b/Apis/common/Settings/DatabaseSettings.cs @@ -1,4 +1,4 @@ -namespace Shared.Models.Settings +namespace Common.Settings { public class DatabaseSettings { diff --git a/Apis/shared-models/Settings/InternalApiSettings.cs b/Apis/common/Settings/InternalApiSettings.cs similarity index 82% rename from Apis/shared-models/Settings/InternalApiSettings.cs rename to Apis/common/Settings/InternalApiSettings.cs index 14b0637..d988232 100644 --- a/Apis/shared-models/Settings/InternalApiSettings.cs +++ b/Apis/common/Settings/InternalApiSettings.cs @@ -1,4 +1,4 @@ -namespace Shared.Models.Settings +namespace Common.Settings { public class InternalApiSettings { diff --git a/Apis/shared-models/Settings/OllamaSettings.cs b/Apis/common/Settings/OllamaSettings.cs similarity index 86% rename from Apis/shared-models/Settings/OllamaSettings.cs rename to Apis/common/Settings/OllamaSettings.cs index 6cb4584..2b3a11f 100644 --- a/Apis/shared-models/Settings/OllamaSettings.cs +++ b/Apis/common/Settings/OllamaSettings.cs @@ -1,4 +1,4 @@ -namespace Shared.Models.Settings +namespace Common.Settings { public class OllamaSettings { diff --git a/Apis/shared-models/Settings/OpenAiSettings.cs b/Apis/common/Settings/OpenAiSettings.cs similarity index 86% rename from Apis/shared-models/Settings/OpenAiSettings.cs rename to Apis/common/Settings/OpenAiSettings.cs index 603a55f..e280784 100644 --- a/Apis/shared-models/Settings/OpenAiSettings.cs +++ b/Apis/common/Settings/OpenAiSettings.cs @@ -1,4 +1,4 @@ -namespace Shared.Models.Settings +namespace Common.Settings { public class OpenAiSettings { diff --git a/Apis/common/Settings/PageFetcherApiSettings.cs b/Apis/common/Settings/PageFetcherApiSettings.cs new file mode 100644 index 0000000..c6367ac --- /dev/null +++ b/Apis/common/Settings/PageFetcherApiSettings.cs @@ -0,0 +1,11 @@ +namespace Common.Settings; + +/// +/// Connection settings for the internal page-fetcher-api service. +/// Bound from the PageFetcherApi configuration section. +/// +public sealed class PageFetcherApiSettings +{ + public string BaseUrl { get; set; } = string.Empty; + public string InternalApiKey { get; set; } = string.Empty; +} diff --git a/Apis/shared-models/Settings/RateLimitingSettings.cs b/Apis/common/Settings/RateLimitingSettings.cs similarity index 94% rename from Apis/shared-models/Settings/RateLimitingSettings.cs rename to Apis/common/Settings/RateLimitingSettings.cs index 2f1a730..38bb837 100644 --- a/Apis/shared-models/Settings/RateLimitingSettings.cs +++ b/Apis/common/Settings/RateLimitingSettings.cs @@ -1,4 +1,4 @@ -namespace Shared.Models.Settings +namespace Common.Settings { public class RateLimitingSettings { diff --git a/Apis/shared-models/shared-models.csproj b/Apis/common/common.csproj similarity index 53% rename from Apis/shared-models/shared-models.csproj rename to Apis/common/common.csproj index 0cea718..3762a2e 100644 --- a/Apis/shared-models/shared-models.csproj +++ b/Apis/common/common.csproj @@ -1,14 +1,15 @@ - + net10.0 - Shared.Models + common + Common enable enable - + diff --git a/Apis/cv-matcher-api-models/Requests/CreateJobSearchTokenRequest.cs b/Apis/cv-matcher-api-models/Requests/CreateJobSearchTokenRequest.cs new file mode 100644 index 0000000..1cd9e2f --- /dev/null +++ b/Apis/cv-matcher-api-models/Requests/CreateJobSearchTokenRequest.cs @@ -0,0 +1,12 @@ +namespace CvMatcher.Models.Requests; + +public sealed class CreateJobSearchTokenRequest +{ + public string CvDocumentId { get; set; } = string.Empty; + public string Email { get; set; } = string.Empty; + public string Language { get; set; } = "en"; + public List Keywords { get; set; } = []; + public string? Location { get; set; } + /// Client IP address forwarded by the api layer at CV match time. Null when not available. + public string? ClientIpAddress { get; set; } +} diff --git a/Apis/cv-matcher-api-models/Requests/MatchJobRequest.cs b/Apis/cv-matcher-api-models/Requests/MatchJobRequest.cs index c3b837c..2a6abe2 100644 --- a/Apis/cv-matcher-api-models/Requests/MatchJobRequest.cs +++ b/Apis/cv-matcher-api-models/Requests/MatchJobRequest.cs @@ -7,5 +7,9 @@ public string? JobDescription { get; set; } public bool GdprConsent { get; set; } public string? Email { get; set; } + /// ISO 639-1 language code for the match result (e.g. "en", "ro"). Defaults to "en". + public string? Language { get; set; } + /// Client IP address forwarded by the api layer. Null when called from a background job. + public string? ClientIpAddress { get; set; } } } diff --git a/Apis/cv-matcher-api-models/Requests/StartJobSearchRequest.cs b/Apis/cv-matcher-api-models/Requests/StartJobSearchRequest.cs new file mode 100644 index 0000000..aaf726b --- /dev/null +++ b/Apis/cv-matcher-api-models/Requests/StartJobSearchRequest.cs @@ -0,0 +1,11 @@ +namespace CvMatcher.Models.Requests; + +/// +/// Request body sent by api when activating a one-time job-search link. +/// Carries the caller's IP address so it can be persisted on the session for auditing. +/// +public sealed class StartJobSearchRequest +{ + /// Client IP address forwarded by the api layer. Null when not available. + public string? ClientIpAddress { get; set; } +} diff --git a/Apis/cv-matcher-api-models/Responses/CreateJobSearchTokenResponse.cs b/Apis/cv-matcher-api-models/Responses/CreateJobSearchTokenResponse.cs new file mode 100644 index 0000000..624eb8a --- /dev/null +++ b/Apis/cv-matcher-api-models/Responses/CreateJobSearchTokenResponse.cs @@ -0,0 +1,10 @@ +namespace CvMatcher.Models.Responses; + +public sealed class CreateJobSearchTokenResponse +{ + /// + /// The generated token ID, or null when no job providers are currently enabled. + /// Callers must check for null before building the job-search link. + /// + public string? TokenId { get; set; } +} diff --git a/Apis/cv-matcher-api-models/Responses/JobMatchResponse.cs b/Apis/cv-matcher-api-models/Responses/JobMatchResponse.cs index 8ef3e1d..9be1af9 100644 --- a/Apis/cv-matcher-api-models/Responses/JobMatchResponse.cs +++ b/Apis/cv-matcher-api-models/Responses/JobMatchResponse.cs @@ -8,6 +8,8 @@ public List Gaps { get; set; } = []; public List Recommendations { get; set; } = []; public List Evidence { get; set; } = []; + public List Keywords { get; set; } = []; + public string? Location { get; set; } public bool Cached { get; set; } public string? JobDocumentId { get; set; } public string? JobUrl { get; set; } diff --git a/Apis/cv-matcher-api-models/Responses/StartJobSearchResponse.cs b/Apis/cv-matcher-api-models/Responses/StartJobSearchResponse.cs new file mode 100644 index 0000000..f6de477 --- /dev/null +++ b/Apis/cv-matcher-api-models/Responses/StartJobSearchResponse.cs @@ -0,0 +1,14 @@ +namespace CvMatcher.Models.Responses; + +public sealed class StartJobSearchResponse +{ + public string Status { get; set; } = string.Empty; +} + +public static class StartJobSearchStatus +{ + public const string Started = "Started"; + public const string AlreadyUsed = "AlreadyUsed"; + public const string Expired = "Expired"; + public const string NotFound = "NotFound"; +} diff --git a/Apis/cv-matcher-api-models/Settings/AiSettings.cs b/Apis/cv-matcher-api-models/Settings/AiSettings.cs index 839ddb8..0f1e7d5 100644 --- a/Apis/cv-matcher-api-models/Settings/AiSettings.cs +++ b/Apis/cv-matcher-api-models/Settings/AiSettings.cs @@ -1,8 +1,8 @@ -using Shared.Models.Settings; +using Common.Settings; namespace CvMatcher.Models.Settings; -public sealed class AiSettings : Shared.Models.Settings.AiSettings +public sealed class AiSettings : Common.Settings.AiSettings { public OpenAiSettings OpenAI { get; set; } = new(); public OllamaSettings Ollama { get; set; } = new(); diff --git a/Apis/cv-matcher-api-models/Settings/JobSearchSettings.cs b/Apis/cv-matcher-api-models/Settings/JobSearchSettings.cs new file mode 100644 index 0000000..9a2b907 --- /dev/null +++ b/Apis/cv-matcher-api-models/Settings/JobSearchSettings.cs @@ -0,0 +1,29 @@ +namespace CvMatcher.Models.Settings; + +public sealed class JobSearchSettings +{ + public bool Enabled { get; set; } = true; + public string JobSearchLinkBaseUrl { get; set; } = string.Empty; + public int TokenExpiryDays { get; set; } = 7; + public int MinMatchScore { get; set; } = 15; + public int MaxJobsToMatch { get; set; } = 15; +} + +/// +/// Runtime DTO for a job provider. Populated from cvSearch.JobProviders at session-creation +/// time and snapshotted to JobSearchSessionEntity.ProviderConfigJson. +/// +public sealed class JobProviderConfig +{ + public string Name { get; set; } = string.Empty; + public bool Enabled { get; set; } = true; + public string SearchUrlTemplate { get; set; } = string.Empty; + public string JobLinkContains { get; set; } = string.Empty; + public List InitialKeywords { get; set; } = []; + public int MaxResults { get; set; } = 20; + /// + /// When false, the Stage 2 anchor-text keyword filter is skipped. + /// Set to false for providers whose search URL already filters by relevance server-side. + /// + public bool RequireKeywordInAnchor { get; set; } = true; +} diff --git a/Apis/cv-matcher-api-models/cv-matcher-api-models.csproj b/Apis/cv-matcher-api-models/cv-matcher-api-models.csproj index aeeb632..9dddb25 100644 --- a/Apis/cv-matcher-api-models/cv-matcher-api-models.csproj +++ b/Apis/cv-matcher-api-models/cv-matcher-api-models.csproj @@ -8,7 +8,7 @@ - + diff --git a/Apis/cv-matcher-api/CLAUDE.md b/Apis/cv-matcher-api/CLAUDE.md new file mode 100644 index 0000000..4867458 --- /dev/null +++ b/Apis/cv-matcher-api/CLAUDE.md @@ -0,0 +1,60 @@ +# cv-matcher-api — Internal CV Match Engine + +Internal port 8082. Only reachable from `api` and `cv-search-job` via `X-Internal-Api-Key`. + +## Responsibilities + +- Indexes CV PDFs into the RAG system via `rag-api` +- Matches a CV against a job posting URL (scrapes job HTML, scores pair with LLM) +- Manages job search tokens and sessions for the one-click job search feature +- Owns two EF DbContexts: `CvMatcherDbContext` (schema `cvMatcher`) and `CvSearchDbContext` (schema `cvSearch`) +- Runs EF migrations for both contexts on startup + +## Key routes + +| Method | Route | Description | +|--------|-------|-------------| +| POST | `/api/cv/upload` | Index CV PDF into RAG | +| POST | `/api/cv/match-job` | Score CV against a job URL (LLM call) | +| POST | `/api/cv/find-jobs` | Find matching jobs from the RAG index | +| POST | `/api/cv/job-search/token` | Create a job search token (called by api after a match) | +| POST | `/api/cv/job-search/token/{tokenId}/start` | Validate token, create Pending session (called by api on link click) | +| GET | `/api/health` | Health check | + +## Core services + +- `CvMatcherService` — orchestrates upload + match; calls `IRagApiClient` and `IMatcherAiClient` +- `JobTextExtractor` — fetches a job page URL and extracts plain text +- `JobTokenService` — creates tokens; validates + starts job search sessions; extracts CV keywords using simple heuristics (first 5 meaningful non-empty lines of CV text, split into words) + +## AI providers + +Configured under `Ai:Provider` (`OpenAI` or `Ollama`). Both providers implement `IMatcherAiClient`. +Default model: `gpt-4o-mini`. Timeout: 90 s. + +## Database contexts + +Both contexts use the same SQL Server connection string (from `Database:*` settings). + +- `CvMatcherDbContext` — schema `cvMatcher`; migrations in `cv-matcher-data` assembly (`Apis/cv-matcher-data/`) +- `CvSearchDbContext` — schema `cvSearch`; migrations in `cv-search-data` assembly (`Apis/cv-search-data/`) + +## Keyword extraction (JobTokenService.ExtractKeywords) + +No LLM call. Takes the first 5 non-empty lines of CV text that are: +- Longer than 5 characters +- Not purely numeric or contact-line patterns + +Splits into words, strips punctuation, deduplicates, returns up to 10 comma-separated keywords. +These keywords are stored in `JobSearchSessionEntity.Keywords` and used by `cv-search-job` for scraping. + +## Settings + +| Section | Notes | +|---------|-------| +| `Database` | Shared SQL Server connection | +| `RagApi` | BaseUrl + InternalApiKey for rag-api | +| `Ai` | Provider, model, timeout | +| `Matcher` | TopK, DeepScoreTopN, MaxJobTextChars | +| `JobSearch` | TokenExpiryDays, providers list (stored in session JSON) | +| `InternalApi` | ApiKey used by UseInternalApiKeyProtection middleware | diff --git a/Apis/cv-matcher-api/Clients/Ai/CachedMatcherAiClient.cs b/Apis/cv-matcher-api/Clients/Ai/CachedMatcherAiClient.cs index 05014e6..8fd03e6 100644 --- a/Apis/cv-matcher-api/Clients/Ai/CachedMatcherAiClient.cs +++ b/Apis/cv-matcher-api/Clients/Ai/CachedMatcherAiClient.cs @@ -1,6 +1,6 @@ using Microsoft.Extensions.Options; using CvMatcher.Models.Settings; -using Api.Data.Repositories.Contracts; +using CvMatcher.Data.Repositories.Contracts; using Api.Clients.Ai.Contracts; using CommonHelpers; diff --git a/Apis/cv-matcher-api/Clients/Ai/MatcherAiClient.cs b/Apis/cv-matcher-api/Clients/Ai/MatcherAiClient.cs index ae8f9cb..3fe3968 100644 --- a/Apis/cv-matcher-api/Clients/Ai/MatcherAiClient.cs +++ b/Apis/cv-matcher-api/Clients/Ai/MatcherAiClient.cs @@ -3,7 +3,7 @@ using System.Text; using System.Text.Json; using System.Text.Json.Serialization; using Api.Clients.Ai.Contracts; -using Api.Data.Repositories.Contracts; +using CvMatcher.Data.Repositories.Contracts; using CommonHelpers; using CvMatcher.Models.Settings; using Microsoft.Extensions.Options; diff --git a/Apis/cv-matcher-api/Controllers/CvController.cs b/Apis/cv-matcher-api/Controllers/CvController.cs index 87804aa..c0d0ee4 100644 --- a/Apis/cv-matcher-api/Controllers/CvController.cs +++ b/Apis/cv-matcher-api/Controllers/CvController.cs @@ -2,12 +2,16 @@ using CvMatcher.Models.Requests; using Api.Services.Contracts; using Microsoft.AspNetCore.Mvc; using CvMatcher.Models.Responses; -using Shared.Models.Requests; +using Common.Requests; using Swashbuckle.AspNetCore.Annotations; -using Shared.Models.Responses; +using Common.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 new file mode 100644 index 0000000..11149a3 --- /dev/null +++ b/Apis/cv-matcher-api/Controllers/JobSearchController.cs @@ -0,0 +1,97 @@ +using Api.Services.Contracts; +using CvMatcher.Models.Requests; +using CvMatcher.Models.Responses; +using Microsoft.AspNetCore.Mvc; +using Common.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 +{ + private readonly IJobTokenService _tokenService; + private readonly ILogger _logger; + + public JobSearchController(IJobTokenService tokenService, ILogger logger) + { + _tokenService = tokenService; + _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) + { + try + { + if (string.IsNullOrWhiteSpace(request.CvDocumentId) || string.IsNullOrWhiteSpace(request.Email)) + return BadRequest(new ErrorResponse { Error = "CvDocumentId and Email are required.", Code = "invalid_request" }); + + var tokenId = await _tokenService.CreateTokenAsync(request.CvDocumentId, request.Email, request.Language, request.Keywords, request.Location, request.ClientIpAddress, ct); + return Ok(new CreateJobSearchTokenResponse { TokenId = tokenId }); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to create job search token."); + return StatusCode(StatusCodes.Status500InternalServerError, new ErrorResponse { Error = "Failed to create token.", Code = "token_create_failed" }); + } + } + + /// + /// 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, [FromBody] StartJobSearchRequest? request, CancellationToken ct) + { + try + { + var status = await _tokenService.TriggerStartAsync(tokenId, request?.ClientIpAddress, ct); + return Ok(new StartJobSearchResponse { Status = status }); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to start job search for token {TokenId}.", tokenId); + return StatusCode(StatusCodes.Status500InternalServerError, new ErrorResponse { Error = "Failed to start search.", Code = "start_failed" }); + } + } +} diff --git a/Apis/cv-matcher-api/Data/Entities/CvMatchResultEntity.cs b/Apis/cv-matcher-api/Data/Entities/CvMatchResultEntity.cs deleted file mode 100644 index 2776358..0000000 --- a/Apis/cv-matcher-api/Data/Entities/CvMatchResultEntity.cs +++ /dev/null @@ -1,11 +0,0 @@ -namespace Api.Data.Entities; - -public sealed class CvMatchResultEntity -{ - public string Id { get; set; } = string.Empty; - public string CvDocumentId { get; set; } = string.Empty; - public string JobDocumentId { get; set; } = string.Empty; - public string ResultJson { get; set; } = string.Empty; - public int Score { get; set; } - public DateTime CreatedAt { get; set; } = DateTime.UtcNow; -} diff --git a/Apis/cv-matcher-api/Dockerfile b/Apis/cv-matcher-api/Dockerfile index 343c535..426ecb6 100644 --- a/Apis/cv-matcher-api/Dockerfile +++ b/Apis/cv-matcher-api/Dockerfile @@ -2,17 +2,28 @@ FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build ARG BUILD_CONFIGURATION=Release WORKDIR /src +COPY Directory.Packages.props ./ COPY Apis/cv-matcher-api/cv-matcher-api.csproj Apis/cv-matcher-api/ -COPY Apis/shared-models/shared-models.csproj Apis/shared-models/ +COPY Apis/cv-search-data/cv-search-data.csproj Apis/cv-search-data/ +COPY Apis/cv-matcher-data/cv-matcher-data.csproj Apis/cv-matcher-data/ +COPY Apis/common/common.csproj Apis/common/ COPY Apis/cv-matcher-api-models/cv-matcher-api-models.csproj Apis/cv-matcher-api-models/ +COPY Apis/page-fetcher-api-models/page-fetcher-api-models.csproj Apis/page-fetcher-api-models/ +COPY Apis/myai-data/myai-data.csproj Apis/myai-data/ +COPY Apis/shared-data/shared-data.csproj Apis/shared-data/ COPY Helpers/common-helpers/common-helpers.csproj Helpers/common-helpers/ COPY Helpers/startup-helpers/startup-helpers.csproj Helpers/startup-helpers/ RUN dotnet restore Apis/cv-matcher-api/cv-matcher-api.csproj COPY Apis/cv-matcher-api/ Apis/cv-matcher-api/ -COPY Apis/shared-models/ Apis/shared-models/ +COPY Apis/cv-search-data/ Apis/cv-search-data/ +COPY Apis/cv-matcher-data/ Apis/cv-matcher-data/ +COPY Apis/common/ Apis/common/ COPY Apis/cv-matcher-api-models/ Apis/cv-matcher-api-models/ +COPY Apis/page-fetcher-api-models/ Apis/page-fetcher-api-models/ +COPY Apis/myai-data/ Apis/myai-data/ +COPY Apis/shared-data/ Apis/shared-data/ COPY Helpers/common-helpers/ Helpers/common-helpers/ COPY Helpers/startup-helpers/ Helpers/startup-helpers/ @@ -25,4 +36,4 @@ ENV ASPNETCORE_URLS=http://0.0.0.0:8080 COPY --from=build /app/publish . -ENTRYPOINT ["dotnet", "cv-matcher-api.dll"] \ No newline at end of file +ENTRYPOINT ["dotnet", "cv-matcher-api.dll"] diff --git a/Apis/cv-matcher-api/Migrations/20260507140442_InitialCvMatcherSchema.cs b/Apis/cv-matcher-api/Migrations/20260507140442_InitialCvMatcherSchema.cs deleted file mode 100644 index 4c62b0f..0000000 --- a/Apis/cv-matcher-api/Migrations/20260507140442_InitialCvMatcherSchema.cs +++ /dev/null @@ -1,70 +0,0 @@ -using System; -using Microsoft.EntityFrameworkCore.Migrations; - -#nullable disable - -namespace Api.Migrations -{ - /// - public partial class InitialCvMatcherSchema : Migration - { - /// - protected override void Up(MigrationBuilder migrationBuilder) - { - migrationBuilder.EnsureSchema( - name: "cvMatcher"); - - migrationBuilder.CreateTable( - name: "ChatCache", - schema: "cvMatcher", - columns: table => new - { - CacheKey = table.Column(type: "nvarchar(64)", maxLength: 64, nullable: false), - Model = table.Column(type: "nvarchar(120)", maxLength: 120, nullable: false), - Temperature = table.Column(type: "decimal(4,2)", nullable: false), - ResponseText = table.Column(type: "nvarchar(max)", nullable: false), - CreatedAt = table.Column(type: "datetime2", nullable: false, defaultValueSql: "SYSUTCDATETIME()") - }, - constraints: table => - { - table.PrimaryKey("PK_ChatCache", x => x.CacheKey); - }); - - migrationBuilder.CreateTable( - name: "Results", - schema: "cvMatcher", - columns: table => new - { - Id = table.Column(type: "nvarchar(64)", maxLength: 64, nullable: false), - CvDocumentId = table.Column(type: "nvarchar(64)", maxLength: 64, nullable: false), - JobDocumentId = table.Column(type: "nvarchar(64)", maxLength: 64, nullable: false), - ResultJson = table.Column(type: "nvarchar(max)", nullable: false), - Score = table.Column(type: "int", nullable: false), - CreatedAt = table.Column(type: "datetime2", nullable: false, defaultValueSql: "SYSUTCDATETIME()") - }, - constraints: table => - { - table.PrimaryKey("PK_Results", x => x.Id); - }); - - migrationBuilder.CreateIndex( - name: "IX_Results_CvDocumentId_JobDocumentId", - schema: "cvMatcher", - table: "Results", - columns: new[] { "CvDocumentId", "JobDocumentId" }, - unique: true); - } - - /// - protected override void Down(MigrationBuilder migrationBuilder) - { - migrationBuilder.DropTable( - name: "ChatCache", - schema: "cvMatcher"); - - migrationBuilder.DropTable( - name: "Results", - schema: "cvMatcher"); - } - } -} diff --git a/Apis/cv-matcher-api/Program.cs b/Apis/cv-matcher-api/Program.cs index ce602b9..0fd642c 100644 --- a/Apis/cv-matcher-api/Program.cs +++ b/Apis/cv-matcher-api/Program.cs @@ -2,16 +2,18 @@ using Api.Clients.Ai; using Api.Clients.Ai.Contracts; using Api.Clients.Api; using Api.Clients.Api.Contracts; -using Api.Data; -using Api.Data.Repositories; -using Api.Data.Repositories.Contracts; +using CvMatcher.Data; +using CvMatcher.Data.Repositories; +using CvMatcher.Data.Repositories.Contracts; using Api.Services; using Api.Services.Contracts; using CvMatcher.Models.Settings; +using CvSearch.Data; using Microsoft.EntityFrameworkCore; using Refit; using Serilog; -using Shared.Models.Settings; +using Common.Settings; +using PageFetcher.Models; using StartupHelpers; using System.Reflection; @@ -34,6 +36,17 @@ try builder.Services.Configure(builder.Configuration.GetSection("InternalApi")); builder.Services.Configure(builder.Configuration.GetSection("Ai")); builder.Services.Configure(builder.Configuration.GetSection("Matcher")); + builder.Services.Configure(builder.Configuration.GetSection("JobSearch")); + builder.Services.Configure(builder.Configuration.GetSection("PageFetcherApi")); + + builder.Services.AddRefitClient() + .ConfigureHttpClient((sp, c) => + { + var settings = sp.GetRequiredService>().Value; + c.BaseAddress = new Uri(settings.BaseUrl.TrimEnd('/') + "/"); + if (!string.IsNullOrWhiteSpace(settings.InternalApiKey)) + c.DefaultRequestHeaders.Add("X-Internal-Api-Key", settings.InternalApiKey); + }); builder.Services.AddRefitClient() .ConfigureHttpClient((sp, c) => @@ -48,7 +61,7 @@ try builder.Services.AddScoped(); builder.Services.AddHttpClient(); - builder.Services.AddHttpClient(); + builder.Services.AddScoped(); builder.Services.AddDbContext(options => { @@ -58,11 +71,24 @@ try options.UseSqlServer(connectionString, sql => { sql.MigrationsHistoryTable(CvMatcherDbContext.MigrationTableName, CvMatcherDbContext.SchemaName); + sql.MigrationsAssembly("cv-matcher-data"); + }); + }); + + builder.Services.AddDbContext(options => + { + var connectionString = builder.Services.GetConfiguredDbConnectionString(builder.Configuration); + options.UseSqlServer(connectionString, sql => + { + sql.MigrationsAssembly("cv-search-data"); + sql.MigrationsHistoryTable(CvSearchDbContext.MigrationTableName, CvSearchDbContext.SchemaName); }); }); builder.Services.AddScoped(); + builder.Services.AddScoped(); builder.Services.AddScoped(); + builder.Services.AddScoped(); builder.Services.AddControllers(); builder.Services.AddSwaggerWithXmlComments(Assembly.GetExecutingAssembly(), ServiceName); @@ -90,6 +116,11 @@ try var db = scope.ServiceProvider.GetRequiredService(); db.Database.Migrate(); } + using (var scope = app.Services.CreateScope()) + { + var db = scope.ServiceProvider.GetRequiredService(); + db.Database.Migrate(); + } Log.Information("{Service} startup complete", ServiceName); app.Run(); diff --git a/Apis/cv-matcher-api/Services/Contracts/ICvMatcherService.cs b/Apis/cv-matcher-api/Services/Contracts/ICvMatcherService.cs index 9c483a5..32df0b8 100644 --- a/Apis/cv-matcher-api/Services/Contracts/ICvMatcherService.cs +++ b/Apis/cv-matcher-api/Services/Contracts/ICvMatcherService.cs @@ -3,9 +3,34 @@ using CvMatcher.Models.Responses; namespace Api.Services.Contracts; +/// +/// Orchestrates CV indexing, job matching, and job discovery operations. +/// public interface ICvMatcherService { + /// + /// Indexes a CV PDF into the RAG system and returns document metadata. + /// Returns cached metadata without re-indexing when the same text hash already exists. + /// + /// Uploaded CV PDF file. + /// Cancellation token. + /// Upload response with document ID, hash, and indexing statistics. Task UploadCvAsync(IFormFile file, CancellationToken ct); + + /// + /// Scores a CV against a specific job posting URL or pasted description using the LLM. + /// Caches the result so repeat requests for the same (CV, job, language) triple are served instantly. + /// + /// Match request containing CV document ID, job URL or description, and language preference. + /// Cancellation token. + /// Structured match response with score, summary, strengths, gaps, and recommendations. Task MatchJobAsync(MatchJobRequest request, CancellationToken ct); + + /// + /// Searches the RAG index for job documents most similar to the given CV and scores the top candidates. + /// + /// Request containing the CV document ID and optional result count limit. + /// Cancellation token. + /// Response with the CV document ID and a list of ranked match results. Task FindJobsAsync(FindJobsRequest request, CancellationToken ct); } diff --git a/Apis/cv-matcher-api/Services/Contracts/IJobTextExtractor.cs b/Apis/cv-matcher-api/Services/Contracts/IJobTextExtractor.cs index 850521c..746fda6 100644 --- a/Apis/cv-matcher-api/Services/Contracts/IJobTextExtractor.cs +++ b/Apis/cv-matcher-api/Services/Contracts/IJobTextExtractor.cs @@ -1,6 +1,17 @@ namespace Api.Services.Contracts; +/// +/// Extracts plain text from a job posting, either from a pasted description or by fetching and parsing a URL. +/// public interface IJobTextExtractor { + /// + /// Returns normalised plain text for the job posting. + /// Prefers when provided; otherwise fetches and strips HTML from . + /// + /// URL of the job posting page, used when no description is pasted. + /// Pasted job description text; takes priority over URL fetching. + /// Cancellation token. + /// Normalised plain text, truncated to the configured maximum character limit. Task ExtractAsync(string? jobUrl, string? jobDescription, CancellationToken ct); } diff --git a/Apis/cv-matcher-api/Services/Contracts/IJobTokenService.cs b/Apis/cv-matcher-api/Services/Contracts/IJobTokenService.cs new file mode 100644 index 0000000..29122df --- /dev/null +++ b/Apis/cv-matcher-api/Services/Contracts/IJobTokenService.cs @@ -0,0 +1,35 @@ +namespace Api.Services.Contracts; + +/// +/// Manages one-time job search tokens and the sessions they trigger. +/// +public interface IJobTokenService +{ + /// + /// Creates a new single-use job search token linked to the given CV document and user. + /// The token expires after the number of days configured in JobSearch:TokenExpiryDays. + /// + /// Identifier of the indexed CV document. + /// Email address of the user who will receive the results. + /// Preferred language for result emails (e.g. "en", "ro"). + /// Job search keywords extracted by the LLM during the match call. + /// Candidate location extracted from the CV (e.g. "Cluj-Napoca, Romania"). Null if not available. + /// Cancellation token. + /// + /// The generated token ID to embed in the one-click job search link, + /// or null when no job providers are currently enabled (link should be suppressed). + /// + Task CreateTokenAsync(string cvDocumentId, string email, string language, IReadOnlyList keywords, string? location, string? clientIpAddress, CancellationToken ct); + + /// + /// Validates the token and, if valid, marks it as used and creates a Pending job search session. + /// + /// The token ID from the one-click link. + /// Client IP address forwarded by the api layer. Null when not available. + /// Cancellation token. + /// + /// One of the StartJobSearchStatus string constants: + /// Started, AlreadyUsed, Expired, or NotFound. + /// + Task TriggerStartAsync(string tokenId, string? clientIpAddress, CancellationToken ct); +} diff --git a/Apis/cv-matcher-api/Services/CvMatcherService.cs b/Apis/cv-matcher-api/Services/CvMatcherService.cs index 43d5bff..65b7327 100644 --- a/Apis/cv-matcher-api/Services/CvMatcherService.cs +++ b/Apis/cv-matcher-api/Services/CvMatcherService.cs @@ -1,7 +1,7 @@ using System.Text.Json; using Api.Clients.Ai.Contracts; using Api.Clients.Api.Contracts; -using Api.Data.Repositories.Contracts; +using CvMatcher.Data.Repositories.Contracts; using CvMatcher.Models.Requests; using CvMatcher.Models.Responses; using CvMatcher.Models.Settings; @@ -10,12 +10,16 @@ using Microsoft.Extensions.Options; namespace Api.Services; +/// +/// Orchestrates CV upload, RAG indexing, job text extraction, LLM scoring, and result caching. +/// public sealed class CvMatcherService : ICvMatcherService { private readonly IRagApiClient _rag; private readonly IJobTextExtractor _jobTextExtractor; private readonly IMatcherAiClient _ai; private readonly IMatcherRepository _repository; + private readonly IAiPromptsRepository _aiPrompts; private readonly MatcherSettings _settings; public CvMatcherService( @@ -23,15 +27,18 @@ public sealed class CvMatcherService : ICvMatcherService IJobTextExtractor jobTextExtractor, IMatcherAiClient ai, IMatcherRepository repository, + IAiPromptsRepository aiPrompts, IOptions options) { _rag = rag; _jobTextExtractor = jobTextExtractor; _ai = ai; _repository = repository; + _aiPrompts = aiPrompts; _settings = options.Value; } + /// public async Task UploadCvAsync(IFormFile file, CancellationToken ct) { var response = await _rag.IndexCvPdfAsync(file, ct); @@ -48,6 +55,7 @@ public sealed class CvMatcherService : ICvMatcherService }; } + /// public async Task FindJobsAsync(FindJobsRequest request, CancellationToken ct) { var cv = await _rag.GetDocumentAsync(request.CvDocumentId, ct) ?? throw new InvalidOperationException("CV document not found."); @@ -69,12 +77,13 @@ public sealed class CvMatcherService : ICvMatcherService { var job = await _rag.GetDocumentAsync(result.DocumentId, ct); if (job is null) continue; - jobs.Add(await ScorePairAsync(cv, job, result.MatchedChunks.Select(x => x.Text).ToArray(), request.Email, ct)); + jobs.Add(await ScorePairAsync(cv, job, result.MatchedChunks.Select(x => x.Text).ToArray(), request.Email, null, NormalizeLanguage(null), ct)); } return new FindJobsResponse { CvDocumentId = request.CvDocumentId, Jobs = jobs }; } + /// public async Task MatchJobAsync(MatchJobRequest request, CancellationToken ct) { if (!request.GdprConsent) throw new InvalidOperationException("GDPR consent is required."); @@ -98,23 +107,27 @@ public sealed class CvMatcherService : ICvMatcherService .FirstOrDefault(x => x.DocumentId == job.DocumentId)? .MatchedChunks.Select(x => x.Text).ToArray() ?? []; - return await ScorePairAsync(cv, jobDocument, matchedChunks, request.Email, ct); + return await ScorePairAsync(cv, jobDocument, matchedChunks, request.Email, request.ClientIpAddress, NormalizeLanguage(request.Language), ct); } - private async Task ScorePairAsync(RagDocumentDetails cv, RagDocumentDetails job, IReadOnlyList evidenceChunks, string? email, CancellationToken ct) + /// + /// Scores a (CV, job) pair with the LLM. + /// Returns a cached result immediately when the same (CV, job, language) triple has been scored before. + /// When no evidence chunks are available from the vector search, falls back to the raw job text. + /// + private async Task ScorePairAsync(RagDocumentDetails cv, RagDocumentDetails job, IReadOnlyList evidenceChunks, string? email, string? clientIpAddress, string language, CancellationToken ct) { - var cached = await _repository.GetMatchAsync(cv.Id, job.Id, ct); + var cached = await _repository.GetMatchAsync(cv.Id, job.Id, language, ct); if (cached is not null) return cached; var cvText = Limit(cv.Text, 18000); var jobText = Limit(job.Text, 14000); var evidence = evidenceChunks.Count > 0 ? string.Join("\n\n", evidenceChunks.Take(4)) : Limit(job.Text, 4000); - const string systemPrompt = """ - You are a strict CV-to-job matching engine. Return JSON only. Score realistically from 0 to 100. - Penalize missing required skills. Do not invent experience. Use concise business language. - JSON shape: {"score":number,"summary":"...","strengths":["..."],"gaps":["..."],"recommendations":["..."],"evidence":["..."]} - """; + var systemPrompt = await _aiPrompts.GetAsync("ai.cv-match.system-prompt", language, ct) + ?? throw new InvalidOperationException( + $"AI prompt not found: key='ai.cv-match.system-prompt', language='{language}'. " + + $"This is a configuration error. Ensure the cvMatcher.AiPrompts table is properly seeded with language-specific prompts."); var userPrompt = $""" CV: @@ -132,17 +145,14 @@ public sealed class CvMatcherService : ICvMatcherService result.JobDocumentId = job.Id; result.JobUrl = job.SourceUrl; result.Cached = false; - await _repository.SaveMatchAsync(cv.Id, job.Id, result, ct); - - //await _email.SendMatchAsync( - // email, - // $"MyAi.ro CV Match: {result.Score}% - {job.Title}", - // BuildEmailBody(cv, job, result), - // ct); - + await _repository.SaveMatchAsync(cv.Id, job.Id, language, result, email, clientIpAddress, ct); return result; } + /// + /// Deserialises the LLM's JSON output into a . + /// Returns a safe fallback response instead of throwing when the JSON cannot be parsed. + /// private static JobMatchResponse ParseResult(string json) { try @@ -163,38 +173,28 @@ public sealed class CvMatcherService : ICvMatcherService }; } + /// + /// Builds a descriptive search query from the CV text for use in vector similarity search. + /// private static string BuildCvSearchProfile(string cvText) { var text = Limit(cvText, 10000); return $"Candidate profile, skills, technologies, seniority, industry experience, project experience: {text}"; } + /// + /// Extracts a short job title from the first sentence-like fragment of the job text. + /// private static string ExtractJobTitle(string jobText) { var first = jobText.Split('.', '\n', '\r').Select(x => x.Trim()).FirstOrDefault(x => x.Length is > 8 and < 140); return first ?? "Job description"; } + /// Returns the base language code, lower-cased, defaulting to "en". + private static string NormalizeLanguage(string? language) => + string.IsNullOrWhiteSpace(language) ? "en" : language.ToLowerInvariant().Split('-')[0].Trim(); + + /// Truncates to at most characters. private static string Limit(string value, int max) => value.Length <= max ? value : value[..max]; - - //private static string BuildEmailBody(RagDocumentDetails cv, RagDocumentDetails job, JobMatchResponse result) => $""" - // CV Matcher result - - // CV: {cv.Title} - // Job: {job.Title} - // Job URL: {job.SourceUrl ?? "N/A"} - // Score: {result.Score}% - - // Summary: - // {result.Summary} - - // Strengths: - // - {string.Join("\n- ", result.Strengths)} - - // Gaps: - // - {string.Join("\n- ", result.Gaps)} - - // Recommendations: - // - {string.Join("\n- ", result.Recommendations)} - // """; } diff --git a/Apis/cv-matcher-api/Services/JobTextExtractor.cs b/Apis/cv-matcher-api/Services/JobTextExtractor.cs index 668e018..c16b201 100644 --- a/Apis/cv-matcher-api/Services/JobTextExtractor.cs +++ b/Apis/cv-matcher-api/Services/JobTextExtractor.cs @@ -1,24 +1,26 @@ -using System.Net; -using System.Text.RegularExpressions; using CvMatcher.Models.Settings; using Api.Services.Contracts; using Microsoft.Extensions.Options; +using PageFetcher.Models; namespace Api.Services; +/// +/// Extracts normalised plain text from a job posting, either from a pasted description or by +/// fetching the job page text via page-fetcher-api (headless Chromium rendering). +/// public sealed class JobTextExtractor : IJobTextExtractor { - private readonly HttpClient _http; + private readonly IPageFetcherApiClient _pageFetcher; private readonly MatcherSettings _settings; - public JobTextExtractor(HttpClient http, IOptions options) + public JobTextExtractor(IPageFetcherApiClient pageFetcher, IOptions options) { - _http = http; + _pageFetcher = pageFetcher; _settings = options.Value; - _http.Timeout = TimeSpan.FromSeconds(25); - _http.DefaultRequestHeaders.UserAgent.ParseAdd("MyAi.ro CV Matcher/1.0"); } + /// public async Task ExtractAsync(string? jobUrl, string? jobDescription, CancellationToken ct) { var pasted = Normalize(jobDescription ?? string.Empty); @@ -26,23 +28,28 @@ public sealed class JobTextExtractor : IJobTextExtractor if (string.IsNullOrWhiteSpace(jobUrl)) return string.Empty; if (!Uri.TryCreate(jobUrl, UriKind.Absolute, out var uri) || uri.Scheme is not ("http" or "https")) - { throw new InvalidOperationException("Invalid job URL."); - } - var html = await _http.GetStringAsync(uri, ct); - html = Regex.Replace(html, "", " ", RegexOptions.IgnoreCase); - html = Regex.Replace(html, "", " ", RegexOptions.IgnoreCase); - html = Regex.Replace(html, "<[^>]+>", " "); - return Limit(Normalize(WebUtility.HtmlDecode(html))); + var response = await _pageFetcher.FetchAsync(new FetchPageRequest + { + Url = jobUrl, + CallerService = "cv-matcher-api" + }, ct); + + if (!response.Success) + throw new InvalidOperationException($"Failed to fetch job page: {response.Error}"); + + return Limit(Normalize(response.Text)); } + /// Truncates text to the configured maximum character count. private string Limit(string value) { var max = Math.Max(4000, _settings.MaxJobTextChars); return value.Length <= max ? value : value[..max]; } + /// Collapses all whitespace runs to single spaces and trims the result. private static string Normalize(string value) { if (string.IsNullOrWhiteSpace(value)) return string.Empty; diff --git a/Apis/cv-matcher-api/Services/JobTokenService.cs b/Apis/cv-matcher-api/Services/JobTokenService.cs new file mode 100644 index 0000000..6c9f404 --- /dev/null +++ b/Apis/cv-matcher-api/Services/JobTokenService.cs @@ -0,0 +1,137 @@ +using System.Text.Json; +using Api.Services.Contracts; +using CvMatcher.Models.Responses; +using CvSearch.Data; +using CvSearch.Data.Entities; +using CvMatcher.Models.Settings; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Options; + +namespace Api.Services; + +/// +/// Creates and validates one-time job search tokens, and creates the corresponding search sessions. +/// Provider configuration is read from cvSearch.JobProviders at session-creation time and +/// snapshotted into JobSearchSessionEntity.ProviderConfigJson so subsequent config changes +/// do not affect already-queued sessions. +/// Keywords are extracted by the LLM during the CV-to-job match call and stored on the token, +/// then copied to the session when the user clicks the link — no extra RAG call needed. +/// +public sealed class JobTokenService : IJobTokenService +{ + private readonly CvSearchDbContext _db; + private readonly JobSearchSettings _settings; + private readonly ILogger _logger; + + public JobTokenService( + CvSearchDbContext db, + IOptions settings, + ILogger logger) + { + _db = db; + _settings = settings.Value; + _logger = logger; + } + + /// + public async Task CreateTokenAsync(string cvDocumentId, string email, string language, IReadOnlyList keywords, string? location, string? clientIpAddress, CancellationToken ct) + { + var hasEnabledProviders = await _db.JobProviders.AnyAsync(p => p.Enabled, ct); + if (!hasEnabledProviders) + { + _logger.LogDebug("Job search token skipped — no enabled providers in cvSearch.JobProviders"); + return null; + } + + var token = new JobSearchTokenEntity + { + Id = Guid.NewGuid().ToString("N"), + CvDocumentId = cvDocumentId, + Email = email, + Language = language, + Keywords = string.Join(",", keywords), + Location = location, + ClientIpAddress = clientIpAddress, + ExpiresAt = DateTime.UtcNow.AddDays(_settings.TokenExpiryDays), + Used = false, + CreatedAt = DateTime.UtcNow + }; + + _db.JobSearchTokens.Add(token); + await _db.SaveChangesAsync(ct); + _logger.LogInformation("Job search token created. TokenId={TokenId}, CvDocumentId={CvDocumentId}, Keywords={Keywords}, Location={Location}", token.Id, cvDocumentId, token.Keywords, token.Location); + return token.Id; + } + + /// + public async Task TriggerStartAsync(string tokenId, string? clientIpAddress, CancellationToken ct) + { + var token = await _db.JobSearchTokens.FirstOrDefaultAsync(x => x.Id == tokenId, ct); + if (token is null) return StartJobSearchStatus.NotFound; + if (token.Used) return StartJobSearchStatus.AlreadyUsed; + if (token.ExpiresAt <= DateTime.UtcNow) return StartJobSearchStatus.Expired; + + token.Used = true; + await _db.SaveChangesAsync(ct); + + var keywords = token.Keywords; + + var enabledProviders = await _db.JobProviders + .Where(p => p.Enabled) + .OrderBy(p => p.DisplayOrder) + .ToListAsync(ct); + + var providerConfigJson = JsonSerializer.Serialize( + enabledProviders.Select(ToConfig).ToList(), + new JsonSerializerOptions(JsonSerializerDefaults.Web)); + + var session = new JobSearchSessionEntity + { + Id = Guid.NewGuid().ToString("N"), + TokenId = token.Id, + CvDocumentId = token.CvDocumentId, + Email = token.Email, + Language = token.Language, + Status = JobSearchStatus.Pending, + Keywords = keywords, + Location = token.Location, + ClientIpAddress = clientIpAddress, + ProviderConfigJson = providerConfigJson, + CreatedAt = DateTime.UtcNow + }; + + _db.JobSearchSessions.Add(session); + await _db.SaveChangesAsync(ct); + _logger.LogInformation( + "Job search session created. SessionId={SessionId}, Keywords={Keywords}, Providers={Providers}", + session.Id, keywords, string.Join(", ", enabledProviders.Select(p => p.Name))); + + return StartJobSearchStatus.Started; + } + + private static JobProviderConfig ToConfig(JobProviderEntity entity) + { + List keywords; + try + { + keywords = JsonSerializer.Deserialize>(entity.InitialKeywordsJson, + new JsonSerializerOptions(JsonSerializerDefaults.Web)) ?? []; + } + catch + { + keywords = []; + } + + return new JobProviderConfig + { + Name = entity.Name, + Enabled = entity.Enabled, + SearchUrlTemplate = entity.SearchUrlTemplate, + JobLinkContains = entity.JobLinkContains, + InitialKeywords = keywords, + MaxResults = entity.MaxResults, + RequireKeywordInAnchor = entity.RequireKeywordInAnchor + }; + } + +} diff --git a/Apis/cv-matcher-api/appsettings.json b/Apis/cv-matcher-api/appsettings.json index 0379e6c..0d52579 100644 --- a/Apis/cv-matcher-api/appsettings.json +++ b/Apis/cv-matcher-api/appsettings.json @@ -2,8 +2,7 @@ "Serilog": { "Using": [ "Serilog.Sinks.Console", - "Serilog.Sinks.File", - "Serilog.Sinks.Email" + "Serilog.Sinks.File" ], "MinimumLevel": { "Default": "Information", @@ -30,25 +29,6 @@ "retainedFileCountLimit": 30, "outputTemplate": "{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} [{Level:u3}] {SourceContext}: {Message:lj}{NewLine}{Exception}" } - }, - { - "Name": "Email", - "Args": { - "restrictedToMinimumLevel": "Error", - "fromEmail": "", - "toEmail": "", - "mailServer": "", - "networkCredential": { - "userName": "", - "password": "" - }, - "port": 587, - "enableSsl": true, - "emailSubject": "[mihes.ro API] Error Alert", - "outputTemplate": "{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} [{Level:u3}] {SourceContext}{NewLine}{Message:lj}{NewLine}{Exception}", - "batchPostingLimit": 10, - "period": "0.00:05:00" - } } ], "Enrich": [ @@ -106,5 +86,12 @@ "TopK": 10, "DeepScoreTopN": 5, "MaxJobTextChars": 60000 + }, + "JobSearch": { + "Enabled": true, + "JobSearchLinkBaseUrl": "https://myai.ro", + "TokenExpiryDays": 7, + "MinMatchScore": 15, + "MaxJobsToMatch": 15 } } diff --git a/Apis/cv-matcher-api/cv-matcher-api.csproj b/Apis/cv-matcher-api/cv-matcher-api.csproj index 3abf857..505514a 100644 --- a/Apis/cv-matcher-api/cv-matcher-api.csproj +++ b/Apis/cv-matcher-api/cv-matcher-api.csproj @@ -58,28 +58,31 @@ - - - - - + + + + + all runtime; build; native; contentfiles; analyzers; buildtransitive - - - - - - - - + + + + + + + + - - - + + + + + + diff --git a/Apis/cv-matcher-api/Data/CvMatcherDbContext.cs b/Apis/cv-matcher-data/CvMatcherDbContext.cs similarity index 55% rename from Apis/cv-matcher-api/Data/CvMatcherDbContext.cs rename to Apis/cv-matcher-data/CvMatcherDbContext.cs index 5889116..941d0b7 100644 --- a/Apis/cv-matcher-api/Data/CvMatcherDbContext.cs +++ b/Apis/cv-matcher-data/CvMatcherDbContext.cs @@ -1,13 +1,12 @@ -using Api.Data.Entities; +using CvMatcher.Data.Entities; using Microsoft.EntityFrameworkCore; -namespace Api.Data; - +namespace CvMatcher.Data; public sealed class CvMatcherDbContext : DbContext { - public const string SchemaName = "cvMatcher"; - public const string MigrationTableName = "_Migrations"; + public const string SchemaName = MigrationConstants.SchemaName; + public const string MigrationTableName = MigrationConstants.MigrationTableName; public CvMatcherDbContext(DbContextOptions options) : base(options) { @@ -15,6 +14,14 @@ public sealed class CvMatcherDbContext : DbContext public DbSet CvMatchResults => Set(); public DbSet CvMatcherChatCache => Set(); + public DbSet AiPrompts => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + { + base.OnConfiguring(optionsBuilder); + // Configure migration history table to use schema-qualified name: [cvMatcher].[_Migrations] + optionsBuilder.UseSqlServer(x => x.MigrationsHistoryTable(MigrationTableName, SchemaName)); + } protected override void OnModelCreating(ModelBuilder modelBuilder) { @@ -29,7 +36,9 @@ public sealed class CvMatcherDbContext : DbContext entity.Property(x => x.JobDocumentId).HasMaxLength(64).IsRequired(); entity.Property(x => x.ResultJson).IsRequired(); entity.Property(x => x.CreatedAt).HasDefaultValueSql("SYSUTCDATETIME()"); - entity.HasIndex(x => new { x.CvDocumentId, x.JobDocumentId }).IsUnique(); + entity.Property(x => x.Email).HasMaxLength(256); + entity.Property(x => x.ClientIpAddress).HasMaxLength(45); + entity.HasIndex(x => new { x.CvDocumentId, x.JobDocumentId, x.Language }).IsUnique(); }); modelBuilder.Entity(entity => @@ -42,5 +51,16 @@ public sealed class CvMatcherDbContext : DbContext entity.Property(x => x.ResponseText).IsRequired(); entity.Property(x => x.CreatedAt).HasDefaultValueSql("SYSUTCDATETIME()"); }); + + modelBuilder.Entity(entity => + { + entity.ToTable("AiPrompts"); + entity.HasKey(x => new { x.Key, x.Language }); + entity.Property(x => x.Key).HasMaxLength(128); + entity.Property(x => x.Language).HasMaxLength(8); + entity.Property(x => x.Value).IsRequired(); + entity.Property(x => x.Description).HasMaxLength(500).HasDefaultValue(string.Empty); + entity.Property(x => x.UpdatedAt).HasDefaultValueSql("SYSUTCDATETIME()"); + }); } } diff --git a/Apis/cv-matcher-data/Entities/AiPromptEntity.cs b/Apis/cv-matcher-data/Entities/AiPromptEntity.cs new file mode 100644 index 0000000..47bd670 --- /dev/null +++ b/Apis/cv-matcher-data/Entities/AiPromptEntity.cs @@ -0,0 +1,10 @@ +namespace CvMatcher.Data.Entities; + +public sealed class AiPromptEntity +{ + public string Key { get; set; } = string.Empty; + public string Language { get; set; } = string.Empty; + public string Value { get; set; } = string.Empty; + public string Description { get; set; } = string.Empty; + public DateTime UpdatedAt { get; set; } +} diff --git a/Apis/cv-matcher-data/Entities/CvMatchResultEntity.cs b/Apis/cv-matcher-data/Entities/CvMatchResultEntity.cs new file mode 100644 index 0000000..4c32054 --- /dev/null +++ b/Apis/cv-matcher-data/Entities/CvMatchResultEntity.cs @@ -0,0 +1,14 @@ +using Shared.Data.Entities; + +namespace CvMatcher.Data.Entities; + +public sealed class CvMatchResultEntity : BaseEntity +{ + public string CvDocumentId { get; set; } = string.Empty; + public string JobDocumentId { get; set; } = string.Empty; + public string Language { get; set; } = "en"; + public string ResultJson { get; set; } = string.Empty; + public int Score { get; set; } + public string? Email { get; set; } + public string? ClientIpAddress { get; set; } +} diff --git a/Apis/cv-matcher-api/Data/Entities/CvMatcherChatCacheEntity.cs b/Apis/cv-matcher-data/Entities/CvMatcherChatCacheEntity.cs similarity index 80% rename from Apis/cv-matcher-api/Data/Entities/CvMatcherChatCacheEntity.cs rename to Apis/cv-matcher-data/Entities/CvMatcherChatCacheEntity.cs index 4ad845a..2bb669f 100644 --- a/Apis/cv-matcher-api/Data/Entities/CvMatcherChatCacheEntity.cs +++ b/Apis/cv-matcher-data/Entities/CvMatcherChatCacheEntity.cs @@ -1,5 +1,6 @@ -namespace Api.Data.Entities; +namespace CvMatcher.Data.Entities; +// CacheKey PK — BaseEntity not applicable public sealed class CvMatcherChatCacheEntity { public string CacheKey { get; set; } = string.Empty; diff --git a/Apis/cv-matcher-data/MigrationConstants.cs b/Apis/cv-matcher-data/MigrationConstants.cs new file mode 100644 index 0000000..5c5808b --- /dev/null +++ b/Apis/cv-matcher-data/MigrationConstants.cs @@ -0,0 +1,11 @@ +namespace CvMatcher.Data; + +/// +/// Schema constants used by CvMatcherDbContext and migrations. +/// Centralized to avoid hardcoded strings and ensure consistency. +/// +public static class MigrationConstants +{ + public const string SchemaName = "cvMatcher"; + public const string MigrationTableName = "_Migrations"; +} diff --git a/Apis/cv-matcher-api/Migrations/20260507140442_InitialCvMatcherSchema.Designer.cs b/Apis/cv-matcher-data/Migrations/20260601133028_InitialSchema.Designer.cs similarity index 63% rename from Apis/cv-matcher-api/Migrations/20260507140442_InitialCvMatcherSchema.Designer.cs rename to Apis/cv-matcher-data/Migrations/20260601133028_InitialSchema.Designer.cs index 1ef6774..af78445 100644 --- a/Apis/cv-matcher-api/Migrations/20260507140442_InitialCvMatcherSchema.Designer.cs +++ b/Apis/cv-matcher-data/Migrations/20260601133028_InitialSchema.Designer.cs @@ -1,6 +1,6 @@ // using System; -using Api.Data; +using CvMatcher.Data; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Metadata; @@ -9,11 +9,11 @@ using Microsoft.EntityFrameworkCore.Storage.ValueConversion; #nullable disable -namespace Api.Migrations +namespace CvMatcher.Data.Migrations { [DbContext(typeof(CvMatcherDbContext))] - [Migration("20260507140442_InitialCvMatcherSchema")] - partial class InitialCvMatcherSchema + [Migration("20260601133028_InitialSchema")] + partial class InitialSchema { /// protected override void BuildTargetModel(ModelBuilder modelBuilder) @@ -26,7 +26,38 @@ namespace Api.Migrations SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); - modelBuilder.Entity("Api.Data.Entities.CvMatchResultEntity", b => + modelBuilder.Entity("CvMatcher.Data.Entities.AiPromptEntity", b => + { + b.Property("Key") + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("Language") + .HasMaxLength(8) + .HasColumnType("nvarchar(8)"); + + b.Property("Description") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)") + .HasDefaultValue(""); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("datetime2") + .HasDefaultValueSql("SYSUTCDATETIME()"); + + b.Property("Value") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.HasKey("Key", "Language"); + + b.ToTable("AiPrompts", "cvMatcher"); + }); + + modelBuilder.Entity("CvMatcher.Data.Entities.CvMatchResultEntity", b => { b.Property("Id") .HasMaxLength(64) @@ -47,6 +78,10 @@ namespace Api.Migrations .HasMaxLength(64) .HasColumnType("nvarchar(64)"); + b.Property("Language") + .IsRequired() + .HasColumnType("nvarchar(450)"); + b.Property("ResultJson") .IsRequired() .HasColumnType("nvarchar(max)"); @@ -56,13 +91,13 @@ namespace Api.Migrations b.HasKey("Id"); - b.HasIndex("CvDocumentId", "JobDocumentId") + b.HasIndex("CvDocumentId", "JobDocumentId", "Language") .IsUnique(); b.ToTable("Results", "cvMatcher"); }); - modelBuilder.Entity("Api.Data.Entities.CvMatcherChatCacheEntity", b => + modelBuilder.Entity("CvMatcher.Data.Entities.CvMatcherChatCacheEntity", b => { b.Property("CacheKey") .HasMaxLength(64) diff --git a/Apis/cv-matcher-data/Migrations/20260601133028_InitialSchema.cs b/Apis/cv-matcher-data/Migrations/20260601133028_InitialSchema.cs new file mode 100644 index 0000000..78e799e --- /dev/null +++ b/Apis/cv-matcher-data/Migrations/20260601133028_InitialSchema.cs @@ -0,0 +1,110 @@ +using System; +using CvMatcher.Data; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace CvMatcher.Data.Migrations +{ + /// + public partial class InitialSchema : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.EnsureSchema( + name: MigrationConstants.SchemaName); + + migrationBuilder.CreateTable( + name: "AiPrompts", + schema: MigrationConstants.SchemaName, + columns: table => new + { + Key = table.Column(type: "nvarchar(128)", maxLength: 128, nullable: false), + Language = table.Column(type: "nvarchar(8)", maxLength: 8, nullable: false), + Value = table.Column(type: "nvarchar(max)", nullable: false), + Description = table.Column(type: "nvarchar(500)", maxLength: 500, nullable: false, defaultValue: ""), + UpdatedAt = table.Column(type: "datetime2", nullable: false, defaultValueSql: "SYSUTCDATETIME()") + }, + constraints: table => + { + table.PrimaryKey("PK_AiPrompts", x => new { x.Key, x.Language }); + }); + + migrationBuilder.CreateTable( + name: "ChatCache", + schema: MigrationConstants.SchemaName, + columns: table => new + { + CacheKey = table.Column(type: "nvarchar(64)", maxLength: 64, nullable: false), + Model = table.Column(type: "nvarchar(120)", maxLength: 120, nullable: false), + Temperature = table.Column(type: "decimal(4,2)", nullable: false), + ResponseText = table.Column(type: "nvarchar(max)", nullable: false), + CreatedAt = table.Column(type: "datetime2", nullable: false, defaultValueSql: "SYSUTCDATETIME()") + }, + constraints: table => + { + table.PrimaryKey("PK_ChatCache", x => x.CacheKey); + }); + + migrationBuilder.CreateTable( + name: "Results", + schema: MigrationConstants.SchemaName, + columns: table => new + { + Id = table.Column(type: "nvarchar(64)", maxLength: 64, nullable: false), + CvDocumentId = table.Column(type: "nvarchar(64)", maxLength: 64, nullable: false), + JobDocumentId = table.Column(type: "nvarchar(64)", maxLength: 64, nullable: false), + Language = table.Column(type: "nvarchar(450)", nullable: false), + ResultJson = table.Column(type: "nvarchar(max)", nullable: false), + Score = table.Column(type: "int", nullable: false), + CreatedAt = table.Column(type: "datetime2", nullable: false, defaultValueSql: "SYSUTCDATETIME()") + }, + constraints: table => + { + table.PrimaryKey("PK_Results", x => x.Id); + }); + + migrationBuilder.CreateIndex( + name: "IX_Results_CvDocumentId_JobDocumentId_Language", + schema: MigrationConstants.SchemaName, + table: "Results", + columns: new[] { "CvDocumentId", "JobDocumentId", "Language" }, + unique: true); + + Seed(migrationBuilder); + } + + private static void Seed(MigrationBuilder m) + { + void Row(string key, string lang, string value, string description = "") + => m.InsertData("AiPrompts", ["Key", "Language", "Value", "Description"], [key, lang, value, description], MigrationConstants.SchemaName); + + // AI system prompt for CV matching — English + Row("ai.cv-match.system-prompt", "en", + "You are a strict CV-to-job matching engine. Return JSON only. Score realistically from 0 to 100. Penalize missing required skills. Do not invent experience. Use concise business language. All text fields in the JSON response must be in English.\nJSON shape: {\"score\":number,\"summary\":\"one-line summary in English\",\"strengths\":[\"strength 1 in English\"],\"gaps\":[\"gap 1 in English\"],\"recommendations\":[\"recommendation 1 in English\"],\"evidence\":[\"evidence 1 in English\"],\"keywords\":[\"keyword1\",\"keyword2\",\"keyword3\"]}", + "System prompt for CV-to-job matching in English. Instructs LLM to return JSON with CV strengths, gaps, and recommendations relative to the job."); + + // AI system prompt for CV matching — Romanian + Row("ai.cv-match.system-prompt", "ro", + "Ești un motor strict de potrivire CV-job. Returnează doar JSON. Punctează realist între 0 și 100. Penalizează abilitățile lipsă necesare. Nu inventa experiență. Folosește limbaj profesional concis. Toate câmpurile text din răspunsul JSON trebuie să fie în limba română.\nJSON shape: {\"score\":number,\"summary\":\"rezumat pe o linie în română\",\"strengths\":[\"punct forte 1 în română\"],\"gaps\":[\"lipsă 1 în română\"],\"recommendations\":[\"recomandare 1 în română\"],\"evidence\":[\"dovadă 1 în română\"],\"keywords\":[\"cuvant1\",\"cuvant2\",\"cuvant3\"]}", + "System prompt pentru potrivire CV-job în limba română. Instruiește LLM-ul să returneze JSON cu punctele forte ale CV-ului, lacunele și recomandări relative la job."); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "AiPrompts", + schema: MigrationConstants.SchemaName); + + migrationBuilder.DropTable( + name: "ChatCache", + schema: MigrationConstants.SchemaName); + + migrationBuilder.DropTable( + name: "Results", + schema: MigrationConstants.SchemaName); + } + } +} diff --git a/Apis/cv-matcher-data/Migrations/20260608124331_ImproveKeywordsAndAddLocation.Designer.cs b/Apis/cv-matcher-data/Migrations/20260608124331_ImproveKeywordsAndAddLocation.Designer.cs new file mode 100644 index 0000000..8529302 --- /dev/null +++ b/Apis/cv-matcher-data/Migrations/20260608124331_ImproveKeywordsAndAddLocation.Designer.cs @@ -0,0 +1,130 @@ +// +using System; +using CvMatcher.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace CvMatcher.Data.Migrations +{ + [DbContext(typeof(CvMatcherDbContext))] + [Migration("20260608124331_ImproveKeywordsAndAddLocation")] + partial class ImproveKeywordsAndAddLocation + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("cvMatcher") + .HasAnnotation("ProductVersion", "10.0.7") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("CvMatcher.Data.Entities.AiPromptEntity", b => + { + b.Property("Key") + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("Language") + .HasMaxLength(8) + .HasColumnType("nvarchar(8)"); + + b.Property("Description") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)") + .HasDefaultValue(""); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("datetime2") + .HasDefaultValueSql("SYSUTCDATETIME()"); + + b.Property("Value") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.HasKey("Key", "Language"); + + b.ToTable("AiPrompts", "cvMatcher"); + }); + + modelBuilder.Entity("CvMatcher.Data.Entities.CvMatchResultEntity", b => + { + b.Property("Id") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("datetime2") + .HasDefaultValueSql("SYSUTCDATETIME()"); + + b.Property("CvDocumentId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("JobDocumentId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("Language") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.Property("ResultJson") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Score") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("CvDocumentId", "JobDocumentId", "Language") + .IsUnique(); + + b.ToTable("Results", "cvMatcher"); + }); + + modelBuilder.Entity("CvMatcher.Data.Entities.CvMatcherChatCacheEntity", b => + { + b.Property("CacheKey") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("datetime2") + .HasDefaultValueSql("SYSUTCDATETIME()"); + + b.Property("Model") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("nvarchar(120)"); + + b.Property("ResponseText") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Temperature") + .HasColumnType("decimal(4,2)"); + + b.HasKey("CacheKey"); + + b.ToTable("ChatCache", "cvMatcher"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Apis/cv-matcher-data/Migrations/20260608124331_ImproveKeywordsAndAddLocation.cs b/Apis/cv-matcher-data/Migrations/20260608124331_ImproveKeywordsAndAddLocation.cs new file mode 100644 index 0000000..235a28c --- /dev/null +++ b/Apis/cv-matcher-data/Migrations/20260608124331_ImproveKeywordsAndAddLocation.cs @@ -0,0 +1,65 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace CvMatcher.Data.Migrations +{ + /// + public partial class ImproveKeywordsAndAddLocation : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + // Update English prompt: tighter keywords instruction (job-board search terms, not abstract + // concepts) and add location field so the LLM extracts the candidate's city/country. + migrationBuilder.UpdateData( + schema: MigrationConstants.SchemaName, + table: "AiPrompts", + keyColumns: ["Key", "Language"], + keyValues: ["ai.cv-match.system-prompt", "en"], + columns: ["Value", "Description"], + values: [ + "You are a strict CV-to-job matching engine. Return JSON only. Score realistically from 0 to 100. Penalize missing required skills. Do not invent experience. Use concise business language. All text fields in the JSON response must be in English.\nJSON shape: {\"score\":number,\"summary\":\"one-line summary in English\",\"strengths\":[\"strength 1 in English\"],\"gaps\":[\"gap 1 in English\"],\"recommendations\":[\"recommendation 1 in English\"],\"evidence\":[\"evidence 1 in English\"],\"keywords\":[\"Senior .NET Developer\",\"C#\",\"Azure\"],\"location\":\"City, Country\"}.\nFor 'keywords': extract 2-4 short, concrete terms a recruiter would search for on a job board — the candidate's primary role title and key technologies (e.g. 'Senior .NET Developer', 'C#', 'Azure'). Avoid abstract concepts like 'leadership', 'cloud', or 'microservices'.\nFor 'location': extract the candidate's city and country from the CV (e.g. 'Cluj-Napoca, Romania'). Use an empty string if not found.", + "System prompt for CV-to-job matching in English. Extracts job-board-friendly keywords (role title + key tech) and candidate location." + ]); + + // Update Romanian prompt: same improvements. + migrationBuilder.UpdateData( + schema: MigrationConstants.SchemaName, + table: "AiPrompts", + keyColumns: ["Key", "Language"], + keyValues: ["ai.cv-match.system-prompt", "ro"], + columns: ["Value", "Description"], + values: [ + "Ești un motor strict de potrivire CV-job. Returnează doar JSON. Punctează realist între 0 și 100. Penalizează abilitățile lipsă necesare. Nu inventa experiență. Folosește limbaj profesional concis. Toate câmpurile text din răspunsul JSON trebuie să fie în limba română.\nJSON shape: {\"score\":number,\"summary\":\"rezumat pe o linie în română\",\"strengths\":[\"punct forte 1 în română\"],\"gaps\":[\"lipsă 1 în română\"],\"recommendations\":[\"recomandare 1 în română\"],\"evidence\":[\"dovadă 1 în română\"],\"keywords\":[\"Senior .NET Developer\",\"C#\",\"Azure\"],\"location\":\"Oraș, Țară\"}.\nPentru 'keywords': extrage 2-4 termeni scurți și concreți pe care un recrutor i-ar căuta pe un site de joburi — titlul principal al rolului și tehnologiile cheie (ex. 'Senior .NET Developer', 'C#', 'Azure'). Evită concepte abstracte precum 'leadership', 'cloud' sau 'microservicii'.\nPentru 'location': extrage orașul și țara candidatului din CV (ex. 'Cluj-Napoca, România'). Folosește string gol dacă nu se găsește.", + "System prompt pentru potrivire CV-job în limba română. Extrage cuvinte cheie prietenoase pentru site-uri de joburi (titlu rol + tehnologii cheie) și locația candidatului." + ]); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.UpdateData( + schema: MigrationConstants.SchemaName, + table: "AiPrompts", + keyColumns: ["Key", "Language"], + keyValues: ["ai.cv-match.system-prompt", "en"], + columns: ["Value", "Description"], + values: [ + "You are a strict CV-to-job matching engine. Return JSON only. Score realistically from 0 to 100. Penalize missing required skills. Do not invent experience. Use concise business language. All text fields in the JSON response must be in English.\nJSON shape: {\"score\":number,\"summary\":\"one-line summary in English\",\"strengths\":[\"strength 1 in English\"],\"gaps\":[\"gap 1 in English\"],\"recommendations\":[\"recommendation 1 in English\"],\"evidence\":[\"evidence 1 in English\"],\"keywords\":[\"keyword1\",\"keyword2\",\"keyword3\"]}", + "System prompt for CV-to-job matching in English. Instructs LLM to return JSON with CV strengths, gaps, and recommendations relative to the job." + ]); + + migrationBuilder.UpdateData( + schema: MigrationConstants.SchemaName, + table: "AiPrompts", + keyColumns: ["Key", "Language"], + keyValues: ["ai.cv-match.system-prompt", "ro"], + columns: ["Value", "Description"], + values: [ + "Ești un motor strict de potrivire CV-job. Returnează doar JSON. Punctează realist între 0 și 100. Penalizează abilitățile lipsă necesare. Nu inventa experiență. Folosește limbaj profesional concis. Toate câmpurile text din răspunsul JSON trebuie să fie în limba română.\nJSON shape: {\"score\":number,\"summary\":\"rezumat pe o linie în română\",\"strengths\":[\"punct forte 1 în română\"],\"gaps\":[\"lipsă 1 în română\"],\"recommendations\":[\"recomandare 1 în română\"],\"evidence\":[\"dovadă 1 în română\"],\"keywords\":[\"cuvant1\",\"cuvant2\",\"cuvant3\"]}", + "System prompt pentru potrivire CV-job în limba română. Instruiește LLM-ul să returneze JSON cu punctele forte ale CV-ului, lacunele și recomandări relative la job." + ]); + } + } +} diff --git a/Apis/cv-matcher-data/Migrations/20260608155310_AddEmailAndIpToResults.Designer.cs b/Apis/cv-matcher-data/Migrations/20260608155310_AddEmailAndIpToResults.Designer.cs new file mode 100644 index 0000000..f11d46b --- /dev/null +++ b/Apis/cv-matcher-data/Migrations/20260608155310_AddEmailAndIpToResults.Designer.cs @@ -0,0 +1,138 @@ +// +using System; +using CvMatcher.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace CvMatcher.Data.Migrations +{ + [DbContext(typeof(CvMatcherDbContext))] + [Migration("20260608155310_AddEmailAndIpToResults")] + partial class AddEmailAndIpToResults + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("cvMatcher") + .HasAnnotation("ProductVersion", "10.0.7") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("CvMatcher.Data.Entities.AiPromptEntity", b => + { + b.Property("Key") + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("Language") + .HasMaxLength(8) + .HasColumnType("nvarchar(8)"); + + b.Property("Description") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)") + .HasDefaultValue(""); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("datetime2") + .HasDefaultValueSql("SYSUTCDATETIME()"); + + b.Property("Value") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.HasKey("Key", "Language"); + + b.ToTable("AiPrompts", "cvMatcher"); + }); + + modelBuilder.Entity("CvMatcher.Data.Entities.CvMatchResultEntity", b => + { + b.Property("Id") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("ClientIpAddress") + .HasMaxLength(45) + .HasColumnType("nvarchar(45)"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("datetime2") + .HasDefaultValueSql("SYSUTCDATETIME()"); + + b.Property("CvDocumentId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("JobDocumentId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("Language") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.Property("ResultJson") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Score") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("CvDocumentId", "JobDocumentId", "Language") + .IsUnique(); + + b.ToTable("Results", "cvMatcher"); + }); + + modelBuilder.Entity("CvMatcher.Data.Entities.CvMatcherChatCacheEntity", b => + { + b.Property("CacheKey") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("datetime2") + .HasDefaultValueSql("SYSUTCDATETIME()"); + + b.Property("Model") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("nvarchar(120)"); + + b.Property("ResponseText") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Temperature") + .HasColumnType("decimal(4,2)"); + + b.HasKey("CacheKey"); + + b.ToTable("ChatCache", "cvMatcher"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Apis/cv-matcher-data/Migrations/20260608155310_AddEmailAndIpToResults.cs b/Apis/cv-matcher-data/Migrations/20260608155310_AddEmailAndIpToResults.cs new file mode 100644 index 0000000..897bbda --- /dev/null +++ b/Apis/cv-matcher-data/Migrations/20260608155310_AddEmailAndIpToResults.cs @@ -0,0 +1,45 @@ +using CvMatcher.Data; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace CvMatcher.Data.Migrations +{ + /// + public partial class AddEmailAndIpToResults : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "ClientIpAddress", + schema: MigrationConstants.SchemaName, + table: "Results", + type: "nvarchar(45)", + maxLength: 45, + nullable: true); + + migrationBuilder.AddColumn( + name: "Email", + schema: MigrationConstants.SchemaName, + table: "Results", + type: "nvarchar(256)", + maxLength: 256, + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "ClientIpAddress", + schema: MigrationConstants.SchemaName, + table: "Results"); + + migrationBuilder.DropColumn( + name: "Email", + schema: MigrationConstants.SchemaName, + table: "Results"); + } + } +} diff --git a/Apis/cv-matcher-api/Migrations/CvMatcherDbContextModelSnapshot.cs b/Apis/cv-matcher-data/Migrations/CvMatcherDbContextModelSnapshot.cs similarity index 60% rename from Apis/cv-matcher-api/Migrations/CvMatcherDbContextModelSnapshot.cs rename to Apis/cv-matcher-data/Migrations/CvMatcherDbContextModelSnapshot.cs index 9a435fa..6a8d37a 100644 --- a/Apis/cv-matcher-api/Migrations/CvMatcherDbContextModelSnapshot.cs +++ b/Apis/cv-matcher-data/Migrations/CvMatcherDbContextModelSnapshot.cs @@ -1,6 +1,6 @@ // using System; -using Api.Data; +using CvMatcher.Data; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Metadata; @@ -8,7 +8,7 @@ using Microsoft.EntityFrameworkCore.Storage.ValueConversion; #nullable disable -namespace Api.Migrations +namespace CvMatcher.Data.Migrations { [DbContext(typeof(CvMatcherDbContext))] partial class CvMatcherDbContextModelSnapshot : ModelSnapshot @@ -23,12 +23,47 @@ namespace Api.Migrations SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); - modelBuilder.Entity("Api.Data.Entities.CvMatchResultEntity", b => + modelBuilder.Entity("CvMatcher.Data.Entities.AiPromptEntity", b => + { + b.Property("Key") + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("Language") + .HasMaxLength(8) + .HasColumnType("nvarchar(8)"); + + b.Property("Description") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)") + .HasDefaultValue(""); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("datetime2") + .HasDefaultValueSql("SYSUTCDATETIME()"); + + b.Property("Value") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.HasKey("Key", "Language"); + + b.ToTable("AiPrompts", "cvMatcher"); + }); + + modelBuilder.Entity("CvMatcher.Data.Entities.CvMatchResultEntity", b => { b.Property("Id") .HasMaxLength(64) .HasColumnType("nvarchar(64)"); + b.Property("ClientIpAddress") + .HasMaxLength(45) + .HasColumnType("nvarchar(45)"); + b.Property("CreatedAt") .ValueGeneratedOnAdd() .HasColumnType("datetime2") @@ -39,11 +74,19 @@ namespace Api.Migrations .HasMaxLength(64) .HasColumnType("nvarchar(64)"); + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + b.Property("JobDocumentId") .IsRequired() .HasMaxLength(64) .HasColumnType("nvarchar(64)"); + b.Property("Language") + .IsRequired() + .HasColumnType("nvarchar(450)"); + b.Property("ResultJson") .IsRequired() .HasColumnType("nvarchar(max)"); @@ -53,13 +96,13 @@ namespace Api.Migrations b.HasKey("Id"); - b.HasIndex("CvDocumentId", "JobDocumentId") + b.HasIndex("CvDocumentId", "JobDocumentId", "Language") .IsUnique(); b.ToTable("Results", "cvMatcher"); }); - modelBuilder.Entity("Api.Data.Entities.CvMatcherChatCacheEntity", b => + modelBuilder.Entity("CvMatcher.Data.Entities.CvMatcherChatCacheEntity", b => { b.Property("CacheKey") .HasMaxLength(64) diff --git a/Apis/cv-matcher-data/Repositories/Contracts/IAiPromptsRepository.cs b/Apis/cv-matcher-data/Repositories/Contracts/IAiPromptsRepository.cs new file mode 100644 index 0000000..1fe2c08 --- /dev/null +++ b/Apis/cv-matcher-data/Repositories/Contracts/IAiPromptsRepository.cs @@ -0,0 +1,6 @@ +namespace CvMatcher.Data.Repositories.Contracts; + +public interface IAiPromptsRepository +{ + Task GetAsync(string key, string language, CancellationToken ct); +} diff --git a/Apis/cv-matcher-api/Data/Repositories/Contracts/IMatcherRepository.cs b/Apis/cv-matcher-data/Repositories/Contracts/IMatcherRepository.cs similarity index 67% rename from Apis/cv-matcher-api/Data/Repositories/Contracts/IMatcherRepository.cs rename to Apis/cv-matcher-data/Repositories/Contracts/IMatcherRepository.cs index c4c4493..069f93b 100644 --- a/Apis/cv-matcher-api/Data/Repositories/Contracts/IMatcherRepository.cs +++ b/Apis/cv-matcher-data/Repositories/Contracts/IMatcherRepository.cs @@ -1,12 +1,12 @@ using CvMatcher.Models.Responses; -namespace Api.Data.Repositories.Contracts; +namespace CvMatcher.Data.Repositories.Contracts; public interface IMatcherRepository { Task InitializeAsync(CancellationToken ct); - Task GetMatchAsync(string cvDocumentId, string jobDocumentId, CancellationToken ct); - Task SaveMatchAsync(string cvDocumentId, string jobDocumentId, JobMatchResponse response, CancellationToken ct); + Task GetMatchAsync(string cvDocumentId, string jobDocumentId, string language, CancellationToken ct); + Task SaveMatchAsync(string cvDocumentId, string jobDocumentId, string language, JobMatchResponse response, string? email, string? clientIpAddress, CancellationToken ct); Task GetChatCompletionAsync(string cacheKey, CancellationToken ct); Task SaveChatCompletionAsync(string cacheKey, string model, decimal temperature, string responseText, CancellationToken ct); } diff --git a/Apis/cv-matcher-data/Repositories/EfAiPromptsRepository.cs b/Apis/cv-matcher-data/Repositories/EfAiPromptsRepository.cs new file mode 100644 index 0000000..5cfff67 --- /dev/null +++ b/Apis/cv-matcher-data/Repositories/EfAiPromptsRepository.cs @@ -0,0 +1,24 @@ +using CvMatcher.Data; +using CvMatcher.Data.Repositories.Contracts; +using Microsoft.EntityFrameworkCore; + +namespace CvMatcher.Data.Repositories; + +public sealed class EfAiPromptsRepository : IAiPromptsRepository +{ + private readonly CvMatcherDbContext _db; + + public EfAiPromptsRepository(CvMatcherDbContext db) + { + _db = db; + } + + public async Task GetAsync(string key, string language, CancellationToken ct) + { + return await _db.AiPrompts + .AsNoTracking() + .Where(x => x.Key == key && x.Language == language) + .Select(x => x.Value) + .FirstOrDefaultAsync(ct); + } +} diff --git a/Apis/cv-matcher-api/Data/Repositories/EfMatcherRepository.cs b/Apis/cv-matcher-data/Repositories/EfMatcherRepository.cs similarity index 55% rename from Apis/cv-matcher-api/Data/Repositories/EfMatcherRepository.cs rename to Apis/cv-matcher-data/Repositories/EfMatcherRepository.cs index c81618b..33b8efa 100644 --- a/Apis/cv-matcher-api/Data/Repositories/EfMatcherRepository.cs +++ b/Apis/cv-matcher-data/Repositories/EfMatcherRepository.cs @@ -1,11 +1,12 @@ using System.Text.Json; -using Api.Data; -using Api.Data.Entities; -using Api.Data.Repositories.Contracts; +using CvMatcher.Data; +using CvMatcher.Data.Entities; +using CvMatcher.Data.Repositories.Contracts; using CvMatcher.Models.Responses; using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; -namespace Api.Data.Repositories; +namespace CvMatcher.Data.Repositories; public sealed class EfMatcherRepository : IMatcherRepository { @@ -24,11 +25,11 @@ public sealed class EfMatcherRepository : IMatcherRepository //await _db.Database.EnsureCreatedAsync(ct); } - public async Task GetMatchAsync(string cvDocumentId, string jobDocumentId, CancellationToken ct) + public async Task GetMatchAsync(string cvDocumentId, string jobDocumentId, string language, CancellationToken ct) { var json = await _db.CvMatchResults .AsNoTracking() - .Where(x => x.CvDocumentId == cvDocumentId && x.JobDocumentId == jobDocumentId) + .Where(x => x.CvDocumentId == cvDocumentId && x.JobDocumentId == jobDocumentId && x.Language == language) .Select(x => x.ResultJson) .FirstOrDefaultAsync(ct); @@ -39,25 +40,41 @@ public sealed class EfMatcherRepository : IMatcherRepository return result; } - public async Task SaveMatchAsync(string cvDocumentId, string jobDocumentId, JobMatchResponse response, CancellationToken ct) + public async Task SaveMatchAsync(string cvDocumentId, string jobDocumentId, string language, JobMatchResponse response, string? email, string? clientIpAddress, CancellationToken ct) { var exists = await _db.CvMatchResults.AnyAsync( - x => x.CvDocumentId == cvDocumentId && x.JobDocumentId == jobDocumentId, + x => x.CvDocumentId == cvDocumentId && x.JobDocumentId == jobDocumentId && x.Language == language, ct); if (exists) return; - _db.CvMatchResults.Add(new CvMatchResultEntity + try { - Id = Guid.NewGuid().ToString("N"), - CvDocumentId = cvDocumentId, - JobDocumentId = jobDocumentId, - ResultJson = JsonSerializer.Serialize(response, new JsonSerializerOptions(JsonSerializerDefaults.Web)), - Score = response.Score, - CreatedAt = DateTime.UtcNow - }); + _db.CvMatchResults.Add(new CvMatchResultEntity + { + Id = Guid.NewGuid().ToString("N"), + CvDocumentId = cvDocumentId, + JobDocumentId = jobDocumentId, + Language = language, + ResultJson = JsonSerializer.Serialize(response, new JsonSerializerOptions(JsonSerializerDefaults.Web)), + Score = response.Score, + Email = email, + ClientIpAddress = clientIpAddress, + CreatedAt = DateTime.UtcNow + }); - await _db.SaveChangesAsync(ct); + await _db.SaveChangesAsync(ct); + } + catch (DbUpdateException ex) when (ex.InnerException?.Message.Contains("IX_Results_CvDocumentId_JobDocumentId_Language") == true + || ex.InnerException?.Message.Contains("unique") == true) + { + // Duplicate key violation: record was inserted between the AnyAsync check and SaveChangesAsync. + // This is safe to ignore — the match result already exists in the database. + _logger.LogWarning( + "Duplicate match result ignored: CV={CvDocumentId} Job={JobDocumentId} Language={Language}. " + + "Record was likely inserted concurrently. This is expected behavior in high-concurrency scenarios.", + cvDocumentId, jobDocumentId, language); + } } public async Task GetChatCompletionAsync(string cacheKey, CancellationToken ct) diff --git a/Apis/cv-matcher-data/cv-matcher-data.csproj b/Apis/cv-matcher-data/cv-matcher-data.csproj new file mode 100644 index 0000000..8cf0d79 --- /dev/null +++ b/Apis/cv-matcher-data/cv-matcher-data.csproj @@ -0,0 +1,20 @@ + + + net10.0 + enable + enable + cv-matcher-data + CvMatcher.Data + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + diff --git a/Apis/cv-search-data/Data/CvSearchDbContext.cs b/Apis/cv-search-data/Data/CvSearchDbContext.cs new file mode 100644 index 0000000..06171b2 --- /dev/null +++ b/Apis/cv-search-data/Data/CvSearchDbContext.cs @@ -0,0 +1,88 @@ +using CvSearch.Data.Entities; +using Microsoft.EntityFrameworkCore; + +namespace CvSearch.Data; + +public sealed class CvSearchDbContext : DbContext +{ + public const string SchemaName = MigrationConstants.SchemaName; + public const string MigrationTableName = MigrationConstants.MigrationTableName; + + public CvSearchDbContext(DbContextOptions options) : base(options) { } + + public DbSet JobSearchTokens => Set(); + public DbSet JobSearchSessions => Set(); + public DbSet JobSearchResults => Set(); + public DbSet JobProviders => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + { + base.OnConfiguring(optionsBuilder); + // Configure migration history table to use schema-qualified name: [cvSearch].[_Migrations] + optionsBuilder.UseSqlServer(x => x.MigrationsHistoryTable(MigrationTableName, SchemaName)); + } + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.HasDefaultSchema(SchemaName); + + modelBuilder.Entity(entity => + { + entity.ToTable("JobSearchTokens"); + entity.HasKey(x => x.Id); + entity.Property(x => x.Id).HasMaxLength(64); + entity.Property(x => x.CvDocumentId).HasMaxLength(64).IsRequired(); + entity.Property(x => x.Email).HasMaxLength(256).IsRequired(); + entity.Property(x => x.Language).HasMaxLength(8).HasDefaultValue("en").IsRequired(); + entity.Property(x => x.Keywords).HasMaxLength(1000).HasDefaultValue(string.Empty); + entity.Property(x => x.Used).HasDefaultValue(false); + entity.Property(x => x.ClientIpAddress).HasMaxLength(45); + entity.Property(x => x.CreatedAt).HasDefaultValueSql("SYSUTCDATETIME()"); + }); + + modelBuilder.Entity(entity => + { + entity.ToTable("JobSearchSessions"); + entity.HasKey(x => x.Id); + entity.Property(x => x.Id).HasMaxLength(64); + entity.Property(x => x.TokenId).HasMaxLength(64).IsRequired(); + entity.Property(x => x.CvDocumentId).HasMaxLength(64).IsRequired(); + entity.Property(x => x.Email).HasMaxLength(256).IsRequired(); + entity.Property(x => x.Status).HasMaxLength(32).IsRequired(); + entity.Property(x => x.Keywords).HasMaxLength(1000); + entity.Property(x => x.ProviderConfigJson).IsRequired(false); + entity.Property(x => x.Language).HasMaxLength(8).HasDefaultValue("en").IsRequired(); + entity.Property(x => x.ClientIpAddress).HasMaxLength(45); + entity.Property(x => x.CreatedAt).HasDefaultValueSql("SYSUTCDATETIME()"); + entity.HasIndex(x => x.Status); + }); + + modelBuilder.Entity(entity => + { + entity.ToTable("JobSearchResults"); + entity.HasKey(x => x.Id); + entity.Property(x => x.Id).HasMaxLength(64); + entity.Property(x => x.SessionId).HasMaxLength(64).IsRequired(); + entity.Property(x => x.ProviderName).HasMaxLength(128); + entity.Property(x => x.JobUrl).HasMaxLength(2048); + entity.Property(x => x.JobTitle).HasMaxLength(512); + entity.Property(x => x.Email).HasMaxLength(256); + entity.Property(x => x.ClientIpAddress).HasMaxLength(45); + entity.Property(x => x.CreatedAt).HasDefaultValueSql("SYSUTCDATETIME()"); + entity.HasIndex(x => x.SessionId); + }); + + modelBuilder.Entity(entity => + { + entity.ToTable("JobProviders"); + entity.HasKey(x => x.Id); + entity.Property(x => x.Id).UseIdentityColumn(); + entity.Property(x => x.Name).HasMaxLength(128).IsRequired(); + entity.Property(x => x.SearchUrlTemplate).HasMaxLength(1024).IsRequired(); + entity.Property(x => x.JobLinkContains).HasMaxLength(256).IsRequired(); + entity.Property(x => x.InitialKeywordsJson).HasMaxLength(2000).HasDefaultValue("[]").IsRequired(); + entity.Property(x => x.MaxResults).HasDefaultValue(20); + entity.Property(x => x.DisplayOrder).HasDefaultValue(0); + }); + } +} diff --git a/Apis/cv-search-data/Data/Entities/JobProviderEntity.cs b/Apis/cv-search-data/Data/Entities/JobProviderEntity.cs new file mode 100644 index 0000000..cdc7c66 --- /dev/null +++ b/Apis/cv-search-data/Data/Entities/JobProviderEntity.cs @@ -0,0 +1,39 @@ +namespace CvSearch.Data.Entities; + +/// +/// Persisted job-board provider configuration. Stored in cvSearch.JobProviders. +/// Providers are loaded from here at session-creation time and snapshotted into +/// JobSearchSessionEntity.ProviderConfigJson so runtime config changes do not +/// affect already-queued sessions. +/// +public sealed class JobProviderEntity +{ + public int Id { get; set; } + + /// Display name (e.g. "ejobs.ro"). + public string Name { get; set; } = string.Empty; + + /// When false the provider is skipped at session-creation and the job-search link is hidden. + public bool Enabled { get; set; } + + /// URL template with {keywords} placeholder (URL-encoded keywords are substituted at runtime). + public string SearchUrlTemplate { get; set; } = string.Empty; + + /// Substring that must appear in an anchor href to pass the stage-1 link filter. + public string JobLinkContains { get; set; } = string.Empty; + + /// JSON array of baseline keywords merged with CV keywords before building the search URL. + public string InitialKeywordsJson { get; set; } = "[]"; + + /// Maximum number of job URLs to collect from this provider per session. + public int MaxResults { get; set; } = 20; + + /// Controls display ordering in future admin UIs. + public int DisplayOrder { get; set; } + + /// + /// When false, the Stage 2 anchor-text keyword filter is skipped. + /// Set to false for providers whose search URL already filters by relevance server-side (ejobs.ro, bestjobs.eu). + /// + public bool RequireKeywordInAnchor { get; set; } = true; +} diff --git a/Apis/cv-search-data/Data/Entities/JobSearchResultEntity.cs b/Apis/cv-search-data/Data/Entities/JobSearchResultEntity.cs new file mode 100644 index 0000000..a1d0f67 --- /dev/null +++ b/Apis/cv-search-data/Data/Entities/JobSearchResultEntity.cs @@ -0,0 +1,18 @@ +using Shared.Data.Entities; + +namespace CvSearch.Data.Entities; + +public sealed class JobSearchResultEntity : BaseEntity +{ + public string SessionId { get; set; } = string.Empty; + public string ProviderName { get; set; } = string.Empty; + public string JobUrl { get; set; } = string.Empty; + public string JobTitle { get; set; } = string.Empty; + public string JobText { get; set; } = string.Empty; + public int Score { get; set; } + public string ResultJson { get; set; } = string.Empty; + /// Email address of the user who triggered the search. Copied from the parent session. + public string? Email { get; set; } + /// Client IP address at link-click time. Copied from the parent session. + public string? ClientIpAddress { get; set; } +} diff --git a/Apis/cv-search-data/Data/Entities/JobSearchSessionEntity.cs b/Apis/cv-search-data/Data/Entities/JobSearchSessionEntity.cs new file mode 100644 index 0000000..e0620c7 --- /dev/null +++ b/Apis/cv-search-data/Data/Entities/JobSearchSessionEntity.cs @@ -0,0 +1,25 @@ +using Shared.Data.Entities; + +namespace CvSearch.Data.Entities; + +public sealed class JobSearchSessionEntity : BaseEntity +{ + public string TokenId { get; set; } = string.Empty; + public string CvDocumentId { get; set; } = string.Empty; + public string Email { get; set; } = string.Empty; + public string Status { get; set; } = JobSearchStatus.Pending; + public string Keywords { get; set; } = string.Empty; + public string? Location { get; set; } + /// Client IP address captured when the user clicked the one-time job-search link. Null for sessions created before this field was added. + public string? ClientIpAddress { get; set; } + public string? ProviderConfigJson { get; set; } + public string Language { get; set; } = "en"; +} + +public static class JobSearchStatus +{ + public const string Pending = "Pending"; + public const string Processing = "Processing"; + public const string Done = "Done"; + public const string Failed = "Failed"; +} diff --git a/Apis/cv-search-data/Data/Entities/JobSearchTokenEntity.cs b/Apis/cv-search-data/Data/Entities/JobSearchTokenEntity.cs new file mode 100644 index 0000000..0b90939 --- /dev/null +++ b/Apis/cv-search-data/Data/Entities/JobSearchTokenEntity.cs @@ -0,0 +1,16 @@ +using Shared.Data.Entities; + +namespace CvSearch.Data.Entities; + +public sealed class JobSearchTokenEntity : BaseEntity +{ + public string CvDocumentId { get; set; } = string.Empty; + public string Email { get; set; } = string.Empty; + public string Language { get; set; } = "en"; + public DateTime ExpiresAt { get; set; } + public bool Used { get; set; } + public string Keywords { get; set; } = string.Empty; + public string? Location { get; set; } + /// Client IP address captured when the user submitted the CV match request. Null for tokens created before this field was added. + public string? ClientIpAddress { get; set; } +} diff --git a/Apis/cv-search-data/Data/MigrationConstants.cs b/Apis/cv-search-data/Data/MigrationConstants.cs new file mode 100644 index 0000000..1399d8f --- /dev/null +++ b/Apis/cv-search-data/Data/MigrationConstants.cs @@ -0,0 +1,11 @@ +namespace CvSearch.Data; + +/// +/// Schema constants used by CvSearchDbContext and migrations. +/// Centralized to avoid hardcoded strings and ensure consistency. +/// +public static class MigrationConstants +{ + public const string SchemaName = "cvSearch"; + public const string MigrationTableName = "_Migrations"; +} diff --git a/Apis/cv-search-data/Migrations/20260522093356_AddJobSearchTables.Designer.cs b/Apis/cv-search-data/Migrations/20260522093356_AddJobSearchTables.Designer.cs new file mode 100644 index 0000000..26141b2 --- /dev/null +++ b/Apis/cv-search-data/Migrations/20260522093356_AddJobSearchTables.Designer.cs @@ -0,0 +1,160 @@ +// +using System; +using CvSearch.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace CvSearch.Data.Migrations +{ + [DbContext(typeof(CvSearchDbContext))] + [Migration("20260522093356_AddJobSearchTables")] + partial class AddJobSearchTables + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("cvSearch") + .HasAnnotation("ProductVersion", "10.0.7") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("CvSearch.Data.Entities.JobSearchResultEntity", b => + { + b.Property("Id") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("datetime2") + .HasDefaultValueSql("SYSUTCDATETIME()"); + + b.Property("JobText") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("JobTitle") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("nvarchar(512)"); + + b.Property("JobUrl") + .IsRequired() + .HasMaxLength(2048) + .HasColumnType("nvarchar(2048)"); + + b.Property("ProviderName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("ResultJson") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Score") + .HasColumnType("int"); + + b.Property("SessionId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.HasKey("Id"); + + b.HasIndex("SessionId"); + + b.ToTable("JobSearchResults", "cvSearch"); + }); + + modelBuilder.Entity("CvSearch.Data.Entities.JobSearchSessionEntity", b => + { + b.Property("Id") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("datetime2") + .HasDefaultValueSql("SYSUTCDATETIME()"); + + b.Property("CvDocumentId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("Keywords") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.Property("ProviderConfigJson") + .HasColumnType("nvarchar(max)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.Property("TokenId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.HasKey("Id"); + + b.HasIndex("Status"); + + b.ToTable("JobSearchSessions", "cvSearch"); + }); + + modelBuilder.Entity("CvSearch.Data.Entities.JobSearchTokenEntity", b => + { + b.Property("Id") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("datetime2") + .HasDefaultValueSql("SYSUTCDATETIME()"); + + b.Property("CvDocumentId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("ExpiresAt") + .HasColumnType("datetime2"); + + b.Property("Used") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(false); + + b.HasKey("Id"); + + b.ToTable("JobSearchTokens", "cvSearch"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Apis/cv-search-data/Migrations/20260522093356_AddJobSearchTables.cs b/Apis/cv-search-data/Migrations/20260522093356_AddJobSearchTables.cs new file mode 100644 index 0000000..689d5ef --- /dev/null +++ b/Apis/cv-search-data/Migrations/20260522093356_AddJobSearchTables.cs @@ -0,0 +1,103 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; +using CvSearch.Data; + +#nullable disable + +namespace CvSearch.Data.Migrations +{ + /// + public partial class AddJobSearchTables : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.EnsureSchema( + name: MigrationConstants.SchemaName); + + migrationBuilder.CreateTable( + name: "JobSearchResults", + schema: MigrationConstants.SchemaName, + columns: table => new + { + Id = table.Column(type: "nvarchar(64)", maxLength: 64, nullable: false), + SessionId = table.Column(type: "nvarchar(64)", maxLength: 64, nullable: false), + ProviderName = table.Column(type: "nvarchar(128)", maxLength: 128, nullable: false), + JobUrl = table.Column(type: "nvarchar(2048)", maxLength: 2048, nullable: false), + JobTitle = table.Column(type: "nvarchar(512)", maxLength: 512, nullable: false), + JobText = table.Column(type: "nvarchar(max)", nullable: false), + Score = table.Column(type: "int", nullable: false), + ResultJson = table.Column(type: "nvarchar(max)", nullable: false), + CreatedAt = table.Column(type: "datetime2", nullable: false, defaultValueSql: "SYSUTCDATETIME()") + }, + constraints: table => + { + table.PrimaryKey("PK_JobSearchResults", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "JobSearchSessions", + schema: MigrationConstants.SchemaName, + columns: table => new + { + Id = table.Column(type: "nvarchar(64)", maxLength: 64, nullable: false), + TokenId = table.Column(type: "nvarchar(64)", maxLength: 64, nullable: false), + CvDocumentId = table.Column(type: "nvarchar(64)", maxLength: 64, nullable: false), + Email = table.Column(type: "nvarchar(256)", maxLength: 256, nullable: false), + Status = table.Column(type: "nvarchar(32)", maxLength: 32, nullable: false), + Keywords = table.Column(type: "nvarchar(1000)", maxLength: 1000, nullable: false), + ProviderConfigJson = table.Column(type: "nvarchar(max)", nullable: true), + CreatedAt = table.Column(type: "datetime2", nullable: false, defaultValueSql: "SYSUTCDATETIME()") + }, + constraints: table => + { + table.PrimaryKey("PK_JobSearchSessions", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "JobSearchTokens", + schema: MigrationConstants.SchemaName, + columns: table => new + { + Id = table.Column(type: "nvarchar(64)", maxLength: 64, nullable: false), + CvDocumentId = table.Column(type: "nvarchar(64)", maxLength: 64, nullable: false), + Email = table.Column(type: "nvarchar(256)", maxLength: 256, nullable: false), + ExpiresAt = table.Column(type: "datetime2", nullable: false), + Used = table.Column(type: "bit", nullable: false, defaultValue: false), + CreatedAt = table.Column(type: "datetime2", nullable: false, defaultValueSql: "SYSUTCDATETIME()") + }, + constraints: table => + { + table.PrimaryKey("PK_JobSearchTokens", x => x.Id); + }); + + migrationBuilder.CreateIndex( + name: "IX_JobSearchResults_SessionId", + schema: MigrationConstants.SchemaName, + table: "JobSearchResults", + column: "SessionId"); + + migrationBuilder.CreateIndex( + name: "IX_JobSearchSessions_Status", + schema: MigrationConstants.SchemaName, + table: "JobSearchSessions", + column: "Status"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "JobSearchResults", + schema: MigrationConstants.SchemaName); + + migrationBuilder.DropTable( + name: "JobSearchSessions", + schema: MigrationConstants.SchemaName); + + migrationBuilder.DropTable( + name: "JobSearchTokens", + schema: MigrationConstants.SchemaName); + } + } +} diff --git a/Apis/cv-search-data/Migrations/20260524145702_AddLanguageToJobSearchEntities.Designer.cs b/Apis/cv-search-data/Migrations/20260524145702_AddLanguageToJobSearchEntities.Designer.cs new file mode 100644 index 0000000..847c985 --- /dev/null +++ b/Apis/cv-search-data/Migrations/20260524145702_AddLanguageToJobSearchEntities.Designer.cs @@ -0,0 +1,174 @@ +// +using System; +using CvSearch.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace CvSearch.Data.Migrations +{ + [DbContext(typeof(CvSearchDbContext))] + [Migration("20260524145702_AddLanguageToJobSearchEntities")] + partial class AddLanguageToJobSearchEntities + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("cvSearch") + .HasAnnotation("ProductVersion", "10.0.7") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("CvSearch.Data.Entities.JobSearchResultEntity", b => + { + b.Property("Id") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("datetime2") + .HasDefaultValueSql("SYSUTCDATETIME()"); + + b.Property("JobText") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("JobTitle") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("nvarchar(512)"); + + b.Property("JobUrl") + .IsRequired() + .HasMaxLength(2048) + .HasColumnType("nvarchar(2048)"); + + b.Property("ProviderName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("ResultJson") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Score") + .HasColumnType("int"); + + b.Property("SessionId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.HasKey("Id"); + + b.HasIndex("SessionId"); + + b.ToTable("JobSearchResults", "cvSearch"); + }); + + modelBuilder.Entity("CvSearch.Data.Entities.JobSearchSessionEntity", b => + { + b.Property("Id") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("datetime2") + .HasDefaultValueSql("SYSUTCDATETIME()"); + + b.Property("CvDocumentId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("Keywords") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.Property("Language") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(8) + .HasColumnType("nvarchar(8)") + .HasDefaultValue("en"); + + b.Property("ProviderConfigJson") + .HasColumnType("nvarchar(max)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.Property("TokenId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.HasKey("Id"); + + b.HasIndex("Status"); + + b.ToTable("JobSearchSessions", "cvSearch"); + }); + + modelBuilder.Entity("CvSearch.Data.Entities.JobSearchTokenEntity", b => + { + b.Property("Id") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("datetime2") + .HasDefaultValueSql("SYSUTCDATETIME()"); + + b.Property("CvDocumentId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("ExpiresAt") + .HasColumnType("datetime2"); + + b.Property("Language") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(8) + .HasColumnType("nvarchar(8)") + .HasDefaultValue("en"); + + b.Property("Used") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(false); + + b.HasKey("Id"); + + b.ToTable("JobSearchTokens", "cvSearch"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Apis/cv-search-data/Migrations/20260524145702_AddLanguageToJobSearchEntities.cs b/Apis/cv-search-data/Migrations/20260524145702_AddLanguageToJobSearchEntities.cs new file mode 100644 index 0000000..2cebb32 --- /dev/null +++ b/Apis/cv-search-data/Migrations/20260524145702_AddLanguageToJobSearchEntities.cs @@ -0,0 +1,47 @@ +using Microsoft.EntityFrameworkCore.Migrations; +using CvSearch.Data; + +#nullable disable + +namespace CvSearch.Data.Migrations +{ + /// + public partial class AddLanguageToJobSearchEntities : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "Language", + schema: MigrationConstants.SchemaName, + table: "JobSearchTokens", + type: "nvarchar(8)", + maxLength: 8, + nullable: false, + defaultValue: "en"); + + migrationBuilder.AddColumn( + name: "Language", + schema: MigrationConstants.SchemaName, + table: "JobSearchSessions", + type: "nvarchar(8)", + maxLength: 8, + nullable: false, + defaultValue: "en"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "Language", + schema: MigrationConstants.SchemaName, + table: "JobSearchTokens"); + + migrationBuilder.DropColumn( + name: "Language", + schema: MigrationConstants.SchemaName, + table: "JobSearchSessions"); + } + } +} diff --git a/Apis/cv-search-data/Migrations/20260529084440_AddJobProviders.Designer.cs b/Apis/cv-search-data/Migrations/20260529084440_AddJobProviders.Designer.cs new file mode 100644 index 0000000..a079dc6 --- /dev/null +++ b/Apis/cv-search-data/Migrations/20260529084440_AddJobProviders.Designer.cs @@ -0,0 +1,222 @@ +// +using System; +using CvSearch.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace CvSearch.Data.Migrations +{ + [DbContext(typeof(CvSearchDbContext))] + [Migration("20260529084440_AddJobProviders")] + partial class AddJobProviders + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("cvSearch") + .HasAnnotation("ProductVersion", "10.0.7") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("CvSearch.Data.Entities.JobProviderEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("DisplayOrder") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasDefaultValue(0); + + b.Property("Enabled") + .HasColumnType("bit"); + + b.Property("InitialKeywordsJson") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)") + .HasDefaultValue("[]"); + + b.Property("JobLinkContains") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("MaxResults") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasDefaultValue(20); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("SearchUrlTemplate") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("nvarchar(1024)"); + + b.HasKey("Id"); + + b.ToTable("JobProviders", "cvSearch"); + }); + + modelBuilder.Entity("CvSearch.Data.Entities.JobSearchResultEntity", b => + { + b.Property("Id") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("datetime2") + .HasDefaultValueSql("SYSUTCDATETIME()"); + + b.Property("JobText") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("JobTitle") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("nvarchar(512)"); + + b.Property("JobUrl") + .IsRequired() + .HasMaxLength(2048) + .HasColumnType("nvarchar(2048)"); + + b.Property("ProviderName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("ResultJson") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Score") + .HasColumnType("int"); + + b.Property("SessionId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.HasKey("Id"); + + b.HasIndex("SessionId"); + + b.ToTable("JobSearchResults", "cvSearch"); + }); + + modelBuilder.Entity("CvSearch.Data.Entities.JobSearchSessionEntity", b => + { + b.Property("Id") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("datetime2") + .HasDefaultValueSql("SYSUTCDATETIME()"); + + b.Property("CvDocumentId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("Keywords") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.Property("Language") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(8) + .HasColumnType("nvarchar(8)") + .HasDefaultValue("en"); + + b.Property("ProviderConfigJson") + .HasColumnType("nvarchar(max)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.Property("TokenId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.HasKey("Id"); + + b.HasIndex("Status"); + + b.ToTable("JobSearchSessions", "cvSearch"); + }); + + modelBuilder.Entity("CvSearch.Data.Entities.JobSearchTokenEntity", b => + { + b.Property("Id") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("datetime2") + .HasDefaultValueSql("SYSUTCDATETIME()"); + + b.Property("CvDocumentId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("ExpiresAt") + .HasColumnType("datetime2"); + + b.Property("Language") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(8) + .HasColumnType("nvarchar(8)") + .HasDefaultValue("en"); + + b.Property("Used") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(false); + + b.HasKey("Id"); + + b.ToTable("JobSearchTokens", "cvSearch"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Apis/cv-search-data/Migrations/20260529084440_AddJobProviders.cs b/Apis/cv-search-data/Migrations/20260529084440_AddJobProviders.cs new file mode 100644 index 0000000..795417e --- /dev/null +++ b/Apis/cv-search-data/Migrations/20260529084440_AddJobProviders.cs @@ -0,0 +1,55 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace CvSearch.Data.Migrations +{ + /// + public partial class AddJobProviders : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "JobProviders", + schema: MigrationConstants.SchemaName, + columns: table => new + { + Id = table.Column(type: "int", nullable: false) + .Annotation("SqlServer:Identity", "1, 1"), + Name = table.Column(type: "nvarchar(128)", maxLength: 128, nullable: false), + Enabled = table.Column(type: "bit", nullable: false), + SearchUrlTemplate = table.Column(type: "nvarchar(1024)", maxLength: 1024, nullable: false), + JobLinkContains = table.Column(type: "nvarchar(256)", maxLength: 256, nullable: false), + InitialKeywordsJson = table.Column(type: "nvarchar(2000)", maxLength: 2000, nullable: false, defaultValue: "[]"), + MaxResults = table.Column(type: "int", nullable: false, defaultValue: 20), + DisplayOrder = table.Column(type: "int", nullable: false, defaultValue: 0) + }, + constraints: table => + { + table.PrimaryKey("PK_JobProviders", x => x.Id); + }); + + // Seed the three default providers — all disabled so the feature is opt-in per environment. + // Enable a provider by setting its Enabled column to 1 via SQL or a future admin UI. + migrationBuilder.InsertData( + schema: MigrationConstants.SchemaName, + table: "JobProviders", + columns: ["Name", "Enabled", "SearchUrlTemplate", "JobLinkContains", "InitialKeywordsJson", "MaxResults", "DisplayOrder"], + values: new object[,] + { + { "ejobs.ro", false, "https://www.ejobs.ro/user/locuri-de-munca/?utm_source=myai&q={keywords}", "/user/locuri-de-munca/", "[]", 20, 0 }, + { "bestjobs.eu", false, "https://www.bestjobs.eu/ro/locuri-de-munca?keywords={keywords}", "/ro/locuri-de-munca/", "[]", 20, 1 }, + { "linkedin.com", false, "https://www.linkedin.com/jobs/search/?keywords={keywords}", "/jobs/view/", "[]", 20, 2 }, + }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "JobProviders", + schema: MigrationConstants.SchemaName); + } + } +} diff --git a/Apis/cv-search-data/Migrations/20260529130000_AddKeywordsToJobSearchTokens.Designer.cs b/Apis/cv-search-data/Migrations/20260529130000_AddKeywordsToJobSearchTokens.Designer.cs new file mode 100644 index 0000000..d616025 --- /dev/null +++ b/Apis/cv-search-data/Migrations/20260529130000_AddKeywordsToJobSearchTokens.Designer.cs @@ -0,0 +1,229 @@ +// +using System; +using CvSearch.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace CvSearch.Data.Migrations +{ + [DbContext(typeof(CvSearchDbContext))] + [Migration("20260529130000_AddKeywordsToJobSearchTokens")] + partial class AddKeywordsToJobSearchTokens + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("cvSearch") + .HasAnnotation("ProductVersion", "10.0.7") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("CvSearch.Data.Entities.JobProviderEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("DisplayOrder") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasDefaultValue(0); + + b.Property("Enabled") + .HasColumnType("bit"); + + b.Property("InitialKeywordsJson") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)") + .HasDefaultValue("[]"); + + b.Property("JobLinkContains") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("MaxResults") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasDefaultValue(20); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("SearchUrlTemplate") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("nvarchar(1024)"); + + b.HasKey("Id"); + + b.ToTable("JobProviders", "cvSearch"); + }); + + modelBuilder.Entity("CvSearch.Data.Entities.JobSearchResultEntity", b => + { + b.Property("Id") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("datetime2") + .HasDefaultValueSql("SYSUTCDATETIME()"); + + b.Property("JobText") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("JobTitle") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("nvarchar(512)"); + + b.Property("JobUrl") + .IsRequired() + .HasMaxLength(2048) + .HasColumnType("nvarchar(2048)"); + + b.Property("ProviderName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("ResultJson") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Score") + .HasColumnType("int"); + + b.Property("SessionId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.HasKey("Id"); + + b.HasIndex("SessionId"); + + b.ToTable("JobSearchResults", "cvSearch"); + }); + + modelBuilder.Entity("CvSearch.Data.Entities.JobSearchSessionEntity", b => + { + b.Property("Id") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("datetime2") + .HasDefaultValueSql("SYSUTCDATETIME()"); + + b.Property("CvDocumentId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("Keywords") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.Property("Language") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(8) + .HasColumnType("nvarchar(8)") + .HasDefaultValue("en"); + + b.Property("ProviderConfigJson") + .HasColumnType("nvarchar(max)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.Property("TokenId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.HasKey("Id"); + + b.HasIndex("Status"); + + b.ToTable("JobSearchSessions", "cvSearch"); + }); + + modelBuilder.Entity("CvSearch.Data.Entities.JobSearchTokenEntity", b => + { + b.Property("Id") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("datetime2") + .HasDefaultValueSql("SYSUTCDATETIME()"); + + b.Property("CvDocumentId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("ExpiresAt") + .HasColumnType("datetime2"); + + b.Property("Keywords") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)") + .HasDefaultValue(""); + + b.Property("Language") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(8) + .HasColumnType("nvarchar(8)") + .HasDefaultValue("en"); + + b.Property("Used") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(false); + + b.HasKey("Id"); + + b.ToTable("JobSearchTokens", "cvSearch"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Apis/cv-search-data/Migrations/20260529130000_AddKeywordsToJobSearchTokens.cs b/Apis/cv-search-data/Migrations/20260529130000_AddKeywordsToJobSearchTokens.cs new file mode 100644 index 0000000..f346cd2 --- /dev/null +++ b/Apis/cv-search-data/Migrations/20260529130000_AddKeywordsToJobSearchTokens.cs @@ -0,0 +1,33 @@ +using Microsoft.EntityFrameworkCore.Migrations; +using CvSearch.Data; + +#nullable disable + +namespace CvSearch.Data.Migrations +{ + /// + public partial class AddKeywordsToJobSearchTokens : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "Keywords", + schema: MigrationConstants.SchemaName, + table: "JobSearchTokens", + type: "nvarchar(1000)", + maxLength: 1000, + nullable: false, + defaultValue: ""); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "Keywords", + schema: MigrationConstants.SchemaName, + table: "JobSearchTokens"); + } + } +} diff --git a/Apis/cv-search-data/Migrations/20260529160000_FixBestJobsLinkFilter.Designer.cs b/Apis/cv-search-data/Migrations/20260529160000_FixBestJobsLinkFilter.Designer.cs new file mode 100644 index 0000000..cab7fbc --- /dev/null +++ b/Apis/cv-search-data/Migrations/20260529160000_FixBestJobsLinkFilter.Designer.cs @@ -0,0 +1,229 @@ +// +using System; +using CvSearch.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace CvSearch.Data.Migrations +{ + [DbContext(typeof(CvSearchDbContext))] + [Migration("20260529160000_FixBestJobsLinkFilter")] + partial class FixBestJobsLinkFilter + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("cvSearch") + .HasAnnotation("ProductVersion", "10.0.7") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("CvSearch.Data.Entities.JobProviderEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("DisplayOrder") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasDefaultValue(0); + + b.Property("Enabled") + .HasColumnType("bit"); + + b.Property("InitialKeywordsJson") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)") + .HasDefaultValue("[]"); + + b.Property("JobLinkContains") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("MaxResults") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasDefaultValue(20); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("SearchUrlTemplate") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("nvarchar(1024)"); + + b.HasKey("Id"); + + b.ToTable("JobProviders", "cvSearch"); + }); + + modelBuilder.Entity("CvSearch.Data.Entities.JobSearchResultEntity", b => + { + b.Property("Id") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("datetime2") + .HasDefaultValueSql("SYSUTCDATETIME()"); + + b.Property("JobText") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("JobTitle") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("nvarchar(512)"); + + b.Property("JobUrl") + .IsRequired() + .HasMaxLength(2048) + .HasColumnType("nvarchar(2048)"); + + b.Property("ProviderName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("ResultJson") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Score") + .HasColumnType("int"); + + b.Property("SessionId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.HasKey("Id"); + + b.HasIndex("SessionId"); + + b.ToTable("JobSearchResults", "cvSearch"); + }); + + modelBuilder.Entity("CvSearch.Data.Entities.JobSearchSessionEntity", b => + { + b.Property("Id") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("datetime2") + .HasDefaultValueSql("SYSUTCDATETIME()"); + + b.Property("CvDocumentId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("Keywords") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.Property("Language") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(8) + .HasColumnType("nvarchar(8)") + .HasDefaultValue("en"); + + b.Property("ProviderConfigJson") + .HasColumnType("nvarchar(max)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.Property("TokenId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.HasKey("Id"); + + b.HasIndex("Status"); + + b.ToTable("JobSearchSessions", "cvSearch"); + }); + + modelBuilder.Entity("CvSearch.Data.Entities.JobSearchTokenEntity", b => + { + b.Property("Id") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("datetime2") + .HasDefaultValueSql("SYSUTCDATETIME()"); + + b.Property("CvDocumentId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("ExpiresAt") + .HasColumnType("datetime2"); + + b.Property("Keywords") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)") + .HasDefaultValue(""); + + b.Property("Language") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(8) + .HasColumnType("nvarchar(8)") + .HasDefaultValue("en"); + + b.Property("Used") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(false); + + b.HasKey("Id"); + + b.ToTable("JobSearchTokens", "cvSearch"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Apis/cv-search-data/Migrations/20260529160000_FixBestJobsLinkFilter.cs b/Apis/cv-search-data/Migrations/20260529160000_FixBestJobsLinkFilter.cs new file mode 100644 index 0000000..646396f --- /dev/null +++ b/Apis/cv-search-data/Migrations/20260529160000_FixBestJobsLinkFilter.cs @@ -0,0 +1,37 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace CvSearch.Data.Migrations +{ + /// + public partial class FixBestJobsLinkFilter : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + // bestjobs.eu individual job listings use /loc-de-munca/{slug}. + // The original seed value /ro/locuri-de-munca/ matched only category nav links, + // so zero job URLs passed the stage-1 filter. + migrationBuilder.UpdateData( + schema: MigrationConstants.SchemaName, + table: "JobProviders", + keyColumn: "Id", + keyValue: 2, + column: "JobLinkContains", + value: "/loc-de-munca/"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.UpdateData( + schema: MigrationConstants.SchemaName, + table: "JobProviders", + keyColumn: "Id", + keyValue: 2, + column: "JobLinkContains", + value: "/ro/locuri-de-munca/"); + } + } +} diff --git a/Apis/cv-search-data/Migrations/20260529170000_AddHeadlessBrowserToProviders.Designer.cs b/Apis/cv-search-data/Migrations/20260529170000_AddHeadlessBrowserToProviders.Designer.cs new file mode 100644 index 0000000..a7b54b7 --- /dev/null +++ b/Apis/cv-search-data/Migrations/20260529170000_AddHeadlessBrowserToProviders.Designer.cs @@ -0,0 +1,234 @@ +// +using System; +using CvSearch.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace CvSearch.Data.Migrations +{ + [DbContext(typeof(CvSearchDbContext))] + [Migration("20260529170000_AddHeadlessBrowserToProviders")] + partial class AddHeadlessBrowserToProviders + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("cvSearch") + .HasAnnotation("ProductVersion", "10.0.7") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("CvSearch.Data.Entities.JobProviderEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("DisplayOrder") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasDefaultValue(0); + + b.Property("Enabled") + .HasColumnType("bit"); + + b.Property("InitialKeywordsJson") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)") + .HasDefaultValue("[]"); + + b.Property("JobLinkContains") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("MaxResults") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasDefaultValue(20); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("SearchUrlTemplate") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("nvarchar(1024)"); + + b.Property("UseHeadlessBrowser") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(false); + + b.HasKey("Id"); + + b.ToTable("JobProviders", "cvSearch"); + }); + + modelBuilder.Entity("CvSearch.Data.Entities.JobSearchResultEntity", b => + { + b.Property("Id") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("datetime2") + .HasDefaultValueSql("SYSUTCDATETIME()"); + + b.Property("JobText") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("JobTitle") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("nvarchar(512)"); + + b.Property("JobUrl") + .IsRequired() + .HasMaxLength(2048) + .HasColumnType("nvarchar(2048)"); + + b.Property("ProviderName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("ResultJson") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Score") + .HasColumnType("int"); + + b.Property("SessionId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.HasKey("Id"); + + b.HasIndex("SessionId"); + + b.ToTable("JobSearchResults", "cvSearch"); + }); + + modelBuilder.Entity("CvSearch.Data.Entities.JobSearchSessionEntity", b => + { + b.Property("Id") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("datetime2") + .HasDefaultValueSql("SYSUTCDATETIME()"); + + b.Property("CvDocumentId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("Keywords") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.Property("Language") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(8) + .HasColumnType("nvarchar(8)") + .HasDefaultValue("en"); + + b.Property("ProviderConfigJson") + .HasColumnType("nvarchar(max)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.Property("TokenId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.HasKey("Id"); + + b.HasIndex("Status"); + + b.ToTable("JobSearchSessions", "cvSearch"); + }); + + modelBuilder.Entity("CvSearch.Data.Entities.JobSearchTokenEntity", b => + { + b.Property("Id") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("datetime2") + .HasDefaultValueSql("SYSUTCDATETIME()"); + + b.Property("CvDocumentId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("ExpiresAt") + .HasColumnType("datetime2"); + + b.Property("Keywords") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)") + .HasDefaultValue(""); + + b.Property("Language") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(8) + .HasColumnType("nvarchar(8)") + .HasDefaultValue("en"); + + b.Property("Used") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(false); + + b.HasKey("Id"); + + b.ToTable("JobSearchTokens", "cvSearch"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Apis/cv-search-data/Migrations/20260529170000_AddHeadlessBrowserToProviders.cs b/Apis/cv-search-data/Migrations/20260529170000_AddHeadlessBrowserToProviders.cs new file mode 100644 index 0000000..a8a42f3 --- /dev/null +++ b/Apis/cv-search-data/Migrations/20260529170000_AddHeadlessBrowserToProviders.cs @@ -0,0 +1,50 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace CvSearch.Data.Migrations +{ + /// + public partial class AddHeadlessBrowserToProviders : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "UseHeadlessBrowser", + schema: MigrationConstants.SchemaName, + table: "JobProviders", + type: "bit", + nullable: false, + defaultValue: false); + + // ejobs.ro (Id=1) is a Nuxt SPA — the old /user/ URL 404s and plain HTTP GET + // returns only the JS bundle, not actual job listings. + // Fix: use the correct search URL and headless Chromium to render job results. + migrationBuilder.UpdateData( + schema: MigrationConstants.SchemaName, + table: "JobProviders", + keyColumn: "Id", + keyValue: 1, + columns: ["SearchUrlTemplate", "JobLinkContains", "UseHeadlessBrowser"], + values: new object[] { "https://www.ejobs.ro/locuri-de-munca?q={keywords}", "/locuri-de-munca/", true }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.UpdateData( + schema: MigrationConstants.SchemaName, + table: "JobProviders", + keyColumn: "Id", + keyValue: 1, + columns: ["SearchUrlTemplate", "JobLinkContains", "UseHeadlessBrowser"], + values: new object[] { "https://www.ejobs.ro/user/locuri-de-munca/?utm_source=myai&q={keywords}", "/user/locuri-de-munca/", false }); + + migrationBuilder.DropColumn( + name: "UseHeadlessBrowser", + schema: MigrationConstants.SchemaName, + table: "JobProviders"); + } + } +} diff --git a/Apis/cv-search-data/Migrations/20260608124304_AddRequireKeywordInAnchorAndLocation.Designer.cs b/Apis/cv-search-data/Migrations/20260608124304_AddRequireKeywordInAnchorAndLocation.Designer.cs new file mode 100644 index 0000000..c8720bc --- /dev/null +++ b/Apis/cv-search-data/Migrations/20260608124304_AddRequireKeywordInAnchorAndLocation.Designer.cs @@ -0,0 +1,243 @@ +// +using System; +using CvSearch.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace CvSearch.Data.Migrations +{ + [DbContext(typeof(CvSearchDbContext))] + [Migration("20260608124304_AddRequireKeywordInAnchorAndLocation")] + partial class AddRequireKeywordInAnchorAndLocation + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("cvSearch") + .HasAnnotation("ProductVersion", "10.0.7") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("CvSearch.Data.Entities.JobProviderEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("DisplayOrder") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasDefaultValue(0); + + b.Property("Enabled") + .HasColumnType("bit"); + + b.Property("InitialKeywordsJson") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)") + .HasDefaultValue("[]"); + + b.Property("JobLinkContains") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("MaxResults") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasDefaultValue(20); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("RequireKeywordInAnchor") + .HasColumnType("bit"); + + b.Property("SearchUrlTemplate") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("nvarchar(1024)"); + + b.Property("UseHeadlessBrowser") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(false); + + b.HasKey("Id"); + + b.ToTable("JobProviders", "cvSearch"); + }); + + modelBuilder.Entity("CvSearch.Data.Entities.JobSearchResultEntity", b => + { + b.Property("Id") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("datetime2") + .HasDefaultValueSql("SYSUTCDATETIME()"); + + b.Property("JobText") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("JobTitle") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("nvarchar(512)"); + + b.Property("JobUrl") + .IsRequired() + .HasMaxLength(2048) + .HasColumnType("nvarchar(2048)"); + + b.Property("ProviderName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("ResultJson") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Score") + .HasColumnType("int"); + + b.Property("SessionId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.HasKey("Id"); + + b.HasIndex("SessionId"); + + b.ToTable("JobSearchResults", "cvSearch"); + }); + + modelBuilder.Entity("CvSearch.Data.Entities.JobSearchSessionEntity", b => + { + b.Property("Id") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("datetime2") + .HasDefaultValueSql("SYSUTCDATETIME()"); + + b.Property("CvDocumentId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("Keywords") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.Property("Language") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(8) + .HasColumnType("nvarchar(8)") + .HasDefaultValue("en"); + + b.Property("Location") + .HasColumnType("nvarchar(max)"); + + b.Property("ProviderConfigJson") + .HasColumnType("nvarchar(max)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.Property("TokenId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.HasKey("Id"); + + b.HasIndex("Status"); + + b.ToTable("JobSearchSessions", "cvSearch"); + }); + + modelBuilder.Entity("CvSearch.Data.Entities.JobSearchTokenEntity", b => + { + b.Property("Id") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("datetime2") + .HasDefaultValueSql("SYSUTCDATETIME()"); + + b.Property("CvDocumentId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("ExpiresAt") + .HasColumnType("datetime2"); + + b.Property("Keywords") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)") + .HasDefaultValue(""); + + b.Property("Language") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(8) + .HasColumnType("nvarchar(8)") + .HasDefaultValue("en"); + + b.Property("Location") + .HasColumnType("nvarchar(max)"); + + b.Property("Used") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(false); + + b.HasKey("Id"); + + b.ToTable("JobSearchTokens", "cvSearch"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Apis/cv-search-data/Migrations/20260608124304_AddRequireKeywordInAnchorAndLocation.cs b/Apis/cv-search-data/Migrations/20260608124304_AddRequireKeywordInAnchorAndLocation.cs new file mode 100644 index 0000000..7e6a230 --- /dev/null +++ b/Apis/cv-search-data/Migrations/20260608124304_AddRequireKeywordInAnchorAndLocation.cs @@ -0,0 +1,74 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace CvSearch.Data.Migrations +{ + /// + public partial class AddRequireKeywordInAnchorAndLocation : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "Location", + schema: "cvSearch", + table: "JobSearchTokens", + type: "nvarchar(max)", + nullable: true); + + migrationBuilder.AddColumn( + name: "Location", + schema: "cvSearch", + table: "JobSearchSessions", + type: "nvarchar(max)", + nullable: true); + + migrationBuilder.AddColumn( + name: "RequireKeywordInAnchor", + schema: "cvSearch", + table: "JobProviders", + type: "bit", + nullable: false, + defaultValue: true); + + // ejobs.ro (Id=1) and bestjobs.eu (Id=2) do server-side keyword filtering via their + // search URL — the Stage 2 anchor-text filter rejects all Romanian job titles because + // they rarely contain abstract LLM keywords. + migrationBuilder.UpdateData( + schema: "cvSearch", + table: "JobProviders", + keyColumn: "Id", + keyValue: 1, + column: "RequireKeywordInAnchor", + value: false); + + migrationBuilder.UpdateData( + schema: "cvSearch", + table: "JobProviders", + keyColumn: "Id", + keyValue: 2, + column: "RequireKeywordInAnchor", + value: false); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "Location", + schema: "cvSearch", + table: "JobSearchTokens"); + + migrationBuilder.DropColumn( + name: "Location", + schema: "cvSearch", + table: "JobSearchSessions"); + + migrationBuilder.DropColumn( + name: "RequireKeywordInAnchor", + schema: "cvSearch", + table: "JobProviders"); + } + } +} diff --git a/Apis/cv-search-data/Migrations/20260608124452_AddLocationToProviders.Designer.cs b/Apis/cv-search-data/Migrations/20260608124452_AddLocationToProviders.Designer.cs new file mode 100644 index 0000000..91c742b --- /dev/null +++ b/Apis/cv-search-data/Migrations/20260608124452_AddLocationToProviders.Designer.cs @@ -0,0 +1,243 @@ +// +using System; +using CvSearch.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace CvSearch.Data.Migrations +{ + [DbContext(typeof(CvSearchDbContext))] + [Migration("20260608124452_AddLocationToProviders")] + partial class AddLocationToProviders + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("cvSearch") + .HasAnnotation("ProductVersion", "10.0.7") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("CvSearch.Data.Entities.JobProviderEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("DisplayOrder") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasDefaultValue(0); + + b.Property("Enabled") + .HasColumnType("bit"); + + b.Property("InitialKeywordsJson") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)") + .HasDefaultValue("[]"); + + b.Property("JobLinkContains") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("MaxResults") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasDefaultValue(20); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("RequireKeywordInAnchor") + .HasColumnType("bit"); + + b.Property("SearchUrlTemplate") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("nvarchar(1024)"); + + b.Property("UseHeadlessBrowser") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(false); + + b.HasKey("Id"); + + b.ToTable("JobProviders", "cvSearch"); + }); + + modelBuilder.Entity("CvSearch.Data.Entities.JobSearchResultEntity", b => + { + b.Property("Id") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("datetime2") + .HasDefaultValueSql("SYSUTCDATETIME()"); + + b.Property("JobText") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("JobTitle") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("nvarchar(512)"); + + b.Property("JobUrl") + .IsRequired() + .HasMaxLength(2048) + .HasColumnType("nvarchar(2048)"); + + b.Property("ProviderName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("ResultJson") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Score") + .HasColumnType("int"); + + b.Property("SessionId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.HasKey("Id"); + + b.HasIndex("SessionId"); + + b.ToTable("JobSearchResults", "cvSearch"); + }); + + modelBuilder.Entity("CvSearch.Data.Entities.JobSearchSessionEntity", b => + { + b.Property("Id") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("datetime2") + .HasDefaultValueSql("SYSUTCDATETIME()"); + + b.Property("CvDocumentId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("Keywords") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.Property("Language") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(8) + .HasColumnType("nvarchar(8)") + .HasDefaultValue("en"); + + b.Property("Location") + .HasColumnType("nvarchar(max)"); + + b.Property("ProviderConfigJson") + .HasColumnType("nvarchar(max)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.Property("TokenId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.HasKey("Id"); + + b.HasIndex("Status"); + + b.ToTable("JobSearchSessions", "cvSearch"); + }); + + modelBuilder.Entity("CvSearch.Data.Entities.JobSearchTokenEntity", b => + { + b.Property("Id") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("datetime2") + .HasDefaultValueSql("SYSUTCDATETIME()"); + + b.Property("CvDocumentId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("ExpiresAt") + .HasColumnType("datetime2"); + + b.Property("Keywords") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)") + .HasDefaultValue(""); + + b.Property("Language") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(8) + .HasColumnType("nvarchar(8)") + .HasDefaultValue("en"); + + b.Property("Location") + .HasColumnType("nvarchar(max)"); + + b.Property("Used") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(false); + + b.HasKey("Id"); + + b.ToTable("JobSearchTokens", "cvSearch"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Apis/cv-search-data/Migrations/20260608124452_AddLocationToProviders.cs b/Apis/cv-search-data/Migrations/20260608124452_AddLocationToProviders.cs new file mode 100644 index 0000000..01cca6d --- /dev/null +++ b/Apis/cv-search-data/Migrations/20260608124452_AddLocationToProviders.cs @@ -0,0 +1,71 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace CvSearch.Data.Migrations +{ + /// + public partial class AddLocationToProviders : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + // ejobs.ro (Id=1): location in URL path as slug, keywords via q= param. + // Verified URL structure: /locuri-de-munca/{location-slug}?q={keywords} + migrationBuilder.UpdateData( + schema: MigrationConstants.SchemaName, + table: "JobProviders", + keyColumn: "Id", + keyValue: 1, + column: "SearchUrlTemplate", + value: "https://www.ejobs.ro/locuri-de-munca/{location-slug}?q={keywords}"); + + // bestjobs.eu (Id=2): location in URL path as slug, keywords via query param. + // Verified URL structure: /ro/locuri-de-munca-in-{location-slug}?keywords={keywords} + migrationBuilder.UpdateData( + schema: MigrationConstants.SchemaName, + table: "JobProviders", + keyColumn: "Id", + keyValue: 2, + column: "SearchUrlTemplate", + value: "https://bestjobs.eu/ro/locuri-de-munca-in-{location-slug}?keywords={keywords}"); + + // linkedin.com (Id=3): location as query parameter. + migrationBuilder.UpdateData( + schema: MigrationConstants.SchemaName, + table: "JobProviders", + keyColumn: "Id", + keyValue: 3, + column: "SearchUrlTemplate", + value: "https://www.linkedin.com/jobs/search/?keywords={keywords}&location={location}"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.UpdateData( + schema: MigrationConstants.SchemaName, + table: "JobProviders", + keyColumn: "Id", + keyValue: 1, + column: "SearchUrlTemplate", + value: "https://www.ejobs.ro/locuri-de-munca?q={keywords}"); + + migrationBuilder.UpdateData( + schema: MigrationConstants.SchemaName, + table: "JobProviders", + keyColumn: "Id", + keyValue: 2, + column: "SearchUrlTemplate", + value: "https://www.bestjobs.eu/ro/locuri-de-munca?keywords={keywords}"); + + migrationBuilder.UpdateData( + schema: MigrationConstants.SchemaName, + table: "JobProviders", + keyColumn: "Id", + keyValue: 3, + column: "SearchUrlTemplate", + value: "https://www.linkedin.com/jobs/search/?keywords={keywords}"); + } + } +} diff --git a/Apis/cv-search-data/Migrations/20260608154221_RemoveUseHeadlessBrowser.Designer.cs b/Apis/cv-search-data/Migrations/20260608154221_RemoveUseHeadlessBrowser.Designer.cs new file mode 100644 index 0000000..12e4cf4 --- /dev/null +++ b/Apis/cv-search-data/Migrations/20260608154221_RemoveUseHeadlessBrowser.Designer.cs @@ -0,0 +1,238 @@ +// +using System; +using CvSearch.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace CvSearch.Data.Migrations +{ + [DbContext(typeof(CvSearchDbContext))] + [Migration("20260608154221_RemoveUseHeadlessBrowser")] + partial class RemoveUseHeadlessBrowser + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("cvSearch") + .HasAnnotation("ProductVersion", "10.0.7") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("CvSearch.Data.Entities.JobProviderEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("DisplayOrder") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasDefaultValue(0); + + b.Property("Enabled") + .HasColumnType("bit"); + + b.Property("InitialKeywordsJson") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)") + .HasDefaultValue("[]"); + + b.Property("JobLinkContains") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("MaxResults") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasDefaultValue(20); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("RequireKeywordInAnchor") + .HasColumnType("bit"); + + b.Property("SearchUrlTemplate") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("nvarchar(1024)"); + + b.HasKey("Id"); + + b.ToTable("JobProviders", "cvSearch"); + }); + + modelBuilder.Entity("CvSearch.Data.Entities.JobSearchResultEntity", b => + { + b.Property("Id") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("datetime2") + .HasDefaultValueSql("SYSUTCDATETIME()"); + + b.Property("JobText") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("JobTitle") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("nvarchar(512)"); + + b.Property("JobUrl") + .IsRequired() + .HasMaxLength(2048) + .HasColumnType("nvarchar(2048)"); + + b.Property("ProviderName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("ResultJson") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Score") + .HasColumnType("int"); + + b.Property("SessionId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.HasKey("Id"); + + b.HasIndex("SessionId"); + + b.ToTable("JobSearchResults", "cvSearch"); + }); + + modelBuilder.Entity("CvSearch.Data.Entities.JobSearchSessionEntity", b => + { + b.Property("Id") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("datetime2") + .HasDefaultValueSql("SYSUTCDATETIME()"); + + b.Property("CvDocumentId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("Keywords") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.Property("Language") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(8) + .HasColumnType("nvarchar(8)") + .HasDefaultValue("en"); + + b.Property("Location") + .HasColumnType("nvarchar(max)"); + + b.Property("ProviderConfigJson") + .HasColumnType("nvarchar(max)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.Property("TokenId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.HasKey("Id"); + + b.HasIndex("Status"); + + b.ToTable("JobSearchSessions", "cvSearch"); + }); + + modelBuilder.Entity("CvSearch.Data.Entities.JobSearchTokenEntity", b => + { + b.Property("Id") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("datetime2") + .HasDefaultValueSql("SYSUTCDATETIME()"); + + b.Property("CvDocumentId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("ExpiresAt") + .HasColumnType("datetime2"); + + b.Property("Keywords") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)") + .HasDefaultValue(""); + + b.Property("Language") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(8) + .HasColumnType("nvarchar(8)") + .HasDefaultValue("en"); + + b.Property("Location") + .HasColumnType("nvarchar(max)"); + + b.Property("Used") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(false); + + b.HasKey("Id"); + + b.ToTable("JobSearchTokens", "cvSearch"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Apis/cv-search-data/Migrations/20260608154221_RemoveUseHeadlessBrowser.cs b/Apis/cv-search-data/Migrations/20260608154221_RemoveUseHeadlessBrowser.cs new file mode 100644 index 0000000..0fb30bf --- /dev/null +++ b/Apis/cv-search-data/Migrations/20260608154221_RemoveUseHeadlessBrowser.cs @@ -0,0 +1,32 @@ +using CvSearch.Data; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace CvSearch.Data.Migrations +{ + /// + public partial class RemoveUseHeadlessBrowser : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "UseHeadlessBrowser", + schema: MigrationConstants.SchemaName, + table: "JobProviders"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "UseHeadlessBrowser", + schema: MigrationConstants.SchemaName, + table: "JobProviders", + type: "bit", + nullable: false, + defaultValue: false); + } + } +} diff --git a/Apis/cv-search-data/Migrations/20260608161102_AddEmailIpToSessionAndResults.Designer.cs b/Apis/cv-search-data/Migrations/20260608161102_AddEmailIpToSessionAndResults.Designer.cs new file mode 100644 index 0000000..b3c4c8e --- /dev/null +++ b/Apis/cv-search-data/Migrations/20260608161102_AddEmailIpToSessionAndResults.Designer.cs @@ -0,0 +1,250 @@ +// +using System; +using CvSearch.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace CvSearch.Data.Migrations +{ + [DbContext(typeof(CvSearchDbContext))] + [Migration("20260608161102_AddEmailIpToSessionAndResults")] + partial class AddEmailIpToSessionAndResults + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("cvSearch") + .HasAnnotation("ProductVersion", "10.0.7") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("CvSearch.Data.Entities.JobProviderEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("DisplayOrder") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasDefaultValue(0); + + b.Property("Enabled") + .HasColumnType("bit"); + + b.Property("InitialKeywordsJson") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)") + .HasDefaultValue("[]"); + + b.Property("JobLinkContains") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("MaxResults") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasDefaultValue(20); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("RequireKeywordInAnchor") + .HasColumnType("bit"); + + b.Property("SearchUrlTemplate") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("nvarchar(1024)"); + + b.HasKey("Id"); + + b.ToTable("JobProviders", "cvSearch"); + }); + + modelBuilder.Entity("CvSearch.Data.Entities.JobSearchResultEntity", b => + { + b.Property("Id") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("ClientIpAddress") + .HasMaxLength(45) + .HasColumnType("nvarchar(45)"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("datetime2") + .HasDefaultValueSql("SYSUTCDATETIME()"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("JobText") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("JobTitle") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("nvarchar(512)"); + + b.Property("JobUrl") + .IsRequired() + .HasMaxLength(2048) + .HasColumnType("nvarchar(2048)"); + + b.Property("ProviderName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("ResultJson") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Score") + .HasColumnType("int"); + + b.Property("SessionId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.HasKey("Id"); + + b.HasIndex("SessionId"); + + b.ToTable("JobSearchResults", "cvSearch"); + }); + + modelBuilder.Entity("CvSearch.Data.Entities.JobSearchSessionEntity", b => + { + b.Property("Id") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("ClientIpAddress") + .HasMaxLength(45) + .HasColumnType("nvarchar(45)"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("datetime2") + .HasDefaultValueSql("SYSUTCDATETIME()"); + + b.Property("CvDocumentId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("Keywords") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.Property("Language") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(8) + .HasColumnType("nvarchar(8)") + .HasDefaultValue("en"); + + b.Property("Location") + .HasColumnType("nvarchar(max)"); + + b.Property("ProviderConfigJson") + .HasColumnType("nvarchar(max)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.Property("TokenId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.HasKey("Id"); + + b.HasIndex("Status"); + + b.ToTable("JobSearchSessions", "cvSearch"); + }); + + modelBuilder.Entity("CvSearch.Data.Entities.JobSearchTokenEntity", b => + { + b.Property("Id") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("datetime2") + .HasDefaultValueSql("SYSUTCDATETIME()"); + + b.Property("CvDocumentId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("ExpiresAt") + .HasColumnType("datetime2"); + + b.Property("Keywords") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)") + .HasDefaultValue(""); + + b.Property("Language") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(8) + .HasColumnType("nvarchar(8)") + .HasDefaultValue("en"); + + b.Property("Location") + .HasColumnType("nvarchar(max)"); + + b.Property("Used") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(false); + + b.HasKey("Id"); + + b.ToTable("JobSearchTokens", "cvSearch"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Apis/cv-search-data/Migrations/20260608161102_AddEmailIpToSessionAndResults.cs b/Apis/cv-search-data/Migrations/20260608161102_AddEmailIpToSessionAndResults.cs new file mode 100644 index 0000000..add51d5 --- /dev/null +++ b/Apis/cv-search-data/Migrations/20260608161102_AddEmailIpToSessionAndResults.cs @@ -0,0 +1,58 @@ +using CvSearch.Data; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace CvSearch.Data.Migrations +{ + /// + public partial class AddEmailIpToSessionAndResults : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "ClientIpAddress", + schema: MigrationConstants.SchemaName, + table: "JobSearchSessions", + type: "nvarchar(45)", + maxLength: 45, + nullable: true); + + migrationBuilder.AddColumn( + name: "ClientIpAddress", + schema: MigrationConstants.SchemaName, + table: "JobSearchResults", + type: "nvarchar(45)", + maxLength: 45, + nullable: true); + + migrationBuilder.AddColumn( + name: "Email", + schema: MigrationConstants.SchemaName, + table: "JobSearchResults", + type: "nvarchar(256)", + maxLength: 256, + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "ClientIpAddress", + schema: MigrationConstants.SchemaName, + table: "JobSearchSessions"); + + migrationBuilder.DropColumn( + name: "ClientIpAddress", + schema: MigrationConstants.SchemaName, + table: "JobSearchResults"); + + migrationBuilder.DropColumn( + name: "Email", + schema: MigrationConstants.SchemaName, + table: "JobSearchResults"); + } + } +} diff --git a/Apis/cv-search-data/Migrations/20260608161930_AddClientIpToJobSearchTokens.Designer.cs b/Apis/cv-search-data/Migrations/20260608161930_AddClientIpToJobSearchTokens.Designer.cs new file mode 100644 index 0000000..1290c19 --- /dev/null +++ b/Apis/cv-search-data/Migrations/20260608161930_AddClientIpToJobSearchTokens.Designer.cs @@ -0,0 +1,254 @@ +// +using System; +using CvSearch.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace CvSearch.Data.Migrations +{ + [DbContext(typeof(CvSearchDbContext))] + [Migration("20260608161930_AddClientIpToJobSearchTokens")] + partial class AddClientIpToJobSearchTokens + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("cvSearch") + .HasAnnotation("ProductVersion", "10.0.7") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("CvSearch.Data.Entities.JobProviderEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("DisplayOrder") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasDefaultValue(0); + + b.Property("Enabled") + .HasColumnType("bit"); + + b.Property("InitialKeywordsJson") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)") + .HasDefaultValue("[]"); + + b.Property("JobLinkContains") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("MaxResults") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasDefaultValue(20); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("RequireKeywordInAnchor") + .HasColumnType("bit"); + + b.Property("SearchUrlTemplate") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("nvarchar(1024)"); + + b.HasKey("Id"); + + b.ToTable("JobProviders", "cvSearch"); + }); + + modelBuilder.Entity("CvSearch.Data.Entities.JobSearchResultEntity", b => + { + b.Property("Id") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("ClientIpAddress") + .HasMaxLength(45) + .HasColumnType("nvarchar(45)"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("datetime2") + .HasDefaultValueSql("SYSUTCDATETIME()"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("JobText") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("JobTitle") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("nvarchar(512)"); + + b.Property("JobUrl") + .IsRequired() + .HasMaxLength(2048) + .HasColumnType("nvarchar(2048)"); + + b.Property("ProviderName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("ResultJson") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Score") + .HasColumnType("int"); + + b.Property("SessionId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.HasKey("Id"); + + b.HasIndex("SessionId"); + + b.ToTable("JobSearchResults", "cvSearch"); + }); + + modelBuilder.Entity("CvSearch.Data.Entities.JobSearchSessionEntity", b => + { + b.Property("Id") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("ClientIpAddress") + .HasMaxLength(45) + .HasColumnType("nvarchar(45)"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("datetime2") + .HasDefaultValueSql("SYSUTCDATETIME()"); + + b.Property("CvDocumentId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("Keywords") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.Property("Language") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(8) + .HasColumnType("nvarchar(8)") + .HasDefaultValue("en"); + + b.Property("Location") + .HasColumnType("nvarchar(max)"); + + b.Property("ProviderConfigJson") + .HasColumnType("nvarchar(max)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.Property("TokenId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.HasKey("Id"); + + b.HasIndex("Status"); + + b.ToTable("JobSearchSessions", "cvSearch"); + }); + + modelBuilder.Entity("CvSearch.Data.Entities.JobSearchTokenEntity", b => + { + b.Property("Id") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("ClientIpAddress") + .HasMaxLength(45) + .HasColumnType("nvarchar(45)"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("datetime2") + .HasDefaultValueSql("SYSUTCDATETIME()"); + + b.Property("CvDocumentId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("ExpiresAt") + .HasColumnType("datetime2"); + + b.Property("Keywords") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)") + .HasDefaultValue(""); + + b.Property("Language") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(8) + .HasColumnType("nvarchar(8)") + .HasDefaultValue("en"); + + b.Property("Location") + .HasColumnType("nvarchar(max)"); + + b.Property("Used") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(false); + + b.HasKey("Id"); + + b.ToTable("JobSearchTokens", "cvSearch"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Apis/cv-search-data/Migrations/20260608161930_AddClientIpToJobSearchTokens.cs b/Apis/cv-search-data/Migrations/20260608161930_AddClientIpToJobSearchTokens.cs new file mode 100644 index 0000000..dbaed5c --- /dev/null +++ b/Apis/cv-search-data/Migrations/20260608161930_AddClientIpToJobSearchTokens.cs @@ -0,0 +1,32 @@ +using CvSearch.Data; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace CvSearch.Data.Migrations +{ + /// + public partial class AddClientIpToJobSearchTokens : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "ClientIpAddress", + schema: MigrationConstants.SchemaName, + table: "JobSearchTokens", + type: "nvarchar(45)", + maxLength: 45, + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "ClientIpAddress", + schema: MigrationConstants.SchemaName, + table: "JobSearchTokens"); + } + } +} diff --git a/Apis/cv-search-data/Migrations/CvSearchDbContextModelSnapshot.cs b/Apis/cv-search-data/Migrations/CvSearchDbContextModelSnapshot.cs new file mode 100644 index 0000000..baae9f9 --- /dev/null +++ b/Apis/cv-search-data/Migrations/CvSearchDbContextModelSnapshot.cs @@ -0,0 +1,251 @@ +// +using System; +using CvSearch.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace CvSearch.Data.Migrations +{ + [DbContext(typeof(CvSearchDbContext))] + partial class CvSearchDbContextModelSnapshot : ModelSnapshot + { + protected override void BuildModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("cvSearch") + .HasAnnotation("ProductVersion", "10.0.7") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("CvSearch.Data.Entities.JobProviderEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("DisplayOrder") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasDefaultValue(0); + + b.Property("Enabled") + .HasColumnType("bit"); + + b.Property("InitialKeywordsJson") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)") + .HasDefaultValue("[]"); + + b.Property("JobLinkContains") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("MaxResults") + .ValueGeneratedOnAdd() + .HasColumnType("int") + .HasDefaultValue(20); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("RequireKeywordInAnchor") + .HasColumnType("bit"); + + b.Property("SearchUrlTemplate") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("nvarchar(1024)"); + + b.HasKey("Id"); + + b.ToTable("JobProviders", "cvSearch"); + }); + + modelBuilder.Entity("CvSearch.Data.Entities.JobSearchResultEntity", b => + { + b.Property("Id") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("ClientIpAddress") + .HasMaxLength(45) + .HasColumnType("nvarchar(45)"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("datetime2") + .HasDefaultValueSql("SYSUTCDATETIME()"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("JobText") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("JobTitle") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("nvarchar(512)"); + + b.Property("JobUrl") + .IsRequired() + .HasMaxLength(2048) + .HasColumnType("nvarchar(2048)"); + + b.Property("ProviderName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("ResultJson") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Score") + .HasColumnType("int"); + + b.Property("SessionId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.HasKey("Id"); + + b.HasIndex("SessionId"); + + b.ToTable("JobSearchResults", "cvSearch"); + }); + + modelBuilder.Entity("CvSearch.Data.Entities.JobSearchSessionEntity", b => + { + b.Property("Id") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("ClientIpAddress") + .HasMaxLength(45) + .HasColumnType("nvarchar(45)"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("datetime2") + .HasDefaultValueSql("SYSUTCDATETIME()"); + + b.Property("CvDocumentId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("Keywords") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)"); + + b.Property("Language") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(8) + .HasColumnType("nvarchar(8)") + .HasDefaultValue("en"); + + b.Property("Location") + .HasColumnType("nvarchar(max)"); + + b.Property("ProviderConfigJson") + .HasColumnType("nvarchar(max)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("nvarchar(32)"); + + b.Property("TokenId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.HasKey("Id"); + + b.HasIndex("Status"); + + b.ToTable("JobSearchSessions", "cvSearch"); + }); + + modelBuilder.Entity("CvSearch.Data.Entities.JobSearchTokenEntity", b => + { + b.Property("Id") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("ClientIpAddress") + .HasMaxLength(45) + .HasColumnType("nvarchar(45)"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("datetime2") + .HasDefaultValueSql("SYSUTCDATETIME()"); + + b.Property("CvDocumentId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("ExpiresAt") + .HasColumnType("datetime2"); + + b.Property("Keywords") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(1000) + .HasColumnType("nvarchar(1000)") + .HasDefaultValue(""); + + b.Property("Language") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(8) + .HasColumnType("nvarchar(8)") + .HasDefaultValue("en"); + + b.Property("Location") + .HasColumnType("nvarchar(max)"); + + b.Property("Used") + .ValueGeneratedOnAdd() + .HasColumnType("bit") + .HasDefaultValue(false); + + b.HasKey("Id"); + + b.ToTable("JobSearchTokens", "cvSearch"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Apis/cv-search-data/cv-search-data.csproj b/Apis/cv-search-data/cv-search-data.csproj new file mode 100644 index 0000000..7209708 --- /dev/null +++ b/Apis/cv-search-data/cv-search-data.csproj @@ -0,0 +1,23 @@ + + + + net10.0 + cv-search-data + CvSearch.Data + enable + enable + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + diff --git a/Apis/email-api-models/Clients/IEmailApiClient.cs b/Apis/email-api-models/Clients/IEmailApiClient.cs new file mode 100644 index 0000000..c3a8ca2 --- /dev/null +++ b/Apis/email-api-models/Clients/IEmailApiClient.cs @@ -0,0 +1,10 @@ +using Email.Models.Requests; +using Refit; + +namespace Email.Models.Clients; + +public interface IEmailApiClient +{ + [Post("/api/email/send")] + Task SendAsync(SendEmailRequest request, CancellationToken ct = default); +} diff --git a/Apis/email-api-models/Requests/SendEmailRequest.cs b/Apis/email-api-models/Requests/SendEmailRequest.cs new file mode 100644 index 0000000..b4930c5 --- /dev/null +++ b/Apis/email-api-models/Requests/SendEmailRequest.cs @@ -0,0 +1,10 @@ +namespace Email.Models.Requests; + +public sealed class SendEmailRequest +{ + public required List To { get; init; } + public string? ReplyTo { get; init; } + public required string Subject { get; init; } + public required string HtmlBody { get; init; } + public string? AttachmentPath { get; init; } +} diff --git a/Apis/email-api-models/Settings/EmailApiSettings.cs b/Apis/email-api-models/Settings/EmailApiSettings.cs new file mode 100644 index 0000000..d7f70dc --- /dev/null +++ b/Apis/email-api-models/Settings/EmailApiSettings.cs @@ -0,0 +1,7 @@ +namespace Email.Models.Settings; + +public sealed class EmailApiSettings +{ + public string BaseUrl { get; set; } = ""; + public string InternalApiKey { get; set; } = ""; +} diff --git a/Apis/email-api-models/Settings/SmtpSettings.cs b/Apis/email-api-models/Settings/SmtpSettings.cs new file mode 100644 index 0000000..74d42eb --- /dev/null +++ b/Apis/email-api-models/Settings/SmtpSettings.cs @@ -0,0 +1,10 @@ +namespace Email.Models.Settings; + +public sealed class SmtpSettings +{ + public string Host { get; set; } = ""; + public int Port { get; set; } = 587; + public string Username { get; set; } = ""; + public string Password { get; set; } = ""; + public bool UseStartTls { get; set; } = true; +} diff --git a/Apis/email-api-models/email-api-models.csproj b/Apis/email-api-models/email-api-models.csproj new file mode 100644 index 0000000..45aaa9d --- /dev/null +++ b/Apis/email-api-models/email-api-models.csproj @@ -0,0 +1,12 @@ + + + net10.0 + enable + enable + email-api-models + EmailApi.Models + + + + + diff --git a/Apis/email-api/CLAUDE.md b/Apis/email-api/CLAUDE.md new file mode 100644 index 0000000..0ea58d0 --- /dev/null +++ b/Apis/email-api/CLAUDE.md @@ -0,0 +1,40 @@ +# email-api — Internal Email Sending Service + +Internal only. Reachable at `http://email-api:8080` within `myai-network`. Not exposed to the internet. + +## Responsibilities + +- Accepts `POST /api/email/send` requests from internal services (`api`, `cv-search-job`) +- Wraps the provided HTML body fragment in a branded HTML shell (blue header, white card, grey footer) +- Sends the email via SMTP using MailKit +- Attaches files from the shared `Files` volume when `AttachmentPath` is provided +- Protected by `X-Internal-Api-Key` via `UseInternalApiKeyProtection()` + +## Key route + +| Method | Route | Description | +|--------|-------|-------------| +| POST | `/api/email/send` | Send an HTML email. Returns 204 No Content. | + +## Request body (`SendEmailRequest`) + +| Field | Required | Notes | +|-------|----------|-------| +| `To` | ✅ | List of recipient addresses | +| `ReplyTo` | ❌ | Optional reply-to address | +| `Subject` | ✅ | Plain text (service prepends `[ENV_NAME]`) | +| `HtmlBody` | ✅ | HTML fragment — wrapped in branded shell by this service | +| `AttachmentPath` | ❌ | Path relative to `FileStorage:Path`, e.g. `"{cvDocumentId}.pdf"` | + +## Consumers + +- `api` — via `IEmailApiClient` Refit interface (contact, subscribe, file-download, match emails) +- `cv-search-job` — via `IEmailApiClient` Refit interface (job search results email) + +## Settings + +| Section | Env var | Notes | +|---------|---------|-------| +| `Smtp` | `Smtp__Host`, `Smtp__Username`, `Smtp__Password`, etc. | SMTP server config — only configured here, not in consumers | +| `FileStorage` | `FileStorage__Path` | Must match the shared `Files` volume mount path | +| `InternalApi` | `EmailApi__InternalApiKey` | API key enforced on every request | diff --git a/Apis/email-api/Controllers/EmailController.cs b/Apis/email-api/Controllers/EmailController.cs new file mode 100644 index 0000000..539fd20 --- /dev/null +++ b/Apis/email-api/Controllers/EmailController.cs @@ -0,0 +1,47 @@ +using Api.Services; +using Email.Models.Requests; +using Microsoft.AspNetCore.Mvc; +using Swashbuckle.AspNetCore.Annotations; + +namespace Api.Controllers; + +/// +/// Internal email relay. Accepts an HTML body fragment from trusted callers +/// (api, cv-search-job), wraps it in the branded HTML shell, and dispatches +/// via SMTP. Protected by X-Internal-Api-Key. +/// +[ApiController] +[Route("api/email")] +public sealed class EmailController : ControllerBase +{ + private readonly SmtpEmailDispatcher _dispatcher; + + public EmailController(SmtpEmailDispatcher dispatcher) => _dispatcher = dispatcher; + + /// + /// Sends an HTML email via SMTP. The supplied body fragment is wrapped in + /// the branded HTML shell before dispatch. Attachments are resolved from + /// the shared file storage volume using the relative path in + /// . + /// + /// Email payload: recipients, subject, HTML body fragment, optional attachment path. + /// Cancellation token. + /// 204 No Content on success. + [HttpPost("send")] + [SwaggerOperation( + Summary = "Send an HTML email via SMTP", + Description = "Wraps the provided HTML body in the branded shell and sends via SMTP. " + + "If AttachmentPath is set, resolves the file from the shared file-storage volume. " + + "Returns 204 on success; 400 when the request body is invalid; 500 on SMTP failure.")] + [SwaggerResponse(StatusCodes.Status204NoContent, "Email dispatched successfully")] + [SwaggerResponse(StatusCodes.Status400BadRequest, "Request body is missing or invalid")] + [SwaggerResponse(StatusCodes.Status500InternalServerError, "SMTP dispatch failed")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status500InternalServerError)] + public async Task Send([FromBody] SendEmailRequest request, CancellationToken ct) + { + await _dispatcher.SendAsync(request, ct); + return NoContent(); + } +} diff --git a/Apis/email-api/Dockerfile b/Apis/email-api/Dockerfile new file mode 100644 index 0000000..6218374 --- /dev/null +++ b/Apis/email-api/Dockerfile @@ -0,0 +1,35 @@ +FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build +ARG BUILD_CONFIGURATION=Release +WORKDIR /src + +COPY Apis/email-api/email-api.csproj Apis/email-api/ +COPY Apis/email-data/email-data.csproj Apis/email-data/ +COPY Apis/shared-data/shared-data.csproj Apis/shared-data/ +COPY Apis/email-api-models/email-api-models.csproj Apis/email-api-models/ +COPY Apis/api-models/api-models.csproj Apis/api-models/ +COPY Apis/common/common.csproj Apis/common/ +COPY Helpers/common-helpers/common-helpers.csproj Helpers/common-helpers/ +COPY Helpers/startup-helpers/startup-helpers.csproj Helpers/startup-helpers/ +COPY Directory.Packages.props ./ + +RUN dotnet restore Apis/email-api/email-api.csproj + +COPY Apis/email-api/ Apis/email-api/ +COPY Apis/email-data/ Apis/email-data/ +COPY Apis/shared-data/ Apis/shared-data/ +COPY Apis/email-api-models/ Apis/email-api-models/ +COPY Apis/api-models/ Apis/api-models/ +COPY Apis/common/ Apis/common/ +COPY Helpers/common-helpers/ Helpers/common-helpers/ +COPY Helpers/startup-helpers/ Helpers/startup-helpers/ + +RUN dotnet publish Apis/email-api/email-api.csproj -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=false + +FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS final +WORKDIR /app +EXPOSE 8080 +ENV ASPNETCORE_URLS=http://0.0.0.0:8080 + +COPY --from=build /app/publish . + +ENTRYPOINT ["dotnet", "email-api.dll"] diff --git a/Apis/email-api/Program.cs b/Apis/email-api/Program.cs new file mode 100644 index 0000000..902fd7e --- /dev/null +++ b/Apis/email-api/Program.cs @@ -0,0 +1,79 @@ +using System.Reflection; +using Email.Data; +using Email.Data.Repositories; +using Email.Data.Repositories.Contracts; +using Email.Data.Services; +using Api.Services; +using Microsoft.EntityFrameworkCore; +using Email.Models.Settings; +using Models.Settings; +using Serilog; +using StartupHelpers; + +StartupExtensions.LoadDotEnvFile(); + +const string ServiceName = "email-api"; +var appVersion = StartupExtensions.GetApplicationVersion(Assembly.GetExecutingAssembly()); + +try +{ + var builder = WebApplication.CreateBuilder(args); + + builder.ConfigureJsonSerilog(ServiceName, appVersion); + Log.Information("Starting {Service} version {AppVersion}", ServiceName, appVersion); + + builder.AddAzureKeyVaultIfConfigured(); + + builder.Services.AddControllers(); + builder.Services.AddSwaggerWithXmlComments(Assembly.GetExecutingAssembly(), "Email API"); + + builder.Services.Configure(builder.Configuration.GetSection("Smtp")); + builder.Services.Configure(builder.Configuration.GetSection("FileStorage")); + + builder.Services.AddDbContext(options => + { + var connectionString = builder.Services.GetConfiguredDbConnectionString(builder.Configuration); + options.UseSqlServer(connectionString, sql => + { + sql.MigrationsHistoryTable(EmailDbContext.MigrationTableName, EmailDbContext.SchemaName); + sql.MigrationsAssembly("email-data"); + }); + }); + + builder.Services.AddScoped(); + builder.Services.AddSingleton(); + + builder.Services.AddScoped(); + + var app = builder.Build(); + + app.LogStartupDiagnostics(ServiceName); + + app.UseDefaultSerilogRequestLogging(); + app.UseJsonExceptionHandler(ServiceName); + app.UseInternalApiKeyProtection(); + app.UseSwaggerInDevelopment("Email API", "EmailAPI"); + + app.UseRouting(); + app.UseAuthorization(); + app.MapControllers(); + + Log.Information("Running EF Core migrations if any"); + using (var scope = app.Services.CreateScope()) + { + var db = scope.ServiceProvider.GetRequiredService(); + db.Database.Migrate(); + } + + Log.Information("{Service} startup complete. Listening for requests...", ServiceName); + app.Run(); +} +catch (Exception ex) +{ + Log.Fatal(ex, "{Service} terminated unexpectedly", ServiceName); +} +finally +{ + Log.Information("Shutting down {Service}", ServiceName); + Log.CloseAndFlush(); +} diff --git a/Apis/email-api/Properties/launchSettings.json b/Apis/email-api/Properties/launchSettings.json new file mode 100644 index 0000000..48b6a0b --- /dev/null +++ b/Apis/email-api/Properties/launchSettings.json @@ -0,0 +1,12 @@ +{ + "profiles": { + "email-api": { + "commandName": "Project", + "launchBrowser": true, + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + }, + "applicationUrl": "https://localhost:61871;http://localhost:61872" + } + } +} \ No newline at end of file diff --git a/Apis/email-api/Services/SmtpEmailDispatcher.cs b/Apis/email-api/Services/SmtpEmailDispatcher.cs new file mode 100644 index 0000000..d5fdc38 --- /dev/null +++ b/Apis/email-api/Services/SmtpEmailDispatcher.cs @@ -0,0 +1,102 @@ +using Email.Data.Services; +using Email.Models.Requests; +using MailKit.Net.Smtp; +using MailKit.Security; +using Microsoft.Extensions.Options; +using MimeKit; +using Email.Models.Settings; +using Models.Settings; + +namespace Api.Services; + +/// +/// Wraps an HTML body fragment in the branded HTML shell and sends the resulting email via SMTP using MailKit. +/// Attaches files from the shared file-storage volume when an attachment path is provided. +/// +public sealed class SmtpEmailDispatcher +{ + private readonly SmtpSettings _smtp; + private readonly FileStorageSettings _fileStorage; + private readonly IEmailTemplateService _templates; + private readonly ILogger _log; + private readonly string _environmentName; + + public SmtpEmailDispatcher( + IOptions smtp, + IOptions fileStorage, + IEmailTemplateService templates, + ILogger log) + { + _smtp = smtp.Value; + _fileStorage = fileStorage.Value; + _templates = templates; + _log = log; + _environmentName = Environment.GetEnvironmentVariable("APP_ENVIRONMENT_NAME") ?? "Development"; + } + + /// + /// Builds a from , wraps the body in the HTML shell, + /// optionally attaches a file, and sends via the configured SMTP server. + /// Logs a warning and returns without throwing when the SMTP host is not configured. + /// + /// Email payload containing recipients, subject, HTML body, and optional attachment path. + /// Cancellation token. + public async Task SendAsync(SendEmailRequest req, CancellationToken ct) + { + if (string.IsNullOrWhiteSpace(_smtp.Host)) + { + _log.LogWarning("SMTP host not configured — email skipped (to: {To})", string.Join(", ", req.To)); + return; + } + + var msg = new MimeMessage(); + msg.From.Add(MailboxAddress.Parse(_smtp.Username)); + + foreach (var to in req.To) + msg.To.Add(MailboxAddress.Parse(to)); + + if (!string.IsNullOrWhiteSpace(req.ReplyTo)) + msg.ReplyTo.Add(MailboxAddress.Parse(req.ReplyTo)); + + msg.Subject = $"[{_environmentName}] {req.Subject}".Trim(); + + var shellStart = _templates.Get("email.html-shell.start", "*"); + var shellEnd = _templates.Get("email.html-shell.end", "*"); + + var builder = new BodyBuilder + { + HtmlBody = shellStart + req.HtmlBody + shellEnd + }; + + if (!string.IsNullOrWhiteSpace(req.AttachmentPath)) + { + var fullPath = Path.Combine(_fileStorage.Path, req.AttachmentPath); + if (File.Exists(fullPath)) + { + builder.Attachments.Add(fullPath); + _log.LogDebug("Attachment added: {Path}", fullPath); + } + else + { + _log.LogWarning("Attachment not found, skipping: {Path}", fullPath); + } + } + + msg.Body = builder.ToMessageBody(); + + _log.LogInformation("Sending email to {Recipients} subject {Subject}", + string.Join(", ", req.To), req.Subject); + + using var client = new SmtpClient(); + var tls = _smtp.UseStartTls ? SecureSocketOptions.StartTls : SecureSocketOptions.Auto; + await client.ConnectAsync(_smtp.Host, _smtp.Port, tls, ct); + + if (!string.IsNullOrWhiteSpace(_smtp.Username)) + await client.AuthenticateAsync(_smtp.Username, _smtp.Password, ct); + + await client.SendAsync(msg, ct); + await client.DisconnectAsync(true, ct); + + _log.LogInformation("Email sent successfully to {Recipients}", string.Join(", ", req.To)); + } +} diff --git a/Apis/email-api/appsettings.json b/Apis/email-api/appsettings.json new file mode 100644 index 0000000..60d29b4 --- /dev/null +++ b/Apis/email-api/appsettings.json @@ -0,0 +1,78 @@ +{ + "Serilog": { + "Using": [ + "Serilog.Sinks.Console", + "Serilog.Sinks.File" + ], + "MinimumLevel": { + "Default": "Information", + "Override": { + "Microsoft.AspNetCore": "Warning", + "Microsoft.AspNetCore.Hosting": "Information", + "Microsoft.AspNetCore.Routing": "Warning", + "System.Net.Http.HttpClient": "Warning", + "EmailApi": "Information" + } + }, + "WriteTo": [ + { + "Name": "Console", + "Args": { + "outputTemplate": "[{Timestamp:HH:mm:ss} {Level:u3}] {SourceContext}: {Message:lj}{NewLine}{Exception}" + } + }, + { + "Name": "File", + "Args": { + "path": "logs/email-api-.log", + "rollingInterval": "Day", + "retainedFileCountLimit": 30, + "outputTemplate": "{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} [{Level:u3}] {SourceContext}: {Message:lj}{NewLine}{Exception}" + } + } + ], + "Enrich": [ + "FromLogContext", + "WithMachineName", + "WithEnvironmentName" + ] + }, + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning", + "Microsoft.AspNetCore.Hosting": "Information", + "Microsoft.AspNetCore.Routing": "Warning", + "System.Net.Http.HttpClient": "Warning", + "EmailApi": "Information" + } + }, + "LogEnvironmentOnStartup": true, + "AllowedHosts": "*", + "KeyVault": { + "VaultUri": "", + "Enabled": false + }, + "Database": { + "Host": "localhost", + "Port": 1433, + "Name": "MyAiDb", + "User": "sa", + "Password": "", + "TrustServerCertificate": true + }, + "InternalApi": { + "ApiKey": "", + "RequireApiKey": true + }, + "Smtp": { + "Host": "mail.easysoft.ro", + "Port": 587, + "Username": "no-reply@easysoft.ro", + "Password": "", + "UseStartTls": true + }, + "FileStorage": { + "Path": "Files" + } +} diff --git a/Apis/email-api/email-api.csproj b/Apis/email-api/email-api.csproj new file mode 100644 index 0000000..ad71f39 --- /dev/null +++ b/Apis/email-api/email-api.csproj @@ -0,0 +1,29 @@ + + + net10.0 + enable + enable + Linux + EmailApi + true + $(NoWarn);1591 + + + + + + + + + + + + + + + + + + + + diff --git a/Apis/email-data/EmailDbContext.cs b/Apis/email-data/EmailDbContext.cs new file mode 100644 index 0000000..01bf02b --- /dev/null +++ b/Apis/email-data/EmailDbContext.cs @@ -0,0 +1,38 @@ +using Email.Data.Entities; +using Microsoft.EntityFrameworkCore; + +namespace Email.Data; + +public sealed class EmailDbContext : DbContext +{ + public const string SchemaName = MigrationConstants.SchemaName; + public const string MigrationTableName = MigrationConstants.MigrationTableName; + + public EmailDbContext(DbContextOptions options) : base(options) { } + + public DbSet Templates => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + { + base.OnConfiguring(optionsBuilder); + // Configure migration history table to use schema-qualified name: [email].[_Migrations] + optionsBuilder.UseSqlServer(x => x.MigrationsHistoryTable(MigrationTableName, SchemaName)); + } + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.HasDefaultSchema(SchemaName); + + modelBuilder.Entity(entity => + { + entity.ToTable("Templates"); + entity.HasKey(x => new { x.Key, x.Language }); + entity.Property(x => x.Key).HasMaxLength(128); + entity.Property(x => x.Language).HasMaxLength(8); + entity.Property(x => x.Value).IsRequired(); + entity.Property(x => x.Description).HasMaxLength(500).HasDefaultValue(string.Empty); + entity.Property(x => x.UpdatedAt).HasDefaultValueSql("SYSUTCDATETIME()"); + entity.Property(x => x.OperatorCopy).HasMaxLength(256).HasDefaultValue(string.Empty); + }); + } +} diff --git a/Apis/email-data/Entities/EmailTemplateEntity.cs b/Apis/email-data/Entities/EmailTemplateEntity.cs new file mode 100644 index 0000000..8e0c8f4 --- /dev/null +++ b/Apis/email-data/Entities/EmailTemplateEntity.cs @@ -0,0 +1,12 @@ +namespace Email.Data.Entities; + +// composite PK (Key + Language) — BaseEntity not applicable +public sealed class EmailTemplateEntity +{ + public string Key { get; set; } = string.Empty; + public string Language { get; set; } = string.Empty; + public string Value { get; set; } = string.Empty; + public string Description { get; set; } = string.Empty; + public DateTime UpdatedAt { get; set; } + public string OperatorCopy { get; set; } = string.Empty; +} diff --git a/Apis/email-data/MigrationConstants.cs b/Apis/email-data/MigrationConstants.cs new file mode 100644 index 0000000..9cc17f7 --- /dev/null +++ b/Apis/email-data/MigrationConstants.cs @@ -0,0 +1,11 @@ +namespace Email.Data; + +/// +/// Schema constants used by EmailDbContext and migrations. +/// Centralized to avoid hardcoded strings and ensure consistency. +/// +public static class MigrationConstants +{ + public const string SchemaName = "email"; + public const string MigrationTableName = "_Migrations"; +} diff --git a/Apis/email-data/Migrations/20260601133043_InitialSchema.Designer.cs b/Apis/email-data/Migrations/20260601133043_InitialSchema.Designer.cs new file mode 100644 index 0000000..540cb27 --- /dev/null +++ b/Apis/email-data/Migrations/20260601133043_InitialSchema.Designer.cs @@ -0,0 +1,69 @@ +// +using System; +using Email.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace Email.Data.Migrations +{ + [DbContext(typeof(EmailDbContext))] + [Migration("20260601133043_InitialSchema")] + partial class InitialSchema + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("email") + .HasAnnotation("ProductVersion", "10.0.7") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("Email.Data.Entities.EmailTemplateEntity", b => + { + b.Property("Key") + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("Language") + .HasMaxLength(8) + .HasColumnType("nvarchar(8)"); + + b.Property("Description") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)") + .HasDefaultValue(""); + + b.Property("OperatorCopy") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)") + .HasDefaultValue(""); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("datetime2") + .HasDefaultValueSql("SYSUTCDATETIME()"); + + b.Property("Value") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.HasKey("Key", "Language"); + + b.ToTable("Templates", "email"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Apis/email-data/Migrations/20260601133043_InitialSchema.cs b/Apis/email-data/Migrations/20260601133043_InitialSchema.cs new file mode 100644 index 0000000..98a2518 --- /dev/null +++ b/Apis/email-data/Migrations/20260601133043_InitialSchema.cs @@ -0,0 +1,231 @@ +using System; +using Email.Data; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Email.Data.Migrations +{ + /// + public partial class InitialSchema : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.EnsureSchema( + name: MigrationConstants.SchemaName); + + migrationBuilder.CreateTable( + name: "Templates", + schema: MigrationConstants.SchemaName, + columns: table => new + { + Key = table.Column(type: "nvarchar(128)", maxLength: 128, nullable: false), + Language = table.Column(type: "nvarchar(8)", maxLength: 8, nullable: false), + Value = table.Column(type: "nvarchar(max)", nullable: false), + Description = table.Column(type: "nvarchar(500)", maxLength: 500, nullable: false, defaultValue: ""), + UpdatedAt = table.Column(type: "datetime2", nullable: false, defaultValueSql: "SYSUTCDATETIME()"), + OperatorCopy = table.Column(type: "nvarchar(256)", maxLength: 256, nullable: false, defaultValue: "") + }, + constraints: table => + { + table.PrimaryKey("PK_Templates", x => new { x.Key, x.Language }); + }); + + Seed(migrationBuilder); + } + + private static void Seed(MigrationBuilder m) + { + void Row(string key, string lang, string value, string description = "") + => m.InsertData("Templates", ["Key", "Language", "Value", "Description"], [key, lang, value, description], MigrationConstants.SchemaName); + + // Match result email — subject + Row("email.match.subject", "en", "MyAi.ro CV Match: {{score}}% - {{jobLabel}}", "Subject for the CV match result email"); + Row("email.match.subject", "ro", "MyAi.ro Potrivire CV: {{score}}% - {{jobLabel}}", "Subiect email rezultat potrivire CV"); + + // Match result email — body (HTML formatted) + Row("email.match.body", "en", + @"

CV Match Report

+ + + + + + + + + + + + + + + + + +
CV ID{{cvDocumentId}}
Job{{jobLabel}}
URL{{jobUrl}}
Score{{score}}%
+

Summary

+

{{summary}}

+

Strengths

+
{{strengths}}
+

Gaps

+
{{gaps}}
+

Recommendations

+
{{recommendations}}
", + "Body for the CV match result email (HTML formatted)"); + Row("email.match.body", "ro", + @"

Report Potrivire CV

+ + + + + + + + + + + + + + + + + +
ID Document CV{{cvDocumentId}}
Job{{jobLabel}}
URL{{jobUrl}}
Scor{{score}}%
+

Rezumat

+

{{summary}}

+

Puncte Forte

+
{{strengths}}
+

Lipsuri

+
{{gaps}}
+

Recomandări

+
{{recommendations}}
", + "Corpul emailului pentru rezultatul potrivirii CV (format HTML)"); + + // Match result email — job search CTA footer (HTML formatted) + Row("email.match.job-search-footer", "en", + @"
+

Want to find more jobs matching your CV?

+ + + + +
+ Search Jobs +
+

(link valid for {{expiryDays}} days)

", + "Job search CTA appended to match result email (HTML formatted)"); + Row("email.match.job-search-footer", "ro", + @"
+

Vrei să găsești mai multe joburi potrivite CV-ului tău?

+ + + + +
+ Caută Joburi +
+

(link valabil {{expiryDays}} zile)

", + "CTA cautare joburi adaugat la emailul de potrivire CV (format HTML)"); + + // Job search results email — subject + Row("email.search-results.subject", "en", "MyAi.ro: {{count}} jobs matching your CV", "Subject for job search results email"); + Row("email.search-results.subject", "ro", "MyAi.ro: {{count}} joburi potrivite CV-ului tau", "Subiect email rezultate cautare joburi"); + + // Job search results email — body preamble (items appended in code) - HTML formatted + Row("email.search-results.body", "en", + @"

Job Search Results

+

MyAi.ro found {{count}} jobs matching your CV:

+{{items}}", + "Body preamble for job search results email (HTML formatted)"); + Row("email.search-results.body", "ro", + @"

Rezultate Căutare Joburi

+

MyAi.ro a găsit {{count}} joburi potrivite CV-ului tău:

+{{items}}", + "Corpul emailului de rezultate cautare joburi (format HTML)"); + + // Job search results email — scan summary block (keywords + providers used) + Row("email.search-results.scan-summary", "en", + @" + + + +
+
Keywords used: {{keywordsHtml}}
+
Providers scanned: {{providers}}
+
", + "Scan summary block prepended to job search results email (HTML formatted)"); + Row("email.search-results.scan-summary", "ro", + @" + + + +
+
Cuvinte cheie folosite: {{keywordsHtml}}
+
Furnizori scanați: {{providers}}
+
", + "Bloc rezumat scanare adaugat la emailul de rezultate cautare joburi (format HTML)"); + + // Job search results email — single job result item card + Row("email.search-results.item", "en", + @" + + + +
+ {{index}}. {{jobTitle}} + {{score}}% match + [{{providerName}}]
+ {{jobUrl}} + {{summary}} +
", + "Single job result card in job search results email (HTML formatted)"); + Row("email.search-results.item", "ro", + @" + + + +
+ {{index}}. {{jobTitle}} + {{score}}% potrivire + [{{providerName}}]
+ {{jobUrl}} + {{summary}} +
", + "Card job individual in emailul de rezultate cautare joburi (format HTML)"); + + // Job search results email — no results found - HTML formatted + Row("email.search-results.empty", "en", + @" + + + +
+

No jobs found
+ MyAi.ro found no jobs matching your CV at this moment. Please try again later or update your CV to improve your match results.

+
", + "No results message for job search results email (HTML formatted)"); + Row("email.search-results.empty", "ro", + @" + + + +
+

Niciun job găsit
+ MyAi.ro nu a găsit joburi potrivite CV-ului tău în acest moment. Te rugăm să încerci din nou mai târziu sau să-ți actualizezi CV-ul pentru a obține rezultate mai bune.

+
", + "Mesaj fara rezultate pentru emailul de cautare joburi (format HTML)"); + + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "Templates", + schema: MigrationConstants.SchemaName); + } + } +} diff --git a/Apis/email-data/Migrations/20260601145256_AddHtmlShellTemplates.Designer.cs b/Apis/email-data/Migrations/20260601145256_AddHtmlShellTemplates.Designer.cs new file mode 100644 index 0000000..99112d4 --- /dev/null +++ b/Apis/email-data/Migrations/20260601145256_AddHtmlShellTemplates.Designer.cs @@ -0,0 +1,70 @@ +// +using System; +using Email.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace Email.Data.Migrations +{ + [DbContext(typeof(EmailDbContext))] + [Migration("20260601145256_AddHtmlShellTemplates")] + partial class AddHtmlShellTemplates + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { + #pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("email") + .HasAnnotation("ProductVersion", "10.0.7") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("Email.Data.Entities.EmailTemplateEntity", b => + { + b.Property("Key") + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("Language") + .HasMaxLength(8) + .HasColumnType("nvarchar(8)"); + + b.Property("Description") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)") + .HasDefaultValue(""); + + b.Property("OperatorCopy") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)") + .HasDefaultValue(""); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("datetime2") + .HasDefaultValueSql("SYSUTCDATETIME()"); + + b.Property("Value") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.HasKey("Key", "Language"); + + b.ToTable("Templates", "email"); + }); + + #pragma warning restore 612, 618 + } + } +} diff --git a/Apis/email-data/Migrations/20260601145256_AddHtmlShellTemplates.cs b/Apis/email-data/Migrations/20260601145256_AddHtmlShellTemplates.cs new file mode 100644 index 0000000..43c8188 --- /dev/null +++ b/Apis/email-data/Migrations/20260601145256_AddHtmlShellTemplates.cs @@ -0,0 +1,45 @@ +using Microsoft.EntityFrameworkCore.Migrations; +using Email.Data; + +#nullable disable + +namespace Email.Data.Migrations +{ + /// + public partial class AddHtmlShellTemplates : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + // HTML email shell — opening tags (blue header, white card container) + migrationBuilder.InsertData( + table: "Templates", + columns: new[] { "Key", "Language", "Value", "Description" }, + values: new object[] { "email.html-shell.start", "*", "\n\n\n \n \n\n\n \n \n \n \n
\n \n \n \n \n \n \n \n \n \n \n
\n

MyAi.ro

\n
\n", "Opening HTML wrapper for branded emails (blue header, white content area)" }, + schema: MigrationConstants.SchemaName); + + // HTML email shell — closing tags (footer) + migrationBuilder.InsertData( + table: "Templates", + columns: new[] { "Key", "Language", "Value", "Description" }, + values: new object[] { "email.html-shell.end", "*", "\n
\n

© 2026 MyAi.ro. All rights reserved.

\n
\n
\n\n\n", "Closing HTML wrapper for branded emails (footer and closing tags)" }, + schema: MigrationConstants.SchemaName); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DeleteData( + table: "Templates", + keyColumns: new[] { "Key", "Language" }, + keyValues: new object[] { "email.html-shell.start", "*" }, + schema: MigrationConstants.SchemaName); + + migrationBuilder.DeleteData( + table: "Templates", + keyColumns: new[] { "Key", "Language" }, + keyValues: new object[] { "email.html-shell.end", "*" }, + schema: MigrationConstants.SchemaName); + } + } +} diff --git a/Apis/email-data/Migrations/20260608125339_AddLocationToScanSummaryTemplate.Designer.cs b/Apis/email-data/Migrations/20260608125339_AddLocationToScanSummaryTemplate.Designer.cs new file mode 100644 index 0000000..587918e --- /dev/null +++ b/Apis/email-data/Migrations/20260608125339_AddLocationToScanSummaryTemplate.Designer.cs @@ -0,0 +1,69 @@ +// +using System; +using Email.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace Email.Data.Migrations +{ + [DbContext(typeof(EmailDbContext))] + [Migration("20260608125339_AddLocationToScanSummaryTemplate")] + partial class AddLocationToScanSummaryTemplate + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("email") + .HasAnnotation("ProductVersion", "10.0.7") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("Email.Data.Entities.EmailTemplateEntity", b => + { + b.Property("Key") + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("Language") + .HasMaxLength(8) + .HasColumnType("nvarchar(8)"); + + b.Property("Description") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)") + .HasDefaultValue(""); + + b.Property("OperatorCopy") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)") + .HasDefaultValue(""); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("datetime2") + .HasDefaultValueSql("SYSUTCDATETIME()"); + + b.Property("Value") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.HasKey("Key", "Language"); + + b.ToTable("Templates", "email"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Apis/email-data/Migrations/20260608125339_AddLocationToScanSummaryTemplate.cs b/Apis/email-data/Migrations/20260608125339_AddLocationToScanSummaryTemplate.cs new file mode 100644 index 0000000..9b73100 --- /dev/null +++ b/Apis/email-data/Migrations/20260608125339_AddLocationToScanSummaryTemplate.cs @@ -0,0 +1,80 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Email.Data.Migrations +{ + /// + public partial class AddLocationToScanSummaryTemplate : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.UpdateData( + schema: MigrationConstants.SchemaName, + table: "Templates", + keyColumns: ["Key", "Language"], + keyValues: ["email.search-results.scan-summary", "en"], + columns: ["Value"], + values: [@" + + + +
+
Keywords used: {{keywordsHtml}}
+
Location: {{location}}
+
Providers scanned: {{providers}}
+
"]); + + migrationBuilder.UpdateData( + schema: MigrationConstants.SchemaName, + table: "Templates", + keyColumns: ["Key", "Language"], + keyValues: ["email.search-results.scan-summary", "ro"], + columns: ["Value"], + values: [@" + + + +
+
Cuvinte cheie folosite: {{keywordsHtml}}
+
Locație căutată: {{location}}
+
Furnizori scanați: {{providers}}
+
"]); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.UpdateData( + schema: MigrationConstants.SchemaName, + table: "Templates", + keyColumns: ["Key", "Language"], + keyValues: ["email.search-results.scan-summary", "en"], + columns: ["Value"], + values: [@" + + + +
+
Keywords used: {{keywordsHtml}}
+
Providers scanned: {{providers}}
+
"]); + + migrationBuilder.UpdateData( + schema: MigrationConstants.SchemaName, + table: "Templates", + keyColumns: ["Key", "Language"], + keyValues: ["email.search-results.scan-summary", "ro"], + columns: ["Value"], + values: [@" + + + +
+
Cuvinte cheie folosite: {{keywordsHtml}}
+
Furnizori scanați: {{providers}}
+
"]); + } + } +} diff --git a/Apis/email-data/Migrations/EmailDbContextModelSnapshot.cs b/Apis/email-data/Migrations/EmailDbContextModelSnapshot.cs new file mode 100644 index 0000000..6556cfb --- /dev/null +++ b/Apis/email-data/Migrations/EmailDbContextModelSnapshot.cs @@ -0,0 +1,66 @@ +// +using System; +using Email.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace Email.Data.Migrations +{ + [DbContext(typeof(EmailDbContext))] + partial class EmailDbContextModelSnapshot : ModelSnapshot + { + protected override void BuildModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("email") + .HasAnnotation("ProductVersion", "10.0.7") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("Email.Data.Entities.EmailTemplateEntity", b => + { + b.Property("Key") + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("Language") + .HasMaxLength(8) + .HasColumnType("nvarchar(8)"); + + b.Property("Description") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)") + .HasDefaultValue(""); + + b.Property("OperatorCopy") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)") + .HasDefaultValue(""); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("datetime2") + .HasDefaultValueSql("SYSUTCDATETIME()"); + + b.Property("Value") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.HasKey("Key", "Language"); + + b.ToTable("Templates", "email"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Apis/email-data/Repositories/Contracts/IEmailTemplateRepository.cs b/Apis/email-data/Repositories/Contracts/IEmailTemplateRepository.cs new file mode 100644 index 0000000..341a1ae --- /dev/null +++ b/Apis/email-data/Repositories/Contracts/IEmailTemplateRepository.cs @@ -0,0 +1,8 @@ +using Email.Data.Entities; + +namespace Email.Data.Repositories.Contracts; + +public interface IEmailTemplateRepository +{ + Task> GetAllAsync(CancellationToken ct); +} diff --git a/Apis/email-data/Repositories/EfEmailTemplateRepository.cs b/Apis/email-data/Repositories/EfEmailTemplateRepository.cs new file mode 100644 index 0000000..406fd0c --- /dev/null +++ b/Apis/email-data/Repositories/EfEmailTemplateRepository.cs @@ -0,0 +1,18 @@ +using Email.Data.Entities; +using Email.Data.Repositories.Contracts; +using Microsoft.EntityFrameworkCore; + +namespace Email.Data.Repositories; + +public sealed class EfEmailTemplateRepository : IEmailTemplateRepository +{ + private readonly EmailDbContext _db; + + public EfEmailTemplateRepository(EmailDbContext db) + { + _db = db; + } + + public async Task> GetAllAsync(CancellationToken ct) + => await _db.Templates.AsNoTracking().ToListAsync(ct); +} diff --git a/Apis/email-data/Services/EmailTemplateService.cs b/Apis/email-data/Services/EmailTemplateService.cs new file mode 100644 index 0000000..f59220b --- /dev/null +++ b/Apis/email-data/Services/EmailTemplateService.cs @@ -0,0 +1,109 @@ +using System.Collections.Concurrent; +using Email.Data.Repositories.Contracts; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +namespace Email.Data.Services; + +/// +/// Singleton implementation of that caches all email templates +/// from the database and refreshes them every 10 minutes. +/// Uses to resolve the scoped repository from a singleton lifetime. +/// +public sealed class EmailTemplateService : IEmailTemplateService +{ + private readonly IServiceScopeFactory _scopeFactory; + private readonly ILogger _logger; + private ConcurrentDictionary _valueCache = new(StringComparer.OrdinalIgnoreCase); + private ConcurrentDictionary _operatorCache = new(StringComparer.OrdinalIgnoreCase); + private DateTime _loadedAt = DateTime.MinValue; + private static readonly TimeSpan CacheTtl = TimeSpan.FromMinutes(10); + + public EmailTemplateService(IServiceScopeFactory scopeFactory, ILogger logger) + { + _scopeFactory = scopeFactory; + _logger = logger; + } + + /// + public string Get(string key, string language = "en") + { + EnsureCacheLoaded(); + + if (_valueCache.TryGetValue(CacheKey(key, language), out var value)) + return value; + + if (!string.Equals(language, "en", StringComparison.OrdinalIgnoreCase) + && _valueCache.TryGetValue(CacheKey(key, "en"), out var fallback)) + return fallback; + + throw new InvalidOperationException( + $"Email template not found: key='{key}', language='{language}'. " + + $"This is a configuration error. Ensure the email.Templates table is properly seeded."); + } + + /// + public string Render(string key, string language, params (string Key, string Value)[] placeholders) + { + var template = Get(key, language); + foreach (var (k, v) in placeholders) + template = template.Replace($"{{{{{k}}}}}", v, StringComparison.OrdinalIgnoreCase); + return template; + } + + /// + public string? GetOperatorCopy(string key, string language) + { + EnsureCacheLoaded(); + + if (_operatorCache.TryGetValue(CacheKey(key, language), out var specific) + && !string.IsNullOrWhiteSpace(specific)) + return specific; + + // Fall back to first non-empty OperatorCopy in the cache + foreach (var val in _operatorCache.Values) + { + if (!string.IsNullOrWhiteSpace(val)) + return val; + } + + return null; + } + + /// + /// Reloads all templates from the database when the cache TTL has expired. + /// Swaps both caches atomically; logs an error and continues serving the stale cache on failure. + /// + private void EnsureCacheLoaded() + { + if (DateTime.UtcNow - _loadedAt < CacheTtl) return; + + try + { + using var scope = _scopeFactory.CreateScope(); + var repo = scope.ServiceProvider.GetRequiredService(); + var rows = repo.GetAllAsync(CancellationToken.None).GetAwaiter().GetResult(); + + var freshValues = new ConcurrentDictionary(StringComparer.OrdinalIgnoreCase); + var freshOperator = new ConcurrentDictionary(StringComparer.OrdinalIgnoreCase); + + foreach (var row in rows) + { + freshValues[CacheKey(row.Key, row.Language)] = row.Value; + freshOperator[CacheKey(row.Key, row.Language)] = row.OperatorCopy; + } + + _valueCache = freshValues; + _operatorCache = freshOperator; + _loadedAt = DateTime.UtcNow; + _logger.LogDebug("Email template cache refreshed. {Count} templates loaded.", rows.Count); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to refresh email template cache. Serving stale cache."); + } + } + + /// Builds the dictionary key used for both caches. + private static string CacheKey(string key, string language) => $"{key}::{language}"; +} diff --git a/Apis/email-data/Services/IEmailTemplateService.cs b/Apis/email-data/Services/IEmailTemplateService.cs new file mode 100644 index 0000000..8787181 --- /dev/null +++ b/Apis/email-data/Services/IEmailTemplateService.cs @@ -0,0 +1,38 @@ +namespace Email.Data.Services; + +/// +/// Provides access to localised email templates stored in the emailApi.EmailTemplates table. +/// Implementations are expected to cache templates and refresh periodically. +/// +public interface IEmailTemplateService +{ + /// + /// Returns the template value for the given key and language. + /// Falls back to "en" when the requested language has no entry. + /// Returns the raw key string when no matching template is found. + /// + /// Template key (e.g. "email.match.subject"). + /// Two-letter language code (e.g. "en", "ro"). + /// Template value string. + string Get(string key, string language = "en"); + + /// + /// Retrieves the template and substitutes {{placeholder}} tokens with the provided values. + /// + /// Template key. + /// Two-letter language code. + /// Named replacement pairs in the form ("name", value). + /// Rendered template string with all placeholders replaced. + string Render(string key, string language, params (string Key, string Value)[] placeholders); + + /// + /// Returns the operator copy address for the given template key. + /// Uses the specific row's OperatorCopy value when non-empty; otherwise falls back + /// to the first non-empty OperatorCopy across all cached rows, so future template rows + /// with an empty value automatically inherit the globally configured address. + /// + /// Template key used to look up the specific row (typically the subject key). + /// Two-letter language code. + /// Operator copy email address, or null when none is configured. + string? GetOperatorCopy(string key, string language); +} diff --git a/Apis/email-data/email-data.csproj b/Apis/email-data/email-data.csproj new file mode 100644 index 0000000..a83b425 --- /dev/null +++ b/Apis/email-data/email-data.csproj @@ -0,0 +1,20 @@ + + + net10.0 + email-data + Email.Data + enable + enable + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + diff --git a/Apis/myai-data/Data/Entities/TemplateEntity.cs b/Apis/myai-data/Data/Entities/TemplateEntity.cs new file mode 100644 index 0000000..154ad43 --- /dev/null +++ b/Apis/myai-data/Data/Entities/TemplateEntity.cs @@ -0,0 +1,11 @@ +namespace MyAi.Data.Entities; + +// composite PK (Key + Language) — BaseEntity not applicable +public sealed class TemplateEntity +{ + public string Key { get; set; } = string.Empty; + public string Language { get; set; } = string.Empty; + public string Value { get; set; } = string.Empty; + public string Description { get; set; } = string.Empty; + public DateTime UpdatedAt { get; set; } +} diff --git a/Apis/myai-data/Data/MigrationConstants.cs b/Apis/myai-data/Data/MigrationConstants.cs new file mode 100644 index 0000000..6fa140c --- /dev/null +++ b/Apis/myai-data/Data/MigrationConstants.cs @@ -0,0 +1,11 @@ +namespace MyAi.Data; + +/// +/// Schema constants used by MyAiDbContext and migrations. +/// Centralized to avoid hardcoded strings and ensure consistency. +/// +public static class MigrationConstants +{ + public const string SchemaName = "myAi"; + public const string MigrationTableName = "_Migrations"; +} diff --git a/Apis/myai-data/Data/MyAiDbContext.cs b/Apis/myai-data/Data/MyAiDbContext.cs new file mode 100644 index 0000000..eded870 --- /dev/null +++ b/Apis/myai-data/Data/MyAiDbContext.cs @@ -0,0 +1,37 @@ +using MyAi.Data.Entities; +using Microsoft.EntityFrameworkCore; + +namespace MyAi.Data; + +public sealed class MyAiDbContext : DbContext +{ + public const string SchemaName = MigrationConstants.SchemaName; + public const string MigrationTableName = MigrationConstants.MigrationTableName; + + public MyAiDbContext(DbContextOptions options) : base(options) { } + + public DbSet Templates => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + { + base.OnConfiguring(optionsBuilder); + // Configure migration history table to use schema-qualified name: [myAi].[_Migrations] + optionsBuilder.UseSqlServer(x => x.MigrationsHistoryTable(MigrationTableName, SchemaName)); + } + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.HasDefaultSchema(SchemaName); + + modelBuilder.Entity(entity => + { + entity.ToTable("Templates"); + entity.HasKey(x => new { x.Key, x.Language }); + entity.Property(x => x.Key).HasMaxLength(128); + entity.Property(x => x.Language).HasMaxLength(8); + entity.Property(x => x.Value).IsRequired(); + entity.Property(x => x.Description).HasMaxLength(500).HasDefaultValue(string.Empty); + entity.Property(x => x.UpdatedAt).HasDefaultValueSql("SYSUTCDATETIME()"); + }); + } +} diff --git a/Apis/myai-data/Migrations/20260524145351_AddTemplates.Designer.cs b/Apis/myai-data/Migrations/20260524145351_AddTemplates.Designer.cs new file mode 100644 index 0000000..63cf0c0 --- /dev/null +++ b/Apis/myai-data/Migrations/20260524145351_AddTemplates.Designer.cs @@ -0,0 +1,62 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using MyAi.Data; + +#nullable disable + +namespace MyAi.Data.Migrations +{ + [DbContext(typeof(MyAiDbContext))] + [Migration("20260524145351_AddTemplates")] + partial class AddTemplates + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("myAi") + .HasAnnotation("ProductVersion", "10.0.7") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("MyAi.Data.Entities.TemplateEntity", b => + { + b.Property("Key") + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("Language") + .HasMaxLength(8) + .HasColumnType("nvarchar(8)"); + + b.Property("Description") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)") + .HasDefaultValue(""); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("datetime2") + .HasDefaultValueSql("SYSUTCDATETIME()"); + + b.Property("Value") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.HasKey("Key", "Language"); + + b.ToTable("Templates", "myAi"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Apis/myai-data/Migrations/20260524145351_AddTemplates.cs b/Apis/myai-data/Migrations/20260524145351_AddTemplates.cs new file mode 100644 index 0000000..3b68ef7 --- /dev/null +++ b/Apis/myai-data/Migrations/20260524145351_AddTemplates.cs @@ -0,0 +1,119 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; +using MyAi.Data; + +#nullable disable + +namespace MyAi.Data.Migrations +{ + /// + public partial class AddTemplates : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.EnsureSchema( + name: MigrationConstants.SchemaName); + + migrationBuilder.CreateTable( + name: "Templates", + schema: MigrationConstants.SchemaName, + columns: table => new + { + Key = table.Column(type: "nvarchar(128)", maxLength: 128, nullable: false), + Language = table.Column(type: "nvarchar(8)", maxLength: 8, nullable: false), + Value = table.Column(type: "nvarchar(max)", nullable: false), + Description = table.Column(type: "nvarchar(500)", maxLength: 500, nullable: false, defaultValue: ""), + UpdatedAt = table.Column(type: "datetime2", nullable: false, defaultValueSql: "SYSUTCDATETIME()") + }, + constraints: table => + { + table.PrimaryKey("PK_Templates", x => new { x.Key, x.Language }); + }); + + Seed(migrationBuilder); + } + + private static void Seed(MigrationBuilder m) + { + void Row(string key, string lang, string value, string description = "") + => m.InsertData("Templates", ["Key", "Language", "Value", "Description"], [key, lang, value, description], MigrationConstants.SchemaName); + + // Match result email — subject + Row("email.match.subject", "en", "MyAi.ro CV Match: {{score}}% - {{jobLabel}}", "Subject for the CV match result email"); + Row("email.match.subject", "ro", "MyAi.ro Potrivire CV: {{score}}% - {{jobLabel}}", "Subiect email rezultat potrivire CV"); + + // Match result email — body + Row("email.match.body", "en", + "CV Matcher result\n\nCV Document ID: {{cvDocumentId}}\nJob: {{jobLabel}}\nJob URL: {{jobUrl}}\nScore: {{score}}%\n\nSummary:\n{{summary}}\n\nStrengths:\n{{strengths}}\n\nGaps:\n{{gaps}}\n\nRecommendations:\n{{recommendations}}", + "Body for the CV match result email"); + Row("email.match.body", "ro", + "Rezultat potrivire CV\n\nID document CV: {{cvDocumentId}}\nJob: {{jobLabel}}\nURL job: {{jobUrl}}\nScor: {{score}}%\n\nRezumat:\n{{summary}}\n\nPuncte forte:\n{{strengths}}\n\nLipsuri:\n{{gaps}}\n\nRecomandări:\n{{recommendations}}", + "Corpul emailului pentru rezultatul potrivirii CV"); + + // Match result email — job search CTA footer + Row("email.match.job-search-footer", "en", + "\n\n---\nWant to find more jobs matching your CV?\nClick: {{jobSearchLink}}\n(link valid for {{expiryDays}} days)", + "Job search CTA appended to match result email"); + Row("email.match.job-search-footer", "ro", + "\n\n---\nVrei sa gasesti mai multe joburi potrivite CV-ului tau?\nClick: {{jobSearchLink}}\n(link valabil {{expiryDays}} zile)", + "CTA cautare joburi adaugat la emailul de potrivire CV"); + + // Job search results email — subject + Row("email.search-results.subject", "en", "MyAi.ro: {{count}} jobs matching your CV", "Subject for job search results email"); + Row("email.search-results.subject", "ro", "MyAi.ro: {{count}} joburi potrivite CV-ului tau", "Subiect email rezultate cautare joburi"); + + // Job search results email — body preamble (items appended in code) + Row("email.search-results.body", "en", "MyAi.ro found {{count}} jobs matching your CV:\n\n{{items}}", "Body preamble for job search results email"); + Row("email.search-results.body", "ro", "MyAi.ro a gasit {{count}} joburi potrivite CV-ului tau:\n\n{{items}}", "Corpul emailului de rezultate cautare joburi"); + + // Job search results email — no results found + Row("email.search-results.empty", "en", "MyAi.ro found no jobs matching your CV. Try again later or update your CV.", "No results message for job search results email"); + Row("email.search-results.empty", "ro", "MyAi.ro nu a gasit joburi care sa corespunda CV-ului tau. Incercati mai tarziu sau ajustati CV-ul.", "Mesaj fara rezultate pentru emailul de cautare joburi"); + + // HTML job-search page shell — wraps title + message in a centered card page + Row("html.job-search.shell", "*", + "{{title}} - MyAi.ro

{{title}}

{{message}}

", + "Full HTML shell for job-search status pages. Placeholders: {{title}}, {{message}}."); + + // HTML job-search start page messages + Row("html.job-search.started.title", "en", "Job search started", "Title for job search started page"); + Row("html.job-search.started.message", "en", "Your job search has started. Results will be sent to your email shortly.", "Message for job search started page"); + Row("html.job-search.started.title", "ro", "Căutare joburi pornită", "Titlu pagina cautare joburi pornita"); + Row("html.job-search.started.message", "ro", "Căutarea joburilor a început. Rezultatele vor fi trimise pe email în scurt timp.", "Mesaj pagina cautare joburi pornita"); + + Row("html.job-search.already-used.title", "en", "Link already used", "Title for already-used page"); + Row("html.job-search.already-used.message", "en", "This job search link has already been used.", "Message for already-used page"); + Row("html.job-search.already-used.title", "ro", "Link deja folosit", "Titlu pagina link deja folosit"); + Row("html.job-search.already-used.message", "ro", "Acest link de cautare joburi a fost deja folosit.", "Mesaj pagina link deja folosit"); + + Row("html.job-search.expired.title", "en", "Link expired", "Title for expired link page"); + Row("html.job-search.expired.message", "en", "This job search link has expired. Please request a new CV match to get a fresh link.", "Message for expired link page"); + Row("html.job-search.expired.title", "ro", "Link expirat", "Titlu pagina link expirat"); + Row("html.job-search.expired.message", "ro", "Acest link de cautare joburi a expirat. Solicita o noua potrivire CV pentru a primi un link nou.", "Mesaj pagina link expirat"); + + Row("html.job-search.invalid.title", "en", "Invalid link", "Title for invalid link page"); + Row("html.job-search.invalid.message", "en", "This job search link is not valid.", "Message for invalid link page"); + Row("html.job-search.invalid.title", "ro", "Link invalid", "Titlu pagina link invalid"); + Row("html.job-search.invalid.message", "ro", "Acest link de cautare joburi nu este valid.", "Mesaj pagina link invalid"); + + Row("html.job-search.error.title", "en", "Error", "Title for error page"); + Row("html.job-search.error.message", "en", "An error occurred. Please try again later.", "Message for error page"); + Row("html.job-search.error.title", "ro", "Eroare", "Titlu pagina eroare"); + Row("html.job-search.error.message", "ro", "A apărut o eroare. Te rugăm să încerci din nou mai târziu.", "Mesaj pagina eroare"); + + // AI system prompt for CV matching (language is a {{languageName}} variable inside it) + Row("ai.cv-match.system-prompt", "*", + "You are a strict CV-to-job matching engine. Return JSON only. Score realistically from 0 to 100.\nPenalize missing required skills. Do not invent experience. Use concise business language.\nRespond entirely in {{languageName}} — all text fields in the JSON must be in {{languageName}}.\nJSON shape: {\"score\":number,\"summary\":\"...\",\"strengths\":[\"...\"],\"gaps\":[\"...\"],\"recommendations\":[\"...\"],\"evidence\":[\"...\"]}", + "System prompt template for the CV-to-job LLM matching call. {{languageName}} is substituted at runtime."); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "Templates", + schema: MigrationConstants.SchemaName); + } + } +} diff --git a/Apis/myai-data/Migrations/20260527120000_UpdateEmailTemplatesToHtml.Designer.cs b/Apis/myai-data/Migrations/20260527120000_UpdateEmailTemplatesToHtml.Designer.cs new file mode 100644 index 0000000..1bde4cd --- /dev/null +++ b/Apis/myai-data/Migrations/20260527120000_UpdateEmailTemplatesToHtml.Designer.cs @@ -0,0 +1,62 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using MyAi.Data; + +#nullable disable + +namespace MyAi.Data.Migrations +{ + [DbContext(typeof(MyAiDbContext))] + [Migration("20260527120000_UpdateEmailTemplatesToHtml")] + partial class UpdateEmailTemplatesToHtml + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("myAi") + .HasAnnotation("ProductVersion", "10.0.7") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("MyAi.Data.Entities.TemplateEntity", b => + { + b.Property("Key") + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("Language") + .HasMaxLength(8) + .HasColumnType("nvarchar(8)"); + + b.Property("Description") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)") + .HasDefaultValue(""); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("datetime2") + .HasDefaultValueSql("SYSUTCDATETIME()"); + + b.Property("Value") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.HasKey("Key", "Language"); + + b.ToTable("Templates", "myAi"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Apis/myai-data/Migrations/20260527120000_UpdateEmailTemplatesToHtml.cs b/Apis/myai-data/Migrations/20260527120000_UpdateEmailTemplatesToHtml.cs new file mode 100644 index 0000000..38352fe --- /dev/null +++ b/Apis/myai-data/Migrations/20260527120000_UpdateEmailTemplatesToHtml.cs @@ -0,0 +1,144 @@ +using Microsoft.EntityFrameworkCore.Migrations; +using MyAi.Data; + +#nullable disable + +namespace MyAi.Data.Migrations +{ + /// + public partial class UpdateEmailTemplatesToHtml : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + void Update(string key, string lang, string value) + => migrationBuilder.UpdateData("Templates", ["Key", "Language"], [key, lang], + ["Value"], [value], MigrationConstants.SchemaName); + + // email.match.body — en + Update("email.match.body", "en", + "

CV Match Report

" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "
CV ID{{cvDocumentId}}
Job{{jobLabel}}
URL{{jobUrl}}
Score{{score}}%
" + + "

Summary

" + + "

{{summary}}

" + + "

Strengths

{{strengths}}" + + "

Gaps

{{gaps}}" + + "

Recommendations

{{recommendations}}"); + + // email.match.body — ro + Update("email.match.body", "ro", + "

Raport Potrivire CV

" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "" + + "
ID Document CV{{cvDocumentId}}
Job{{jobLabel}}
URL{{jobUrl}}
Scor{{score}}%
" + + "

Rezumat

" + + "

{{summary}}

" + + "

Puncte forte

{{strengths}}" + + "

Lipsuri

{{gaps}}" + + "

Recomandări

{{recommendations}}"); + + // email.match.job-search-footer — en + Update("email.match.job-search-footer", "en", + "
" + + "

" + + "Want to find matching jobs automatically? " + + "Start a job search →
" + + "Link valid for {{expiryDays}} days." + + "

" + + "
"); + + // email.match.job-search-footer — ro + Update("email.match.job-search-footer", "ro", + "
" + + "

" + + "Vrei să găsești joburi potrivite automat? " + + "Pornește o căutare de joburi →
" + + "Link valabil {{expiryDays}} zile." + + "

" + + "
"); + + // email.search-results.body — en + Update("email.search-results.body", "en", + "

Job Search Results

" + + "

Found {{count}} matching job(s):

" + + "{{items}}"); + + // email.search-results.body — ro + Update("email.search-results.body", "ro", + "

Rezultate Căutare Joburi

" + + "

Am găsit {{count}} job(uri) potrivite:

" + + "{{items}}"); + + // email.search-results.empty — en + Update("email.search-results.empty", "en", + "
" + + "

No matching jobs found

" + + "

Your job search completed but no matching jobs were found. Try again later or adjust your CV.

" + + "
"); + + // email.search-results.empty — ro + Update("email.search-results.empty", "ro", + "
" + + "

Niciun job potrivit găsit

" + + "

Căutarea s-a finalizat dar nu au fost găsite joburi potrivite. Încearcă mai târziu sau ajustează CV-ul.

" + + "
"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + void Update(string key, string lang, string value) + => migrationBuilder.UpdateData("Templates", ["Key", "Language"], [key, lang], + ["Value"], [value], MigrationConstants.SchemaName); + + Update("email.match.body", "en", + "CV Matcher result\n\nCV Document ID: {{cvDocumentId}}\nJob: {{jobLabel}}\nJob URL: {{jobUrl}}\nScore: {{score}}%\n\nSummary:\n{{summary}}\n\nStrengths:\n{{strengths}}\n\nGaps:\n{{gaps}}\n\nRecommendations:\n{{recommendations}}"); + Update("email.match.body", "ro", + "Rezultat potrivire CV\n\nID document CV: {{cvDocumentId}}\nJob: {{jobLabel}}\nURL job: {{jobUrl}}\nScor: {{score}}%\n\nRezumat:\n{{summary}}\n\nPuncte forte:\n{{strengths}}\n\nLipsuri:\n{{gaps}}\n\nRecomandări:\n{{recommendations}}"); + Update("email.match.job-search-footer", "en", + "\n\n---\nWant to find more jobs matching your CV?\nClick: {{jobSearchLink}}\n(link valid for {{expiryDays}} days)"); + Update("email.match.job-search-footer", "ro", + "\n\n---\nVrei sa gasesti mai multe joburi potrivite CV-ului tau?\nClick: {{jobSearchLink}}\n(link valabil {{expiryDays}} zile)"); + Update("email.search-results.body", "en", + "MyAi.ro found {{count}} jobs matching your CV:\n\n{{items}}"); + Update("email.search-results.body", "ro", + "MyAi.ro a gasit {{count}} joburi potrivite CV-ului tau:\n\n{{items}}"); + Update("email.search-results.empty", "en", + "MyAi.ro found no jobs matching your CV. Try again later or update your CV."); + Update("email.search-results.empty", "ro", + "MyAi.ro nu a gasit joburi care sa corespunda CV-ului tau. Incercati mai tarziu sau ajustati CV-ul."); + } + } +} diff --git a/Apis/myai-data/Migrations/20260528120000_DeleteMigratedTemplates.Designer.cs b/Apis/myai-data/Migrations/20260528120000_DeleteMigratedTemplates.Designer.cs new file mode 100644 index 0000000..d41e5cf --- /dev/null +++ b/Apis/myai-data/Migrations/20260528120000_DeleteMigratedTemplates.Designer.cs @@ -0,0 +1,62 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using MyAi.Data; + +#nullable disable + +namespace MyAi.Data.Migrations +{ + [DbContext(typeof(MyAiDbContext))] + [Migration("20260528120000_DeleteMigratedTemplates")] + partial class DeleteMigratedTemplates + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("myAi") + .HasAnnotation("ProductVersion", "10.0.7") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("MyAi.Data.Entities.TemplateEntity", b => + { + b.Property("Key") + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("Language") + .HasMaxLength(8) + .HasColumnType("nvarchar(8)"); + + b.Property("Description") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)") + .HasDefaultValue(""); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("datetime2") + .HasDefaultValueSql("SYSUTCDATETIME()"); + + b.Property("Value") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.HasKey("Key", "Language"); + + b.ToTable("Templates", "myAi"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Apis/myai-data/Migrations/20260528120000_DeleteMigratedTemplates.cs b/Apis/myai-data/Migrations/20260528120000_DeleteMigratedTemplates.cs new file mode 100644 index 0000000..8ecf6f1 --- /dev/null +++ b/Apis/myai-data/Migrations/20260528120000_DeleteMigratedTemplates.cs @@ -0,0 +1,25 @@ +using Microsoft.EntityFrameworkCore.Migrations; +using MyAi.Data; + +#nullable disable + +namespace MyAi.Data.Migrations +{ + /// + public partial class DeleteMigratedTemplates : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.Sql($"DELETE FROM [{MigrationConstants.SchemaName}].[Templates] WHERE [Key] LIKE 'email.%'"); + migrationBuilder.Sql($"DELETE FROM [{MigrationConstants.SchemaName}].[Templates] WHERE [Key] LIKE 'ai.%'"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + // Rows were migrated to emailApi.EmailTemplates and cvMatcher.AiPrompts. + // Re-inserting them here is intentionally omitted. + } + } +} diff --git a/Apis/myai-data/Migrations/20260601190000_AddHtmlJobSearchShell.cs b/Apis/myai-data/Migrations/20260601190000_AddHtmlJobSearchShell.cs new file mode 100644 index 0000000..87b638a --- /dev/null +++ b/Apis/myai-data/Migrations/20260601190000_AddHtmlJobSearchShell.cs @@ -0,0 +1,37 @@ +using Microsoft.EntityFrameworkCore.Migrations; +using MyAi.Data; + +#nullable disable + +namespace MyAi.Data.Migrations +{ + /// + public partial class AddHtmlJobSearchShell : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.InsertData( + table: "Templates", + columns: ["Key", "Language", "Value", "Description"], + values: new object[] + { + "html.job-search.shell", + "*", + "{{title}} - MyAi.ro

{{title}}

{{message}}

", + "Full HTML shell for job-search status pages. Placeholders: {{title}}, {{message}}." + }, + schema: MigrationConstants.SchemaName); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DeleteData( + table: "Templates", + keyColumns: ["Key", "Language"], + keyValues: new object[] { "html.job-search.shell", "*" }, + schema: MigrationConstants.SchemaName); + } + } +} diff --git a/Apis/myai-data/Migrations/MyAiDbContextModelSnapshot.cs b/Apis/myai-data/Migrations/MyAiDbContextModelSnapshot.cs new file mode 100644 index 0000000..71e85d6 --- /dev/null +++ b/Apis/myai-data/Migrations/MyAiDbContextModelSnapshot.cs @@ -0,0 +1,59 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using MyAi.Data; + +#nullable disable + +namespace MyAi.Data.Migrations +{ + [DbContext(typeof(MyAiDbContext))] + partial class MyAiDbContextModelSnapshot : ModelSnapshot + { + protected override void BuildModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("myAi") + .HasAnnotation("ProductVersion", "10.0.7") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("MyAi.Data.Entities.TemplateEntity", b => + { + b.Property("Key") + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("Language") + .HasMaxLength(8) + .HasColumnType("nvarchar(8)"); + + b.Property("Description") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)") + .HasDefaultValue(""); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("datetime2") + .HasDefaultValueSql("SYSUTCDATETIME()"); + + b.Property("Value") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.HasKey("Key", "Language"); + + b.ToTable("Templates", "myAi"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Apis/myai-data/Services/DbTemplateService.cs b/Apis/myai-data/Services/DbTemplateService.cs new file mode 100644 index 0000000..0dd5b6d --- /dev/null +++ b/Apis/myai-data/Services/DbTemplateService.cs @@ -0,0 +1,82 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using MyAi.Data; +using System.Collections.Concurrent; + +namespace MyAi.Data.Services; + +/// +/// Singleton implementation of that caches all templates from the +/// myAi.Templates table and refreshes them every 10 minutes. +/// Uses to resolve the scoped DbContext from a singleton lifetime. +/// +public sealed class DbTemplateService : ITemplateService +{ + private readonly IServiceScopeFactory _scopeFactory; + private readonly ILogger _logger; + private ConcurrentDictionary _cache = new(StringComparer.OrdinalIgnoreCase); + private DateTime _loadedAt = DateTime.MinValue; + private static readonly TimeSpan CacheTtl = TimeSpan.FromMinutes(10); + + public DbTemplateService(IServiceScopeFactory scopeFactory, ILogger logger) + { + _scopeFactory = scopeFactory; + _logger = logger; + } + + /// + public string Get(string key, string language = "en") + { + EnsureCacheLoaded(); + + if (_cache.TryGetValue(CacheKey(key, language), out var value)) + return value; + + if (!string.Equals(language, "en", StringComparison.OrdinalIgnoreCase) + && _cache.TryGetValue(CacheKey(key, "en"), out var fallback)) + return fallback; + + _logger.LogWarning("Template not found: key={Key}, language={Language}", key, language); + return key; + } + + /// + public string Render(string key, string language, params (string Key, string Value)[] placeholders) + { + var template = Get(key, language); + foreach (var (k, v) in placeholders) + template = template.Replace($"{{{{{k}}}}}", v, StringComparison.OrdinalIgnoreCase); + return template; + } + + /// + /// Reloads all templates from the database when the cache TTL has expired. + /// Swaps the cache atomically; logs an error and continues serving the stale cache on failure. + /// + private void EnsureCacheLoaded() + { + if (DateTime.UtcNow - _loadedAt < CacheTtl) return; + + try + { + using var scope = _scopeFactory.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + var rows = db.Templates.AsNoTracking().ToList(); + var fresh = new ConcurrentDictionary(StringComparer.OrdinalIgnoreCase); + foreach (var row in rows) + fresh[CacheKey(row.Key, row.Language)] = row.Value; + + _cache = fresh; + _loadedAt = DateTime.UtcNow; + _logger.LogDebug("Template cache refreshed. {Count} templates loaded.", rows.Count); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to refresh template cache. Serving stale cache."); + } + } + + /// Builds the dictionary key used in the cache. + private static string CacheKey(string key, string language) => $"{key}::{language}"; +} diff --git a/Apis/myai-data/Services/ITemplateService.cs b/Apis/myai-data/Services/ITemplateService.cs new file mode 100644 index 0000000..e457dbd --- /dev/null +++ b/Apis/myai-data/Services/ITemplateService.cs @@ -0,0 +1,27 @@ +namespace MyAi.Data.Services; + +/// +/// Provides access to localised string templates stored in the myAi.Templates table. +/// Implementations are expected to cache templates and refresh periodically. +/// +public interface ITemplateService +{ + /// + /// Returns the template value for the given key and language. + /// Falls back to "en" when the requested language has no entry. + /// Returns the raw key string when no matching template is found. + /// + /// Template key (e.g. "html.job-search-start.title"). + /// Two-letter language code (e.g. "en", "ro"). + /// Template value string. + string Get(string key, string language = "en"); + + /// + /// Retrieves the template and substitutes {{placeholder}} tokens with the provided values. + /// + /// Template key. + /// Two-letter language code. + /// Named replacement pairs in the form ("name", value). + /// Rendered template string with all placeholders replaced. + string Render(string key, string language, params (string Key, string Value)[] placeholders); +} diff --git a/Apis/myai-data/myai-data.csproj b/Apis/myai-data/myai-data.csproj new file mode 100644 index 0000000..4095db1 --- /dev/null +++ b/Apis/myai-data/myai-data.csproj @@ -0,0 +1,23 @@ + + + + net10.0 + myai-data + MyAi.Data + enable + enable + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + diff --git a/Apis/page-fetcher-api-models/FetchPageRequest.cs b/Apis/page-fetcher-api-models/FetchPageRequest.cs new file mode 100644 index 0000000..80f4e73 --- /dev/null +++ b/Apis/page-fetcher-api-models/FetchPageRequest.cs @@ -0,0 +1,26 @@ +namespace PageFetcher.Models; + +/// +/// Request to fetch a web page via the page-fetcher-api. +/// +public sealed class FetchPageRequest +{ + /// Absolute HTTP or HTTPS URL to fetch. + public string Url { get; set; } = string.Empty; + + /// + /// Playwright wait condition. Accepted values: networkidle (default), domcontentloaded, load. + /// + public string WaitFor { get; set; } = "networkidle"; + + /// + /// Identifies the calling service for audit purposes (e.g. cv-matcher-api, cv-search-job). + /// + public string CallerService { get; set; } = string.Empty; + + /// + /// Optional reference to the job search session that triggered this fetch. + /// Stored on pageFetcher.PageFetches for cross-schema audit queries. + /// + public string? JobSearchSessionId { get; set; } +} diff --git a/Apis/page-fetcher-api-models/FetchPageResponse.cs b/Apis/page-fetcher-api-models/FetchPageResponse.cs new file mode 100644 index 0000000..e4e17f8 --- /dev/null +++ b/Apis/page-fetcher-api-models/FetchPageResponse.cs @@ -0,0 +1,25 @@ +namespace PageFetcher.Models; + +/// +/// Result of a page fetch operation. +/// +public sealed class FetchPageResponse +{ + /// Final URL after any redirects. + public string Url { get; set; } = string.Empty; + + /// HTTP status code returned by the page. 0 on network failure. + public int StatusCode { get; set; } + + /// Full rendered HTML as returned by Playwright. + public string Html { get; set; } = string.Empty; + + /// Plain text extracted from the HTML (script/style stripped, whitespace normalised). + public string Text { get; set; } = string.Empty; + + /// Whether the fetch succeeded. false on timeout or network error. + public bool Success { get; set; } + + /// Exception message when is false. + public string? Error { get; set; } +} diff --git a/Apis/page-fetcher-api-models/IPageFetcherApiClient.cs b/Apis/page-fetcher-api-models/IPageFetcherApiClient.cs new file mode 100644 index 0000000..bfed7b4 --- /dev/null +++ b/Apis/page-fetcher-api-models/IPageFetcherApiClient.cs @@ -0,0 +1,16 @@ +using Refit; + +namespace PageFetcher.Models; + +/// +/// Refit client for the internal page-fetcher-api service. +/// All calls require the X-Internal-Api-Key header, configured at registration time. +/// +public interface IPageFetcherApiClient +{ + /// + /// Fetches a web page via headless Chromium and returns the rendered HTML and extracted plain text. + /// + [Post("/api/page/fetch")] + Task FetchAsync([Body] FetchPageRequest request, CancellationToken ct = default); +} diff --git a/Apis/page-fetcher-api-models/Settings/PageFetcherSettings.cs b/Apis/page-fetcher-api-models/Settings/PageFetcherSettings.cs new file mode 100644 index 0000000..85646f2 --- /dev/null +++ b/Apis/page-fetcher-api-models/Settings/PageFetcherSettings.cs @@ -0,0 +1,17 @@ +namespace PageFetcher.Models.Settings; + +/// +/// Runtime settings for the page-fetcher service. +/// Bound from the PageFetcher configuration section. +/// +public sealed class PageFetcherSettings +{ + /// Default Playwright wait condition (networkidle, load, domcontentloaded). + public string DefaultWaitFor { get; set; } = "networkidle"; + + /// Page navigation timeout in seconds. + public int TimeoutSeconds { get; set; } = 30; + + /// Maximum characters stored/returned in the extracted text field. + public int MaxTextChars { get; set; } = 60_000; +} diff --git a/Apis/page-fetcher-api-models/page-fetcher-api-models.csproj b/Apis/page-fetcher-api-models/page-fetcher-api-models.csproj new file mode 100644 index 0000000..da460be --- /dev/null +++ b/Apis/page-fetcher-api-models/page-fetcher-api-models.csproj @@ -0,0 +1,12 @@ + + + net10.0 + enable + enable + page-fetcher-api-models + PageFetcher.Models + + + + + diff --git a/Apis/page-fetcher-api/Controllers/PageController.cs b/Apis/page-fetcher-api/Controllers/PageController.cs new file mode 100644 index 0000000..dece501 --- /dev/null +++ b/Apis/page-fetcher-api/Controllers/PageController.cs @@ -0,0 +1,47 @@ +using Api.Services; +using Microsoft.AspNetCore.Mvc; +using PageFetcher.Models; +using Swashbuckle.AspNetCore.Annotations; + +namespace Api.Controllers; + +/// +/// Handles page-fetch requests: navigates to the URL via Playwright and returns rendered HTML and extracted text. +/// +[ApiController] +[Route("api/page")] +public sealed class PageController : ControllerBase +{ + private readonly PageFetcherService _service; + private readonly ILogger _logger; + + public PageController(PageFetcherService service, ILogger logger) + { + _service = service; + _logger = logger; + } + + /// + /// Fetches a web page via headless Chromium. + /// Returns rendered HTML and extracted plain text. + /// + [HttpPost("fetch")] + [SwaggerOperation(Summary = "Fetch a web page", Description = "Navigates to the given URL using Playwright, returns rendered HTML and stripped plain text.")] + [SwaggerResponse(StatusCodes.Status200OK, "Page fetched successfully", typeof(FetchPageResponse))] + [SwaggerResponse(StatusCodes.Status400BadRequest, "Invalid or non-HTTP(S) URL")] + public async Task> Fetch([FromBody] FetchPageRequest request, CancellationToken ct) + { + if (string.IsNullOrWhiteSpace(request.Url)) + return BadRequest(new { Error = "Url is required." }); + + if (!Uri.TryCreate(request.Url, UriKind.Absolute, out var uri) || + (uri.Scheme != Uri.UriSchemeHttp && uri.Scheme != Uri.UriSchemeHttps)) + return BadRequest(new { Error = "Url must be an absolute HTTP or HTTPS URL." }); + + _logger.LogInformation("Fetch request: {Url} | caller={Caller} | waitFor={WaitFor}", + request.Url, request.CallerService, request.WaitFor); + + var result = await _service.FetchAsync(request, ct); + return Ok(result); + } +} diff --git a/Apis/page-fetcher-api/Dockerfile b/Apis/page-fetcher-api/Dockerfile new file mode 100644 index 0000000..3f9b8b2 --- /dev/null +++ b/Apis/page-fetcher-api/Dockerfile @@ -0,0 +1,50 @@ +FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build +ARG BUILD_CONFIGURATION=Release +WORKDIR /src +COPY Directory.Packages.props ./ + +COPY Apis/page-fetcher-api/page-fetcher-api.csproj Apis/page-fetcher-api/ +COPY Apis/page-fetcher-data/page-fetcher-data.csproj Apis/page-fetcher-data/ +COPY Apis/page-fetcher-api-models/page-fetcher-api-models.csproj Apis/page-fetcher-api-models/ +COPY Apis/common/common.csproj Apis/common/ +COPY Apis/shared-data/shared-data.csproj Apis/shared-data/ +COPY Helpers/startup-helpers/startup-helpers.csproj Helpers/startup-helpers/ +COPY Helpers/common-helpers/common-helpers.csproj Helpers/common-helpers/ + +RUN dotnet restore Apis/page-fetcher-api/page-fetcher-api.csproj + +COPY Apis/page-fetcher-api/ Apis/page-fetcher-api/ +COPY Apis/page-fetcher-data/ Apis/page-fetcher-data/ +COPY Apis/page-fetcher-api-models/ Apis/page-fetcher-api-models/ +COPY Apis/common/ Apis/common/ +COPY Apis/shared-data/ Apis/shared-data/ +COPY Helpers/startup-helpers/ Helpers/startup-helpers/ +COPY Helpers/common-helpers/ Helpers/common-helpers/ + +RUN dotnet publish Apis/page-fetcher-api/page-fetcher-api.csproj -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=false + +# Download Playwright Chromium browser in the build stage. +# Node.js is only needed here to run npx — it is not copied to the final image. +ENV PLAYWRIGHT_BROWSERS_PATH=/ms-playwright +RUN apt-get update && apt-get install -y --no-install-recommends nodejs npm \ + && npx --yes playwright@1.60.0 install chromium \ + && rm -rf /var/lib/apt/lists/* + +FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS final +WORKDIR /app + +# System libraries required by Chromium on Debian bookworm +RUN apt-get update && apt-get install -y --no-install-recommends \ + libnss3 libnspr4 libatk1.0-0 libatk-bridge2.0-0 libcups2 libdrm2 \ + libxkbcommon0 libxcomposite1 libxdamage1 libxfixes3 libxrandr2 \ + libgbm1 libasound2t64 libpango-1.0-0 libcairo2 libatspi2.0-0 \ + libwayland-client0 libx11-xcb1 libx11-6 libxcb1 libxext6 \ + && rm -rf /var/lib/apt/lists/* + +# Copy the Playwright Chromium browser from the build stage +ENV PLAYWRIGHT_BROWSERS_PATH=/ms-playwright +COPY --from=build /ms-playwright /ms-playwright + +COPY --from=build /app/publish . + +ENTRYPOINT ["dotnet", "page-fetcher-api.dll"] diff --git a/Apis/page-fetcher-api/Program.cs b/Apis/page-fetcher-api/Program.cs new file mode 100644 index 0000000..4a8bd2c --- /dev/null +++ b/Apis/page-fetcher-api/Program.cs @@ -0,0 +1,75 @@ +using System.Reflection; +using Microsoft.EntityFrameworkCore; +using PageFetcher.Data; +using Api.Services; +using PageFetcher.Models.Settings; +using Serilog; +using StartupHelpers; + +StartupExtensions.LoadDotEnvFile(); + +const string ServiceName = "page-fetcher-api"; +var appVersion = StartupExtensions.GetApplicationVersion(Assembly.GetExecutingAssembly()); + +try +{ + var builder = WebApplication.CreateBuilder(args); + + builder.ConfigureJsonSerilog(ServiceName, appVersion); + Log.Information("Starting {Service} version {AppVersion}", ServiceName, appVersion); + + builder.AddAzureKeyVaultIfConfigured(); + + builder.Services.Configure(builder.Configuration.GetSection("PageFetcher")); + + builder.Services.AddDbContext(options => + { + var connectionString = builder.Services.GetConfiguredDbConnectionString(builder.Configuration); + options.UseSqlServer(connectionString, sql => + { + sql.MigrationsHistoryTable(PageFetchDbContext.MigrationTableName, PageFetchDbContext.SchemaName); + sql.MigrationsAssembly("page-fetcher-data"); + }); + }); + + // Playwright browser: singleton hosted service, shared across all requests + builder.Services.AddSingleton(); + builder.Services.AddHostedService(sp => sp.GetRequiredService()); + + builder.Services.AddScoped(); + + builder.Services.AddControllers(); + builder.Services.AddSwaggerWithXmlComments(Assembly.GetExecutingAssembly(), "Page Fetcher API"); + + var app = builder.Build(); + + app.LogStartupDiagnostics(ServiceName); + + app.UseDefaultSerilogRequestLogging(); + app.UseJsonExceptionHandler(ServiceName); + app.UseInternalApiKeyProtection(); + app.UseSwaggerInDevelopment("Page Fetcher API", "PageFetcherAPI"); + + app.UseRouting(); + app.UseAuthorization(); + app.MapControllers(); + + Log.Information("Running EF Core migrations if any"); + using (var scope = app.Services.CreateScope()) + { + var db = scope.ServiceProvider.GetRequiredService(); + db.Database.Migrate(); + } + + Log.Information("{Service} startup complete. Listening for requests...", ServiceName); + app.Run(); +} +catch (Exception ex) +{ + Log.Fatal(ex, "{Service} terminated unexpectedly", ServiceName); +} +finally +{ + Log.Information("Shutting down {Service}", ServiceName); + Log.CloseAndFlush(); +} diff --git a/Apis/page-fetcher-api/Properties/launchSettings.json b/Apis/page-fetcher-api/Properties/launchSettings.json new file mode 100644 index 0000000..c9995ec --- /dev/null +++ b/Apis/page-fetcher-api/Properties/launchSettings.json @@ -0,0 +1,12 @@ +{ + "profiles": { + "page-fetcher-api": { + "commandName": "Project", + "launchBrowser": true, + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + }, + "applicationUrl": "https://localhost:50268;http://localhost:50269" + } + } +} \ No newline at end of file diff --git a/Apis/page-fetcher-api/Services/PageFetcherService.cs b/Apis/page-fetcher-api/Services/PageFetcherService.cs new file mode 100644 index 0000000..df61770 --- /dev/null +++ b/Apis/page-fetcher-api/Services/PageFetcherService.cs @@ -0,0 +1,145 @@ +using System.Diagnostics; +using System.Net; +using System.Text.RegularExpressions; +using Microsoft.Extensions.Options; +using Microsoft.Playwright; +using PageFetcher.Data; +using PageFetcher.Data.Entities; +using PageFetcher.Models; +using PageFetcher.Models.Settings; + +namespace Api.Services; + +/// +/// Fetches a web page via Playwright, extracts plain text, persists the result to the database, +/// and returns a . +/// +public sealed class PageFetcherService +{ + private readonly PlaywrightBrowserService _browserService; + private readonly PageFetchDbContext _db; + private readonly PageFetcherSettings _settings; + private readonly ILogger _logger; + + public PageFetcherService( + PlaywrightBrowserService browserService, + PageFetchDbContext db, + IOptions settings, + ILogger logger) + { + _browserService = browserService; + _db = db; + _settings = settings.Value; + _logger = logger; + } + + /// + /// Fetches the page at using Playwright, saves the fetch record, + /// and returns the HTML and extracted text. + /// Returns a failed response (with = false) rather than throwing + /// on network or navigation errors. + /// + public async Task FetchAsync(FetchPageRequest request, CancellationToken ct) + { + var sw = Stopwatch.StartNew(); + string html = string.Empty; + string text = string.Empty; + int? statusCode = null; + bool success = false; + string? errorMessage = null; + string finalUrl = request.Url; + + try + { + var page = await _browserService.Browser.NewPageAsync(); + await using var _ = page.ConfigureAwait(false); + + var waitUntil = request.WaitFor?.ToLowerInvariant() switch + { + "load" => WaitUntilState.Load, + "domcontentloaded" => WaitUntilState.DOMContentLoaded, + _ => WaitUntilState.NetworkIdle + }; + + IResponse? response; + try + { + response = await page.GotoAsync(request.Url, new PageGotoOptions + { + WaitUntil = waitUntil, + Timeout = _settings.TimeoutSeconds * 1_000 + }); + } + catch (TimeoutException) + { + _logger.LogWarning("Playwright NetworkIdle timeout for {Url}, using partial content", request.Url); + response = null; + } + + statusCode = response?.Status; + finalUrl = page.Url; + html = await page.ContentAsync(); + text = ExtractText(html); + success = true; + + _logger.LogInformation("Fetched {Url} → HTTP {Status} | HTML {HtmlLen} chars | text {TextLen} chars | {DurationMs} ms", + request.Url, statusCode?.ToString() ?? "timeout", html.Length, text.Length, sw.ElapsedMilliseconds); + } + catch (Exception ex) + { + errorMessage = ex.Message; + _logger.LogError(ex, "Failed to fetch {Url}", request.Url); + } + finally + { + sw.Stop(); + } + + // Persist fetch record + var entity = new PageFetchEntity + { + Id = Guid.NewGuid().ToString("N"), + Url = request.Url, + CallerService = request.CallerService ?? string.Empty, + JobSearchSessionId = request.JobSearchSessionId, + HttpStatusCode = statusCode, + Html = html, + Text = text, + DurationMs = sw.ElapsedMilliseconds, + Success = success, + ErrorMessage = errorMessage + }; + + _db.PageFetches.Add(entity); + await _db.SaveChangesAsync(ct); + + return new FetchPageResponse + { + Url = finalUrl, + StatusCode = statusCode ?? 0, + Html = html, + Text = text, + Success = success, + Error = errorMessage + }; + } + + /// + /// Strips script/style blocks and all HTML tags from raw HTML, normalises whitespace, + /// and truncates to . + /// + private string ExtractText(string html) + { + if (string.IsNullOrWhiteSpace(html)) return string.Empty; + + var text = html; + text = Regex.Replace(text, "", " ", RegexOptions.IgnoreCase); + text = Regex.Replace(text, "", " ", RegexOptions.IgnoreCase); + text = Regex.Replace(text, "<[^>]+>", " "); + text = WebUtility.HtmlDecode(text); + text = string.Join(' ', text.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries)).Trim(); + + var max = Math.Max(4_000, _settings.MaxTextChars); + return text.Length <= max ? text : text[..max]; + } +} diff --git a/Apis/page-fetcher-api/Services/PlaywrightBrowserService.cs b/Apis/page-fetcher-api/Services/PlaywrightBrowserService.cs new file mode 100644 index 0000000..8e25acf --- /dev/null +++ b/Apis/page-fetcher-api/Services/PlaywrightBrowserService.cs @@ -0,0 +1,49 @@ +using Microsoft.Playwright; + +namespace Api.Services; + +/// +/// Singleton hosted service that owns the Playwright Chromium browser process for the lifetime of the application. +/// Launches the browser once at startup and exposes it for injection into . +/// +public sealed class PlaywrightBrowserService : IHostedService, IAsyncDisposable +{ + private IPlaywright? _playwright; + private IBrowser? _browser; + private readonly ILogger _logger; + + public PlaywrightBrowserService(ILogger logger) + { + _logger = logger; + } + + /// The running Chromium browser instance. Available after completes. + public IBrowser Browser => _browser ?? throw new InvalidOperationException("Browser has not been started yet."); + + /// + public async Task StartAsync(CancellationToken cancellationToken) + { + _logger.LogInformation("Launching Playwright Chromium browser..."); + _playwright = await Playwright.CreateAsync(); + _browser = await _playwright.Chromium.LaunchAsync(new BrowserTypeLaunchOptions + { + Headless = true, + Args = ["--no-sandbox", "--disable-setuid-sandbox", "--disable-dev-shm-usage"] + }); + _logger.LogInformation("Playwright Chromium browser launched successfully."); + } + + /// + public async Task StopAsync(CancellationToken cancellationToken) + { + _logger.LogInformation("Closing Playwright Chromium browser..."); + if (_browser is not null) await _browser.CloseAsync(); + } + + /// + public async ValueTask DisposeAsync() + { + if (_browser is not null) await _browser.DisposeAsync(); + _playwright?.Dispose(); + } +} diff --git a/Apis/page-fetcher-api/appsettings.json b/Apis/page-fetcher-api/appsettings.json new file mode 100644 index 0000000..35a63ae --- /dev/null +++ b/Apis/page-fetcher-api/appsettings.json @@ -0,0 +1,73 @@ +{ + "Serilog": { + "Using": [ + "Serilog.Sinks.Console", + "Serilog.Sinks.File" + ], + "MinimumLevel": { + "Default": "Information", + "Override": { + "Microsoft.AspNetCore": "Warning", + "Microsoft.AspNetCore.Hosting": "Information", + "Microsoft.AspNetCore.Routing": "Warning", + "System.Net.Http.HttpClient": "Warning", + "PageFetcherApi": "Information" + } + }, + "WriteTo": [ + { + "Name": "Console", + "Args": { + "outputTemplate": "[{Timestamp:HH:mm:ss} {Level:u3}] {SourceContext}: {Message:lj}{NewLine}{Exception}" + } + }, + { + "Name": "File", + "Args": { + "path": "logs/page-fetcher-api-.log", + "rollingInterval": "Day", + "retainedFileCountLimit": 30, + "outputTemplate": "{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} [{Level:u3}] {SourceContext}: {Message:lj}{NewLine}{Exception}" + } + } + ], + "Enrich": [ + "FromLogContext", + "WithMachineName", + "WithEnvironmentName" + ] + }, + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning", + "Microsoft.AspNetCore.Hosting": "Information", + "Microsoft.AspNetCore.Routing": "Warning", + "System.Net.Http.HttpClient": "Warning", + "PageFetcherApi": "Information" + } + }, + "LogEnvironmentOnStartup": true, + "AllowedHosts": "*", + "KeyVault": { + "VaultUri": "", + "Enabled": false + }, + "Database": { + "Host": "localhost", + "Port": 1433, + "Name": "MyAiDb", + "User": "sa", + "Password": "", + "TrustServerCertificate": true + }, + "InternalApi": { + "ApiKey": "", + "RequireApiKey": true + }, + "PageFetcher": { + "DefaultWaitFor": "networkidle", + "TimeoutSeconds": 30, + "MaxTextChars": 60000 + } +} diff --git a/Apis/page-fetcher-api/page-fetcher-api.csproj b/Apis/page-fetcher-api/page-fetcher-api.csproj new file mode 100644 index 0000000..40123f4 --- /dev/null +++ b/Apis/page-fetcher-api/page-fetcher-api.csproj @@ -0,0 +1,34 @@ + + + net10.0 + enable + enable + Linux + PageFetcherApi + true + $(NoWarn);1591 + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + + + + + + + diff --git a/Apis/page-fetcher-data/Data/Entities/PageFetchEntity.cs b/Apis/page-fetcher-data/Data/Entities/PageFetchEntity.cs new file mode 100644 index 0000000..16c08d1 --- /dev/null +++ b/Apis/page-fetcher-data/Data/Entities/PageFetchEntity.cs @@ -0,0 +1,40 @@ +using Shared.Data.Entities; + +namespace PageFetcher.Data.Entities; + +/// +/// Audit record of a single page-fetch operation performed by the page-fetcher-api. +/// Stores the full rendered HTML and extracted plain text for every URL fetched. +/// +public sealed class PageFetchEntity : BaseEntity +{ + /// The URL that was requested. + public string Url { get; set; } = string.Empty; + + /// Name of the service that requested the fetch (e.g. cv-matcher-api, cv-search-job). + public string CallerService { get; set; } = string.Empty; + + /// HTTP status code returned by the remote server. null on network failure. + public int? HttpStatusCode { get; set; } + + /// Full rendered HTML as returned by Playwright. + public string Html { get; set; } = string.Empty; + + /// Plain text extracted from the HTML (script/style stripped, whitespace normalised). + public string Text { get; set; } = string.Empty; + + /// Playwright round-trip time in milliseconds. + public long DurationMs { get; set; } + + /// true when the page was fetched successfully; false on timeout or network error. + public bool Success { get; set; } + + /// Exception message when is false. + public string? ErrorMessage { get; set; } + + /// + /// Optional reference to the cvSearch.JobSearchSessions row that triggered this fetch. + /// Null for fetches not originating from a job search session (e.g. direct CV-to-job matches). + /// + public string? JobSearchSessionId { get; set; } +} diff --git a/Apis/page-fetcher-data/MigrationConstants.cs b/Apis/page-fetcher-data/MigrationConstants.cs new file mode 100644 index 0000000..9f4d74b --- /dev/null +++ b/Apis/page-fetcher-data/MigrationConstants.cs @@ -0,0 +1,8 @@ +namespace PageFetcher.Data; + +/// Schema and migration-history table name constants for the pageFetcher EF schema. +public static class MigrationConstants +{ + public const string SchemaName = "pageFetcher"; + public const string MigrationTableName = "_Migrations"; +} diff --git a/Apis/page-fetcher-data/Migrations/20260608143523_InitialSchema.Designer.cs b/Apis/page-fetcher-data/Migrations/20260608143523_InitialSchema.Designer.cs new file mode 100644 index 0000000..a036246 --- /dev/null +++ b/Apis/page-fetcher-data/Migrations/20260608143523_InitialSchema.Designer.cs @@ -0,0 +1,82 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using PageFetcher.Data; + +#nullable disable + +namespace PageFetcher.Data.Migrations +{ + [DbContext(typeof(PageFetchDbContext))] + [Migration("20260608143523_InitialSchema")] + partial class InitialSchema + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("pageFetcher") + .HasAnnotation("ProductVersion", "10.0.7") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("PageFetcher.Data.Entities.PageFetchEntity", b => + { + b.Property("Id") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("CallerService") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("datetime2") + .HasDefaultValueSql("SYSUTCDATETIME()"); + + b.Property("DurationMs") + .HasColumnType("bigint"); + + b.Property("ErrorMessage") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("Html") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("HttpStatusCode") + .HasColumnType("int"); + + b.Property("Success") + .HasColumnType("bit"); + + b.Property("Text") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Url") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.HasKey("Id"); + + b.HasIndex("CreatedAt"); + + b.HasIndex("Url"); + + b.ToTable("PageFetches", "pageFetcher"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Apis/page-fetcher-data/Migrations/20260608143523_InitialSchema.cs b/Apis/page-fetcher-data/Migrations/20260608143523_InitialSchema.cs new file mode 100644 index 0000000..7f23daa --- /dev/null +++ b/Apis/page-fetcher-data/Migrations/20260608143523_InitialSchema.cs @@ -0,0 +1,59 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace PageFetcher.Data.Migrations +{ + /// + public partial class InitialSchema : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.EnsureSchema( + name: MigrationConstants.SchemaName); + + migrationBuilder.CreateTable( + name: "PageFetches", + schema: MigrationConstants.SchemaName, + columns: table => new + { + Id = table.Column(type: "nvarchar(64)", maxLength: 64, nullable: false), + Url = table.Column(type: "nvarchar(2000)", maxLength: 2000, nullable: false), + CallerService = table.Column(type: "nvarchar(64)", maxLength: 64, nullable: false), + HttpStatusCode = table.Column(type: "int", nullable: true), + Html = table.Column(type: "nvarchar(max)", nullable: false), + Text = table.Column(type: "nvarchar(max)", nullable: false), + DurationMs = table.Column(type: "bigint", nullable: false), + Success = table.Column(type: "bit", nullable: false), + ErrorMessage = table.Column(type: "nvarchar(2000)", maxLength: 2000, nullable: true), + CreatedAt = table.Column(type: "datetime2", nullable: false, defaultValueSql: "SYSUTCDATETIME()") + }, + constraints: table => + { + table.PrimaryKey("PK_PageFetches", x => x.Id); + }); + + migrationBuilder.CreateIndex( + name: "IX_PageFetches_CreatedAt", + schema: MigrationConstants.SchemaName, + table: "PageFetches", + column: "CreatedAt"); + + migrationBuilder.CreateIndex( + name: "IX_PageFetches_Url", + schema: MigrationConstants.SchemaName, + table: "PageFetches", + column: "Url"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "PageFetches", + schema: MigrationConstants.SchemaName); + } + } +} diff --git a/Apis/page-fetcher-data/Migrations/20260608165542_AddJobSearchSessionId.Designer.cs b/Apis/page-fetcher-data/Migrations/20260608165542_AddJobSearchSessionId.Designer.cs new file mode 100644 index 0000000..a39a3db --- /dev/null +++ b/Apis/page-fetcher-data/Migrations/20260608165542_AddJobSearchSessionId.Designer.cs @@ -0,0 +1,88 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using PageFetcher.Data; + +#nullable disable + +namespace PageFetcher.Data.Migrations +{ + [DbContext(typeof(PageFetchDbContext))] + [Migration("20260608165542_AddJobSearchSessionId")] + partial class AddJobSearchSessionId + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("pageFetcher") + .HasAnnotation("ProductVersion", "10.0.7") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("PageFetcher.Data.Entities.PageFetchEntity", b => + { + b.Property("Id") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("CallerService") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("datetime2") + .HasDefaultValueSql("SYSUTCDATETIME()"); + + b.Property("DurationMs") + .HasColumnType("bigint"); + + b.Property("ErrorMessage") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("Html") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("HttpStatusCode") + .HasColumnType("int"); + + b.Property("JobSearchSessionId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("Success") + .HasColumnType("bit"); + + b.Property("Text") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Url") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.HasKey("Id"); + + b.HasIndex("CreatedAt"); + + b.HasIndex("JobSearchSessionId"); + + b.HasIndex("Url"); + + b.ToTable("PageFetches", "pageFetcher"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Apis/page-fetcher-data/Migrations/20260608165542_AddJobSearchSessionId.cs b/Apis/page-fetcher-data/Migrations/20260608165542_AddJobSearchSessionId.cs new file mode 100644 index 0000000..aa1ea36 --- /dev/null +++ b/Apis/page-fetcher-data/Migrations/20260608165542_AddJobSearchSessionId.cs @@ -0,0 +1,43 @@ +using PageFetcher.Data; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace PageFetcher.Data.Migrations +{ + /// + public partial class AddJobSearchSessionId : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "JobSearchSessionId", + schema: MigrationConstants.SchemaName, + table: "PageFetches", + type: "nvarchar(64)", + maxLength: 64, + nullable: true); + + migrationBuilder.CreateIndex( + name: "IX_PageFetches_JobSearchSessionId", + schema: MigrationConstants.SchemaName, + table: "PageFetches", + column: "JobSearchSessionId"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropIndex( + name: "IX_PageFetches_JobSearchSessionId", + schema: MigrationConstants.SchemaName, + table: "PageFetches"); + + migrationBuilder.DropColumn( + name: "JobSearchSessionId", + schema: MigrationConstants.SchemaName, + table: "PageFetches"); + } + } +} diff --git a/Apis/page-fetcher-data/Migrations/PageFetchDbContextModelSnapshot.cs b/Apis/page-fetcher-data/Migrations/PageFetchDbContextModelSnapshot.cs new file mode 100644 index 0000000..af3a679 --- /dev/null +++ b/Apis/page-fetcher-data/Migrations/PageFetchDbContextModelSnapshot.cs @@ -0,0 +1,85 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using PageFetcher.Data; + +#nullable disable + +namespace PageFetcher.Data.Migrations +{ + [DbContext(typeof(PageFetchDbContext))] + partial class PageFetchDbContextModelSnapshot : ModelSnapshot + { + protected override void BuildModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("pageFetcher") + .HasAnnotation("ProductVersion", "10.0.7") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("PageFetcher.Data.Entities.PageFetchEntity", b => + { + b.Property("Id") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("CallerService") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("datetime2") + .HasDefaultValueSql("SYSUTCDATETIME()"); + + b.Property("DurationMs") + .HasColumnType("bigint"); + + b.Property("ErrorMessage") + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.Property("Html") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("HttpStatusCode") + .HasColumnType("int"); + + b.Property("JobSearchSessionId") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("Success") + .HasColumnType("bit"); + + b.Property("Text") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Url") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("nvarchar(2000)"); + + b.HasKey("Id"); + + b.HasIndex("CreatedAt"); + + b.HasIndex("JobSearchSessionId"); + + b.HasIndex("Url"); + + b.ToTable("PageFetches", "pageFetcher"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Apis/page-fetcher-data/PageFetchDbContext.cs b/Apis/page-fetcher-data/PageFetchDbContext.cs new file mode 100644 index 0000000..fbd9e22 --- /dev/null +++ b/Apis/page-fetcher-data/PageFetchDbContext.cs @@ -0,0 +1,48 @@ +using Microsoft.EntityFrameworkCore; +using PageFetcher.Data.Entities; + +namespace PageFetcher.Data; + +/// +/// EF Core DbContext for the pageFetcher schema. +/// Owns the PageFetches audit table. +/// +public sealed class PageFetchDbContext : DbContext +{ + public const string SchemaName = MigrationConstants.SchemaName; + public const string MigrationTableName = MigrationConstants.MigrationTableName; + + public PageFetchDbContext(DbContextOptions options) : base(options) { } + + public DbSet PageFetches => Set(); + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + { + base.OnConfiguring(optionsBuilder); + optionsBuilder.UseSqlServer(x => x.MigrationsHistoryTable(MigrationTableName, SchemaName)); + } + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.HasDefaultSchema(SchemaName); + + modelBuilder.Entity(entity => + { + entity.ToTable("PageFetches"); + entity.HasKey(x => x.Id); + entity.Property(x => x.Id).HasMaxLength(64); + entity.Property(x => x.Url).HasMaxLength(2000).IsRequired(); + entity.Property(x => x.CallerService).HasMaxLength(64).IsRequired(); + entity.Property(x => x.Html).IsRequired(); + entity.Property(x => x.Text).IsRequired(); + entity.Property(x => x.ErrorMessage).HasMaxLength(2000); + entity.Property(x => x.JobSearchSessionId).HasMaxLength(64); + entity.Property(x => x.CreatedAt).HasDefaultValueSql("SYSUTCDATETIME()"); + + entity.HasIndex(x => x.JobSearchSessionId); + + entity.HasIndex(x => x.Url); + entity.HasIndex(x => x.CreatedAt); + }); + } +} diff --git a/Apis/page-fetcher-data/page-fetcher-data.csproj b/Apis/page-fetcher-data/page-fetcher-data.csproj new file mode 100644 index 0000000..2bf865a --- /dev/null +++ b/Apis/page-fetcher-data/page-fetcher-data.csproj @@ -0,0 +1,19 @@ + + + net10.0 + enable + enable + page-fetcher-data + PageFetcher.Data + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + diff --git a/Apis/rag-api-models/Settings/OllamaSettings.cs b/Apis/rag-api-models/Settings/OllamaSettings.cs index b75ffe2..e99b845 100644 --- a/Apis/rag-api-models/Settings/OllamaSettings.cs +++ b/Apis/rag-api-models/Settings/OllamaSettings.cs @@ -1,6 +1,6 @@ namespace Rag.Models.Settings; -public sealed class OllamaSettings : Shared.Models.Settings.OllamaSettings +public sealed class OllamaSettings : Common.Settings.OllamaSettings { public string EmbeddingModel { get; set; } = "nomic-embed-text"; } diff --git a/Apis/rag-api-models/Settings/OpenAiSettings.cs b/Apis/rag-api-models/Settings/OpenAiSettings.cs index b1c7f36..80eccbc 100644 --- a/Apis/rag-api-models/Settings/OpenAiSettings.cs +++ b/Apis/rag-api-models/Settings/OpenAiSettings.cs @@ -1,6 +1,6 @@ namespace Rag.Models.Settings; -public sealed class OpenAiSettings: Shared.Models.Settings.OpenAiSettings +public sealed class OpenAiSettings : Common.Settings.OpenAiSettings { public string EmbeddingModel { get; set; } = "text-embedding-3-small"; } diff --git a/Apis/rag-api-models/rag-api-models.csproj b/Apis/rag-api-models/rag-api-models.csproj index b19eedd..d5098be 100644 --- a/Apis/rag-api-models/rag-api-models.csproj +++ b/Apis/rag-api-models/rag-api-models.csproj @@ -8,7 +8,7 @@ - + diff --git a/Apis/rag-api/Clients/Ai/CachedRagAiClient.cs b/Apis/rag-api/Clients/Ai/CachedRagAiClient.cs index 4821285..0aa29b2 100644 --- a/Apis/rag-api/Clients/Ai/CachedRagAiClient.cs +++ b/Apis/rag-api/Clients/Ai/CachedRagAiClient.cs @@ -1,6 +1,6 @@ using Microsoft.Extensions.Options; using Rag.Models.Settings; -using Api.Data.Repositories.Contracts; +using Rag.Data.Repositories.Contracts; using Api.Clients.Ai.Contracts; using CommonHelpers; diff --git a/Apis/rag-api/Controllers/RagController.cs b/Apis/rag-api/Controllers/RagController.cs index 0354752..d3e8cfc 100644 --- a/Apis/rag-api/Controllers/RagController.cs +++ b/Apis/rag-api/Controllers/RagController.cs @@ -3,10 +3,14 @@ using Api.Services.Contracts; using Rag.Models.Requests; using Rag.Models.Responses; using Swashbuckle.AspNetCore.Annotations; -using Shared.Models.Responses; +using Common.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) diff --git a/Apis/rag-api/Dockerfile b/Apis/rag-api/Dockerfile index 9878095..186e3e1 100644 --- a/Apis/rag-api/Dockerfile +++ b/Apis/rag-api/Dockerfile @@ -1,18 +1,23 @@ FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build ARG BUILD_CONFIGURATION=Release WORKDIR /src +COPY Directory.Packages.props ./ COPY Apis/rag-api/rag-api.csproj Apis/rag-api/ -COPY Apis/shared-models/shared-models.csproj Apis/shared-models/ +COPY Apis/rag-data/rag-data.csproj Apis/rag-data/ +COPY Apis/common/common.csproj Apis/common/ COPY Apis/rag-api-models/rag-api-models.csproj Apis/rag-api-models/ +COPY Apis/shared-data/shared-data.csproj Apis/shared-data/ COPY Helpers/common-helpers/common-helpers.csproj Helpers/common-helpers/ COPY Helpers/startup-helpers/startup-helpers.csproj Helpers/startup-helpers/ RUN dotnet restore Apis/rag-api/rag-api.csproj COPY Apis/rag-api/ Apis/rag-api/ -COPY Apis/shared-models/ Apis/shared-models/ +COPY Apis/rag-data/ Apis/rag-data/ +COPY Apis/common/ Apis/common/ COPY Apis/rag-api-models/ Apis/rag-api-models/ +COPY Apis/shared-data/ Apis/shared-data/ COPY Helpers/common-helpers/ Helpers/common-helpers/ COPY Helpers/startup-helpers/ Helpers/startup-helpers/ @@ -25,4 +30,4 @@ ENV ASPNETCORE_URLS=http://0.0.0.0:8080 COPY --from=build /app/publish . -ENTRYPOINT ["dotnet", "rag-api.dll"] \ No newline at end of file +ENTRYPOINT ["dotnet", "rag-api.dll"] diff --git a/Apis/rag-api/Program.cs b/Apis/rag-api/Program.cs index daadebf..a269169 100644 --- a/Apis/rag-api/Program.cs +++ b/Apis/rag-api/Program.cs @@ -1,15 +1,15 @@ using System.Reflection; using Api.Clients.Ai; using Api.Clients.Ai.Contracts; -using Api.Data; -using Api.Data.Repositories; -using Api.Data.Repositories.Contracts; +using Rag.Data; +using Rag.Data.Repositories; +using Rag.Data.Repositories.Contracts; using Api.Services; using Api.Services.Contracts; using Microsoft.EntityFrameworkCore; using Rag.Models.Settings; using Serilog; -using Shared.Models.Settings; +using Common.Settings; using StartupHelpers; StartupExtensions.LoadDotEnvFile(); @@ -39,11 +39,10 @@ try options.UseSqlServer(connectionString, sql => { sql.MigrationsHistoryTable(RagDbContext.MigrationTableName, RagDbContext.SchemaName); + sql.MigrationsAssembly("rag-data"); }); }); - builder.Services.AddHttpClient(); - builder.Services.AddScoped(); builder.Services.AddHttpClient(); builder.Services.AddScoped(); builder.Services.AddScoped(); diff --git a/Apis/rag-api/Services/Contracts/IDocumentClassifier.cs b/Apis/rag-api/Services/Contracts/IDocumentClassifier.cs index 00766ab..fbaa2ae 100644 --- a/Apis/rag-api/Services/Contracts/IDocumentClassifier.cs +++ b/Apis/rag-api/Services/Contracts/IDocumentClassifier.cs @@ -2,7 +2,20 @@ using Rag.Models; namespace Api.Services.Contracts; +/// +/// Classifies a document into a known type (cv, job, contract, etc.) and extracts a title. +/// public interface IDocumentClassifier { + /// + /// Determines the document type and title from the provided text. + /// Uses and directly when supplied; + /// otherwise falls back to a keyword-frequency heuristic over the text. + /// + /// Full document text to classify. + /// Caller-supplied document type hint; skips heuristic when non-empty. + /// Caller-supplied document title; skips title extraction when non-empty. + /// Cancellation token. + /// A with type, confidence score, and title. Task ClassifyAsync(string text, string? providedType, string? providedTitle, CancellationToken ct); } diff --git a/Apis/rag-api/Services/Contracts/IRagService.cs b/Apis/rag-api/Services/Contracts/IRagService.cs index 3d68812..001e794 100644 --- a/Apis/rag-api/Services/Contracts/IRagService.cs +++ b/Apis/rag-api/Services/Contracts/IRagService.cs @@ -3,10 +3,46 @@ using Rag.Models.Responses; namespace Api.Services.Contracts; +/// +/// Core RAG (Retrieval-Augmented Generation) operations: document indexing, vector search, and retrieval. +/// public interface IRagService { + /// + /// Indexes a plain-text document by classifying it, chunking the text, generating embeddings, + /// and persisting the document and its chunks. Returns cached metadata when the text hash already exists. + /// + /// Indexing request with text, optional document type, title, and source URL. + /// Cancellation token. + /// Response with document ID, hash, type, and chunk/character counts. Task IndexTextAsync(IndexDocumentRequest request, CancellationToken ct); + + /// + /// Extracts text from a PDF file, then indexes it the same way as . + /// Returns cached metadata when the extracted text hash already exists. + /// + /// Uploaded PDF file (must be ≤ configured max size). + /// Optional document type hint; if omitted the classifier is used. + /// Optional title override; if omitted the title is extracted from the text. + /// Optional source URL to associate with the document. + /// Cancellation token. + /// Response with document ID, hash, type, and chunk/character counts. Task IndexPdfAsync(IFormFile file, string? documentType, string? title, string? sourceUrl, CancellationToken ct); + + /// + /// Performs a vector similarity search over indexed document chunks, groups results by document, + /// and returns the top-K documents with their best-matching chunks. + /// + /// Search request with query text, optional document type filter, and top-K limit. + /// Cancellation token. + /// Ranked list of matching documents with scored chunk excerpts. Task SearchAsync(SearchRequest request, CancellationToken ct); + + /// + /// Retrieves full document details — including the original text — by document ID. + /// + /// The document's unique identifier. + /// Cancellation token. + /// Document details, or null if no document with that ID exists. Task GetDocumentAsync(string documentId, CancellationToken ct); } diff --git a/Apis/rag-api/Services/Contracts/ITextChunker.cs b/Apis/rag-api/Services/Contracts/ITextChunker.cs index 6c7e660..eda76fe 100644 --- a/Apis/rag-api/Services/Contracts/ITextChunker.cs +++ b/Apis/rag-api/Services/Contracts/ITextChunker.cs @@ -1,6 +1,17 @@ namespace Api.Services.Contracts; +/// +/// Splits document text into overlapping chunks suitable for embedding and vector search. +/// public interface ITextChunker { + /// + /// Divides into a list of chunks using a sliding window. + /// Adjacent chunks share characters to preserve cross-boundary context. + /// + /// The full document text to chunk. + /// Maximum character length per chunk (clamped to 300–3000). + /// Number of trailing characters from the previous chunk to repeat at the start of the next (clamped to 0–chunkSize/2). + /// Ordered list of non-empty text chunks. IReadOnlyList Chunk(string text, int chunkSize, int overlap); } diff --git a/Apis/rag-api/Services/Contracts/ITextExtractor.cs b/Apis/rag-api/Services/Contracts/ITextExtractor.cs index 4241474..4c56657 100644 --- a/Apis/rag-api/Services/Contracts/ITextExtractor.cs +++ b/Apis/rag-api/Services/Contracts/ITextExtractor.cs @@ -1,7 +1,23 @@ namespace Api.Services.Contracts; +/// +/// Extracts and normalises plain text from documents. +/// public interface ITextExtractor { + /// + /// Reads all pages of a PDF stream and returns the concatenated, normalised plain text. + /// + /// Readable stream positioned at the start of the PDF file. + /// Cancellation token (checked between pages). + /// Normalised plain text extracted from the PDF. Task ExtractPdfAsync(Stream stream, CancellationToken ct); + + /// + /// Collapses all whitespace sequences in to single spaces and trims the result. + /// Returns an empty string for null/whitespace input. + /// + /// Raw text to normalise. + /// Whitespace-normalised text. string Normalize(string value); } diff --git a/Apis/rag-api/Services/DocumentClassifier.cs b/Apis/rag-api/Services/DocumentClassifier.cs index ae64279..4262bfb 100644 --- a/Apis/rag-api/Services/DocumentClassifier.cs +++ b/Apis/rag-api/Services/DocumentClassifier.cs @@ -4,6 +4,9 @@ using Rag.Models; namespace Api.Services; +/// +/// Classifies documents by type using a keyword-frequency heuristic and extracts a title from the text. +/// public sealed class DocumentClassifier : IDocumentClassifier { private static readonly HashSet KnownTypes = new(StringComparer.OrdinalIgnoreCase) @@ -11,6 +14,7 @@ public sealed class DocumentClassifier : IDocumentClassifier "cv", "job", "article", "contract", "invoice", "product", "documentation", "unknown" }; + /// public Task ClassifyAsync(string text, string? providedType, string? providedTitle, CancellationToken ct) { if (!string.IsNullOrWhiteSpace(providedType)) @@ -24,6 +28,8 @@ public sealed class DocumentClassifier : IDocumentClassifier }); } + // Keyword-frequency heuristic: count how many characteristic terms each document + // type contributes to the text, then pick the type with the highest hit count. var lower = text.ToLowerInvariant(); var scores = new Dictionary(StringComparer.OrdinalIgnoreCase) { @@ -37,6 +43,8 @@ public sealed class DocumentClassifier : IDocumentClassifier var best = scores.OrderByDescending(x => x.Value).First(); var type = best.Value <= 0 ? "unknown" : best.Key; + // Confidence baseline 0.45 + 0.08 per matched keyword term, capped at 0.95. + // Zero hits → 0.25 (effectively unknown). var confidence = best.Value <= 0 ? 0.25 : Math.Min(0.95, 0.45 + best.Value * 0.08); return Task.FromResult(new DocumentClassification @@ -47,14 +55,20 @@ public sealed class DocumentClassifier : IDocumentClassifier }); } + /// Counts how many of the given appear in the lower-cased text. private static int Count(string lower, params string[] terms) => terms.Count(term => lower.Contains(term)); + /// Lowercases and replaces non-alphanumeric characters with hyphens to produce a safe type slug. private static string NormalizeType(string value) { var cleaned = Regex.Replace(value.Trim().ToLowerInvariant(), "[^a-z0-9_-]", "-"); return string.IsNullOrWhiteSpace(cleaned) ? "unknown" : cleaned; } + /// + /// Returns when available; otherwise extracts the first sentence-like + /// fragment from the text, or falls back to a generic "{type} document" label. + /// private static string BuildTitle(string? providedTitle, string text, string documentType) { if (!string.IsNullOrWhiteSpace(providedTitle)) return providedTitle.Trim(); diff --git a/Apis/rag-api/Services/RagService.cs b/Apis/rag-api/Services/RagService.cs index 9799feb..e1ba9d2 100644 --- a/Apis/rag-api/Services/RagService.cs +++ b/Apis/rag-api/Services/RagService.cs @@ -4,13 +4,16 @@ using Api.Services.Contracts; using Rag.Models.Requests; using Rag.Models.Responses; using Rag.Models.Settings; -using Api.Data.Repositories.Contracts; +using Rag.Data.Repositories.Contracts; using Api.Clients.Ai.Contracts; using Rag.Models; using CommonHelpers; namespace Api.Services; +/// +/// Implements the core RAG pipeline: document classification, chunking, embedding, vector search, and retrieval. +/// public sealed class RagService : IRagService { private readonly ITextExtractor _textExtractor; @@ -36,6 +39,7 @@ public sealed class RagService : IRagService _settings = options.Value; } + /// public async Task IndexTextAsync(IndexDocumentRequest request, CancellationToken ct) { var text = _textExtractor.Normalize(request.Text ?? string.Empty); @@ -44,6 +48,7 @@ public sealed class RagService : IRagService return await IndexNormalizedTextAsync(text, request.DocumentType, request.Title, request.SourceUrl, request.Metadata, ct); } + /// public async Task IndexPdfAsync(IFormFile file, string? documentType, string? title, string? sourceUrl, CancellationToken ct) { if (file.Length <= 0) throw new InvalidOperationException("Uploaded file is empty."); @@ -57,6 +62,7 @@ public sealed class RagService : IRagService return await IndexNormalizedTextAsync(text, documentType, title ?? file.FileName, sourceUrl, new Dictionary { ["fileName"] = file.FileName }, ct); } + /// public async Task SearchAsync(SearchRequest request, CancellationToken ct) { var query = _textExtractor.Normalize(request.QueryText); @@ -97,6 +103,7 @@ public sealed class RagService : IRagService return new SearchResponse { Results = results }; } + /// public async Task GetDocumentAsync(string documentId, CancellationToken ct) { var document = await _repository.GetDocumentByIdAsync(documentId, ct); @@ -112,6 +119,11 @@ public sealed class RagService : IRagService }; } + /// + /// Core indexing pipeline: computes a text hash for deduplication, classifies and chunks the text, + /// generates embeddings for each chunk, and persists the document and chunks to the repository. + /// Returns cached metadata without re-indexing when the same text hash and source URL already exist. + /// private async Task IndexNormalizedTextAsync( string text, string? documentType, diff --git a/Apis/rag-api/Services/TextChunker.cs b/Apis/rag-api/Services/TextChunker.cs index 434f2b9..87c3812 100644 --- a/Apis/rag-api/Services/TextChunker.cs +++ b/Apis/rag-api/Services/TextChunker.cs @@ -2,14 +2,20 @@ using Api.Services.Contracts; namespace Api.Services; +/// +/// Splits text into overlapping fixed-size chunks using a sliding window for use in vector embedding pipelines. +/// public sealed class TextChunker : ITextChunker { + /// public IReadOnlyList Chunk(string text, int chunkSize, int overlap) { if (string.IsNullOrWhiteSpace(text)) return []; chunkSize = Math.Clamp(chunkSize, 300, 3000); overlap = Math.Clamp(overlap, 0, chunkSize / 2); + // Sliding window: step forward by (chunkSize - overlap) each iteration so + // adjacent chunks share `overlap` characters, preserving cross-boundary context. var chunks = new List(); var start = 0; while (start < text.Length) diff --git a/Apis/rag-api/Services/TextExtractor.cs b/Apis/rag-api/Services/TextExtractor.cs index 78e85ca..5c67830 100644 --- a/Apis/rag-api/Services/TextExtractor.cs +++ b/Apis/rag-api/Services/TextExtractor.cs @@ -4,8 +4,12 @@ using UglyToad.PdfPig; namespace Api.Services; +/// +/// Extracts and normalises plain text from PDF files using PdfPig. +/// public sealed class TextExtractor : ITextExtractor { + /// public Task ExtractPdfAsync(Stream stream, CancellationToken ct) { using var document = PdfDocument.Open(stream); @@ -19,6 +23,7 @@ public sealed class TextExtractor : ITextExtractor return Task.FromResult(Normalize(builder.ToString())); } + /// public string Normalize(string value) { if (string.IsNullOrWhiteSpace(value)) return string.Empty; diff --git a/Apis/rag-api/appsettings.json b/Apis/rag-api/appsettings.json index 6328bfd..820fb32 100644 --- a/Apis/rag-api/appsettings.json +++ b/Apis/rag-api/appsettings.json @@ -2,8 +2,7 @@ "Serilog": { "Using": [ "Serilog.Sinks.Console", - "Serilog.Sinks.File", - "Serilog.Sinks.Email" + "Serilog.Sinks.File" ], "MinimumLevel": { "Default": "Information", @@ -30,25 +29,6 @@ "retainedFileCountLimit": 30, "outputTemplate": "{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} [{Level:u3}] {SourceContext}: {Message:lj}{NewLine}{Exception}" } - }, - { - "Name": "Email", - "Args": { - "restrictedToMinimumLevel": "Error", - "fromEmail": "", - "toEmail": "", - "mailServer": "", - "networkCredential": { - "userName": "", - "password": "" - }, - "port": 587, - "enableSsl": true, - "emailSubject": "[mihes.ro API] Error Alert", - "outputTemplate": "{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} [{Level:u3}] {SourceContext}{NewLine}{Message:lj}{NewLine}{Exception}", - "batchPostingLimit": 10, - "period": "0.00:05:00" - } } ], "Enrich": [ diff --git a/Apis/rag-api/rag-api.csproj b/Apis/rag-api/rag-api.csproj index 91f151b..d2c4dba 100644 --- a/Apis/rag-api/rag-api.csproj +++ b/Apis/rag-api/rag-api.csproj @@ -58,28 +58,29 @@ - - - - - + + + + + all runtime; build; native; contentfiles; analyzers; buildtransitive - - - - - - - - + + + + + + + + - - - + + + + diff --git a/Apis/rag-api/Data/Entities/RagChatCompletionCacheEntity.cs b/Apis/rag-data/Entities/RagChatCompletionCacheEntity.cs similarity index 81% rename from Apis/rag-api/Data/Entities/RagChatCompletionCacheEntity.cs rename to Apis/rag-data/Entities/RagChatCompletionCacheEntity.cs index 05940b9..c873e60 100644 --- a/Apis/rag-api/Data/Entities/RagChatCompletionCacheEntity.cs +++ b/Apis/rag-data/Entities/RagChatCompletionCacheEntity.cs @@ -1,5 +1,6 @@ -namespace Api.Data.Entities; +namespace Rag.Data.Entities; +// CacheKey PK — BaseEntity not applicable public sealed class RagChatCompletionCacheEntity { public string CacheKey { get; set; } = string.Empty; diff --git a/Apis/rag-api/Data/Entities/RagChunkEntity.cs b/Apis/rag-data/Entities/RagChunkEntity.cs similarity index 78% rename from Apis/rag-api/Data/Entities/RagChunkEntity.cs rename to Apis/rag-data/Entities/RagChunkEntity.cs index b57467c..6bd1734 100644 --- a/Apis/rag-api/Data/Entities/RagChunkEntity.cs +++ b/Apis/rag-data/Entities/RagChunkEntity.cs @@ -1,5 +1,6 @@ -namespace Api.Data.Entities; +namespace Rag.Data.Entities; +// no CreatedAt column in schema — BaseEntity not applicable public sealed class RagChunkEntity { public string Id { get; set; } = string.Empty; diff --git a/Apis/rag-api/Data/Entities/RagDocumentEntity.cs b/Apis/rag-data/Entities/RagDocumentEntity.cs similarity index 70% rename from Apis/rag-api/Data/Entities/RagDocumentEntity.cs rename to Apis/rag-data/Entities/RagDocumentEntity.cs index 739af12..7b09463 100644 --- a/Apis/rag-api/Data/Entities/RagDocumentEntity.cs +++ b/Apis/rag-data/Entities/RagDocumentEntity.cs @@ -1,8 +1,9 @@ -namespace Api.Data.Entities; +using Shared.Data.Entities; -public sealed class RagDocumentEntity +namespace Rag.Data.Entities; + +public sealed class RagDocumentEntity : BaseEntity { - public string Id { get; set; } = string.Empty; public string DocumentType { get; set; } = string.Empty; public string Title { get; set; } = string.Empty; public string? SourceUrl { get; set; } @@ -10,7 +11,6 @@ public sealed class RagDocumentEntity public string TextHash { get; set; } = string.Empty; public double TypeConfidence { get; set; } public string MetadataJson { get; set; } = "{}"; - public DateTime CreatedAt { get; set; } = DateTime.UtcNow; public ICollection Chunks { get; set; } = []; } diff --git a/Apis/rag-api/Data/Entities/RagEmbeddingCacheEntity.cs b/Apis/rag-data/Entities/RagEmbeddingCacheEntity.cs similarity index 81% rename from Apis/rag-api/Data/Entities/RagEmbeddingCacheEntity.cs rename to Apis/rag-data/Entities/RagEmbeddingCacheEntity.cs index 63f8132..e96c433 100644 --- a/Apis/rag-api/Data/Entities/RagEmbeddingCacheEntity.cs +++ b/Apis/rag-data/Entities/RagEmbeddingCacheEntity.cs @@ -1,5 +1,6 @@ -namespace Api.Data.Entities; +namespace Rag.Data.Entities; +// CacheKey PK — BaseEntity not applicable public sealed class RagEmbeddingCacheEntity { public string CacheKey { get; set; } = string.Empty; diff --git a/Apis/rag-data/MigrationConstants.cs b/Apis/rag-data/MigrationConstants.cs new file mode 100644 index 0000000..e77e4f9 --- /dev/null +++ b/Apis/rag-data/MigrationConstants.cs @@ -0,0 +1,11 @@ +namespace Rag.Data; + +/// +/// Schema constants used by RagDbContext and migrations. +/// Centralized to avoid hardcoded strings and ensure consistency. +/// +public static class MigrationConstants +{ + public const string SchemaName = "rag"; + public const string MigrationTableName = "_Migrations"; +} diff --git a/Apis/rag-api/Migrations/20260507140305_InitialRagSchema.Designer.cs b/Apis/rag-data/Migrations/20260507140305_InitialRagSchema.Designer.cs similarity index 92% rename from Apis/rag-api/Migrations/20260507140305_InitialRagSchema.Designer.cs rename to Apis/rag-data/Migrations/20260507140305_InitialRagSchema.Designer.cs index 3fcaaae..54c078e 100644 --- a/Apis/rag-api/Migrations/20260507140305_InitialRagSchema.Designer.cs +++ b/Apis/rag-data/Migrations/20260507140305_InitialRagSchema.Designer.cs @@ -1,6 +1,6 @@ -// +// using System; -using Api.Data; +using Rag.Data; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Metadata; @@ -9,7 +9,7 @@ using Microsoft.EntityFrameworkCore.Storage.ValueConversion; #nullable disable -namespace Api.Migrations +namespace Rag.Data.Migrations { [DbContext(typeof(RagDbContext))] [Migration("20260507140305_InitialRagSchema")] @@ -26,7 +26,7 @@ namespace Api.Migrations SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); - modelBuilder.Entity("Api.Data.Entities.RagChatCompletionCacheEntity", b => + modelBuilder.Entity("Rag.Data.Entities.RagChatCompletionCacheEntity", b => { b.Property("CacheKey") .HasMaxLength(64) @@ -54,7 +54,7 @@ namespace Api.Migrations b.ToTable("ChatCompletionCache", "rag"); }); - modelBuilder.Entity("Api.Data.Entities.RagChunkEntity", b => + modelBuilder.Entity("Rag.Data.Entities.RagChunkEntity", b => { b.Property("Id") .HasMaxLength(64) @@ -83,7 +83,7 @@ namespace Api.Migrations b.ToTable("Chunks", "rag"); }); - modelBuilder.Entity("Api.Data.Entities.RagDocumentEntity", b => + modelBuilder.Entity("Rag.Data.Entities.RagDocumentEntity", b => { b.Property("Id") .HasMaxLength(64) @@ -135,7 +135,7 @@ namespace Api.Migrations b.ToTable("Documents", "rag"); }); - modelBuilder.Entity("Api.Data.Entities.RagEmbeddingCacheEntity", b => + modelBuilder.Entity("Rag.Data.Entities.RagEmbeddingCacheEntity", b => { b.Property("CacheKey") .HasMaxLength(64) @@ -167,9 +167,9 @@ namespace Api.Migrations b.ToTable("EmbeddingCache", "rag"); }); - modelBuilder.Entity("Api.Data.Entities.RagChunkEntity", b => + modelBuilder.Entity("Rag.Data.Entities.RagChunkEntity", b => { - b.HasOne("Api.Data.Entities.RagDocumentEntity", "Document") + b.HasOne("Rag.Data.Entities.RagDocumentEntity", "Document") .WithMany("Chunks") .HasForeignKey("DocumentId") .OnDelete(DeleteBehavior.Cascade) @@ -178,7 +178,7 @@ namespace Api.Migrations b.Navigation("Document"); }); - modelBuilder.Entity("Api.Data.Entities.RagDocumentEntity", b => + modelBuilder.Entity("Rag.Data.Entities.RagDocumentEntity", b => { b.Navigation("Chunks"); }); diff --git a/Apis/rag-api/Migrations/20260507140305_InitialRagSchema.cs b/Apis/rag-data/Migrations/20260507140305_InitialRagSchema.cs similarity index 86% rename from Apis/rag-api/Migrations/20260507140305_InitialRagSchema.cs rename to Apis/rag-data/Migrations/20260507140305_InitialRagSchema.cs index fc6216c..2aa5b6b 100644 --- a/Apis/rag-api/Migrations/20260507140305_InitialRagSchema.cs +++ b/Apis/rag-data/Migrations/20260507140305_InitialRagSchema.cs @@ -1,9 +1,10 @@ -using System; +using System; using Microsoft.EntityFrameworkCore.Migrations; +using Rag.Data; #nullable disable -namespace Api.Migrations +namespace Rag.Data.Migrations { /// public partial class InitialRagSchema : Migration @@ -12,11 +13,11 @@ namespace Api.Migrations protected override void Up(MigrationBuilder migrationBuilder) { migrationBuilder.EnsureSchema( - name: "rag"); + name: MigrationConstants.SchemaName); migrationBuilder.CreateTable( name: "ChatCompletionCache", - schema: "rag", + schema: MigrationConstants.SchemaName, columns: table => new { CacheKey = table.Column(type: "nvarchar(64)", maxLength: 64, nullable: false), @@ -32,7 +33,7 @@ namespace Api.Migrations migrationBuilder.CreateTable( name: "Documents", - schema: "rag", + schema: MigrationConstants.SchemaName, columns: table => new { Id = table.Column(type: "nvarchar(64)", maxLength: 64, nullable: false), @@ -52,7 +53,7 @@ namespace Api.Migrations migrationBuilder.CreateTable( name: "EmbeddingCache", - schema: "rag", + schema: MigrationConstants.SchemaName, columns: table => new { CacheKey = table.Column(type: "nvarchar(64)", maxLength: 64, nullable: false), @@ -68,7 +69,7 @@ namespace Api.Migrations migrationBuilder.CreateTable( name: "Chunks", - schema: "rag", + schema: MigrationConstants.SchemaName, columns: table => new { Id = table.Column(type: "nvarchar(64)", maxLength: 64, nullable: false), @@ -83,7 +84,7 @@ namespace Api.Migrations table.ForeignKey( name: "FK_Chunks_Documents_DocumentId", column: x => x.DocumentId, - principalSchema: "rag", + principalSchema: MigrationConstants.SchemaName, principalTable: "Documents", principalColumn: "Id", onDelete: ReferentialAction.Cascade); @@ -91,25 +92,25 @@ namespace Api.Migrations migrationBuilder.CreateIndex( name: "IX_Chunks_DocumentId", - schema: "rag", + schema: MigrationConstants.SchemaName, table: "Chunks", column: "DocumentId"); migrationBuilder.CreateIndex( name: "IX_Documents_DocumentType", - schema: "rag", + schema: MigrationConstants.SchemaName, table: "Documents", column: "DocumentType"); migrationBuilder.CreateIndex( name: "IX_Documents_TextHash", - schema: "rag", + schema: MigrationConstants.SchemaName, table: "Documents", column: "TextHash"); migrationBuilder.CreateIndex( name: "IX_EmbeddingCache_TextHash", - schema: "rag", + schema: MigrationConstants.SchemaName, table: "EmbeddingCache", column: "TextHash"); } @@ -119,19 +120,19 @@ namespace Api.Migrations { migrationBuilder.DropTable( name: "ChatCompletionCache", - schema: "rag"); + schema: MigrationConstants.SchemaName); migrationBuilder.DropTable( name: "Chunks", - schema: "rag"); + schema: MigrationConstants.SchemaName); migrationBuilder.DropTable( name: "EmbeddingCache", - schema: "rag"); + schema: MigrationConstants.SchemaName); migrationBuilder.DropTable( name: "Documents", - schema: "rag"); + schema: MigrationConstants.SchemaName); } } } diff --git a/Apis/rag-api/Migrations/RagDbContextModelSnapshot.cs b/Apis/rag-data/Migrations/RagDbContextModelSnapshot.cs similarity index 92% rename from Apis/rag-api/Migrations/RagDbContextModelSnapshot.cs rename to Apis/rag-data/Migrations/RagDbContextModelSnapshot.cs index 908ae81..a409235 100644 --- a/Apis/rag-api/Migrations/RagDbContextModelSnapshot.cs +++ b/Apis/rag-data/Migrations/RagDbContextModelSnapshot.cs @@ -1,6 +1,6 @@ -// +// using System; -using Api.Data; +using Rag.Data; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Metadata; @@ -8,7 +8,7 @@ using Microsoft.EntityFrameworkCore.Storage.ValueConversion; #nullable disable -namespace Api.Migrations +namespace Rag.Data.Migrations { [DbContext(typeof(RagDbContext))] partial class RagDbContextModelSnapshot : ModelSnapshot @@ -23,7 +23,7 @@ namespace Api.Migrations SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); - modelBuilder.Entity("Api.Data.Entities.RagChatCompletionCacheEntity", b => + modelBuilder.Entity("Rag.Data.Entities.RagChatCompletionCacheEntity", b => { b.Property("CacheKey") .HasMaxLength(64) @@ -51,7 +51,7 @@ namespace Api.Migrations b.ToTable("ChatCompletionCache", "rag"); }); - modelBuilder.Entity("Api.Data.Entities.RagChunkEntity", b => + modelBuilder.Entity("Rag.Data.Entities.RagChunkEntity", b => { b.Property("Id") .HasMaxLength(64) @@ -80,7 +80,7 @@ namespace Api.Migrations b.ToTable("Chunks", "rag"); }); - modelBuilder.Entity("Api.Data.Entities.RagDocumentEntity", b => + modelBuilder.Entity("Rag.Data.Entities.RagDocumentEntity", b => { b.Property("Id") .HasMaxLength(64) @@ -132,7 +132,7 @@ namespace Api.Migrations b.ToTable("Documents", "rag"); }); - modelBuilder.Entity("Api.Data.Entities.RagEmbeddingCacheEntity", b => + modelBuilder.Entity("Rag.Data.Entities.RagEmbeddingCacheEntity", b => { b.Property("CacheKey") .HasMaxLength(64) @@ -164,9 +164,9 @@ namespace Api.Migrations b.ToTable("EmbeddingCache", "rag"); }); - modelBuilder.Entity("Api.Data.Entities.RagChunkEntity", b => + modelBuilder.Entity("Rag.Data.Entities.RagChunkEntity", b => { - b.HasOne("Api.Data.Entities.RagDocumentEntity", "Document") + b.HasOne("Rag.Data.Entities.RagDocumentEntity", "Document") .WithMany("Chunks") .HasForeignKey("DocumentId") .OnDelete(DeleteBehavior.Cascade) @@ -175,7 +175,7 @@ namespace Api.Migrations b.Navigation("Document"); }); - modelBuilder.Entity("Api.Data.Entities.RagDocumentEntity", b => + modelBuilder.Entity("Rag.Data.Entities.RagDocumentEntity", b => { b.Navigation("Chunks"); }); diff --git a/Apis/rag-api/Data/RagDbContext.cs b/Apis/rag-data/RagDbContext.cs similarity index 85% rename from Apis/rag-api/Data/RagDbContext.cs rename to Apis/rag-data/RagDbContext.cs index 1564ef1..04b1e9f 100644 --- a/Apis/rag-api/Data/RagDbContext.cs +++ b/Apis/rag-data/RagDbContext.cs @@ -1,12 +1,12 @@ -using Api.Data.Entities; +using Rag.Data.Entities; using Microsoft.EntityFrameworkCore; -namespace Api.Data; +namespace Rag.Data; public sealed class RagDbContext : DbContext { - public const string SchemaName = "rag"; - public const string MigrationTableName = "_Migrations"; + public const string SchemaName = MigrationConstants.SchemaName; + public const string MigrationTableName = MigrationConstants.MigrationTableName; public RagDbContext(DbContextOptions options) : base(options) { @@ -17,6 +17,13 @@ public sealed class RagDbContext : DbContext public DbSet RagEmbeddingCache => Set(); public DbSet RagChatCompletionCache => Set(); + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + { + base.OnConfiguring(optionsBuilder); + // Configure migration history table to use schema-qualified name: [rag].[_Migrations] + optionsBuilder.UseSqlServer(x => x.MigrationsHistoryTable(MigrationTableName, SchemaName)); + } + protected override void OnModelCreating(ModelBuilder modelBuilder) { modelBuilder.HasDefaultSchema(SchemaName); diff --git a/Apis/rag-api/Data/Repositories/Contracts/IRagRepository.cs b/Apis/rag-data/Repositories/Contracts/IRagRepository.cs similarity index 95% rename from Apis/rag-api/Data/Repositories/Contracts/IRagRepository.cs rename to Apis/rag-data/Repositories/Contracts/IRagRepository.cs index 2837287..4993761 100644 --- a/Apis/rag-api/Data/Repositories/Contracts/IRagRepository.cs +++ b/Apis/rag-data/Repositories/Contracts/IRagRepository.cs @@ -1,6 +1,6 @@ using Rag.Models; -namespace Api.Data.Repositories.Contracts; +namespace Rag.Data.Repositories.Contracts; public interface IRagRepository { diff --git a/Apis/rag-api/Data/Repositories/EfRagRepository.cs b/Apis/rag-data/Repositories/EfRagRepository.cs similarity index 97% rename from Apis/rag-api/Data/Repositories/EfRagRepository.cs rename to Apis/rag-data/Repositories/EfRagRepository.cs index e07b4ee..c00ea5f 100644 --- a/Apis/rag-api/Data/Repositories/EfRagRepository.cs +++ b/Apis/rag-data/Repositories/EfRagRepository.cs @@ -1,10 +1,11 @@ -using Api.Data; -using Api.Data.Entities; +using Rag.Data; +using Rag.Data.Entities; using Microsoft.EntityFrameworkCore; -using Api.Data.Repositories.Contracts; +using Microsoft.Extensions.Logging; +using Rag.Data.Repositories.Contracts; using Rag.Models; -namespace Api.Data.Repositories; +namespace Rag.Data.Repositories; public sealed class EfRagRepository : IRagRepository { diff --git a/Apis/rag-api/Data/Repositories/VectorSerializer.cs b/Apis/rag-data/Repositories/VectorSerializer.cs similarity index 96% rename from Apis/rag-api/Data/Repositories/VectorSerializer.cs rename to Apis/rag-data/Repositories/VectorSerializer.cs index 2ed02c6..c70d2f3 100644 --- a/Apis/rag-api/Data/Repositories/VectorSerializer.cs +++ b/Apis/rag-data/Repositories/VectorSerializer.cs @@ -1,4 +1,4 @@ -namespace Api.Data.Repositories; +namespace Rag.Data.Repositories; public static class VectorSerializer { diff --git a/Apis/rag-data/rag-data.csproj b/Apis/rag-data/rag-data.csproj new file mode 100644 index 0000000..ee8bc1b --- /dev/null +++ b/Apis/rag-data/rag-data.csproj @@ -0,0 +1,24 @@ + + + + net10.0 + rag-data + Rag.Data + enable + enable + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + diff --git a/Apis/shared-data/Entities/BaseEntity.cs b/Apis/shared-data/Entities/BaseEntity.cs new file mode 100644 index 0000000..05fd6c8 --- /dev/null +++ b/Apis/shared-data/Entities/BaseEntity.cs @@ -0,0 +1,12 @@ +namespace Shared.Data.Entities; + +/// +/// Abstract base for all EF entities that carry a surrogate string PK and an audit timestamp. +/// Entities with a composite PK or a non-Id primary key should NOT inherit this class; +/// document the exception with a brief comment on the entity. +/// +public abstract class BaseEntity +{ + public required string Id { get; init; } + public DateTime CreatedAt { get; init; } +} diff --git a/Apis/shared-data/shared-data.csproj b/Apis/shared-data/shared-data.csproj new file mode 100644 index 0000000..606ae95 --- /dev/null +++ b/Apis/shared-data/shared-data.csproj @@ -0,0 +1,9 @@ + + + net10.0 + shared-data + Shared.Data + enable + enable + + diff --git a/Apis/shared-models/Responses/ErrorResponse.cs b/Apis/shared-models/Responses/ErrorResponse.cs deleted file mode 100644 index c688715..0000000 --- a/Apis/shared-models/Responses/ErrorResponse.cs +++ /dev/null @@ -1,9 +0,0 @@ -namespace Shared.Models.Responses; - -public sealed class ErrorResponse -{ - public string Error { get; init; } = string.Empty; - public string? Code { get; init; } - public string? Detail { get; init; } - public double? Score { get; init; } -} diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..a7786c6 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,220 @@ +# myAi — Solution Guide + +## Infrastructure URLs + +| Purpose | URL | +|---------|-----| +| Staging app | https://myai.easysoft.ro | +| Production app | https://myai.ro | +| Portainer (container management) | https://portainer.easysoft.ro/#!/auth | +| Grafana (logs) | https://grafana.easysoft.ro/login | +| Gitea (source control) | https://git.easysoft.ro | + +The Gitea instance has two deployment repos: +- **staging repo** → auto-deploys to `myai.easysoft.ro` +- **production repo** → auto-deploys to `myai.ro` + +## Staging browser testing + +To verify a feature against staging use the `verify` skill pointed at `https://myai.easysoft.ro`. +Portainer at `portainer.easysoft.ro` can restart containers or inspect live state. +Grafana at `grafana.easysoft.ro` shows structured logs from all containers. + +## Feature workflow (plan → ship) + +When a plan is approved and implementation begins: +1. Add the plan as a **Gitea Wiki page** in the relevant repository (under a `Features/` or `Plans/` namespace) +2. Create **Gitea Issues** — one per logical work chunk — and link them to the Wiki page +3. Reference the issue number in commit messages (`Closes #N`) +4. Issues are closed automatically (or manually) when the code is merged + +This applies to both the staging and production repos as appropriate. + +## Tech stack +- .NET 10, ASP.NET Core, Worker Service +- Entity Framework Core + SQL Server (multi-schema) +- Refit for typed HTTP clients between services +- Serilog (JSON structured logging, Console + File + Email sinks) +- MailKit for SMTP (used exclusively in `email-api`) +- Docker Compose for local and production deployment +- Watchtower for automatic container updates in production + +## Project taxonomy + +| Category | Naming | Contains | EF dependency | +|----------|--------|----------|---------------| +| Executable | `{name}-api`, `{name}-job` | Controllers, Services, Program.cs | Via `ProjectReference` to a `-data` project | +| Domain contracts | `{name}-models`, `{name}-api-models`, `{name}-job-models` | DTOs, Refit interfaces, domain-specific Settings | No | +| Data layer | `{name}-data` | DbContext, EF entities, Migrations | Yes | +| Common contracts | `common` (no suffix) | Infrastructure/technical primitives — no domain ownership | No | +| Common base entities | `shared-data` | Abstract `BaseEntity` class (Id + CreatedAt). No DbContext. | No | + +### The `common` project rule + +`common` holds **only infrastructure/technical primitives** with no specific service domain ownership: `DatabaseSettings`, `InternalApiSettings`, `ErrorResponse`, `RateLimitingSettings`, `UploadFileRequest`, AI provider settings, etc. **Never put a business-domain type in `common`** — domain types belong in the owning service's `-models` project. + +### Where migrations live + +**Migrations always live in the `-data` project**, never in an API or Job project. EF CLI split: `--project` = `-data` project (owns the schema); `--startup-project` = whichever API supplies the DB connection string. + +## Solution layout + +``` +Apis/ + api/ Public-facing proxy API (port 8080). Handles CORS, rate limiting, captcha, email. + api-models/ DTOs and settings for api only. + email-api/ Internal SMTP email relay (no public port). All email sending goes here. + email-api-models/ Refit client + SendEmailRequest + EmailApiSettings (shared by api and cv-search-job). + cv-matcher-api/ Internal CV match engine (port 8082). Runs CvMatcher + CvSearch DB migrations. + cv-matcher-api-models/ DTOs shared between api and cv-matcher-api (incl. JobSearchSettings). + rag-api/ Internal RAG/vector-search service (port 8081). + rag-api-models/ DTOs shared with rag-api. + common/ Cross-service infrastructure primitives (DatabaseSettings, InternalApiSettings, etc.). + shared-data/ Abstract BaseEntity base class. No DbContext. + cv-matcher-data/ CvMatcherDbContext + entities + migrations (schema: cvMatcher). Owns AiPrompts table. + cv-search-data/ CvSearchDbContext + entities + migrations (schema: cvSearch). + email-api-data/ EmailApiDbContext + entities + migrations (schema: emailApi). Owns EmailTemplates table. + rag-data/ RagDbContext + entities + migrations (schema: rag). + myai-data/ MyAiDbContext + entities + migrations (schema: myAi). Keeps only html.* templates. +Helpers/ + startup-helpers/ Shared Program.cs bootstrap: Serilog, Swagger, .env loading, Azure Key Vault, middleware. + common-helpers/ Utility helpers. +Jobs/ + job-scheduler/ IJobTask + JobSchedulerHostedService — the reusable scheduled-job engine. + cv-cleanup-job/ Worker: deletes old CVs from file storage. Runs hourly. + cv-cleanup-job-models/ Job-specific models for cv-cleanup-job (proactive; currently empty). + cv-search-job/ Worker: picks up pending job search sessions, scrapes providers, emails results. + cv-search-job-models/ Job-specific models for cv-search-job (proactive; currently empty). +web/ Razor Pages / Blazor front-end (port 5140). +docker-compose/ docker-compose.yml + .env file. +``` + +Virtual solution folders in `.sln`: `Apis` (executables + web), `Models` (DTOs/contracts), `Data` (data layers), `Jobs`, `Helpers`. + +## Build & restore + +```powershell +dotnet restore myAi.sln +dotnet build myAi.sln +``` + +## Running locally with Docker + +```powershell +docker compose -f docker-compose/docker-compose.yml up --build +``` + +Config lives in `docker-compose/.env`. All env vars use `${VAR:-default}` fallback syntax. + +## Database schemas + +| Schema | Owner DbContext | Migrations project | Startup project | +|-------------|----------------------|-----------------------|-----------------------| +| `cvMatcher` | `CvMatcherDbContext` | `cv-matcher-data` | `cv-matcher-api` | +| `emailApi` | `EmailApiDbContext` | `email-api-data` | `email-api` | +| `rag` | `RagDbContext` | `rag-data` | `rag-api` | +| `cvSearch` | `CvSearchDbContext` | `cv-search-data` | `cv-matcher-api` | +| `myAi` | `MyAiDbContext` | `myai-data` | `api` | + +Both `cv-matcher-api` and `cv-search-job` register `CvSearchDbContext` and call `db.Database.Migrate()` on startup (idempotent — safe for both to run). + +`api` and `cv-search-job` also register `EmailApiDbContext` (read-only — `email-api` is the sole migration owner). They use it to load email templates via `IEmailTemplateService` (10-min cache, singleton). + +## EF Core migrations + +```powershell +# cv-matcher-data (schema: cvMatcher) +dotnet ef migrations add ` + --context CvMatcherDbContext ` + --project Apis/cv-matcher-data ` + --startup-project Apis/cv-matcher-api + +# email-api-data (schema: emailApi) +dotnet ef migrations add ` + --context EmailApiDbContext ` + --project Apis/email-api-data ` + --startup-project Apis/email-api + +# rag-data (schema: rag) +dotnet ef migrations add ` + --context RagDbContext ` + --project Apis/rag-data ` + --startup-project Apis/rag-api + +# cv-search-data (schema: cvSearch) +dotnet ef migrations add ` + --context CvSearchDbContext ` + --project Apis/cv-search-data ` + --startup-project Apis/cv-matcher-api + +# myai-data (schema: myAi) +dotnet ef migrations add ` + --context MyAiDbContext ` + --project Apis/myai-data ` + --startup-project Apis/api +``` + +EF tools version warning ("older than runtime") is expected and harmless. The `HostAbortedException` output during migration scaffolding is normal — EF starts the host to discover DbContext then aborts it. + +## Service dependency chain + +``` +web → api → cv-matcher-api → rag-api + ↓ ↓ + | email-api + ↓ ↑ +cv-search-job +``` + +`api` and `cv-search-job` both call `email-api` for all outbound email (SMTP). +`api` never talks directly to `rag-api` — always via `cv-matcher-api`. + +## Internal API key auth + +All internal service-to-service calls require the `X-Internal-Api-Key` header. + +| Caller | Target | Env var for key | +|--------|--------|-----------------| +| `api`, `cv-search-job` | `email-api` | `EmailApi__InternalApiKey` | +| `api`, `cv-search-job` | `cv-matcher-api` | `CvMatcherApi__InternalApiKey` | +| `cv-matcher-api` | `rag-api` | `RagApi__InternalApiKey` | + +`startup-helpers` provides `UseInternalApiKeyProtection()` middleware (reads `InternalApi:ApiKey`); enforced on `cv-matcher-api`, `rag-api`, and `email-api`. + +## Shared file storage + +CV PDFs are written by `api` to `Apis/api/Files/` and read by `cv-cleanup-job`, `cv-search-job`, and `email-api` (for email attachments). +All four containers mount the same bind volume: +```yaml +- ${FILES_PATH:-/opt/myai/files}:/app/Files +``` +The path inside containers is controlled by `FileStorage__Path` (default: `Files`). +`email-api` receives only the relative filename (e.g. `abc123.pdf`) and resolves it against `FileStorage__Path`. + +## Job task pattern + +Every background worker uses the same pattern from `job-scheduler`: +1. Implement `IJobTask` (has `TaskType` string + `ExecuteAsync(CancellationToken)`) +2. Register as singleton: `services.AddSingleton>(sp => new IJobTask[] { ... })` +3. Register `JobSchedulerHostedService` as hosted service +4. Configure in appsettings under `Jobs:Tasks` array: `TaskType`, `Enabled`, `Interval` + +## Program.cs conventions + +Every service follows this structure: +1. `StartupExtensions.LoadDotEnvFile()` — must be first, loads `docker-compose/.env` +2. `StartupExtensions.GetApplicationVersion(Assembly.GetExecutingAssembly())` +3. `builder.ConfigureJsonSerilog(ServiceName, appVersion)` from startup-helpers +4. `builder.AddAzureKeyVaultIfConfigured()` (APIs only) +5. `app.UseDefaultSerilogRequestLogging()` +6. `app.UseJsonExceptionHandler(ServiceName)` +7. EF migrations in a scoped block before `app.Run()` + +## Coding conventions + +- XML doc comments (`/// `) on all public methods, interfaces, and non-trivial private/protected helpers; Swagger annotations on public controller actions +- Inline `//` comments for non-obvious logic; avoid restating what the code already says clearly +- Use `$$"""..."""` raw string literals (not `$"""`) when the content contains CSS or other curly-brace-heavy text — avoids CS9006 brace-escaping errors +- `sealed` on all concrete service classes +- Settings classes injected via `IOptions` — registered with `Configure(config.GetSection("..."))` +- Refit clients configured via a shared local function when multiple clients share the same base URL and auth header (see `api/Program.cs` → `ConfigureCvMatcherApiClient`) diff --git a/CV.pdf b/CV.pdf new file mode 100644 index 0000000..2c81179 Binary files /dev/null and b/CV.pdf differ diff --git a/Directory.Packages.props b/Directory.Packages.props new file mode 100644 index 0000000..7d04f04 --- /dev/null +++ b/Directory.Packages.props @@ -0,0 +1,40 @@ + + + true + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Helpers/startup-helpers/DatabaseExtensions.cs b/Helpers/startup-helpers/DatabaseExtensions.cs index abe85e3..07c7b60 100644 --- a/Helpers/startup-helpers/DatabaseExtensions.cs +++ b/Helpers/startup-helpers/DatabaseExtensions.cs @@ -1,6 +1,6 @@ using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; -using Shared.Models.Settings; +using Common.Settings; namespace StartupHelpers; diff --git a/Helpers/startup-helpers/RateLimitingExtensions.cs b/Helpers/startup-helpers/RateLimitingExtensions.cs index 03e0a6a..5087c4c 100644 --- a/Helpers/startup-helpers/RateLimitingExtensions.cs +++ b/Helpers/startup-helpers/RateLimitingExtensions.cs @@ -4,7 +4,7 @@ using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; -using Shared.Models.Settings; +using Common.Settings; namespace StartupHelpers; diff --git a/Helpers/startup-helpers/StartupExtensions.cs b/Helpers/startup-helpers/StartupExtensions.cs index d72b74e..c806e1b 100644 --- a/Helpers/startup-helpers/StartupExtensions.cs +++ b/Helpers/startup-helpers/StartupExtensions.cs @@ -1,5 +1,7 @@ +using System.Net; using System.Reflection; using Azure.Identity; +using MailKit.Security; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Diagnostics; using Microsoft.AspNetCore.Http; @@ -9,6 +11,7 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; using Serilog; +using Serilog.Events; using Swashbuckle.AspNetCore.SwaggerGen; using Swashbuckle.AspNetCore.Annotations; @@ -41,6 +44,8 @@ public static class StartupExtensions .Enrich.WithProperty("Service", serviceName) .Enrich.WithProperty("AppVersion", appVersion) .WriteTo.Console(new Serilog.Formatting.Json.JsonFormatter()); + + AddEmailSinkIfConfigured(configuration, context.Configuration, serviceName); }); } @@ -57,9 +62,40 @@ public static class StartupExtensions .Enrich.WithProperty("Service", serviceName) .Enrich.WithProperty("AppVersion", appVersion) .WriteTo.Console(new Serilog.Formatting.Json.JsonFormatter()); + + AddEmailSinkIfConfigured(configuration, builder.Configuration, serviceName); }); } + private static void AddEmailSinkIfConfigured(LoggerConfiguration loggerConfig, IConfiguration appConfig, string serviceName) + { + var from = appConfig["SerilogEmail:From"]; + var to = appConfig["SerilogEmail:To"]; + var host = appConfig["SerilogEmail:Host"]; + + if (string.IsNullOrWhiteSpace(from) || string.IsNullOrWhiteSpace(to) || string.IsNullOrWhiteSpace(host)) + return; + + var port = appConfig.GetValue("SerilogEmail:Port", 587); + var userName = appConfig["SerilogEmail:UserName"]; + var password = appConfig["SerilogEmail:Password"]; + + NetworkCredential? credentials = null; + if (!string.IsNullOrWhiteSpace(userName)) + credentials = new NetworkCredential(userName, password); + + loggerConfig.WriteTo.Email( + from: from, + to: to, + host: host, + port: port, + connectionSecurity: SecureSocketOptions.StartTls, + credentials: credentials, + subject: $"[myAi {serviceName}] Error Alert", + body: "{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} [{Level:u3}] {SourceContext}{NewLine}{Message:lj}{NewLine}{Exception}", + restrictedToMinimumLevel: LogEventLevel.Error); + } + public static void AddAzureKeyVaultIfConfigured(this WebApplicationBuilder builder) { var keyVaultUri = builder.Configuration["KeyVault:VaultUri"]; @@ -191,14 +227,35 @@ public static class StartupExtensions { var feature = context.Features.Get(); var logger = context.RequestServices.GetRequiredService().CreateLogger(serviceName); + + context.Response.ContentType = "application/json"; + + // InvalidOperationException signals an intentional business-rule violation + // (e.g. "Could not extract enough job text"). Surface it as 400 with the + // original message so the caller can show it directly to the user. + if (feature?.Error is InvalidOperationException ioe) + { + logger.LogWarning(ioe, "Business rule violation in {Service}", serviceName); + context.Response.StatusCode = StatusCodes.Status400BadRequest; + await context.Response.WriteAsJsonAsync(new Common.Responses.ErrorResponse + { + Error = ioe.Message, + Code = "validation_error" + }); + return; + } + if (feature?.Error is not null) { logger.LogError(feature.Error, "Unhandled exception in {Service}", serviceName); } context.Response.StatusCode = StatusCodes.Status500InternalServerError; - context.Response.ContentType = "application/json"; - await context.Response.WriteAsJsonAsync(new { error = "Unexpected server error." }); + await context.Response.WriteAsJsonAsync(new Common.Responses.ErrorResponse + { + Error = "Unexpected server error.", + Code = "internal_error" + }); }); }); } diff --git a/Helpers/startup-helpers/startup-helpers.csproj b/Helpers/startup-helpers/startup-helpers.csproj index 00b9f4a..89d7a7c 100644 --- a/Helpers/startup-helpers/startup-helpers.csproj +++ b/Helpers/startup-helpers/startup-helpers.csproj @@ -12,19 +12,19 @@ - - - - - - - - - + + + + + + + + + - + diff --git a/Jobs/cv-cleanup-job-models/cv-cleanup-job-models.csproj b/Jobs/cv-cleanup-job-models/cv-cleanup-job-models.csproj new file mode 100644 index 0000000..1d2bd02 --- /dev/null +++ b/Jobs/cv-cleanup-job-models/cv-cleanup-job-models.csproj @@ -0,0 +1,9 @@ + + + net10.0 + enable + enable + cv-cleanup-job-models + CvCleanup.Job.Models + + diff --git a/Jobs/cv-cleanup-job/Dockerfile b/Jobs/cv-cleanup-job/Dockerfile index 393a490..9982e49 100644 --- a/Jobs/cv-cleanup-job/Dockerfile +++ b/Jobs/cv-cleanup-job/Dockerfile @@ -1,12 +1,13 @@ FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build ARG BUILD_CONFIGURATION=Release WORKDIR /src +COPY Directory.Packages.props ./ COPY Jobs/cv-cleanup-job/cv-cleanup-job.csproj Jobs/cv-cleanup-job/ COPY Jobs/job-scheduler/job-scheduler.csproj Jobs/job-scheduler/ COPY Helpers/startup-helpers/startup-helpers.csproj Helpers/startup-helpers/ COPY Apis/api-models/api-models.csproj Apis/api-models/ -COPY Apis/shared-models/shared-models.csproj Apis/shared-models/ +COPY Apis/common/common.csproj Apis/common/ RUN dotnet restore Jobs/cv-cleanup-job/cv-cleanup-job.csproj @@ -14,7 +15,7 @@ COPY Jobs/cv-cleanup-job/ Jobs/cv-cleanup-job/ COPY Jobs/job-scheduler/ Jobs/job-scheduler/ COPY Helpers/startup-helpers/ Helpers/startup-helpers/ COPY Apis/api-models/ Apis/api-models/ -COPY Apis/shared-models/ Apis/shared-models/ +COPY Apis/common/ Apis/common/ RUN dotnet publish Jobs/cv-cleanup-job/cv-cleanup-job.csproj -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=false diff --git a/Jobs/cv-cleanup-job/appsettings.json b/Jobs/cv-cleanup-job/appsettings.json index 1dc46c2..a033a90 100644 --- a/Jobs/cv-cleanup-job/appsettings.json +++ b/Jobs/cv-cleanup-job/appsettings.json @@ -2,8 +2,7 @@ "Serilog": { "Using": [ "Serilog.Sinks.Console", - "Serilog.Sinks.File", - "Serilog.Sinks.Email" + "Serilog.Sinks.File" ], "MinimumLevel": { "Default": "Information", @@ -31,25 +30,6 @@ "retainedFileCountLimit": 30, "outputTemplate": "{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} [{Level:u3}] {SourceContext}: {Message:lj}{NewLine}{Exception}" } - }, - { - "Name": "Email", - "Args": { - "restrictedToMinimumLevel": "Error", - "fromEmail": "", - "toEmail": "", - "mailServer": "", - "networkCredential": { - "userName": "", - "password": "" - }, - "port": 587, - "enableSsl": true, - "emailSubject": "[mihes.ro CV cleanup job] Error Alert", - "outputTemplate": "{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} [{Level:u3}] {SourceContext}{NewLine}{Message:lj}{NewLine}{Exception}", - "batchPostingLimit": 10, - "period": "0.00:05:00" - } } ], "Enrich": [ diff --git a/Jobs/cv-cleanup-job/cv-cleanup-job.csproj b/Jobs/cv-cleanup-job/cv-cleanup-job.csproj index 9e93dfc..4dd9f40 100644 --- a/Jobs/cv-cleanup-job/cv-cleanup-job.csproj +++ b/Jobs/cv-cleanup-job/cv-cleanup-job.csproj @@ -9,7 +9,7 @@ - + diff --git a/Jobs/cv-search-job-models/cv-search-job-models.csproj b/Jobs/cv-search-job-models/cv-search-job-models.csproj new file mode 100644 index 0000000..94be90d --- /dev/null +++ b/Jobs/cv-search-job-models/cv-search-job-models.csproj @@ -0,0 +1,9 @@ + + + net10.0 + enable + enable + cv-search-job-models + CvSearch.Job.Models + + diff --git a/Jobs/cv-search-job/CLAUDE.md b/Jobs/cv-search-job/CLAUDE.md new file mode 100644 index 0000000..2cc09e1 --- /dev/null +++ b/Jobs/cv-search-job/CLAUDE.md @@ -0,0 +1,90 @@ +# cv-search-job — Internet Job Search Worker + +Background worker. Polls the database every 30 s for pending job search sessions and processes them. + +## What it does (per session) + +1. Reads session from DB (`Status = Pending`) +2. Sets `Status = Processing` +3. Deserializes `ProviderConfigJson` (snapshot of provider configs taken at token-start time) +4. For each enabled provider: calls `HtmlJobSearcher` to scrape job URLs +5. Deduplicates URLs across providers, caps at `MaxJobsToMatch` (default 15) +6. Calls `cv-matcher-api POST /api/cv/match-job` for each URL (uses existing LLM scoring) +7. Saves each result as `JobSearchResultEntity` +8. Filters to `Score >= MinMatchScore` (default 15) +9. Sets `Status = Done`, saves keywords + provider snapshot to session +10. Sends ranked results email via `CvSearchEmailSender` (dual-recipient: user + `Contact:ToEmail`) +11. Attaches CV PDF from shared file storage if it exists + +## Crash recovery + +On every tick, sessions with `Status = Processing` AND `CreatedAt < UtcNow - 10 min` are reset to `Pending`. This handles container restarts mid-processing. + +## HtmlJobSearcher — generic HTML scraper + +No per-provider logic. Config-driven. For each provider: +1. Combines `provider.InitialKeywords` + CV keywords from session, URL-encodes as space-joined string +2. `GET {SearchUrlTemplate}` with keyword substitution +3. Regex-parses all `text` tags +4. Two-stage filter: + - Stage 1: `href` must contain `JobLinkContains` + - Stage 2: anchor text must contain at least one CV keyword +5. Makes hrefs absolute, deduplicates, returns up to `MaxResults` URLs + +## Provider config + +Defined under `JobSearch:Providers` in appsettings / docker-compose env vars. Three providers ship as defaults (all `Enabled: false`): + +| Name | Notes | +|------|-------| +| `ejobs.ro` | Romanian job board; reliable HTML structure | +| `bestjobs.eu` | Romanian job board | +| `linkedin.com` | Likely to return empty results due to bot detection | + +Provider config is snapshotted to `JobSearchSessionEntity.ProviderConfigJson` at session creation time (in `cv-matcher-api`), so changes to config do not affect in-flight sessions. + +To enable a provider via docker-compose env var (index-based): +``` +JobSearch__Providers__0__Enabled=true # ejobs.ro +JobSearch__Providers__1__Enabled=true # bestjobs.eu +JobSearch__Providers__2__Enabled=true # linkedin.com +``` + +## Email + +`CvSearchEmailSender` reads SMTP config directly from `IConfiguration` (same `Smtp:*` keys as `api`). +Sends to both `toEmail` (from session) and `Contact:ToEmail` (operator copy). +CV PDF attached from `{FileStorage:Path}/{cvDocumentId}.pdf` if the file exists. + +## Shared volume + +`../Apis/api/Files:/app/Files` — same bind mount as `api` and `cv-cleanup-job`. +CV PDFs written by `api` are readable here without any API call. + +## Key settings + +| Section | Env var | Notes | +|---------|---------|-------| +| `Database` | `Database__*` | Same SQL Server as other services | +| `CvMatcherApi` | `CvMatcherApi__BaseUrl`, `CvMatcherApi__InternalApiKey` | Internal call to match-job endpoint | +| `Smtp` | `Smtp__*` | Same vars as `api` | +| `Contact` | `Contact__ToEmail` | Operator copy recipient | +| `FileStorage` | `FileStorage__Path` | Must match the shared volume mount path | +| `JobSearch` | `JobSearch__Enabled`, `MinMatchScore`, `MaxJobsToMatch` | Core search limits | +| `Jobs:Tasks:0` | `Jobs__Tasks__0__Interval` | Poll interval (default `00:00:30`) | + +## Logging + +Follows the same scheme as `cv-cleanup-job`: +- **Console** — `[HH:mm:ss LVL] SourceContext: Message` +- **File** — `logs/cv-search-job-.log`, daily rolling, 30-day retention +- **Email** (index 2) — Errors only, wired via `Serilog__WriteTo__2__Args__*` env vars in docker-compose +- **Enrich** — `FromLogContext`, `WithMachineName`, `WithEnvironmentName` + +`Serilog.Sinks.Email` is available transitively through `startup-helpers` — no extra package needed in the csproj. + +## EF migrations + +This project runs `CvSearchDbContext.Database.Migrate()` on startup. +Migrations live in `Apis/cv-search-data/Migrations/`. +To add a migration: see root CLAUDE.md. diff --git a/Jobs/cv-search-job/Clients/ICvMatcherInternalApi.cs b/Jobs/cv-search-job/Clients/ICvMatcherInternalApi.cs new file mode 100644 index 0000000..8b42576 --- /dev/null +++ b/Jobs/cv-search-job/Clients/ICvMatcherInternalApi.cs @@ -0,0 +1,11 @@ +using CvMatcher.Models.Requests; +using CvMatcher.Models.Responses; +using Refit; + +namespace CvSearchJob.Clients; + +public interface ICvMatcherInternalApi +{ + [Post("/api/cv/match-job")] + Task MatchJobAsync([Body] MatchJobRequest request, CancellationToken ct); +} diff --git a/Jobs/cv-search-job/Dockerfile b/Jobs/cv-search-job/Dockerfile new file mode 100644 index 0000000..d64dd9e --- /dev/null +++ b/Jobs/cv-search-job/Dockerfile @@ -0,0 +1,58 @@ +FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build +ARG BUILD_CONFIGURATION=Release +WORKDIR /src +COPY Directory.Packages.props ./ + +COPY Jobs/cv-search-job/cv-search-job.csproj Jobs/cv-search-job/ +COPY Jobs/job-scheduler/job-scheduler.csproj Jobs/job-scheduler/ +COPY Apis/cv-search-data/cv-search-data.csproj Apis/cv-search-data/ +COPY Apis/cv-matcher-api-models/cv-matcher-api-models.csproj Apis/cv-matcher-api-models/ +COPY Apis/email-data/email-data.csproj Apis/email-data/ +COPY Apis/email-api-models/email-api-models.csproj Apis/email-api-models/ +COPY Apis/page-fetcher-api-models/page-fetcher-api-models.csproj Apis/page-fetcher-api-models/ +COPY Apis/common/common.csproj Apis/common/ +COPY Apis/myai-data/myai-data.csproj Apis/myai-data/ +COPY Apis/shared-data/shared-data.csproj Apis/shared-data/ +COPY Helpers/startup-helpers/startup-helpers.csproj Helpers/startup-helpers/ + +RUN dotnet restore Jobs/cv-search-job/cv-search-job.csproj + +COPY Jobs/cv-search-job/ Jobs/cv-search-job/ +COPY Jobs/job-scheduler/ Jobs/job-scheduler/ +COPY Apis/cv-search-data/ Apis/cv-search-data/ +COPY Apis/cv-matcher-api-models/ Apis/cv-matcher-api-models/ +COPY Apis/email-data/ Apis/email-data/ +COPY Apis/email-api-models/ Apis/email-api-models/ +COPY Apis/page-fetcher-api-models/ Apis/page-fetcher-api-models/ +COPY Apis/common/ Apis/common/ +COPY Apis/myai-data/ Apis/myai-data/ +COPY Apis/shared-data/ Apis/shared-data/ +COPY Helpers/startup-helpers/ Helpers/startup-helpers/ + +RUN dotnet publish Jobs/cv-search-job/cv-search-job.csproj -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=false + +# Download Playwright Chromium browser in the build stage. +# Node.js is only needed here to run npx — it is not copied to the final image. +ENV PLAYWRIGHT_BROWSERS_PATH=/ms-playwright +RUN apt-get update && apt-get install -y --no-install-recommends nodejs npm \ + && npx --yes playwright@1.60.0 install chromium \ + && rm -rf /var/lib/apt/lists/* + +FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS final +WORKDIR /app + +# System libraries required by Chromium on Debian bookworm +RUN apt-get update && apt-get install -y --no-install-recommends \ + libnss3 libnspr4 libatk1.0-0 libatk-bridge2.0-0 libcups2 libdrm2 \ + libxkbcommon0 libxcomposite1 libxdamage1 libxfixes3 libxrandr2 \ + libgbm1 libasound2t64 libpango-1.0-0 libcairo2 libatspi2.0-0 \ + libwayland-client0 libx11-xcb1 libx11-6 libxcb1 libxext6 \ + && rm -rf /var/lib/apt/lists/* + +# Copy the Playwright Chromium browser from the build stage +ENV PLAYWRIGHT_BROWSERS_PATH=/ms-playwright +COPY --from=build /ms-playwright /ms-playwright + +COPY --from=build /app/publish . + +ENTRYPOINT ["dotnet", "cv-search-job.dll"] diff --git a/Jobs/cv-search-job/Program.cs b/Jobs/cv-search-job/Program.cs new file mode 100644 index 0000000..094d1f9 --- /dev/null +++ b/Jobs/cv-search-job/Program.cs @@ -0,0 +1,129 @@ +using System.Reflection; +using CvMatcher.Models.Settings; +using CvSearch.Data; +using CvSearchJob.Clients; +using CvSearchJob.Services; +using Email.Data; +using Email.Data.Repositories; +using Email.Data.Repositories.Contracts; +using Email.Data.Services; +using Email.Models.Clients; +using CvSearchJob.Tasks; +using JobScheduler.Scheduling; +using JobScheduler.Tasks; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using PageFetcher.Models; +using Refit; +using Serilog; +using Common.Settings; +using StartupHelpers; + +const string ServiceName = "cv-search-job"; + +StartupExtensions.LoadDotEnvFile(); +var appVersion = StartupExtensions.GetApplicationVersion(Assembly.GetExecutingAssembly()); + +try +{ + var builder = Host.CreateApplicationBuilder(args); + + builder.ConfigureJsonSerilog(ServiceName, appVersion); + Log.Information("Starting {Service} version {AppVersion}", ServiceName, appVersion); + + builder.Services.Configure(builder.Configuration.GetSection("JobSearch")); + builder.Services.Configure(builder.Configuration.GetSection("Database")); + + builder.Services.AddDbContext(options => + { + var connectionString = builder.Services.GetConfiguredDbConnectionString(builder.Configuration); + options.UseSqlServer(connectionString, sql => + { + sql.MigrationsAssembly("cv-search-data"); + sql.MigrationsHistoryTable(CvSearchDbContext.MigrationTableName, CvSearchDbContext.SchemaName); + }); + }); + + builder.Services.AddRefitClient() + .ConfigureHttpClient((sp, client) => + { + var config = sp.GetRequiredService(); + var baseUrl = config["CvMatcherApi:BaseUrl"] ?? string.Empty; + if (!string.IsNullOrWhiteSpace(baseUrl)) + client.BaseAddress = new Uri(baseUrl.TrimEnd('/') + "/"); + var key = config["CvMatcherApi:InternalApiKey"]; + if (!string.IsNullOrWhiteSpace(key)) + client.DefaultRequestHeaders.Add("X-Internal-Api-Key", key); + }); + + builder.Services.AddDbContext(options => + { + var connectionString = builder.Services.GetConfiguredDbConnectionString(builder.Configuration); + options.UseSqlServer(connectionString, sql => + { + sql.MigrationsHistoryTable(EmailDbContext.MigrationTableName, EmailDbContext.SchemaName); + sql.MigrationsAssembly("email-data"); + }); + }); + + builder.Services.AddScoped(); + builder.Services.AddSingleton(); + + builder.Services.AddRefitClient() + .ConfigureHttpClient((sp, client) => + { + var config = sp.GetRequiredService(); + var baseUrl = config["EmailApi:BaseUrl"] ?? string.Empty; + if (!string.IsNullOrWhiteSpace(baseUrl)) + client.BaseAddress = new Uri(baseUrl.TrimEnd('/') + "/"); + var key = config["EmailApi:InternalApiKey"]; + if (!string.IsNullOrWhiteSpace(key)) + client.DefaultRequestHeaders.Add("X-Internal-Api-Key", key); + }); + + builder.Services.AddRefitClient() + .ConfigureHttpClient((sp, client) => + { + var config = sp.GetRequiredService(); + var baseUrl = config["PageFetcherApi:BaseUrl"] ?? string.Empty; + if (!string.IsNullOrWhiteSpace(baseUrl)) + client.BaseAddress = new Uri(baseUrl.TrimEnd('/') + "/"); + var key = config["PageFetcherApi:InternalApiKey"]; + if (!string.IsNullOrWhiteSpace(key)) + client.DefaultRequestHeaders.Add("X-Internal-Api-Key", key); + }); + + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + + builder.Services.AddSingleton(); + builder.Services.AddSingleton>(sp => new IJobTask[] + { + sp.GetRequiredService(), + }); + + builder.Services.AddHostedService(); + + var host = builder.Build(); + + host.LogHostStartupDiagnostics(ServiceName); + + Log.Information("Running EF Core migrations"); + using (var scope = host.Services.CreateScope()) + { + var db = scope.ServiceProvider.GetRequiredService(); + db.Database.Migrate(); + } + + Log.Information("{Service} startup complete. Background scheduler is running.", ServiceName); + await host.RunAsync(); +} +catch (Exception ex) +{ + Log.Fatal(ex, "{Service} terminated unexpectedly", ServiceName); +} +finally +{ + Log.CloseAndFlush(); +} diff --git a/Jobs/cv-search-job/Services/CvSearchEmailSender.cs b/Jobs/cv-search-job/Services/CvSearchEmailSender.cs new file mode 100644 index 0000000..9ecefc6 --- /dev/null +++ b/Jobs/cv-search-job/Services/CvSearchEmailSender.cs @@ -0,0 +1,157 @@ +using CvMatcher.Models.Responses; +using CvSearch.Data.Entities; +using Email.Data.Services; +using Email.Models.Clients; +using Email.Models.Requests; +using Microsoft.Extensions.Logging; + +namespace CvSearchJob.Services; + +/// +/// Sends job search results emails to the session user and the operator copy address, +/// with an optional CV PDF attachment. +/// +public sealed class CvSearchEmailSender +{ + private readonly IEmailApiClient _emailApi; + private readonly IEmailTemplateService _emailTemplates; + private readonly ILogger _logger; + + public CvSearchEmailSender( + IEmailApiClient emailApi, + IEmailTemplateService emailTemplates, + ILogger logger) + { + _emailApi = emailApi; + _emailTemplates = emailTemplates; + _logger = logger; + } + + /// + /// Builds and sends the job search results email. + /// Resolves the recipient list from and the operator copy address + /// stored in the email template. Does nothing when no recipients can be resolved. + /// + /// Primary recipient (the user who triggered the search). + /// Relative filename of the CV PDF to attach, or null. + /// Ranked list of job search results to include in the email body. + /// CV keywords used to drive the job search. + /// Names of the providers that were scanned. + /// Two-letter language code for template rendering. + /// Cancellation token. + public async Task SendResultsAsync( + string toEmail, + string? attachmentFileName, + IReadOnlyList results, + IReadOnlyList keywords, + IReadOnlyList providerNames, + string language, + string? location, + CancellationToken ct) + { + var operatorCopy = _emailTemplates.GetOperatorCopy("email.search-results.subject", language); + + var recipients = new List(); + if (!string.IsNullOrWhiteSpace(toEmail)) recipients.Add(toEmail); + if (!string.IsNullOrWhiteSpace(operatorCopy) && + !recipients.Any(r => string.Equals(r, operatorCopy, StringComparison.OrdinalIgnoreCase))) + recipients.Add(operatorCopy); + + if (recipients.Count == 0) return; + + var htmlBody = BuildBody(results, keywords, providerNames, language, location); + var subject = _emailTemplates.Render("email.search-results.subject", language, + ("count", results.Count.ToString())); + + try + { + await _emailApi.SendAsync(new SendEmailRequest + { + To = recipients, + Subject = subject, + HtmlBody = htmlBody, + AttachmentPath = attachmentFileName + }, ct); + + _logger.LogInformation("Job search results email sent to {Recipients}", + string.Join(", ", recipients)); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to send job search results email to {Recipients}", + string.Join(", ", recipients)); + } + } + + /// + /// Renders the HTML email body from the results list. + /// Returns the empty-results template when no results are present. + /// Prepends a scan summary block showing the keywords and providers used. + /// + private string BuildBody(IReadOnlyList results, IReadOnlyList keywords, IReadOnlyList providerNames, string language, string? location) + { + var scanSummary = BuildScanSummary(keywords, providerNames, language, location); + + if (results.Count == 0) + return scanSummary + _emailTemplates.Get("email.search-results.empty", language); + + var items = new System.Text.StringBuilder(); + for (int i = 0; i < results.Count; i++) + { + var r = results[i]; + var summary = TryParseResult(r.ResultJson)?.Summary; + var summaryHtml = string.IsNullOrWhiteSpace(summary) + ? "" + : $"

{summary}

"; + + items.Append(_emailTemplates.Render("email.search-results.item", language, + ("index", (i + 1).ToString()), + ("jobTitle", r.JobTitle), + ("score", r.Score.ToString()), + ("providerName", r.ProviderName), + ("jobUrl", r.JobUrl), + ("summary", summaryHtml))); + } + + return _emailTemplates.Render("email.search-results.body", language, + ("count", results.Count.ToString()), + ("items", scanSummary + items.ToString())); + } + + /// + /// Renders the scan summary block via template, passing keyword tags and provider list as data. + /// Keyword tags are built here because they are variable-count inline elements, not structural HTML. + /// + private string BuildScanSummary(IReadOnlyList keywords, IReadOnlyList providerNames, string language, string? location) + { + var keywordsHtml = keywords.Count > 0 + ? string.Join(" ", keywords.Select(k => + $"{k}")) + : "none detected"; + + var providers = providerNames.Count > 0 + ? string.Join(", ", providerNames) + : "none"; + + var locationDisplay = string.IsNullOrWhiteSpace(location) ? "-" : location; + + return _emailTemplates.Render("email.search-results.scan-summary", language, + ("keywordsHtml", keywordsHtml), + ("providers", providers), + ("location", locationDisplay)); + } + + /// + /// Attempts to deserialise the stored result JSON into a . + /// Returns null on parse failure so the email still renders without a summary. + /// + private static JobMatchResponse? TryParseResult(string json) + { + try + { + return System.Text.Json.JsonSerializer.Deserialize(json, + new System.Text.Json.JsonSerializerOptions(System.Text.Json.JsonSerializerDefaults.Web)); + } + catch { return null; } + } +} diff --git a/Jobs/cv-search-job/Services/HtmlJobSearcher.cs b/Jobs/cv-search-job/Services/HtmlJobSearcher.cs new file mode 100644 index 0000000..5c5b268 --- /dev/null +++ b/Jobs/cv-search-job/Services/HtmlJobSearcher.cs @@ -0,0 +1,142 @@ +using System.Text.RegularExpressions; +using System.Web; +using CvMatcher.Models.Settings; +using PageFetcher.Models; +using Microsoft.Extensions.Logging; + +namespace CvSearchJob.Services; + +/// +/// A URL and its anchor text as scraped from a job listing search-results page. +/// +public sealed record JobCandidate(string Url, string Title); + +/// +/// Config-driven HTML scraper that fetches a provider's job listing page via page-fetcher-api +/// and extracts matching job URL candidates. +/// Uses a two-stage anchor filter: href must contain the provider's link pattern, and (optionally) +/// anchor text must contain at least one CV keyword. +/// +public sealed class HtmlJobSearcher +{ + private readonly IPageFetcherApiClient _pageFetcher; + private readonly ILogger _logger; + + public HtmlJobSearcher(IPageFetcherApiClient pageFetcher, ILogger logger) + { + _pageFetcher = pageFetcher; + _logger = logger; + } + + /// + /// Fetches the provider's search result page, parses all anchor tags, applies the two-stage filter, + /// and returns up to candidates (URL + title). + /// Returns an empty list when the page fetch fails rather than throwing. + /// + public async Task> SearchJobUrlsAsync( + JobProviderConfig provider, + IReadOnlyList cvKeywords, + string? location, + CancellationToken ct) + { + var allKeywords = provider.InitialKeywords + .Concat(cvKeywords) + .Where(k => !string.IsNullOrWhiteSpace(k)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList(); + + if (allKeywords.Count == 0) + { + _logger.LogWarning("Provider {Provider}: no keywords available (CV keywords empty, InitialKeywords empty), skipping", provider.Name); + return []; + } + + var keywordsEncoded = HttpUtility.UrlEncode(string.Join(" ", allKeywords)); + var locationEncoded = HttpUtility.UrlEncode(location ?? string.Empty); + var locationSlug = (location ?? string.Empty) + .ToLowerInvariant() + .Replace(",", "") + .Replace(" ", "-") + .Trim('-'); + var searchUrl = provider.SearchUrlTemplate + .Replace("{keywords}", keywordsEncoded) + .Replace("{location}", locationEncoded) + .Replace("{location-slug}", locationSlug); + + _logger.LogInformation( + "Provider {Provider}: fetching {Url} | CV keywords: [{Keywords}] | Location: {Location}", + provider.Name, searchUrl, + string.Join(", ", cvKeywords), + location ?? "(none)"); + + var fetchResponse = await _pageFetcher.FetchAsync(new FetchPageRequest + { + Url = searchUrl, + CallerService = "cv-search-job" + }, ct); + + if (!fetchResponse.Success || string.IsNullOrWhiteSpace(fetchResponse.Html)) + { + _logger.LogWarning("Provider {Provider}: page fetch failed — {Error}", provider.Name, fetchResponse.Error); + return []; + } + + var html = fetchResponse.Html; + _logger.LogInformation("Provider {Provider}: received {Length} chars of HTML", provider.Name, html.Length); + + var baseUri = new Uri(searchUrl); + var results = new List(); + var seen = new HashSet(StringComparer.OrdinalIgnoreCase); + + var anchorPattern = new Regex(@"]+href=[""']([^""']+)[""'][^>]*>(.*?)", + RegexOptions.IgnoreCase | RegexOptions.Singleline); + + var allAnchors = anchorPattern.Matches(html); + var stage1Pass = 0; + var stage2Pass = 0; + + foreach (Match match in allAnchors) + { + if (results.Count >= provider.MaxResults) break; + + var href = match.Groups[1].Value.Trim(); + var anchorText = Regex.Replace(match.Groups[2].Value, "<[^>]+>", " ").Trim(); + + if (!href.Contains(provider.JobLinkContains, StringComparison.OrdinalIgnoreCase)) + continue; + + stage1Pass++; + + if (provider.RequireKeywordInAnchor && + !cvKeywords.Any(k => anchorText.Contains(k, StringComparison.OrdinalIgnoreCase))) + { + _logger.LogDebug( + "Provider {Provider}: stage-2 reject | href={Href} | text={Text}", + provider.Name, href, anchorText.Length > 100 ? anchorText[..100] : anchorText); + continue; + } + + stage2Pass++; + + if (!Uri.TryCreate(href, UriKind.Absolute, out var absoluteUri)) + { + if (!Uri.TryCreate(baseUri, href, out absoluteUri)) + continue; + } + + // Skip non-HTTP(S) URLs (e.g. file:// or javascript: that can appear in scraped HTML) + if (absoluteUri.Scheme != Uri.UriSchemeHttp && absoluteUri.Scheme != Uri.UriSchemeHttps) + continue; + + var url = absoluteUri.GetLeftPart(UriPartial.Path); + if (seen.Add(url)) + results.Add(new JobCandidate(url, anchorText)); + } + + _logger.LogInformation( + "Provider {Provider}: {TotalAnchors} anchors found | {Stage1} passed href filter ('{LinkPattern}') | {Stage2} passed keyword filter | {Unique} unique URLs returned", + provider.Name, allAnchors.Count, stage1Pass, provider.JobLinkContains, stage2Pass, results.Count); + + return results; + } +} diff --git a/Jobs/cv-search-job/Tasks/CvSearchJobTask.cs b/Jobs/cv-search-job/Tasks/CvSearchJobTask.cs new file mode 100644 index 0000000..43337d5 --- /dev/null +++ b/Jobs/cv-search-job/Tasks/CvSearchJobTask.cs @@ -0,0 +1,295 @@ +using System.Text.Json; +using CvMatcher.Models.Requests; +using CvSearch.Data; +using CvSearch.Data.Entities; +using CvMatcher.Models.Settings; +using CvSearchJob.Clients; +using CvSearchJob.Services; +using JobScheduler.Tasks; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using PageFetcher.Models; + +namespace CvSearchJob.Tasks; + +/// +/// Background job task that processes pending job search sessions: scrapes providers, +/// scores each URL against the CV via the matcher API, persists results, and sends the results email. +/// +public sealed class CvSearchJobTask : IJobTask +{ + private readonly IServiceScopeFactory _scopeFactory; + private readonly JobSearchSettings _settings; + private readonly HtmlJobSearcher _searcher; + private readonly ICvMatcherInternalApi _matcherApi; + private readonly IPageFetcherApiClient _pageFetcher; + private readonly CvSearchEmailSender _emailSender; + private readonly ILogger _logger; + + public string TaskType => "CvSearch"; + + public CvSearchJobTask( + IServiceScopeFactory scopeFactory, + IOptions settings, + HtmlJobSearcher searcher, + ICvMatcherInternalApi matcherApi, + IPageFetcherApiClient pageFetcher, + CvSearchEmailSender emailSender, + ILogger logger) + { + _scopeFactory = scopeFactory; + _settings = settings.Value; + _searcher = searcher; + _matcherApi = matcherApi; + _pageFetcher = pageFetcher; + _emailSender = emailSender; + _logger = logger; + } + + /// + /// Called by the scheduler on each tick. Resets orphaned sessions, picks the oldest pending session, + /// runs the full search pipeline, and sends the results email. + /// Does nothing when JobSearch:Enabled is false. + /// + public async Task ExecuteAsync(IConfiguration parametersSection, CancellationToken cancellationToken) + { + if (!_settings.Enabled) return; + + using var scope = _scopeFactory.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + + // Recover orphaned Processing sessions (container crashed mid-run) + var stuckCutoff = DateTime.UtcNow.AddMinutes(-10); + var stuckSessions = await db.JobSearchSessions + .Where(s => s.Status == JobSearchStatus.Processing && s.CreatedAt < stuckCutoff) + .ToListAsync(cancellationToken); + foreach (var stuck in stuckSessions) + { + stuck.Status = JobSearchStatus.Pending; + _logger.LogWarning("Reset stuck session {SessionId} back to Pending", stuck.Id); + } + if (stuckSessions.Count > 0) + await db.SaveChangesAsync(cancellationToken); + + var pending = await db.JobSearchSessions + .Where(s => s.Status == JobSearchStatus.Pending) + .OrderBy(s => s.CreatedAt) + .Take(1) + .FirstOrDefaultAsync(cancellationToken); + + if (pending is null) return; + + _logger.LogInformation("Processing job search session {SessionId}", pending.Id); + pending.Status = JobSearchStatus.Processing; + await db.SaveChangesAsync(cancellationToken); + + try + { + var cvKeywords = pending.Keywords + .Split(',', StringSplitOptions.RemoveEmptyEntries) + .Select(k => k.Trim()) + .Where(k => k.Length > 0) + .ToList(); + + var providers = GetProviders(pending.ProviderConfigJson); + + _logger.LogInformation( + "Session {SessionId}: keywords=[{Keywords}] | providers=[{Providers}]", + pending.Id, + cvKeywords.Count > 0 ? string.Join(", ", cvKeywords) : "(none)", + providers.Count > 0 ? string.Join(", ", providers.Select(p => p.Name)) : "(none)"); + + var results = await RunSearchAsync(pending, cvKeywords, providers, db, cancellationToken); + + pending.Status = JobSearchStatus.Done; + await db.SaveChangesAsync(cancellationToken); + + var attachmentFileName = BuildCvFileName(pending.CvDocumentId); + await _emailSender.SendResultsAsync( + pending.Email, + attachmentFileName, + results, + cvKeywords, + providers.Select(p => p.Name).ToList(), + pending.Language, + pending.Location, + cancellationToken); + + _logger.LogInformation("Session {SessionId} done. {Count} results sent.", pending.Id, results.Count); + } + catch (Exception ex) + { + _logger.LogError(ex, "Session {SessionId} failed.", pending.Id); + pending.Status = JobSearchStatus.Failed; + await db.SaveChangesAsync(cancellationToken); + } + } + + /// + /// Runs the full search pipeline for a session: scrapes all providers, deduplicates URLs, + /// fetches each individual job page via page-fetcher-api, applies a keyword pre-filter, + /// scores passing candidates via the matcher API, and persists results that meet the minimum score threshold. + /// + private async Task> RunSearchAsync( + JobSearchSessionEntity session, + List cvKeywords, + List providers, + CvSearchDbContext db, + CancellationToken ct) + { + if (cvKeywords.Count == 0) + _logger.LogWarning("Session {SessionId}: keyword list is empty — scraper will rely on provider InitialKeywords only", session.Id); + + var jobCandidates = new Dictionary(StringComparer.OrdinalIgnoreCase); // url → title + + foreach (var provider in providers) + { + var candidates = await _searcher.SearchJobUrlsAsync(provider, cvKeywords, session.Location, ct); + _logger.LogInformation("Session {SessionId}: provider {Provider} returned {Count} candidates", session.Id, provider.Name, candidates.Count); + foreach (var c in candidates) + jobCandidates.TryAdd(c.Url, c.Title); + } + + var deduped = jobCandidates.Take(_settings.MaxJobsToMatch).ToList(); + _logger.LogInformation( + "Session {SessionId}: {Total} unique URLs across all providers, processing up to {Cap}", + session.Id, jobCandidates.Count, deduped.Count); + + var results = new List(); + + foreach (var (url, title) in deduped) + { + try + { + // Fetch individual job page text via page-fetcher-api + var fetchResponse = await _pageFetcher.FetchAsync(new FetchPageRequest + { + Url = url, + WaitFor = "domcontentloaded", + CallerService = "cv-search-job", + JobSearchSessionId = session.Id + }, ct); + + if (!fetchResponse.Success || string.IsNullOrWhiteSpace(fetchResponse.Text)) + { + _logger.LogWarning("Session {SessionId}: fetch failed for {Url} — {Error}", session.Id, url, fetchResponse.Error); + continue; + } + + var jobText = fetchResponse.Text; + + // Keyword pre-filter: skip LLM call if no CV keyword appears in the job page text + if (cvKeywords.Count > 0 && + !cvKeywords.Any(k => jobText.Contains(k, StringComparison.OrdinalIgnoreCase))) + { + _logger.LogInformation( + "Session {SessionId}: pre-filter skip | {Url} | no CV keyword found in job text", + session.Id, url); + continue; + } + + var matchRequest = new MatchJobRequest + { + CvDocumentId = session.CvDocumentId, + JobUrl = url, + // Pre-fetched text passed directly so cv-matcher-api skips re-fetching the page + JobDescription = jobText, + // User already gave GDPR consent when they clicked the one-time job search link + GdprConsent = true + }; + + var matchResult = await _matcherApi.MatchJobAsync(matchRequest, ct); + + _logger.LogInformation( + "Session {SessionId}: {Url} → score={Score}% (threshold={Threshold}%) {Verdict}", + session.Id, url, matchResult.Score, _settings.MinMatchScore, + matchResult.Score >= _settings.MinMatchScore ? "ACCEPTED" : "rejected"); + + if (matchResult.Score < _settings.MinMatchScore) + continue; + + var entity = new JobSearchResultEntity + { + Id = Guid.NewGuid().ToString("N"), + SessionId = session.Id, + ProviderName = GuessProvider(url, providers), + JobUrl = url, + JobTitle = matchResult.Summary.Split('.').FirstOrDefault()?.Trim() ?? title, + JobText = jobText, + Score = matchResult.Score, + ResultJson = JsonSerializer.Serialize(matchResult, new JsonSerializerOptions(JsonSerializerDefaults.Web)), + Email = session.Email, + ClientIpAddress = session.ClientIpAddress, + CreatedAt = DateTime.UtcNow + }; + + db.JobSearchResults.Add(entity); + await db.SaveChangesAsync(ct); + results.Add(entity); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Session {SessionId}: match failed for {Url}", session.Id, url); + } + } + + results.Sort((a, b) => b.Score.CompareTo(a.Score)); + return results; + } + + /// + /// Deserialises the provider configuration snapshot stored on the session. + /// Providers are always snapshotted from the DB at session-creation time, so the snapshot + /// should always be present. Returns an empty list (with a warning) when it is missing or corrupt. + /// + private List GetProviders(string? providerConfigJson) + { + if (string.IsNullOrWhiteSpace(providerConfigJson)) + { + _logger.LogWarning("Session has no provider config snapshot — returning empty provider list"); + return []; + } + + try + { + return JsonSerializer.Deserialize>(providerConfigJson, + new JsonSerializerOptions(JsonSerializerDefaults.Web)) + ?? []; + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to deserialise provider config snapshot — returning empty provider list"); + return []; + } + } + + /// + /// Infers the provider name from the job URL by matching against each provider's JobLinkContains pattern. + /// Falls back to the URL hostname when no provider matches. + /// + private static string GuessProvider(string url, List providers) + { + foreach (var p in providers) + { + if (!string.IsNullOrWhiteSpace(p.JobLinkContains) && + url.Contains(p.JobLinkContains, StringComparison.OrdinalIgnoreCase)) + return p.Name; + } + + return Uri.TryCreate(url, UriKind.Absolute, out var uri) ? uri.Host : "unknown"; + } + + /// + /// Constructs the CV PDF filename from the document ID. + /// + private static string BuildCvFileName(string cvDocumentId) + { + // Strip non-alphanumeric characters so the filename is safe for all OS/email clients. + var safeId = string.Concat(cvDocumentId.Where(char.IsLetterOrDigit)); + if (string.IsNullOrWhiteSpace(safeId)) safeId = "cv"; + return $"{safeId}.pdf"; + } +} diff --git a/Jobs/cv-search-job/appsettings.json b/Jobs/cv-search-job/appsettings.json new file mode 100644 index 0000000..c8217a8 --- /dev/null +++ b/Jobs/cv-search-job/appsettings.json @@ -0,0 +1,97 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.Hosting.Lifetime": "Information", + "Microsoft.Extensions.Hosting": "Information", + "System.Net.Http.HttpClient": "Warning", + "CvSearchJob": "Information", + "JobScheduler": "Information" + } + }, + "LogEnvironmentOnStartup": true, + "Serilog": { + "Using": [ + "Serilog.Sinks.Console", + "Serilog.Sinks.File" + ], + "MinimumLevel": { + "Default": "Information", + "Override": { + "Microsoft.AspNetCore": "Warning", + "Microsoft.Extensions.Hosting": "Information", + "Microsoft.Hosting.Lifetime": "Information", + "System.Net.Http.HttpClient": "Warning", + "CvSearchJob": "Information", + "JobScheduler": "Information" + } + }, + "WriteTo": [ + { + "Name": "Console", + "Args": { + "outputTemplate": "[{Timestamp:HH:mm:ss} {Level:u3}] {SourceContext}: {Message:lj}{NewLine}{Exception}" + } + }, + { + "Name": "File", + "Args": { + "path": "logs/cv-search-job-.log", + "rollingInterval": "Day", + "retainedFileCountLimit": 30, + "outputTemplate": "{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} [{Level:u3}] {SourceContext}: {Message:lj}{NewLine}{Exception}" + } + } + ], + "Enrich": [ + "FromLogContext", + "WithMachineName", + "WithEnvironmentName" + ] + }, + "Database": { + "Host": "localhost", + "Port": 1433, + "Name": "MyAiDb", + "User": "sa", + "Password": "", + "TrustServerCertificate": true + }, + "CvMatcherApi": { + "BaseUrl": "http://cv-matcher-api:8080", + "InternalApiKey": "" + }, + "EmailApi": { + "BaseUrl": "http://email-api:8080", + "InternalApiKey": "" + }, + "FileStorage": { + "Path": "Files" + }, + "Smtp": { + "Host": "", + "Port": 587, + "Username": "", + "Password": "", + "UseStartTls": false + }, + "Contact": { + "ToEmail": "" + }, + "JobSearch": { + "Enabled": true, + "JobSearchLinkBaseUrl": "https://myai.ro", + "TokenExpiryDays": 7, + "MinMatchScore": 15, + "MaxJobsToMatch": 15 + }, + "Jobs": { + "Tasks": [ + { + "TaskType": "CvSearch", + "Enabled": true, + "Interval": "00:00:30" + } + ] + } +} diff --git a/Jobs/cv-search-job/cv-search-job.csproj b/Jobs/cv-search-job/cv-search-job.csproj new file mode 100644 index 0000000..2cefbb7 --- /dev/null +++ b/Jobs/cv-search-job/cv-search-job.csproj @@ -0,0 +1,33 @@ + + + + net10.0 + enable + enable + CvSearchJob + cv-search-job + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Jobs/job-scheduler/job-scheduler.csproj b/Jobs/job-scheduler/job-scheduler.csproj index ddb1c78..9200dc9 100644 --- a/Jobs/job-scheduler/job-scheduler.csproj +++ b/Jobs/job-scheduler/job-scheduler.csproj @@ -8,10 +8,10 @@ - - - - + + + + diff --git a/docker-compose/.env.template b/docker-compose/.env.template index 8ad75e6..6706fd1 100644 --- a/docker-compose/.env.template +++ b/docker-compose/.env.template @@ -2,6 +2,17 @@ # Copy this file to `.env` (local), `.env.staging`, or `.env.production` and fill the secret values. # Do NOT commit your `.env.*` files containing real secrets. +# Docker image tag — must match the tag CI pushes to the registry for this environment. +# "staging" for the staging Portainer stack, "production" for the production stack. +# For local dev this is ignored (docker-compose.override.yml builds images locally). +IMAGE_TAG=staging + +# 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. +LOGS_PATH=./logs +FILES_PATH=../Apis/api/Files + # Common ASPNETCORE_ENVIRONMENT=Development @@ -81,6 +92,15 @@ Jobs__CvStorageCleanupEnabled=true Jobs__CvStorageCleanupInterval=01:00:00 Jobs__CvStorageMaxTotalSizeMegabytes=40 +# CV search job (job board scraper — triggered by one-click email link) +Jobs__CvSearchEnabled=true +Jobs__CvSearchInterval=00:00:30 +JobSearch__Enabled=true +JobSearch__JobSearchLinkBaseUrl=https://myai.ro +JobSearch__TokenExpiryDays=7 +JobSearch__MinMatchScore=15 +JobSearch__MaxJobsToMatch=15 + # File Storage FileStorage__Path=Files FileStorage__DefaultFileName= @@ -127,3 +147,6 @@ RateLimiting__Policies__contact__QueueLimit=0 RateLimiting__Policies__cvMatcher__PermitLimit=10 RateLimiting__Policies__cvMatcher__Window=00:10:00 RateLimiting__Policies__cvMatcher__QueueLimit=0 +RateLimiting__Policies__download__PermitLimit=5 +RateLimiting__Policies__download__Window=00:01:00 +RateLimiting__Policies__download__QueueLimit=0 diff --git a/docker-compose/docker-compose.dcproj b/docker-compose/docker-compose.dcproj index 327f8f1..1d32a8a 100644 --- a/docker-compose/docker-compose.dcproj +++ b/docker-compose/docker-compose.dcproj @@ -20,5 +20,8 @@ .env + + docker-compose.yml +
\ No newline at end of file diff --git a/docker-compose/docker-compose.override.yml b/docker-compose/docker-compose.override.yml new file mode 100644 index 0000000..0392f7c --- /dev/null +++ b/docker-compose/docker-compose.override.yml @@ -0,0 +1,63 @@ +# Local development overrides — auto-merged by "docker compose up". +# Do NOT paste this into Portainer. It only adds build context, port mappings, +# and env_file loading on top of docker-compose.yml. + +services: + rag-api: + build: + context: .. + dockerfile: Apis/rag-api/Dockerfile + ports: + - "8081:8080" + env_file: + - .env + + cv-matcher-api: + build: + context: .. + dockerfile: Apis/cv-matcher-api/Dockerfile + ports: + - "8082:8080" + env_file: + - .env + + email-api: + build: + context: .. + dockerfile: Apis/email-api/Dockerfile + ports: + - "8083:8080" + env_file: + - .env + + api: + build: + context: .. + dockerfile: Apis/api/Dockerfile + ports: + - "8080:8080" + env_file: + - .env + + cv-cleanup-job: + build: + context: .. + dockerfile: Jobs/cv-cleanup-job/Dockerfile + env_file: + - .env + + cv-search-job: + build: + context: .. + dockerfile: Jobs/cv-search-job/Dockerfile + env_file: + - .env + + web: + build: + context: .. + dockerfile: web/Dockerfile + ports: + - "5140:8080" + env_file: + - .env diff --git a/docker-compose/docker-compose.yml b/docker-compose/docker-compose.yml index 4144943..f2d7274 100644 --- a/docker-compose/docker-compose.yml +++ b/docker-compose/docker-compose.yml @@ -1,15 +1,12 @@ services: rag-api: - image: registry.easysoft.ro/apps/myai-rag-api:production + image: registry.easysoft.ro/apps/myai-rag-api:${IMAGE_TAG:-staging} container_name: myai-rag-api environment: - # ASP.NET - - ASPNETCORE_ENVIRONMENT=${ASPNETCORE_ENVIRONMENT:-Production} + - ASPNETCORE_ENVIRONMENT=${ASPNETCORE_ENVIRONMENT:-Staging} - ASPNETCORE_URLS=${ASPNETCORE_URLS:-http://+:8080} - - APP_ENVIRONMENT_NAME=${APP_ENVIRONMENT_NAME:-myai.production} - - LogEnvironmentOnStartup=${LogEnvironmentOnStartup:-true} + - APP_ENVIRONMENT_NAME=${APP_ENVIRONMENT_NAME:-myai.staging} - # Database: matches rag-api appsettings Database section - Database__Host=${Database__Host:-sqlserver} - Database__Port=${Database__Port:-1433} - Database__Name=${Database__Name:-MyAiDb} @@ -17,11 +14,9 @@ services: - Database__Password=${Database__Password:-} - Database__TrustServerCertificate=${Database__TrustServerCertificate:-true} - # InternalApi: matches rag-api appsettings InternalApi section - - InternalApi__ApiKey=${RagApi__InternalApiKey:-change-this-internal-key} - - InternalApi__RequireApiKey=${RagApi__RequireApiKey:-false} + - InternalApi__ApiKey=${RagApi__InternalApiKey:-} + - InternalApi__RequireApiKey=${RagApi__RequireApiKey:-true} - # Rag: matches rag-api appsettings Rag section - Rag__MaxFileSizeMb=${Rag__MaxFileSizeMb:-8} - Rag__ChunkSize=${Rag__ChunkSize:-900} - Rag__ChunkOverlap=${Rag__ChunkOverlap:-150} @@ -30,7 +25,6 @@ services: - Rag__MaxTopK=${Rag__MaxTopK:-50} - Rag__ClassifyWithAi=${Rag__ClassifyWithAi:-false} - # Ai: matches rag-api appsettings Ai section - Ai__Provider=${Ai__Provider:-OpenAI} - Ai__OpenAI__ApiKey=${Ai__OpenAI__ApiKey:-} - Ai__OpenAI__ChatModel=${Ai__OpenAI__ChatModel:-gpt-4o-mini} @@ -41,20 +35,14 @@ services: - Ai__Ollama__EmbeddingModel=${Ai__Ollama__EmbeddingModel:-nomic-embed-text} - Ai__Ollama__TimeoutSeconds=${Ai__Ollama__TimeoutSeconds:-180} - # Logging / Serilog - - Logging__LogLevel__Default=${Logging__LogLevel__Default:-Information} - - Logging__LogLevel__Microsoft=${Logging__LogLevel__Microsoft:-Warning} - - Logging__LogLevel__Microsoft__AspNetCore=${Logging__LogLevel__Microsoft__AspNetCore:-Warning} - - Logging__LogLevel__Api=${Logging__LogLevel__Api:-Information} - - Serilog__WriteTo__2__Args__fromEmail=${Serilog__WriteTo__2__Args__fromEmail:-} - - Serilog__WriteTo__2__Args__toEmail=${Serilog__WriteTo__2__Args__toEmail:-} - - Serilog__WriteTo__2__Args__mailServer=${Serilog__WriteTo__2__Args__mailServer:-} - - Serilog__WriteTo__2__Args__networkCredential__userName=${Serilog__WriteTo__2__Args__networkCredential__userName:-} - - Serilog__WriteTo__2__Args__networkCredential__password=${Serilog__WriteTo__2__Args__networkCredential__password:-} - - Serilog__WriteTo__2__Args__port=${Serilog__WriteTo__2__Args__port:-587} - - Serilog__WriteTo__2__Args__enableSsl=${Serilog__WriteTo__2__Args__enableSsl:-true} + - SerilogEmail__From=${SerilogEmail__From:-} + - SerilogEmail__To=${SerilogEmail__To:-} + - SerilogEmail__Host=${SerilogEmail__Host:-} + - SerilogEmail__Port=${SerilogEmail__Port:-587} + - SerilogEmail__UserName=${SerilogEmail__UserName:-} + - SerilogEmail__Password=${SerilogEmail__Password:-} volumes: - - /opt/myai/logs/rag-api:/app/logs + - ${LOGS_PATH:-/opt/myai/logs}/rag-api:/app/logs networks: - myai-network restart: unless-stopped @@ -62,18 +50,15 @@ services: - "com.centurylinklabs.watchtower.enable=true" cv-matcher-api: - image: registry.easysoft.ro/apps/myai-cv-matcher-api:production + image: registry.easysoft.ro/apps/myai-cv-matcher-api:${IMAGE_TAG:-staging} container_name: myai-cv-matcher-api depends_on: - rag-api environment: - # ASP.NET - - ASPNETCORE_ENVIRONMENT=${ASPNETCORE_ENVIRONMENT:-Production} + - ASPNETCORE_ENVIRONMENT=${ASPNETCORE_ENVIRONMENT:-Staging} - ASPNETCORE_URLS=${ASPNETCORE_URLS:-http://+:8080} - - APP_ENVIRONMENT_NAME=${APP_ENVIRONMENT_NAME:-myai.production} - - LogEnvironmentOnStartup=${LogEnvironmentOnStartup:-true} + - APP_ENVIRONMENT_NAME=${APP_ENVIRONMENT_NAME:-myai.staging} - # Database: matches cv-matcher-api appsettings Database section - Database__Host=${Database__Host:-sqlserver} - Database__Port=${Database__Port:-1433} - Database__Name=${Database__Name:-MyAiDb} @@ -81,15 +66,15 @@ services: - Database__Password=${Database__Password:-} - Database__TrustServerCertificate=${Database__TrustServerCertificate:-true} - # InternalApi: matches cv-matcher-api appsettings InternalApi section - - InternalApi__ApiKey=${CvMatcherApi__InternalApiKey:-change-this-internal-key} - - InternalApi__RequireApiKey=${CvMatcherApi__RequireApiKey:-false} + - InternalApi__ApiKey=${CvMatcherApi__InternalApiKey:-} + - InternalApi__RequireApiKey=${CvMatcherApi__RequireApiKey:-true} - # RagApi: matches cv-matcher-api appsettings RagApi section - RagApi__BaseUrl=${RagApi__BaseUrl:-http://rag-api:8080} - - RagApi__InternalApiKey=${RagApi__InternalApiKey:-change-this-internal-key} + - RagApi__InternalApiKey=${RagApi__InternalApiKey:-} + + - PageFetcherApi__BaseUrl=${PageFetcherApi__BaseUrl:-http://page-fetcher-api:8080} + - PageFetcherApi__InternalApiKey=${PageFetcherApi__InternalApiKey:-} - # Ai: matches cv-matcher-api appsettings Ai section - Ai__Provider=${Ai__Provider:-OpenAI} - Ai__OpenAI__ApiKey=${Ai__OpenAI__ApiKey:-} - Ai__OpenAI__ChatModel=${Ai__OpenAI__ChatModel:-gpt-4o-mini} @@ -98,25 +83,59 @@ services: - Ai__Ollama__ChatModel=${Ai__Ollama__ChatModel:-llama3.1:8b} - Ai__Ollama__TimeoutSeconds=${Ai__Ollama__TimeoutSeconds:-180} - # Matcher: matches cv-matcher-api appsettings Matcher section - Matcher__TopK=${Matcher__TopK:-10} - Matcher__DeepScoreTopN=${Matcher__DeepScoreTopN:-5} - Matcher__MaxJobTextChars=${Matcher__MaxJobTextChars:-60000} - # Logging / Serilog - - Logging__LogLevel__Default=${Logging__LogLevel__Default:-Information} - - Logging__LogLevel__Microsoft=${Logging__LogLevel__Microsoft:-Warning} - - Logging__LogLevel__Microsoft__AspNetCore=${Logging__LogLevel__Microsoft__AspNetCore:-Warning} - - Logging__LogLevel__Api=${Logging__LogLevel__Api:-Information} - - Serilog__WriteTo__2__Args__fromEmail=${Serilog__WriteTo__2__Args__fromEmail:-} - - Serilog__WriteTo__2__Args__toEmail=${Serilog__WriteTo__2__Args__toEmail:-} - - Serilog__WriteTo__2__Args__mailServer=${Serilog__WriteTo__2__Args__mailServer:-} - - Serilog__WriteTo__2__Args__networkCredential__userName=${Serilog__WriteTo__2__Args__networkCredential__userName:-} - - Serilog__WriteTo__2__Args__networkCredential__password=${Serilog__WriteTo__2__Args__networkCredential__password:-} - - Serilog__WriteTo__2__Args__port=${Serilog__WriteTo__2__Args__port:-587} - - Serilog__WriteTo__2__Args__enableSsl=${Serilog__WriteTo__2__Args__enableSsl:-true} + - SerilogEmail__From=${SerilogEmail__From:-} + - SerilogEmail__To=${SerilogEmail__To:-} + - SerilogEmail__Host=${SerilogEmail__Host:-} + - SerilogEmail__Port=${SerilogEmail__Port:-587} + - SerilogEmail__UserName=${SerilogEmail__UserName:-} + - SerilogEmail__Password=${SerilogEmail__Password:-} volumes: - - /opt/myai/logs/cv-matcher-api:/app/logs + - ${LOGS_PATH:-/opt/myai/logs}/cv-matcher-api:/app/logs + networks: + - myai-network + restart: unless-stopped + labels: + - "com.centurylinklabs.watchtower.enable=true" + + email-api: + image: registry.easysoft.ro/apps/myai-email-api:${IMAGE_TAG:-staging} + container_name: myai-email-api + environment: + - ASPNETCORE_ENVIRONMENT=${ASPNETCORE_ENVIRONMENT:-Staging} + - ASPNETCORE_URLS=${ASPNETCORE_URLS:-http://+:8080} + - APP_ENVIRONMENT_NAME=${APP_ENVIRONMENT_NAME:-myai.staging} + + - Database__Host=${Database__Host:-sqlserver} + - Database__Port=${Database__Port:-1433} + - Database__Name=${Database__Name:-MyAiDb} + - Database__User=${Database__User:-sa} + - Database__Password=${Database__Password:-} + - Database__TrustServerCertificate=${Database__TrustServerCertificate:-true} + + - InternalApi__ApiKey=${EmailApi__InternalApiKey:-} + - InternalApi__RequireApiKey=${EmailApi__RequireApiKey:-true} + + - Smtp__Host=${Smtp__Host:-} + - Smtp__Port=${Smtp__Port:-587} + - Smtp__Username=${Smtp__Username:-} + - Smtp__Password=${Smtp__Password:-} + - Smtp__UseStartTls=${Smtp__UseStartTls:-false} + + - FileStorage__Path=${FileStorage__Path:-Files} + + - SerilogEmail__From=${SerilogEmail__From:-} + - SerilogEmail__To=${SerilogEmail__To:-} + - SerilogEmail__Host=${SerilogEmail__Host:-} + - SerilogEmail__Port=${SerilogEmail__Port:-587} + - SerilogEmail__UserName=${SerilogEmail__UserName:-} + - SerilogEmail__Password=${SerilogEmail__Password:-} + volumes: + - ${LOGS_PATH:-/opt/myai/logs}/email-api:/app/logs + - ${FILES_PATH:-/opt/myai/files}:/app/Files networks: - myai-network restart: unless-stopped @@ -124,53 +143,48 @@ services: - "com.centurylinklabs.watchtower.enable=true" api: - image: registry.easysoft.ro/apps/myai-api:production + image: registry.easysoft.ro/apps/myai-api:${IMAGE_TAG:-staging} container_name: myai-api depends_on: - cv-matcher-api + - email-api environment: - # ASP.NET - - ASPNETCORE_ENVIRONMENT=${ASPNETCORE_ENVIRONMENT:-Production} + - ASPNETCORE_ENVIRONMENT=${ASPNETCORE_ENVIRONMENT:-Staging} - ASPNETCORE_URLS=${ASPNETCORE_URLS:-http://+:8080} - - APP_ENVIRONMENT_NAME=${APP_ENVIRONMENT_NAME:-myai.production} - - LogEnvironmentOnStartup=${LogEnvironmentOnStartup:-true} + - APP_ENVIRONMENT_NAME=${APP_ENVIRONMENT_NAME:-myai.staging} + + - Database__Host=${Database__Host:-sqlserver} + - Database__Port=${Database__Port:-1433} + - Database__Name=${Database__Name:-MyAiDb} + - Database__User=${Database__User:-sa} + - Database__Password=${Database__Password:-} + - Database__TrustServerCertificate=${Database__TrustServerCertificate:-true} - # Google: matches api appsettings Google section - Google__TagManagerId=${Google__TagManagerId:-} - Google__MapKey=${Google__MapKey:-} - # Contact / Subscribe: matches api appsettings Contact and Subscribe sections - Contact__ToEmail=${Contact__ToEmail:-} - - Contact__FromEmail=${Contact__FromEmail:-${Smtp__Username:-}} + - Contact__FromEmail=${Contact__FromEmail:-} - Contact__SubjectPrefix=${Contact__SubjectPrefix:-} - Subscribe__ToEmail=${Subscribe__ToEmail:-} - Subscribe__SubjectPrefix=${Subscribe__SubjectPrefix:-} - # SMTP: matches api appsettings Smtp section - - Smtp__Host=${Smtp__Host:-mail.example.com} - - Smtp__Port=${Smtp__Port:-587} - - Smtp__Username=${Smtp__Username:-} - - Smtp__Password=${Smtp__Password:-} - - Smtp__UseStartTls=${Smtp__UseStartTls:-false} + - EmailApi__BaseUrl=${EmailApi__BaseUrl:-http://email-api:8080} + - EmailApi__InternalApiKey=${EmailApi__InternalApiKey:-} - # Captcha: matches api appsettings Captcha section - Captcha__Provider=${Captcha__Provider:-Recaptcha} - Captcha__SecretKey=${Captcha__SecretKey:-} - Captcha__PublicKey=${Captcha__PublicKey:-} - Captcha__MinimumScore=${Captcha__MinimumScore:-0.5} - # FileStorage: matches api appsettings FileStorage section - FileStorage__Path=${FileStorage__Path:-Files} - FileStorage__DefaultFileName=${FileStorage__DefaultFileName:-} - - FileStorage__ToEmail=${FileStorage__ToEmail:-} - - FileStorage__FromEmail=${FileStorage__FromEmail:-${Smtp__Username:-}} - - FileStorage__SubjectPrefix=${FileStorage__SubjectPrefix:-[File Download]} - # CvMatcherApi: matches api appsettings CvMatcherApi section - CvMatcherApi__BaseUrl=${CvMatcherApi__BaseUrl:-http://cv-matcher-api:8080} - - CvMatcherApi__InternalApiKey=${CvMatcherApi__InternalApiKey:-change-this-internal-key} + - CvMatcherApi__InternalApiKey=${CvMatcherApi__InternalApiKey:-} + + - JobSearch__BaseUrl=${JobSearch__JobSearchLinkBaseUrl:-https://myai.ro} - # Rate Limiting: matches api appsettings RateLimiting section - RateLimiting__Global__PermitLimit=${RateLimiting__Global__PermitLimit:-120} - RateLimiting__Global__Window=${RateLimiting__Global__Window:-00:01:00} - RateLimiting__Global__QueueLimit=${RateLimiting__Global__QueueLimit:-0} @@ -180,26 +194,22 @@ services: - RateLimiting__Policies__cvMatcher__PermitLimit=${RateLimiting__Policies__cvMatcher__PermitLimit:-10} - RateLimiting__Policies__cvMatcher__Window=${RateLimiting__Policies__cvMatcher__Window:-00:10:00} - RateLimiting__Policies__cvMatcher__QueueLimit=${RateLimiting__Policies__cvMatcher__QueueLimit:-0} + - RateLimiting__Policies__download__PermitLimit=${RateLimiting__Policies__download__PermitLimit:-5} + - RateLimiting__Policies__download__Window=${RateLimiting__Policies__download__Window:-00:01:00} + - RateLimiting__Policies__download__QueueLimit=${RateLimiting__Policies__download__QueueLimit:-0} - # CORS: not in the uploaded api appsettings, but used by your API startup config. - - Cors__AllowedOrigins__0=${Cors__AllowedOrigins__0:-http://localhost:5000} - - Cors__AllowedOrigins__1=${Cors__AllowedOrigins__1:-http://web:8080} + - Cors__AllowedOrigins__0=${Cors__AllowedOrigins__0:-} + - Cors__AllowedOrigins__1=${Cors__AllowedOrigins__1:-} - # Logging / Serilog - - Logging__LogLevel__Default=${Logging__LogLevel__Default:-Information} - - Logging__LogLevel__Microsoft=${Logging__LogLevel__Microsoft:-Warning} - - Logging__LogLevel__Microsoft__AspNetCore=${Logging__LogLevel__Microsoft__AspNetCore:-Warning} - - Logging__LogLevel__Api=${Logging__LogLevel__Api:-Information} - - Serilog__WriteTo__2__Args__fromEmail=${Serilog__WriteTo__2__Args__fromEmail:-} - - Serilog__WriteTo__2__Args__toEmail=${Serilog__WriteTo__2__Args__toEmail:-} - - Serilog__WriteTo__2__Args__mailServer=${Serilog__WriteTo__2__Args__mailServer:-} - - Serilog__WriteTo__2__Args__networkCredential__userName=${Serilog__WriteTo__2__Args__networkCredential__userName:-} - - Serilog__WriteTo__2__Args__networkCredential__password=${Serilog__WriteTo__2__Args__networkCredential__password:-} - - Serilog__WriteTo__2__Args__port=${Serilog__WriteTo__2__Args__port:-587} - - Serilog__WriteTo__2__Args__enableSsl=${Serilog__WriteTo__2__Args__enableSsl:-true} + - SerilogEmail__From=${SerilogEmail__From:-} + - SerilogEmail__To=${SerilogEmail__To:-} + - SerilogEmail__Host=${SerilogEmail__Host:-} + - SerilogEmail__Port=${SerilogEmail__Port:-587} + - SerilogEmail__UserName=${SerilogEmail__UserName:-} + - SerilogEmail__Password=${SerilogEmail__Password:-} volumes: - - /opt/myai/logs/api:/app/logs - - /opt/myai/files:/app/Files + - ${LOGS_PATH:-/opt/myai/logs}/api:/app/logs + - ${FILES_PATH:-/opt/myai/files}:/app/Files networks: - myai-network restart: unless-stopped @@ -207,42 +217,114 @@ services: - "com.centurylinklabs.watchtower.enable=true" cv-cleanup-job: - image: registry.easysoft.ro/apps/myai-cv-cleanup-job:production + image: registry.easysoft.ro/apps/myai-cv-cleanup-job:${IMAGE_TAG:-staging} container_name: myai-cv-cleanup-job depends_on: - api environment: - # Worker + diagnostics (matches Jobs/cv-cleanup-job appsettings) - - ASPNETCORE_ENVIRONMENT=${ASPNETCORE_ENVIRONMENT:-Production} - - APP_ENVIRONMENT_NAME=${APP_ENVIRONMENT_NAME:-myai.production} - - LogEnvironmentOnStartup=${LogEnvironmentOnStartup:-true} + - ASPNETCORE_ENVIRONMENT=${ASPNETCORE_ENVIRONMENT:-Staging} + - APP_ENVIRONMENT_NAME=${APP_ENVIRONMENT_NAME:-myai.staging} - # FileStorage: matches cv-cleanup-job appsettings FileStorage section - - FileStorage__Path=Files + - FileStorage__Path=${FileStorage__Path:-Files} - # Jobs: matches cv-cleanup-job appsettings Jobs:Tasks - Jobs__Tasks__0__Enabled=${Jobs__CvStorageCleanupEnabled:-true} - Jobs__Tasks__0__Interval=${Jobs__CvStorageCleanupInterval:-01:00:00} - Jobs__Tasks__0__Parameters__MaxTotalSizeMegabytes=${Jobs__CvStorageMaxTotalSizeMegabytes:-40} - # Logging / Serilog (matches Jobs/cv-cleanup-job appsettings Serilog section; WriteTo index 2 = Email) - - Logging__LogLevel__Default=${Logging__LogLevel__Default:-Information} - - Logging__LogLevel__Microsoft=${Logging__LogLevel__Microsoft:-Warning} - - Logging__LogLevel__Microsoft__Hosting__Lifetime=${Logging__LogLevel__Microsoft__Hosting__Lifetime:-Information} - - Logging__LogLevel__CvCleanupJob=${Logging__LogLevel__CvCleanupJob:-Information} - - Logging__LogLevel__JobScheduler=${Logging__LogLevel__JobScheduler:-Information} - - Serilog__MinimumLevel__Override__CvCleanupJob=${Serilog__MinimumLevel__Override__CvCleanupJob:-Information} - - Serilog__MinimumLevel__Override__JobScheduler=${Serilog__MinimumLevel__Override__JobScheduler:-Information} - - Serilog__WriteTo__2__Args__fromEmail=${Serilog__WriteTo__2__Args__fromEmail:-} - - Serilog__WriteTo__2__Args__toEmail=${Serilog__WriteTo__2__Args__toEmail:-} - - Serilog__WriteTo__2__Args__mailServer=${Serilog__WriteTo__2__Args__mailServer:-} - - Serilog__WriteTo__2__Args__networkCredential__userName=${Serilog__WriteTo__2__Args__networkCredential__userName:-} - - Serilog__WriteTo__2__Args__networkCredential__password=${Serilog__WriteTo__2__Args__networkCredential__password:-} - - Serilog__WriteTo__2__Args__port=${Serilog__WriteTo__2__Args__port:-587} - - Serilog__WriteTo__2__Args__enableSsl=${Serilog__WriteTo__2__Args__enableSsl:-true} + - SerilogEmail__From=${SerilogEmail__From:-} + - SerilogEmail__To=${SerilogEmail__To:-} + - SerilogEmail__Host=${SerilogEmail__Host:-} + - SerilogEmail__Port=${SerilogEmail__Port:-587} + - SerilogEmail__UserName=${SerilogEmail__UserName:-} + - SerilogEmail__Password=${SerilogEmail__Password:-} volumes: - - /opt/myai/logs/cv-cleanup-job:/app/logs - - /opt/myai/files:/app/Files + - ${LOGS_PATH:-/opt/myai/logs}/cv-cleanup-job:/app/logs + - ${FILES_PATH:-/opt/myai/files}:/app/Files + networks: + - myai-network + restart: unless-stopped + labels: + - "com.centurylinklabs.watchtower.enable=true" + + cv-search-job: + image: registry.easysoft.ro/apps/myai-cv-search-job:${IMAGE_TAG:-staging} + container_name: myai-cv-search-job + depends_on: + - cv-matcher-api + - email-api + environment: + - ASPNETCORE_ENVIRONMENT=${ASPNETCORE_ENVIRONMENT:-Staging} + - APP_ENVIRONMENT_NAME=${APP_ENVIRONMENT_NAME:-myai.staging} + + - Database__Host=${Database__Host:-sqlserver} + - Database__Port=${Database__Port:-1433} + - Database__Name=${Database__Name:-MyAiDb} + - Database__User=${Database__User:-sa} + - Database__Password=${Database__Password:-} + - Database__TrustServerCertificate=${Database__TrustServerCertificate:-true} + + - CvMatcherApi__BaseUrl=${CvMatcherApi__BaseUrl:-http://cv-matcher-api:8080} + - CvMatcherApi__InternalApiKey=${CvMatcherApi__InternalApiKey:-} + + - EmailApi__BaseUrl=${EmailApi__BaseUrl:-http://email-api:8080} + - EmailApi__InternalApiKey=${EmailApi__InternalApiKey:-} + + - PageFetcherApi__BaseUrl=${PageFetcherApi__BaseUrl:-http://page-fetcher-api:8080} + - PageFetcherApi__InternalApiKey=${PageFetcherApi__InternalApiKey:-} + + - FileStorage__Path=${FileStorage__Path:-Files} + + - JobSearch__Enabled=${JobSearch__Enabled:-true} + - JobSearch__JobSearchLinkBaseUrl=${JobSearch__JobSearchLinkBaseUrl:-https://myai.ro} + - JobSearch__TokenExpiryDays=${JobSearch__TokenExpiryDays:-7} + - JobSearch__MinMatchScore=${JobSearch__MinMatchScore:-15} + - JobSearch__MaxJobsToMatch=${JobSearch__MaxJobsToMatch:-15} + + - Jobs__Tasks__0__TaskType=CvSearch + - Jobs__Tasks__0__Enabled=${Jobs__CvSearchEnabled:-true} + - Jobs__Tasks__0__Interval=${Jobs__CvSearchInterval:-00:00:30} + + - SerilogEmail__From=${SerilogEmail__From:-} + - SerilogEmail__To=${SerilogEmail__To:-} + - SerilogEmail__Host=${SerilogEmail__Host:-} + - SerilogEmail__Port=${SerilogEmail__Port:-587} + - SerilogEmail__UserName=${SerilogEmail__UserName:-} + - SerilogEmail__Password=${SerilogEmail__Password:-} + volumes: + - ${LOGS_PATH:-/opt/myai/logs}/cv-search-job:/app/logs + - ${FILES_PATH:-/opt/myai/files}:/app/Files + networks: + - myai-network + restart: unless-stopped + labels: + - "com.centurylinklabs.watchtower.enable=true" + + page-fetcher-api: + image: registry.easysoft.ro/apps/myai-page-fetcher-api:${IMAGE_TAG:-staging} + container_name: myai-page-fetcher-api + environment: + - ASPNETCORE_ENVIRONMENT=${ASPNETCORE_ENVIRONMENT:-Staging} + - ASPNETCORE_URLS=${ASPNETCORE_URLS:-http://+:8080} + - APP_ENVIRONMENT_NAME=${APP_ENVIRONMENT_NAME:-myai.staging} + + - Database__Host=${Database__Host:-sqlserver} + - Database__Port=${Database__Port:-1433} + - Database__Name=${Database__Name:-MyAiDb} + - Database__User=${Database__User:-sa} + - Database__Password=${Database__Password:-} + - Database__TrustServerCertificate=${Database__TrustServerCertificate:-true} + + - InternalApi__ApiKey=${PageFetcherApi__InternalApiKey:-} + - InternalApi__RequireApiKey=true + + - SerilogEmail__From=${SerilogEmail__From:-} + - SerilogEmail__To=${SerilogEmail__To:-} + - SerilogEmail__Host=${SerilogEmail__Host:-} + - SerilogEmail__Port=${SerilogEmail__Port:-587} + - SerilogEmail__UserName=${SerilogEmail__UserName:-} + - SerilogEmail__Password=${SerilogEmail__Password:-} + volumes: + - ${LOGS_PATH:-/opt/myai/logs}/page-fetcher-api:/app/logs networks: - myai-network restart: unless-stopped @@ -250,22 +332,23 @@ services: - "com.centurylinklabs.watchtower.enable=true" web: - image: registry.easysoft.ro/apps/myai-web:production + image: registry.easysoft.ro/apps/myai-web:${IMAGE_TAG:-staging} container_name: myai-web depends_on: - api ports: - - "5140:8080" + - "${WEB_PORT:-5000}:8080" environment: - - ASPNETCORE_ENVIRONMENT=${ASPNETCORE_ENVIRONMENT:-Production} + - ASPNETCORE_ENVIRONMENT=${ASPNETCORE_ENVIRONMENT:-Staging} - ASPNETCORE_URLS=${ASPNETCORE_URLS:-http://+:8080} - - APP_ENVIRONMENT_NAME=${APP_ENVIRONMENT_NAME:-myai.production} + - APP_ENVIRONMENT_NAME=${APP_ENVIRONMENT_NAME:-myai.staging} - # Site: matches web appsettings Site section (Normal, UnderConstruction, Unavailable) - Site__Mode=${Site__Mode:-Normal} networks: - myai-network restart: unless-stopped + labels: + - "com.centurylinklabs.watchtower.enable=true" networks: myai-network: diff --git a/docs/skills/general-dev-workflow.md b/docs/skills/general-dev-workflow.md new file mode 100644 index 0000000..cdda4e2 --- /dev/null +++ b/docs/skills/general-dev-workflow.md @@ -0,0 +1,258 @@ +--- +name: general-dev-workflow +description: Structured development workflow for any project. Guides you through Plan → Issue → Implement → Test → PR with checkpoints at each stage to ensure code quality, thorough testing, and well-documented PRs before merge. Use this whenever starting a new feature, bug fix, or change — regardless of tech stack or platform (GitHub, GitLab, Gitea, etc.). Works locally or with remote repositories. +compatibility: Git, GitHub/GitLab/Gitea (auto-detected) +--- + +# General Development Workflow + +This skill guides you through a structured, repeatable development workflow that works across any tech stack and version control platform. The goal is to ensure consistent, high-quality code while reducing context-switching and providing clear checkpoints. + +## The 5-Phase Workflow + +### Phase 1: Plan +**Goal**: Define the change clearly before writing code. + +Start by answering: +- **What** are you building/fixing? (Brief 1-2 sentence summary) +- **Why** does it matter? (Problem it solves, value it adds) +- **Scope**: What's included? What's NOT included? (Define boundaries early) +- **Success criteria**: How will you know it's done? (Tests pass, performance improves, etc.) +- **Dependencies**: Does this require changes elsewhere? Do other features depend on this? +- **Risks**: What could go wrong? (Breaking changes, performance issues, etc.) + +**Output**: A clear plan document or issue description ready for the next phase. + +**Checkpoint**: Does the plan make sense? Is scope realistic? Have you thought through edge cases? + +--- + +### Phase 2: Create Issue (in your VCS) +**Goal**: Formally track the work — and the time spent on it — starting from the moment the plan is approved. + +> ⚠️ **Create the issue only after the plan has been reviewed and approved.** The issue marks the official start of implementation, so its creation timestamp doubles as the implementation start time. Do not create it speculatively while the plan is still being discussed. + +Steps: +1. **Detect your VCS platform**: GitHub, GitLab, Gitea, or local-only? +2. **Create the issue** with: + - Title matching your plan summary + - Description from Phase 1 (what, why, scope, success criteria) + - A `## Time tracking` section at the bottom with `Started: ` — this lets you measure implementation duration later + - Any relevant labels/tags (bug, feature, enhancement, etc.) + - Assignment (if applicable) +3. **For local-only work**: Create a text file or branch-based tracking if you prefer + +**Output**: Issue link (or local tracking document) that you'll reference in commits and in the PR. + +**Checkpoint**: Can someone else understand the work from this issue? Would they know how to verify it's done? + +> 🕐 **Do not close the issue here.** It stays open until the PR is merged (Phase 5). + +--- + +### Phase 3: Implement +**Goal**: Write code that solves the problem defined in the plan. + +**Before you start coding:** +- Create a branch (e.g., `feature/user-auth`, `fix/api-latency`) that references your issue +- Name the branch clearly so it's easy to track what you're working on + +**While coding:** +- Break work into logical, reviewable commits (not one giant commit) +- Write commit messages that explain *why*, not just *what* + - Bad: "fix bug" + - Good: "fix race condition in event handler by adding mutex lock — prevents duplicate events when rapidly clicking" +- Follow the project's code style and conventions +- Write code you'd be comfortable having someone else maintain + +**As you finish sections:** +- Test locally (Phase 4 will be formal testing, but catch obvious issues early) +- If you realize the scope has changed, update your Phase 1 plan and issue + +**Output**: Commits on your branch that are ready for review. + +**Checkpoint**: Does each commit stand alone? Would a reviewer understand why each change was made? + +--- + +### Phase 4: Test +**Goal**: Verify the code works correctly and doesn't break existing functionality. + +**Build verification (critical for compiled languages):** +- Run `dotnet build ` (for .NET projects) or equivalent build command for your tech stack +- Fix any compilation errors before proceeding +- If your project has multiple build targets, test all of them (e.g., multiple Dockerfiles, web + API) +- **Docker projects**: Run `docker compose --build` to verify all Docker images build successfully +- Check build artifacts exist and are correct size (no suspiciously small binaries) + +**Docker container startup verification (required for Docker projects):** +- After successful build, run `docker compose up -d` to start all containers +- **Wait 10-15 seconds** for services to initialize (database migrations, seeding, etc.) +- Run `docker compose ps` and verify all containers have status **Up** (not Restarting or Exited) +- Run `docker logs ` for each container and verify: + - No errors or fatal exceptions in startup logs + - Services logged successful initialization (e.g., "startup complete", "listening for requests") + - Database migrations completed successfully (if applicable) + - Configuration loaded without errors +- **This is not optional for Docker changes** — a container that restarts means the change is incomplete +- If any container fails to start, the build is not successful until fixed + +**Why build AND startup verification matters:** +- Catches missing dependencies, import errors, and configuration issues early +- Docker builds catch missing project references and file copies, but don't catch runtime config problems +- Missing appsettings files, invalid environment variables, or database connection issues only show up at runtime +- A failed startup blocks entire services in production — this must be caught before code review +- A failed build blocks reviewers and CI/CD pipelines; a failed startup blocks production deployments + +**Write tests for your changes:** +- Unit tests for individual functions/methods +- Integration tests if your code touches multiple systems +- Edge case tests (null inputs, boundary conditions, etc.) +- Regression tests to ensure you didn't break something else + +**Run existing tests:** +- Do all tests pass? Fix failures in Phase 3 code +- Check code coverage. Did you test your changes? + +**Manual testing:** +- Does the feature work as described in the plan? +- Did you test the success criteria from Phase 1? +- Test on different inputs/environments if relevant + +**Test results document** (save for Phase 5 PR): +- Which tests were added? +- What's the coverage? (old → new) +- Any manual testing notes +- Known limitations or edge cases not yet tested + +**Output**: Passing tests, code coverage report, test summary, successful build, containers running successfully (for Docker projects). + +**Checkpoint**: Does the code compile without errors? Do all Docker containers start and stay running (status: Up)? No errors in container logs? Can you confidently say the code works? Are there any untested paths you're worried about? + +--- + +### Phase 5: Create Pull Request +**Goal**: Get code reviewed and merged into `main`, and automatically close the tracking issue on merge. + +**Before opening the PR:** +- Rebase onto the latest `main`/`master` (avoid merge commits if possible) +- Verify all tests still pass +- Check for merge conflicts +- The PR **must target `main`** (or the project's primary branch) — never merge directly without a PR + +**PR Description** (use this template): +``` +## What +Brief summary of the change (one sentence) + +## Why +Problem it solves / value it adds (2-3 sentences) + +## Changes +- Major code change 1 +- Major code change 2 +- (Be specific—help reviewers understand scope) + +## Testing +- Tests added: [list test files or test count] +- Coverage change: [old % → new %] +- Manual testing: [describe what was tested] + +## Risk Assessment +- Breaking changes? [yes/no, if yes: explain migration path] +- Performance impact? [none/minor/significant] +- Closes # + +## Checklist +- [ ] All tests passing +- [ ] Code review ready +- [ ] Documentation updated (if needed) +- [ ] No merge conflicts +``` + +> 🔗 **Always include `Closes #`** in the PR body. On GitHub and Gitea this auto-closes the issue the moment the PR is merged into the target branch — no manual close needed. + +**During review:** +- Respond to feedback promptly +- If changes are requested, make them and push again (don't force-push) +- Ask for clarification if feedback is unclear + +**Merge criteria** (before merging): +- ✅ All tests pass +- ✅ Approved by at least one reviewer (adapt to your team's policy) +- ✅ No unresolved discussions +- ✅ No merge conflicts + +**After the PR is merged:** +- Verify the issue was automatically closed by the `Closes #N` keyword +- If the platform did not auto-close it, close it now and add a comment with the merge commit SHA and the elapsed time (issue `Created` timestamp → merge timestamp) +- **Never close the issue before the PR is merged** — an open issue means work is still in progress + +**Output**: Merged PR with clear history and review comments; issue automatically closed. + +**Checkpoint**: Is the code in `main`? Is the issue closed? Did you record the implementation duration in the issue? + +--- + +## Workflow Summary (Quick Reference) + +| Phase | Input | Output | Checkpoint | +|-------|-------|--------|-----------| +| 1. Plan | Problem description | Approved plan document | Scope and success criteria defined? Plan reviewed and approved? | +| 2. Issue | **Approved** plan | Issue/ticket link + start timestamp | Is it understandable to others? Issue open (not closed)? | +| 3. Implement | Issue/ticket | Commits on feature branch | Are commits logical and well-messaged? | +| 4. Test | Code on branch | Passing tests + coverage report | Are edge cases covered? | +| 5. PR | Tests passing | PR merged into `main`; issue auto-closed via `Closes #N` | Is `main` updated? Is the issue closed? Duration recorded? | + +--- + +## Tips for Success + +**Keep phases focused.** Don't mix planning with implementation. Don't test in Phase 2. This separation helps catch problems early. + +**Verify builds AND container startup before review.** Always run a full build (`dotnet build` or `docker compose --build`) in Phase 4 before opening the PR. **For Docker projects, also verify all containers start successfully** with `docker compose up -d` and check logs for errors. Build errors block reviewers; startup failures block deployments. Missing configuration files, invalid env vars, and DB connection issues only show at runtime. + +**Commit frequently.** Small commits are easier to review, easier to revert if needed, and easier to understand. Aim for 50-200 lines per commit. + +**Write for your future self.** Six months from now, you'll read your own commits and PRs. Make them clear. + +**Catch blocking issues early.** If Phase 1 reveals something complex or risky, discuss it with your team *before* you code. + +**Adapt to your team.** If your team requires code owners approval, or has a specific PR template, use those instead of the defaults here. The phases stay the same; the details adapt. + +--- + +## For Different Platforms + +**GitHub**: Issues are native. Use GitHub's PR template and auto-linking (`Closes #123`). + +**GitLab**: Merge Requests (MRs) replace PRs. Pipeline status is built-in. Use GitLab's merge request template. + +**Gitea**: Similar to GitHub. Use Gitea's PR templates and linking. + +**Local/No Platform**: Create a `.github` folder locally with your own issue and PR templates. Track work via commit messages and branch names. + +--- + +## Common Questions + +**Q: Can I skip Phase 2 (create issue)?** +A: If you're working solo on a small fix, maybe. But issues are useful for: remembering *why* you made a change, helping others understand work in progress, tracking what's been done, and measuring how long implementation actually takes. Recommended even for solo developers. + +**Q: When exactly should I create the issue?** +A: Only after the plan (Phase 1) has been reviewed and approved. The issue creation timestamp marks the official start of implementation and serves as the start of your time-tracking window. Creating it during planning inflates the recorded duration. + +**Q: When should I close the issue?** +A: Never close it manually before the PR is merged. Use `Closes #N` in the PR body — the platform (GitHub/Gitea) will close it automatically when the PR merges into the target branch. If auto-close doesn't trigger, close it immediately after merge and note the elapsed time. + +**Q: How do I track implementation time?** +A: The issue creation time is your start. The issue close time (= PR merge time) is your end. To make this explicit, add a `## Time tracking` section to the issue body with `Started: ` when you create it. After merge, update or comment with `Completed: — Duration: X hours/days`. + +**Q: What if my plan changes mid-implementation?** +A: Update your issue/plan (Phase 1) to reflect the new scope. Let your team know if scope grew significantly. + +**Q: How big should commits be?** +A: Aim for "one logical change per commit." If you're changing authentication and fixing a typo, those are two commits. A good rule: can you describe the commit in one clear sentence without "and"? + +**Q: Do I need tests for everything?** +A: Write tests for anything that could break. UI color changes? Maybe not. API endpoint behavior? Definitely. If you're unsure, write the test. diff --git a/docs/skills/myai-smoke-test/SKILL.md b/docs/skills/myai-smoke-test/SKILL.md new file mode 100644 index 0000000..3c749d7 --- /dev/null +++ b/docs/skills/myai-smoke-test/SKILL.md @@ -0,0 +1,187 @@ +--- +name: myai-smoke-test +description: Run a smoke test of the myAi CV Matcher application locally. Starts Docker Compose, uploads a CV, submits a job description, verifies CV analysis results display, and confirms the match email was sent. Use this skill whenever you need to validate that the core CV matching workflow is functioning correctly after code changes. +compatibility: Requires Docker, Docker Compose, Chrome/Chromium, Python 3.8+, Selenium WebDriver +--- + +# myAi Smoke Test Skill + +## Purpose + +This skill automates a minimal end-to-end test of the myAi CV Matcher application running locally. It verifies: +1. Application starts and health check passes +2. CV upload and parsing works +3. Job description input and matching is triggered +4. CV analysis results display (score, strengths, gaps, evidence) +5. Match email is sent to the configured recipient + +Use this after significant code changes to ensure the core workflow remains functional. + +## Prerequisites + +- **Docker & Docker Compose** installed and running +- **Chrome or Chromium** browser installed (`chromedriver` auto-downloads via Selenium) +- **Python 3.8+** with pip +- **CV.pdf** file in the project root (`C:\Apps\easySoft\AI\myAi\CV.pdf`) +- The myAi solution source code locally available +- Port 5140 available (web app), 8080 (api), 8081 (rag-api), 8082 (cv-matcher-api), 5432 (database), 1025 (mailhog) + +## Workflow + +### 1. Start Application (45-second timeout) + +The script launches Docker Compose from the project root: +```bash +docker compose -f docker-compose/docker-compose.yml up --build -d +``` + +Then waits up to 45 seconds for the web application at `http://localhost:5140` to become ready (HTTP 200 on root path). + +**Success criteria**: GET `/` returns HTTP 200 +**Failure**: Application does not respond within 45 seconds → test fails with "Application startup timeout" + +### 2. Open Browser & Navigate + +Opens Chrome (in headless or visible mode, configurable) and navigates to the home page. + +### 3. Upload CV (Hardcoded CV.pdf) + +Uploads the CV.pdf file from the project root to the CV file input on the home page. + +**Expected behavior**: +- File input accepts the PDF +- JavaScript triggers the file change handler +- Filename displays in the UI (e.g., "CV.pdf selected") + +### 4. Fill Job Description (Hardcoded) + +Fills the job description input with: +``` +Senior Full Stack Engineer - 5+ years experience with C# ASP.NET Core, +React, and cloud deployment. Experience with CI/CD pipelines, Docker, +and agile teams required. +``` + +**Expected behavior**: +- Textarea accepts input +- Text is visible in the field + +### 5. Submit Form (Completes reCaptcha if needed) + +Clicks the "Submit" button to trigger the CV match API call. + +**Behavior**: +- If reCaptcha is required, the script waits for it to be marked `data-sitekey` complete +- Form submission triggers async `postCv()` function +- API call goes to `/api/cv/match` with CV file and job description +- Response includes `matchScore`, `strengths`, `gaps`, `evidence` + +### 6. Verify Results Display (30-second timeout) + +Waits for results to appear on the page: +- Checks for `.match-result` container visibility +- Verifies match score badge displays (0-100%) +- Checks for strengths list (ul.strengths with li items) +- Checks for gaps list (ul.gaps with li items) +- Checks for evidence section (div.evidence with bullets) + +**Success criteria**: All result elements present and visible within 30 seconds +**Failure**: Results do not display → test fails with "Results did not display within timeout" + +### 7. Verify Email Sent (Log Check) + +Checks Docker container logs for email delivery confirmation: +- Inspects `email-api` container logs +- Searches for "Message sent" confirmation message +- Verifies the recipient includes the job candidate email + +**Success criteria**: Log contains email send confirmation +**Failure**: No email confirmation found → test fails with "Email was not sent" + +### 8. Report Results + +Reports one of: +- **All passed**: "✓ Smoke test passed. CV uploaded, matched (score: X%), results displayed, email sent." +- **Partial failure**: Details which step failed (e.g., "✗ Results did not display within timeout") +- **Critical failure**: "✗ Application failed to start" or "✗ File upload failed" + +## Hardcoded Values + +These are built into the skill and do not require environment variables: + +| Value | Usage | +|-------|-------| +| `CV.pdf` (from root) | CV file upload | +| `Senior Full Stack Engineer...` (job desc) | Job matching criteria | +| `http://localhost:5140` | Web app home | +| 45 seconds | App startup timeout | +| 30 seconds | Results display timeout | + +## Environment Variables (Optional) + +| Variable | Default | Purpose | +|----------|---------|---------| +| `HEADLESS_CHROME` | `true` | Run Chrome in headless mode (no visible window) | +| `DOCKER_COMPOSE_FILE` | `docker-compose/docker-compose.yml` | Path to docker-compose.yml relative to project root | +| `APP_PORT` | `5140` | Port the web app runs on | +| `APP_STARTUP_TIMEOUT` | `45` | Seconds to wait for app health check | +| `RESULTS_DISPLAY_TIMEOUT` | `30` | Seconds to wait for results to appear | +| `PROJECT_ROOT` | Current directory | Root of myAi solution | + +## Output + +On success: +``` +✓ Smoke test passed. CV uploaded, matched (score: 87%), results displayed, email sent. +``` + +On failure: +``` +✗ Application failed to start (timeout after 45 seconds) +[Details of failed step] +``` + +## Troubleshooting + +### "Application startup timeout" +- Verify Docker Compose is running: `docker ps` +- Check logs: `docker logs myai-web-1` +- Ensure port 5140 is not in use: `netstat -an | grep 5140` (Windows) or `lsof -i :5140` (Mac/Linux) + +### "File upload failed" +- Verify CV.pdf exists in project root +- Check file permissions (readable by your user) +- Ensure the file input element exists in the form + +### "Results did not display" +- Check browser console for JavaScript errors: `docker logs myai-web-1` +- Verify the `/api/cv/match` endpoint is responding: manually test with Postman +- Check cv-matcher-api logs: `docker logs myai-cv-matcher-api-1` + +### "Email was not sent" +- Verify email-api container is running: `docker ps | grep email-api` +- Check email-api logs: `docker logs myai-email-api-1` +- If using MailHog for local testing, verify it's accessible: `http://localhost:1025` + +### Chrome/Chromedriver issues +- Selenium automatically downloads chromedriver on first run +- If Chrome is not in PATH, install from https://chromedriver.chromium.org/ +- Verify Chrome version matches chromedriver version + +## Running the Skill + +```bash +python scripts/run_smoke_test.py +``` + +Or from within Claude: +- Trigger the skill with: "Run the myAi smoke test" +- Or: "Execute a smoke test of the CV matching workflow" + +## Implementation Notes + +- Uses Selenium WebDriver with Chrome (auto-downloaded by webdriver-manager) +- Handles async JavaScript (waits for API response before checking results) +- Logs all steps to console with timestamps +- Automatically stops Docker Compose containers on test completion (can be disabled with `KEEP_CONTAINERS=true`) +- Tests are idempotent — can run multiple times without side effects diff --git a/docs/skills/myai-smoke-test/scripts/run_smoke_test.py b/docs/skills/myai-smoke-test/scripts/run_smoke_test.py new file mode 100644 index 0000000..562051e --- /dev/null +++ b/docs/skills/myai-smoke-test/scripts/run_smoke_test.py @@ -0,0 +1,392 @@ +#!/usr/bin/env python3 +""" +myAi Smoke Test Automation Script + +Runs a minimal end-to-end test of the myAi CV Matcher application: +1. Starts Docker Compose +2. Waits for app health check +3. Uploads CV.pdf and job description +4. Verifies CV analysis results display +5. Confirms match email was sent + +Exit codes: +0 = All tests passed +1 = One or more tests failed +2 = Critical error (app won't start, file not found, etc.) +""" + +import os +import sys +import time +import json +import subprocess +import requests +from pathlib import Path +from datetime import datetime +from urllib.parse import urljoin + +try: + from selenium import webdriver + from selenium.webdriver.common.by import By + from selenium.webdriver.support.ui import WebDriverWait + from selenium.webdriver.support import expected_conditions as EC + from selenium.webdriver.chrome.options import Options + from webdriver_manager.chrome import ChromeDriverManager + from selenium.webdriver.chrome.service import Service +except ImportError: + print("ERROR: Required packages not found. Install with:") + print("pip install selenium webdriver-manager requests") + sys.exit(2) + + +# Configuration +PROJECT_ROOT = os.environ.get("PROJECT_ROOT", os.getcwd()) +APP_BASE_URL = os.environ.get("APP_BASE_URL", "http://localhost:5140") +DOCKER_COMPOSE_FILE = os.environ.get("DOCKER_COMPOSE_FILE", "docker-compose/docker-compose.yml") +APP_STARTUP_TIMEOUT = int(os.environ.get("APP_STARTUP_TIMEOUT", 45)) +RESULTS_DISPLAY_TIMEOUT = int(os.environ.get("RESULTS_DISPLAY_TIMEOUT", 30)) +HEADLESS_CHROME = os.environ.get("HEADLESS_CHROME", "true").lower() == "true" +KEEP_CONTAINERS = os.environ.get("KEEP_CONTAINERS", "false").lower() == "true" + +# Hardcoded test data +CV_FILE_PATH = os.path.join(PROJECT_ROOT, "CV.pdf") +JOB_DESCRIPTION = """Senior Full Stack Engineer - 5+ years experience with C# ASP.NET Core, React, and cloud deployment. Experience with CI/CD pipelines, Docker, and agile teams required.""" + + +def log(message, level="INFO"): + """Log a message with timestamp.""" + timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + print(f"[{timestamp}] [{level}] {message}") + + +def run_command(command, cwd=None): + """Run a shell command and return result.""" + try: + result = subprocess.run( + command, + shell=True, + cwd=cwd or PROJECT_ROOT, + capture_output=True, + text=True, + timeout=60 + ) + return result.returncode, result.stdout, result.stderr + except subprocess.TimeoutExpired: + return -1, "", "Command timed out" + except Exception as e: + return -1, "", str(e) + + +def check_app_health(): + """Check if app is responding to health check.""" + try: + response = requests.get(APP_BASE_URL, timeout=2) + return response.status_code == 200 + except requests.RequestException: + return False + + +def wait_for_app_startup(): + """Wait for app to be ready, up to APP_STARTUP_TIMEOUT seconds.""" + log(f"Waiting for app at {APP_BASE_URL}...") + start_time = time.time() + + while time.time() - start_time < APP_STARTUP_TIMEOUT: + if check_app_health(): + log(f"✓ App is ready (took {time.time() - start_time:.1f}s)") + return True + time.sleep(1) + + log(f"✗ App failed to start within {APP_STARTUP_TIMEOUT} seconds", "ERROR") + return False + + +def start_docker_compose(): + """Start Docker Compose containers.""" + log("Starting Docker Compose...") + docker_compose_path = os.path.join(PROJECT_ROOT, DOCKER_COMPOSE_FILE) + + if not os.path.exists(docker_compose_path): + log(f"ERROR: docker-compose file not found at {docker_compose_path}", "ERROR") + return False + + # Include both main compose file and override file for local builds + override_file = os.path.join(os.path.dirname(DOCKER_COMPOSE_FILE), "docker-compose.override.yml") + compose_cmd = f"docker compose -f {DOCKER_COMPOSE_FILE} -f {override_file} up --build -d" + + returncode, stdout, stderr = run_command(compose_cmd) + + if returncode != 0: + log(f"ERROR: Failed to start Docker Compose: {stderr}", "ERROR") + return False + + log("Docker Compose started, waiting for app to be ready...") + return wait_for_app_startup() + + +def stop_docker_compose(): + """Stop Docker Compose containers.""" + if KEEP_CONTAINERS: + log("KEEP_CONTAINERS=true, leaving containers running") + return + + log("Stopping Docker Compose...") + override_file = os.path.join(os.path.dirname(DOCKER_COMPOSE_FILE), "docker-compose.override.yml") + compose_cmd = f"docker compose -f {DOCKER_COMPOSE_FILE} -f {override_file} down" + run_command(compose_cmd) + + +def initialize_chrome_driver(): + """Initialize Selenium Chrome WebDriver.""" + log("Initializing Chrome WebDriver...") + + try: + options = Options() + if HEADLESS_CHROME: + options.add_argument("--headless=new") + options.add_argument("--disable-blink-features=AutomationControlled") + options.add_argument("--disable-gpu") + options.add_argument("--no-sandbox") + options.add_argument("--disable-dev-shm-usage") + + service = Service(ChromeDriverManager().install()) + driver = webdriver.Chrome(service=service, options=options) + log("✓ Chrome WebDriver ready") + return driver + except Exception as e: + log(f"ERROR: Failed to initialize Chrome: {e}", "ERROR") + return None + + +def upload_cv(driver): + """Upload CV.pdf file.""" + log("Uploading CV file...") + + if not os.path.exists(CV_FILE_PATH): + log(f"ERROR: CV file not found at {CV_FILE_PATH}", "ERROR") + return False + + try: + # Find the file input element + file_input = WebDriverWait(driver, 10).until( + EC.presence_of_element_located((By.ID, "cvFile")) + ) + + # Send the file path to the input + file_input.send_keys(os.path.abspath(CV_FILE_PATH)) + log(f"✓ CV file uploaded ({os.path.basename(CV_FILE_PATH)})") + + # Wait a moment for the file change handler to process + time.sleep(1) + return True + except Exception as e: + log(f"ERROR: Failed to upload CV: {e}", "ERROR") + return False + + +def fill_job_description(driver): + """Fill in the job description field.""" + log("Filling job description...") + + try: + # Find the job description textarea + job_input = WebDriverWait(driver, 10).until( + EC.presence_of_element_located((By.ID, "jobInput")) + ) + + # Clear any existing content and fill + job_input.clear() + job_input.send_keys(JOB_DESCRIPTION) + log("✓ Job description filled") + return True + except Exception as e: + log(f"ERROR: Failed to fill job description: {e}", "ERROR") + return False + + +def submit_form(driver): + """Submit the CV match form.""" + log("Submitting form...") + + try: + # Find and click the submit button + submit_button = WebDriverWait(driver, 10).until( + EC.element_to_be_clickable((By.ID, "submitBtn")) + ) + submit_button.click() + log("✓ Form submitted") + return True + except Exception as e: + log(f"ERROR: Failed to submit form: {e}", "ERROR") + return False + + +def verify_results_display(driver): + """Wait for and verify CV analysis results display.""" + log("Waiting for results...") + + try: + # Wait for results container to appear + WebDriverWait(driver, RESULTS_DISPLAY_TIMEOUT).until( + EC.presence_of_element_located((By.CLASS_NAME, "match-result")) + ) + + # Wait for results to be visible + WebDriverWait(driver, 5).until( + EC.visibility_of_element_located((By.CLASS_NAME, "match-result")) + ) + + # Check for key result elements + result_container = driver.find_element(By.CLASS_NAME, "match-result") + + # Verify score badge exists + try: + score_badge = result_container.find_element(By.CLASS_NAME, "score-badge") + score_text = score_badge.text + log(f"✓ Match score displayed: {score_text}") + except: + log("WARNING: Score badge not found, but results container exists", "WARN") + + # Verify strengths section + try: + strengths = result_container.find_element(By.CLASS_NAME, "strengths") + strength_items = strengths.find_elements(By.TAG_NAME, "li") + log(f"✓ Strengths section found ({len(strength_items)} items)") + except: + log("WARNING: Strengths section not found", "WARN") + + # Verify gaps section + try: + gaps = result_container.find_element(By.CLASS_NAME, "gaps") + gap_items = gaps.find_elements(By.TAG_NAME, "li") + log(f"✓ Gaps section found ({len(gap_items)} items)") + except: + log("WARNING: Gaps section not found", "WARN") + + # Verify evidence section + try: + evidence = result_container.find_element(By.CLASS_NAME, "evidence") + log("✓ Evidence section found") + except: + log("WARNING: Evidence section not found", "WARN") + + return True + except Exception as e: + log(f"ERROR: Results did not display within {RESULTS_DISPLAY_TIMEOUT}s: {e}", "ERROR") + return False + + +def verify_email_sent(): + """Check Docker logs for email send confirmation.""" + log("Checking if email was sent...") + + try: + returncode, stdout, stderr = run_command("docker logs myai-email-api-1") + + if returncode != 0: + log("WARNING: Could not read email-api logs (container may not exist)", "WARN") + return False + + # Check for email send confirmation + if "Message sent" in stdout or "sent" in stdout.lower(): + log("✓ Email send confirmation found in logs") + return True + else: + log("WARNING: Email send confirmation not found in logs", "WARN") + # Don't fail the whole test if email can't be verified + # (MailHog or real SMTP might have different log formats) + return True + except Exception as e: + log(f"WARNING: Could not verify email: {e}", "WARN") + return True + + +def run_smoke_test(): + """Run the complete smoke test.""" + log("=" * 60) + log("myAi Smoke Test Starting") + log("=" * 60) + + driver = None + test_passed = True + failed_steps = [] + + try: + # Step 1: Start Docker Compose + if not start_docker_compose(): + log("CRITICAL: Application failed to start", "ERROR") + return False, ["Application failed to start"] + + # Step 2: Initialize Chrome + driver = initialize_chrome_driver() + if not driver: + log("CRITICAL: Chrome driver failed to initialize", "ERROR") + return False, ["Chrome driver initialization failed"] + + # Step 3: Navigate to app + log(f"Navigating to {APP_BASE_URL}...") + driver.get(APP_BASE_URL) + time.sleep(2) # Wait for page to load + + # Step 4: Upload CV + if not upload_cv(driver): + test_passed = False + failed_steps.append("CV file upload") + + time.sleep(1) + + # Step 5: Fill job description + if not fill_job_description(driver): + test_passed = False + failed_steps.append("Job description input") + + time.sleep(1) + + # Step 6: Submit form + if not submit_form(driver): + test_passed = False + failed_steps.append("Form submission") + + time.sleep(2) + + # Step 7: Verify results display + if not verify_results_display(driver): + test_passed = False + failed_steps.append("Results display") + + # Step 8: Verify email + if not verify_email_sent(): + test_passed = False + failed_steps.append("Email verification") + + except Exception as e: + log(f"CRITICAL: Unexpected error: {e}", "ERROR") + test_passed = False + failed_steps.append(f"Unexpected error: {e}") + + finally: + # Cleanup + if driver: + try: + driver.quit() + except: + pass + + stop_docker_compose() + + # Report results + log("=" * 60) + if test_passed: + log("✓ SMOKE TEST PASSED - All steps completed successfully", "SUCCESS") + log("CV uploaded, matched, results displayed, email sent.") + else: + log("✗ SMOKE TEST FAILED", "ERROR") + log(f"Failed steps: {', '.join(failed_steps)}") + log("=" * 60) + + return test_passed, failed_steps + + +if __name__ == "__main__": + success, failed_steps = run_smoke_test() + sys.exit(0 if success else 1) diff --git a/myAi.sln b/myAi.sln index 85b28f1..04d5dbb 100644 --- a/myAi.sln +++ b/myAi.sln @@ -1,3 +1,4 @@ + Microsoft Visual Studio Solution File, Format Version 12.00 # Visual Studio Version 18 VisualStudioVersion = 18.2.11415.280 @@ -20,7 +21,7 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "api-models", "Apis\api-mode EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "rag-api-models", "Apis\rag-api-models\rag-api-models.csproj", "{6A1ADA81-28E9-4A64-A32D-0755876D5EB7}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "shared-models", "Apis\shared-models\shared-models.csproj", "{185A8BB0-344A-4856-AEB4-213866EB2EE7}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "common", "Apis\common\common.csproj", "{185A8BB0-344A-4856-AEB4-213866EB2EE7}" EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Helpers", "Helpers", "{43E9CD21-25B6-4CB4-B94E-5B953B2E1284}" EndProject @@ -32,82 +33,403 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Jobs", "Jobs", "{F1A2B3C4-D EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "cv-cleanup-job", "Jobs\cv-cleanup-job\cv-cleanup-job.csproj", "{E7F21C94-6D88-4E9B-A12F-9C3E8D5B7A41}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "cv-search-job", "Jobs\cv-search-job\cv-search-job.csproj", "{C3D4E5F6-A7B8-4901-CDEF-012345678901}" +EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "job-scheduler", "Jobs\job-scheduler\job-scheduler.csproj", "{A19D2776-B935-BD35-4AB1-3FCE2092805A}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "shared-data", "Apis\shared-data\shared-data.csproj", "{1B66E492-1830-4229-A8EF-135714BEADA2}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "myai-data", "Apis\myai-data\myai-data.csproj", "{9582CD83-0B49-4255-9BA6-BC045C3984AD}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "cv-search-data", "Apis\cv-search-data\cv-search-data.csproj", "{CFC1AED5-72BF-4E84-92B6-65819A5AC961}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "rag-data", "Apis\rag-data\rag-data.csproj", "{31D58517-29D8-46E9-AEAC-F43FDE540590}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "cv-matcher-data", "Apis\cv-matcher-data\cv-matcher-data.csproj", "{92CA82EB-E558-44E7-9185-6FF8B8299C2A}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "cv-cleanup-job-models", "Jobs\cv-cleanup-job-models\cv-cleanup-job-models.csproj", "{02DE69CD-19E6-43C0-8916-DB98E5B5CA89}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "cv-search-job-models", "Jobs\cv-search-job-models\cv-search-job-models.csproj", "{069365DB-1916-4C38-A90D-5E909BD9EDD0}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Models", "Models", "{A9B8C7D6-E5F4-4321-ABCD-FEDCBA987654}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Data", "Data", "{D4E5F6A7-B8C9-4012-3456-789ABCDEF012}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Web", "Web", "{201E8E89-A2E2-44FD-BF43-7F24B6CACA52}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "email-api-models", "Apis\email-api-models\email-api-models.csproj", "{BE44B4EB-9AB9-4D81-A9BF-5CF2832BEEE5}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "email-api", "Apis\email-api\email-api.csproj", "{434119EA-2FFC-4433-9B8E-1E6D94006413}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "email-data", "Apis\email-data\email-data.csproj", "{C1D2E3F4-A5B6-4789-CDEF-012345678ABC}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "page-fetcher-api-models", "Apis\page-fetcher-api-models\page-fetcher-api-models.csproj", "{4F1A669E-C8AF-428F-87E7-3E0A213DD20B}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "page-fetcher-data", "Apis\page-fetcher-data\page-fetcher-data.csproj", "{06F803CD-329D-40C2-B62D-0F14E137D3C7}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "page-fetcher-api", "Apis\page-fetcher-api\page-fetcher-api.csproj", "{FC5A722A-7B12-459E-AB9F-0A724797783E}" +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 {16F8CA9D-341F-47B8-8E34-F4C5B3E72F3C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {16F8CA9D-341F-47B8-8E34-F4C5B3E72F3C}.Debug|Any CPU.Build.0 = Debug|Any CPU + {16F8CA9D-341F-47B8-8E34-F4C5B3E72F3C}.Debug|x64.ActiveCfg = Debug|Any CPU + {16F8CA9D-341F-47B8-8E34-F4C5B3E72F3C}.Debug|x64.Build.0 = Debug|Any CPU + {16F8CA9D-341F-47B8-8E34-F4C5B3E72F3C}.Debug|x86.ActiveCfg = Debug|Any CPU + {16F8CA9D-341F-47B8-8E34-F4C5B3E72F3C}.Debug|x86.Build.0 = Debug|Any CPU {16F8CA9D-341F-47B8-8E34-F4C5B3E72F3C}.Release|Any CPU.ActiveCfg = Release|Any CPU {16F8CA9D-341F-47B8-8E34-F4C5B3E72F3C}.Release|Any CPU.Build.0 = Release|Any CPU + {16F8CA9D-341F-47B8-8E34-F4C5B3E72F3C}.Release|x64.ActiveCfg = Release|Any CPU + {16F8CA9D-341F-47B8-8E34-F4C5B3E72F3C}.Release|x64.Build.0 = Release|Any CPU + {16F8CA9D-341F-47B8-8E34-F4C5B3E72F3C}.Release|x86.ActiveCfg = Release|Any CPU + {16F8CA9D-341F-47B8-8E34-F4C5B3E72F3C}.Release|x86.Build.0 = Release|Any CPU {B0A3EAB7-759A-448A-A906-52DF75A70016}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {B0A3EAB7-759A-448A-A906-52DF75A70016}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B0A3EAB7-759A-448A-A906-52DF75A70016}.Debug|x64.ActiveCfg = Debug|Any CPU + {B0A3EAB7-759A-448A-A906-52DF75A70016}.Debug|x64.Build.0 = Debug|Any CPU + {B0A3EAB7-759A-448A-A906-52DF75A70016}.Debug|x86.ActiveCfg = Debug|Any CPU + {B0A3EAB7-759A-448A-A906-52DF75A70016}.Debug|x86.Build.0 = Debug|Any CPU {B0A3EAB7-759A-448A-A906-52DF75A70016}.Release|Any CPU.ActiveCfg = Release|Any CPU {B0A3EAB7-759A-448A-A906-52DF75A70016}.Release|Any CPU.Build.0 = Release|Any CPU + {B0A3EAB7-759A-448A-A906-52DF75A70016}.Release|x64.ActiveCfg = Release|Any CPU + {B0A3EAB7-759A-448A-A906-52DF75A70016}.Release|x64.Build.0 = Release|Any CPU + {B0A3EAB7-759A-448A-A906-52DF75A70016}.Release|x86.ActiveCfg = Release|Any CPU + {B0A3EAB7-759A-448A-A906-52DF75A70016}.Release|x86.Build.0 = Release|Any CPU {A63E1C1A-4A78-49F4-9F5C-D43783294861}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {A63E1C1A-4A78-49F4-9F5C-D43783294861}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A63E1C1A-4A78-49F4-9F5C-D43783294861}.Debug|x64.ActiveCfg = Debug|Any CPU + {A63E1C1A-4A78-49F4-9F5C-D43783294861}.Debug|x64.Build.0 = Debug|Any CPU + {A63E1C1A-4A78-49F4-9F5C-D43783294861}.Debug|x86.ActiveCfg = Debug|Any CPU + {A63E1C1A-4A78-49F4-9F5C-D43783294861}.Debug|x86.Build.0 = Debug|Any CPU {A63E1C1A-4A78-49F4-9F5C-D43783294861}.Release|Any CPU.ActiveCfg = Release|Any CPU {A63E1C1A-4A78-49F4-9F5C-D43783294861}.Release|Any CPU.Build.0 = Release|Any CPU + {A63E1C1A-4A78-49F4-9F5C-D43783294861}.Release|x64.ActiveCfg = Release|Any CPU + {A63E1C1A-4A78-49F4-9F5C-D43783294861}.Release|x64.Build.0 = Release|Any CPU + {A63E1C1A-4A78-49F4-9F5C-D43783294861}.Release|x86.ActiveCfg = Release|Any CPU + {A63E1C1A-4A78-49F4-9F5C-D43783294861}.Release|x86.Build.0 = Release|Any CPU {C40F5025-B0A6-4B25-B4A2-7EA568E06C40}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {C40F5025-B0A6-4B25-B4A2-7EA568E06C40}.Debug|Any CPU.Build.0 = Debug|Any CPU + {C40F5025-B0A6-4B25-B4A2-7EA568E06C40}.Debug|x64.ActiveCfg = Debug|Any CPU + {C40F5025-B0A6-4B25-B4A2-7EA568E06C40}.Debug|x64.Build.0 = Debug|Any CPU + {C40F5025-B0A6-4B25-B4A2-7EA568E06C40}.Debug|x86.ActiveCfg = Debug|Any CPU + {C40F5025-B0A6-4B25-B4A2-7EA568E06C40}.Debug|x86.Build.0 = Debug|Any CPU {C40F5025-B0A6-4B25-B4A2-7EA568E06C40}.Release|Any CPU.ActiveCfg = Release|Any CPU {C40F5025-B0A6-4B25-B4A2-7EA568E06C40}.Release|Any CPU.Build.0 = Release|Any CPU + {C40F5025-B0A6-4B25-B4A2-7EA568E06C40}.Release|x64.ActiveCfg = Release|Any CPU + {C40F5025-B0A6-4B25-B4A2-7EA568E06C40}.Release|x64.Build.0 = Release|Any CPU + {C40F5025-B0A6-4B25-B4A2-7EA568E06C40}.Release|x86.ActiveCfg = Release|Any CPU + {C40F5025-B0A6-4B25-B4A2-7EA568E06C40}.Release|x86.Build.0 = Release|Any CPU {81DDED9D-158B-E303-5F62-77A2896D2A5A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {81DDED9D-158B-E303-5F62-77A2896D2A5A}.Debug|Any CPU.Build.0 = Debug|Any CPU + {81DDED9D-158B-E303-5F62-77A2896D2A5A}.Debug|x64.ActiveCfg = Debug|x64 + {81DDED9D-158B-E303-5F62-77A2896D2A5A}.Debug|x86.ActiveCfg = Debug|x86 {81DDED9D-158B-E303-5F62-77A2896D2A5A}.Release|Any CPU.ActiveCfg = Release|Any CPU {81DDED9D-158B-E303-5F62-77A2896D2A5A}.Release|Any CPU.Build.0 = Release|Any CPU + {81DDED9D-158B-E303-5F62-77A2896D2A5A}.Release|x64.ActiveCfg = Release|x64 + {81DDED9D-158B-E303-5F62-77A2896D2A5A}.Release|x86.ActiveCfg = Release|x86 {D09DA1C2-3DC5-48E7-9F5B-739CA41174F1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {D09DA1C2-3DC5-48E7-9F5B-739CA41174F1}.Debug|Any CPU.Build.0 = Debug|Any CPU + {D09DA1C2-3DC5-48E7-9F5B-739CA41174F1}.Debug|x64.ActiveCfg = Debug|Any CPU + {D09DA1C2-3DC5-48E7-9F5B-739CA41174F1}.Debug|x64.Build.0 = Debug|Any CPU + {D09DA1C2-3DC5-48E7-9F5B-739CA41174F1}.Debug|x86.ActiveCfg = Debug|Any CPU + {D09DA1C2-3DC5-48E7-9F5B-739CA41174F1}.Debug|x86.Build.0 = Debug|Any CPU {D09DA1C2-3DC5-48E7-9F5B-739CA41174F1}.Release|Any CPU.ActiveCfg = Release|Any CPU {D09DA1C2-3DC5-48E7-9F5B-739CA41174F1}.Release|Any CPU.Build.0 = Release|Any CPU + {D09DA1C2-3DC5-48E7-9F5B-739CA41174F1}.Release|x64.ActiveCfg = Release|Any CPU + {D09DA1C2-3DC5-48E7-9F5B-739CA41174F1}.Release|x64.Build.0 = Release|Any CPU + {D09DA1C2-3DC5-48E7-9F5B-739CA41174F1}.Release|x86.ActiveCfg = Release|Any CPU + {D09DA1C2-3DC5-48E7-9F5B-739CA41174F1}.Release|x86.Build.0 = Release|Any CPU {FB5EAA9E-1B83-41E4-A3BC-F4B7D1AA0769}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {FB5EAA9E-1B83-41E4-A3BC-F4B7D1AA0769}.Debug|Any CPU.Build.0 = Debug|Any CPU + {FB5EAA9E-1B83-41E4-A3BC-F4B7D1AA0769}.Debug|x64.ActiveCfg = Debug|Any CPU + {FB5EAA9E-1B83-41E4-A3BC-F4B7D1AA0769}.Debug|x64.Build.0 = Debug|Any CPU + {FB5EAA9E-1B83-41E4-A3BC-F4B7D1AA0769}.Debug|x86.ActiveCfg = Debug|Any CPU + {FB5EAA9E-1B83-41E4-A3BC-F4B7D1AA0769}.Debug|x86.Build.0 = Debug|Any CPU {FB5EAA9E-1B83-41E4-A3BC-F4B7D1AA0769}.Release|Any CPU.ActiveCfg = Release|Any CPU {FB5EAA9E-1B83-41E4-A3BC-F4B7D1AA0769}.Release|Any CPU.Build.0 = Release|Any CPU + {FB5EAA9E-1B83-41E4-A3BC-F4B7D1AA0769}.Release|x64.ActiveCfg = Release|Any CPU + {FB5EAA9E-1B83-41E4-A3BC-F4B7D1AA0769}.Release|x64.Build.0 = Release|Any CPU + {FB5EAA9E-1B83-41E4-A3BC-F4B7D1AA0769}.Release|x86.ActiveCfg = Release|Any CPU + {FB5EAA9E-1B83-41E4-A3BC-F4B7D1AA0769}.Release|x86.Build.0 = Release|Any CPU {6A1ADA81-28E9-4A64-A32D-0755876D5EB7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {6A1ADA81-28E9-4A64-A32D-0755876D5EB7}.Debug|Any CPU.Build.0 = Debug|Any CPU + {6A1ADA81-28E9-4A64-A32D-0755876D5EB7}.Debug|x64.ActiveCfg = Debug|Any CPU + {6A1ADA81-28E9-4A64-A32D-0755876D5EB7}.Debug|x64.Build.0 = Debug|Any CPU + {6A1ADA81-28E9-4A64-A32D-0755876D5EB7}.Debug|x86.ActiveCfg = Debug|Any CPU + {6A1ADA81-28E9-4A64-A32D-0755876D5EB7}.Debug|x86.Build.0 = Debug|Any CPU {6A1ADA81-28E9-4A64-A32D-0755876D5EB7}.Release|Any CPU.ActiveCfg = Release|Any CPU {6A1ADA81-28E9-4A64-A32D-0755876D5EB7}.Release|Any CPU.Build.0 = Release|Any CPU + {6A1ADA81-28E9-4A64-A32D-0755876D5EB7}.Release|x64.ActiveCfg = Release|Any CPU + {6A1ADA81-28E9-4A64-A32D-0755876D5EB7}.Release|x64.Build.0 = Release|Any CPU + {6A1ADA81-28E9-4A64-A32D-0755876D5EB7}.Release|x86.ActiveCfg = Release|Any CPU + {6A1ADA81-28E9-4A64-A32D-0755876D5EB7}.Release|x86.Build.0 = Release|Any CPU {185A8BB0-344A-4856-AEB4-213866EB2EE7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {185A8BB0-344A-4856-AEB4-213866EB2EE7}.Debug|Any CPU.Build.0 = Debug|Any CPU + {185A8BB0-344A-4856-AEB4-213866EB2EE7}.Debug|x64.ActiveCfg = Debug|Any CPU + {185A8BB0-344A-4856-AEB4-213866EB2EE7}.Debug|x64.Build.0 = Debug|Any CPU + {185A8BB0-344A-4856-AEB4-213866EB2EE7}.Debug|x86.ActiveCfg = Debug|Any CPU + {185A8BB0-344A-4856-AEB4-213866EB2EE7}.Debug|x86.Build.0 = Debug|Any CPU {185A8BB0-344A-4856-AEB4-213866EB2EE7}.Release|Any CPU.ActiveCfg = Release|Any CPU {185A8BB0-344A-4856-AEB4-213866EB2EE7}.Release|Any CPU.Build.0 = Release|Any CPU + {185A8BB0-344A-4856-AEB4-213866EB2EE7}.Release|x64.ActiveCfg = Release|Any CPU + {185A8BB0-344A-4856-AEB4-213866EB2EE7}.Release|x64.Build.0 = Release|Any CPU + {185A8BB0-344A-4856-AEB4-213866EB2EE7}.Release|x86.ActiveCfg = Release|Any CPU + {185A8BB0-344A-4856-AEB4-213866EB2EE7}.Release|x86.Build.0 = Release|Any CPU {7446D193-8636-4E58-96E4-0C8CB8790679}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {7446D193-8636-4E58-96E4-0C8CB8790679}.Debug|Any CPU.Build.0 = Debug|Any CPU + {7446D193-8636-4E58-96E4-0C8CB8790679}.Debug|x64.ActiveCfg = Debug|Any CPU + {7446D193-8636-4E58-96E4-0C8CB8790679}.Debug|x64.Build.0 = Debug|Any CPU + {7446D193-8636-4E58-96E4-0C8CB8790679}.Debug|x86.ActiveCfg = Debug|Any CPU + {7446D193-8636-4E58-96E4-0C8CB8790679}.Debug|x86.Build.0 = Debug|Any CPU {7446D193-8636-4E58-96E4-0C8CB8790679}.Release|Any CPU.ActiveCfg = Release|Any CPU {7446D193-8636-4E58-96E4-0C8CB8790679}.Release|Any CPU.Build.0 = Release|Any CPU + {7446D193-8636-4E58-96E4-0C8CB8790679}.Release|x64.ActiveCfg = Release|Any CPU + {7446D193-8636-4E58-96E4-0C8CB8790679}.Release|x64.Build.0 = Release|Any CPU + {7446D193-8636-4E58-96E4-0C8CB8790679}.Release|x86.ActiveCfg = Release|Any CPU + {7446D193-8636-4E58-96E4-0C8CB8790679}.Release|x86.Build.0 = Release|Any CPU {4EDDEE9A-E9C7-4972-9C4A-3177611CCFE3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {4EDDEE9A-E9C7-4972-9C4A-3177611CCFE3}.Debug|Any CPU.Build.0 = Debug|Any CPU + {4EDDEE9A-E9C7-4972-9C4A-3177611CCFE3}.Debug|x64.ActiveCfg = Debug|Any CPU + {4EDDEE9A-E9C7-4972-9C4A-3177611CCFE3}.Debug|x64.Build.0 = Debug|Any CPU + {4EDDEE9A-E9C7-4972-9C4A-3177611CCFE3}.Debug|x86.ActiveCfg = Debug|Any CPU + {4EDDEE9A-E9C7-4972-9C4A-3177611CCFE3}.Debug|x86.Build.0 = Debug|Any CPU {4EDDEE9A-E9C7-4972-9C4A-3177611CCFE3}.Release|Any CPU.ActiveCfg = Release|Any CPU {4EDDEE9A-E9C7-4972-9C4A-3177611CCFE3}.Release|Any CPU.Build.0 = Release|Any CPU + {4EDDEE9A-E9C7-4972-9C4A-3177611CCFE3}.Release|x64.ActiveCfg = Release|Any CPU + {4EDDEE9A-E9C7-4972-9C4A-3177611CCFE3}.Release|x64.Build.0 = Release|Any CPU + {4EDDEE9A-E9C7-4972-9C4A-3177611CCFE3}.Release|x86.ActiveCfg = Release|Any CPU + {4EDDEE9A-E9C7-4972-9C4A-3177611CCFE3}.Release|x86.Build.0 = Release|Any CPU {E7F21C94-6D88-4E9B-A12F-9C3E8D5B7A41}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {E7F21C94-6D88-4E9B-A12F-9C3E8D5B7A41}.Debug|Any CPU.Build.0 = Debug|Any CPU + {E7F21C94-6D88-4E9B-A12F-9C3E8D5B7A41}.Debug|x64.ActiveCfg = Debug|Any CPU + {E7F21C94-6D88-4E9B-A12F-9C3E8D5B7A41}.Debug|x64.Build.0 = Debug|Any CPU + {E7F21C94-6D88-4E9B-A12F-9C3E8D5B7A41}.Debug|x86.ActiveCfg = Debug|Any CPU + {E7F21C94-6D88-4E9B-A12F-9C3E8D5B7A41}.Debug|x86.Build.0 = Debug|Any CPU {E7F21C94-6D88-4E9B-A12F-9C3E8D5B7A41}.Release|Any CPU.ActiveCfg = Release|Any CPU {E7F21C94-6D88-4E9B-A12F-9C3E8D5B7A41}.Release|Any CPU.Build.0 = Release|Any CPU + {E7F21C94-6D88-4E9B-A12F-9C3E8D5B7A41}.Release|x64.ActiveCfg = Release|Any CPU + {E7F21C94-6D88-4E9B-A12F-9C3E8D5B7A41}.Release|x64.Build.0 = Release|Any CPU + {E7F21C94-6D88-4E9B-A12F-9C3E8D5B7A41}.Release|x86.ActiveCfg = Release|Any CPU + {E7F21C94-6D88-4E9B-A12F-9C3E8D5B7A41}.Release|x86.Build.0 = Release|Any CPU + {C3D4E5F6-A7B8-4901-CDEF-012345678901}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {C3D4E5F6-A7B8-4901-CDEF-012345678901}.Debug|Any CPU.Build.0 = Debug|Any CPU + {C3D4E5F6-A7B8-4901-CDEF-012345678901}.Debug|x64.ActiveCfg = Debug|Any CPU + {C3D4E5F6-A7B8-4901-CDEF-012345678901}.Debug|x64.Build.0 = Debug|Any CPU + {C3D4E5F6-A7B8-4901-CDEF-012345678901}.Debug|x86.ActiveCfg = Debug|Any CPU + {C3D4E5F6-A7B8-4901-CDEF-012345678901}.Debug|x86.Build.0 = Debug|Any CPU + {C3D4E5F6-A7B8-4901-CDEF-012345678901}.Release|Any CPU.ActiveCfg = Release|Any CPU + {C3D4E5F6-A7B8-4901-CDEF-012345678901}.Release|Any CPU.Build.0 = Release|Any CPU + {C3D4E5F6-A7B8-4901-CDEF-012345678901}.Release|x64.ActiveCfg = Release|Any CPU + {C3D4E5F6-A7B8-4901-CDEF-012345678901}.Release|x64.Build.0 = Release|Any CPU + {C3D4E5F6-A7B8-4901-CDEF-012345678901}.Release|x86.ActiveCfg = Release|Any CPU + {C3D4E5F6-A7B8-4901-CDEF-012345678901}.Release|x86.Build.0 = Release|Any CPU {A19D2776-B935-BD35-4AB1-3FCE2092805A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {A19D2776-B935-BD35-4AB1-3FCE2092805A}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A19D2776-B935-BD35-4AB1-3FCE2092805A}.Debug|x64.ActiveCfg = Debug|Any CPU + {A19D2776-B935-BD35-4AB1-3FCE2092805A}.Debug|x64.Build.0 = Debug|Any CPU + {A19D2776-B935-BD35-4AB1-3FCE2092805A}.Debug|x86.ActiveCfg = Debug|Any CPU + {A19D2776-B935-BD35-4AB1-3FCE2092805A}.Debug|x86.Build.0 = Debug|Any CPU {A19D2776-B935-BD35-4AB1-3FCE2092805A}.Release|Any CPU.ActiveCfg = Release|Any CPU {A19D2776-B935-BD35-4AB1-3FCE2092805A}.Release|Any CPU.Build.0 = Release|Any CPU + {A19D2776-B935-BD35-4AB1-3FCE2092805A}.Release|x64.ActiveCfg = Release|Any CPU + {A19D2776-B935-BD35-4AB1-3FCE2092805A}.Release|x64.Build.0 = Release|Any CPU + {A19D2776-B935-BD35-4AB1-3FCE2092805A}.Release|x86.ActiveCfg = Release|Any CPU + {A19D2776-B935-BD35-4AB1-3FCE2092805A}.Release|x86.Build.0 = Release|Any CPU + {1B66E492-1830-4229-A8EF-135714BEADA2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {1B66E492-1830-4229-A8EF-135714BEADA2}.Debug|Any CPU.Build.0 = Debug|Any CPU + {1B66E492-1830-4229-A8EF-135714BEADA2}.Debug|x64.ActiveCfg = Debug|Any CPU + {1B66E492-1830-4229-A8EF-135714BEADA2}.Debug|x64.Build.0 = Debug|Any CPU + {1B66E492-1830-4229-A8EF-135714BEADA2}.Debug|x86.ActiveCfg = Debug|Any CPU + {1B66E492-1830-4229-A8EF-135714BEADA2}.Debug|x86.Build.0 = Debug|Any CPU + {1B66E492-1830-4229-A8EF-135714BEADA2}.Release|Any CPU.ActiveCfg = Release|Any CPU + {1B66E492-1830-4229-A8EF-135714BEADA2}.Release|Any CPU.Build.0 = Release|Any CPU + {1B66E492-1830-4229-A8EF-135714BEADA2}.Release|x64.ActiveCfg = Release|Any CPU + {1B66E492-1830-4229-A8EF-135714BEADA2}.Release|x64.Build.0 = Release|Any CPU + {1B66E492-1830-4229-A8EF-135714BEADA2}.Release|x86.ActiveCfg = Release|Any CPU + {1B66E492-1830-4229-A8EF-135714BEADA2}.Release|x86.Build.0 = Release|Any CPU + {9582CD83-0B49-4255-9BA6-BC045C3984AD}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {9582CD83-0B49-4255-9BA6-BC045C3984AD}.Debug|Any CPU.Build.0 = Debug|Any CPU + {9582CD83-0B49-4255-9BA6-BC045C3984AD}.Debug|x64.ActiveCfg = Debug|Any CPU + {9582CD83-0B49-4255-9BA6-BC045C3984AD}.Debug|x64.Build.0 = Debug|Any CPU + {9582CD83-0B49-4255-9BA6-BC045C3984AD}.Debug|x86.ActiveCfg = Debug|Any CPU + {9582CD83-0B49-4255-9BA6-BC045C3984AD}.Debug|x86.Build.0 = Debug|Any CPU + {9582CD83-0B49-4255-9BA6-BC045C3984AD}.Release|Any CPU.ActiveCfg = Release|Any CPU + {9582CD83-0B49-4255-9BA6-BC045C3984AD}.Release|Any CPU.Build.0 = Release|Any CPU + {9582CD83-0B49-4255-9BA6-BC045C3984AD}.Release|x64.ActiveCfg = Release|Any CPU + {9582CD83-0B49-4255-9BA6-BC045C3984AD}.Release|x64.Build.0 = Release|Any CPU + {9582CD83-0B49-4255-9BA6-BC045C3984AD}.Release|x86.ActiveCfg = Release|Any CPU + {9582CD83-0B49-4255-9BA6-BC045C3984AD}.Release|x86.Build.0 = Release|Any CPU + {CFC1AED5-72BF-4E84-92B6-65819A5AC961}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {CFC1AED5-72BF-4E84-92B6-65819A5AC961}.Debug|Any CPU.Build.0 = Debug|Any CPU + {CFC1AED5-72BF-4E84-92B6-65819A5AC961}.Debug|x64.ActiveCfg = Debug|Any CPU + {CFC1AED5-72BF-4E84-92B6-65819A5AC961}.Debug|x64.Build.0 = Debug|Any CPU + {CFC1AED5-72BF-4E84-92B6-65819A5AC961}.Debug|x86.ActiveCfg = Debug|Any CPU + {CFC1AED5-72BF-4E84-92B6-65819A5AC961}.Debug|x86.Build.0 = Debug|Any CPU + {CFC1AED5-72BF-4E84-92B6-65819A5AC961}.Release|Any CPU.ActiveCfg = Release|Any CPU + {CFC1AED5-72BF-4E84-92B6-65819A5AC961}.Release|Any CPU.Build.0 = Release|Any CPU + {CFC1AED5-72BF-4E84-92B6-65819A5AC961}.Release|x64.ActiveCfg = Release|Any CPU + {CFC1AED5-72BF-4E84-92B6-65819A5AC961}.Release|x64.Build.0 = Release|Any CPU + {CFC1AED5-72BF-4E84-92B6-65819A5AC961}.Release|x86.ActiveCfg = Release|Any CPU + {CFC1AED5-72BF-4E84-92B6-65819A5AC961}.Release|x86.Build.0 = Release|Any CPU + {31D58517-29D8-46E9-AEAC-F43FDE540590}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {31D58517-29D8-46E9-AEAC-F43FDE540590}.Debug|Any CPU.Build.0 = Debug|Any CPU + {31D58517-29D8-46E9-AEAC-F43FDE540590}.Debug|x64.ActiveCfg = Debug|Any CPU + {31D58517-29D8-46E9-AEAC-F43FDE540590}.Debug|x64.Build.0 = Debug|Any CPU + {31D58517-29D8-46E9-AEAC-F43FDE540590}.Debug|x86.ActiveCfg = Debug|Any CPU + {31D58517-29D8-46E9-AEAC-F43FDE540590}.Debug|x86.Build.0 = Debug|Any CPU + {31D58517-29D8-46E9-AEAC-F43FDE540590}.Release|Any CPU.ActiveCfg = Release|Any CPU + {31D58517-29D8-46E9-AEAC-F43FDE540590}.Release|Any CPU.Build.0 = Release|Any CPU + {31D58517-29D8-46E9-AEAC-F43FDE540590}.Release|x64.ActiveCfg = Release|Any CPU + {31D58517-29D8-46E9-AEAC-F43FDE540590}.Release|x64.Build.0 = Release|Any CPU + {31D58517-29D8-46E9-AEAC-F43FDE540590}.Release|x86.ActiveCfg = Release|Any CPU + {31D58517-29D8-46E9-AEAC-F43FDE540590}.Release|x86.Build.0 = Release|Any CPU + {92CA82EB-E558-44E7-9185-6FF8B8299C2A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {92CA82EB-E558-44E7-9185-6FF8B8299C2A}.Debug|Any CPU.Build.0 = Debug|Any CPU + {92CA82EB-E558-44E7-9185-6FF8B8299C2A}.Debug|x64.ActiveCfg = Debug|Any CPU + {92CA82EB-E558-44E7-9185-6FF8B8299C2A}.Debug|x64.Build.0 = Debug|Any CPU + {92CA82EB-E558-44E7-9185-6FF8B8299C2A}.Debug|x86.ActiveCfg = Debug|Any CPU + {92CA82EB-E558-44E7-9185-6FF8B8299C2A}.Debug|x86.Build.0 = Debug|Any CPU + {92CA82EB-E558-44E7-9185-6FF8B8299C2A}.Release|Any CPU.ActiveCfg = Release|Any CPU + {92CA82EB-E558-44E7-9185-6FF8B8299C2A}.Release|Any CPU.Build.0 = Release|Any CPU + {92CA82EB-E558-44E7-9185-6FF8B8299C2A}.Release|x64.ActiveCfg = Release|Any CPU + {92CA82EB-E558-44E7-9185-6FF8B8299C2A}.Release|x64.Build.0 = Release|Any CPU + {92CA82EB-E558-44E7-9185-6FF8B8299C2A}.Release|x86.ActiveCfg = Release|Any CPU + {92CA82EB-E558-44E7-9185-6FF8B8299C2A}.Release|x86.Build.0 = Release|Any CPU + {02DE69CD-19E6-43C0-8916-DB98E5B5CA89}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {02DE69CD-19E6-43C0-8916-DB98E5B5CA89}.Debug|Any CPU.Build.0 = Debug|Any CPU + {02DE69CD-19E6-43C0-8916-DB98E5B5CA89}.Debug|x64.ActiveCfg = Debug|Any CPU + {02DE69CD-19E6-43C0-8916-DB98E5B5CA89}.Debug|x64.Build.0 = Debug|Any CPU + {02DE69CD-19E6-43C0-8916-DB98E5B5CA89}.Debug|x86.ActiveCfg = Debug|Any CPU + {02DE69CD-19E6-43C0-8916-DB98E5B5CA89}.Debug|x86.Build.0 = Debug|Any CPU + {02DE69CD-19E6-43C0-8916-DB98E5B5CA89}.Release|Any CPU.ActiveCfg = Release|Any CPU + {02DE69CD-19E6-43C0-8916-DB98E5B5CA89}.Release|Any CPU.Build.0 = Release|Any CPU + {02DE69CD-19E6-43C0-8916-DB98E5B5CA89}.Release|x64.ActiveCfg = Release|Any CPU + {02DE69CD-19E6-43C0-8916-DB98E5B5CA89}.Release|x64.Build.0 = Release|Any CPU + {02DE69CD-19E6-43C0-8916-DB98E5B5CA89}.Release|x86.ActiveCfg = Release|Any CPU + {02DE69CD-19E6-43C0-8916-DB98E5B5CA89}.Release|x86.Build.0 = Release|Any CPU + {069365DB-1916-4C38-A90D-5E909BD9EDD0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {069365DB-1916-4C38-A90D-5E909BD9EDD0}.Debug|Any CPU.Build.0 = Debug|Any CPU + {069365DB-1916-4C38-A90D-5E909BD9EDD0}.Debug|x64.ActiveCfg = Debug|Any CPU + {069365DB-1916-4C38-A90D-5E909BD9EDD0}.Debug|x64.Build.0 = Debug|Any CPU + {069365DB-1916-4C38-A90D-5E909BD9EDD0}.Debug|x86.ActiveCfg = Debug|Any CPU + {069365DB-1916-4C38-A90D-5E909BD9EDD0}.Debug|x86.Build.0 = Debug|Any CPU + {069365DB-1916-4C38-A90D-5E909BD9EDD0}.Release|Any CPU.ActiveCfg = Release|Any CPU + {069365DB-1916-4C38-A90D-5E909BD9EDD0}.Release|Any CPU.Build.0 = Release|Any CPU + {069365DB-1916-4C38-A90D-5E909BD9EDD0}.Release|x64.ActiveCfg = Release|Any CPU + {069365DB-1916-4C38-A90D-5E909BD9EDD0}.Release|x64.Build.0 = Release|Any CPU + {069365DB-1916-4C38-A90D-5E909BD9EDD0}.Release|x86.ActiveCfg = Release|Any CPU + {069365DB-1916-4C38-A90D-5E909BD9EDD0}.Release|x86.Build.0 = Release|Any CPU + {BE44B4EB-9AB9-4D81-A9BF-5CF2832BEEE5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {BE44B4EB-9AB9-4D81-A9BF-5CF2832BEEE5}.Debug|Any CPU.Build.0 = Debug|Any CPU + {BE44B4EB-9AB9-4D81-A9BF-5CF2832BEEE5}.Debug|x64.ActiveCfg = Debug|Any CPU + {BE44B4EB-9AB9-4D81-A9BF-5CF2832BEEE5}.Debug|x64.Build.0 = Debug|Any CPU + {BE44B4EB-9AB9-4D81-A9BF-5CF2832BEEE5}.Debug|x86.ActiveCfg = Debug|Any CPU + {BE44B4EB-9AB9-4D81-A9BF-5CF2832BEEE5}.Debug|x86.Build.0 = Debug|Any CPU + {BE44B4EB-9AB9-4D81-A9BF-5CF2832BEEE5}.Release|Any CPU.ActiveCfg = Release|Any CPU + {BE44B4EB-9AB9-4D81-A9BF-5CF2832BEEE5}.Release|Any CPU.Build.0 = Release|Any CPU + {BE44B4EB-9AB9-4D81-A9BF-5CF2832BEEE5}.Release|x64.ActiveCfg = Release|Any CPU + {BE44B4EB-9AB9-4D81-A9BF-5CF2832BEEE5}.Release|x64.Build.0 = Release|Any CPU + {BE44B4EB-9AB9-4D81-A9BF-5CF2832BEEE5}.Release|x86.ActiveCfg = Release|Any CPU + {BE44B4EB-9AB9-4D81-A9BF-5CF2832BEEE5}.Release|x86.Build.0 = Release|Any CPU + {434119EA-2FFC-4433-9B8E-1E6D94006413}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {434119EA-2FFC-4433-9B8E-1E6D94006413}.Debug|Any CPU.Build.0 = Debug|Any CPU + {434119EA-2FFC-4433-9B8E-1E6D94006413}.Debug|x64.ActiveCfg = Debug|Any CPU + {434119EA-2FFC-4433-9B8E-1E6D94006413}.Debug|x64.Build.0 = Debug|Any CPU + {434119EA-2FFC-4433-9B8E-1E6D94006413}.Debug|x86.ActiveCfg = Debug|Any CPU + {434119EA-2FFC-4433-9B8E-1E6D94006413}.Debug|x86.Build.0 = Debug|Any CPU + {434119EA-2FFC-4433-9B8E-1E6D94006413}.Release|Any CPU.ActiveCfg = Release|Any CPU + {434119EA-2FFC-4433-9B8E-1E6D94006413}.Release|Any CPU.Build.0 = Release|Any CPU + {434119EA-2FFC-4433-9B8E-1E6D94006413}.Release|x64.ActiveCfg = Release|Any CPU + {434119EA-2FFC-4433-9B8E-1E6D94006413}.Release|x64.Build.0 = Release|Any CPU + {434119EA-2FFC-4433-9B8E-1E6D94006413}.Release|x86.ActiveCfg = Release|Any CPU + {434119EA-2FFC-4433-9B8E-1E6D94006413}.Release|x86.Build.0 = Release|Any CPU + {C1D2E3F4-A5B6-4789-CDEF-012345678ABC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {C1D2E3F4-A5B6-4789-CDEF-012345678ABC}.Debug|Any CPU.Build.0 = Debug|Any CPU + {C1D2E3F4-A5B6-4789-CDEF-012345678ABC}.Debug|x64.ActiveCfg = Debug|Any CPU + {C1D2E3F4-A5B6-4789-CDEF-012345678ABC}.Debug|x64.Build.0 = Debug|Any CPU + {C1D2E3F4-A5B6-4789-CDEF-012345678ABC}.Debug|x86.ActiveCfg = Debug|Any CPU + {C1D2E3F4-A5B6-4789-CDEF-012345678ABC}.Debug|x86.Build.0 = Debug|Any CPU + {C1D2E3F4-A5B6-4789-CDEF-012345678ABC}.Release|Any CPU.ActiveCfg = Release|Any CPU + {C1D2E3F4-A5B6-4789-CDEF-012345678ABC}.Release|Any CPU.Build.0 = Release|Any CPU + {C1D2E3F4-A5B6-4789-CDEF-012345678ABC}.Release|x64.ActiveCfg = Release|Any CPU + {C1D2E3F4-A5B6-4789-CDEF-012345678ABC}.Release|x64.Build.0 = Release|Any CPU + {C1D2E3F4-A5B6-4789-CDEF-012345678ABC}.Release|x86.ActiveCfg = Release|Any CPU + {C1D2E3F4-A5B6-4789-CDEF-012345678ABC}.Release|x86.Build.0 = Release|Any CPU + {4F1A669E-C8AF-428F-87E7-3E0A213DD20B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {4F1A669E-C8AF-428F-87E7-3E0A213DD20B}.Debug|Any CPU.Build.0 = Debug|Any CPU + {4F1A669E-C8AF-428F-87E7-3E0A213DD20B}.Debug|x64.ActiveCfg = Debug|Any CPU + {4F1A669E-C8AF-428F-87E7-3E0A213DD20B}.Debug|x64.Build.0 = Debug|Any CPU + {4F1A669E-C8AF-428F-87E7-3E0A213DD20B}.Debug|x86.ActiveCfg = Debug|Any CPU + {4F1A669E-C8AF-428F-87E7-3E0A213DD20B}.Debug|x86.Build.0 = Debug|Any CPU + {4F1A669E-C8AF-428F-87E7-3E0A213DD20B}.Release|Any CPU.ActiveCfg = Release|Any CPU + {4F1A669E-C8AF-428F-87E7-3E0A213DD20B}.Release|Any CPU.Build.0 = Release|Any CPU + {4F1A669E-C8AF-428F-87E7-3E0A213DD20B}.Release|x64.ActiveCfg = Release|Any CPU + {4F1A669E-C8AF-428F-87E7-3E0A213DD20B}.Release|x64.Build.0 = Release|Any CPU + {4F1A669E-C8AF-428F-87E7-3E0A213DD20B}.Release|x86.ActiveCfg = Release|Any CPU + {4F1A669E-C8AF-428F-87E7-3E0A213DD20B}.Release|x86.Build.0 = Release|Any CPU + {06F803CD-329D-40C2-B62D-0F14E137D3C7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {06F803CD-329D-40C2-B62D-0F14E137D3C7}.Debug|Any CPU.Build.0 = Debug|Any CPU + {06F803CD-329D-40C2-B62D-0F14E137D3C7}.Debug|x64.ActiveCfg = Debug|Any CPU + {06F803CD-329D-40C2-B62D-0F14E137D3C7}.Debug|x64.Build.0 = Debug|Any CPU + {06F803CD-329D-40C2-B62D-0F14E137D3C7}.Debug|x86.ActiveCfg = Debug|Any CPU + {06F803CD-329D-40C2-B62D-0F14E137D3C7}.Debug|x86.Build.0 = Debug|Any CPU + {06F803CD-329D-40C2-B62D-0F14E137D3C7}.Release|Any CPU.ActiveCfg = Release|Any CPU + {06F803CD-329D-40C2-B62D-0F14E137D3C7}.Release|Any CPU.Build.0 = Release|Any CPU + {06F803CD-329D-40C2-B62D-0F14E137D3C7}.Release|x64.ActiveCfg = Release|Any CPU + {06F803CD-329D-40C2-B62D-0F14E137D3C7}.Release|x64.Build.0 = Release|Any CPU + {06F803CD-329D-40C2-B62D-0F14E137D3C7}.Release|x86.ActiveCfg = Release|Any CPU + {06F803CD-329D-40C2-B62D-0F14E137D3C7}.Release|x86.Build.0 = Release|Any CPU + {FC5A722A-7B12-459E-AB9F-0A724797783E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {FC5A722A-7B12-459E-AB9F-0A724797783E}.Debug|Any CPU.Build.0 = Debug|Any CPU + {FC5A722A-7B12-459E-AB9F-0A724797783E}.Debug|x64.ActiveCfg = Debug|Any CPU + {FC5A722A-7B12-459E-AB9F-0A724797783E}.Debug|x64.Build.0 = Debug|Any CPU + {FC5A722A-7B12-459E-AB9F-0A724797783E}.Debug|x86.ActiveCfg = Debug|Any CPU + {FC5A722A-7B12-459E-AB9F-0A724797783E}.Debug|x86.Build.0 = Debug|Any CPU + {FC5A722A-7B12-459E-AB9F-0A724797783E}.Release|Any CPU.ActiveCfg = Release|Any CPU + {FC5A722A-7B12-459E-AB9F-0A724797783E}.Release|Any CPU.Build.0 = Release|Any CPU + {FC5A722A-7B12-459E-AB9F-0A724797783E}.Release|x64.ActiveCfg = Release|Any CPU + {FC5A722A-7B12-459E-AB9F-0A724797783E}.Release|x64.Build.0 = Release|Any CPU + {FC5A722A-7B12-459E-AB9F-0A724797783E}.Release|x86.ActiveCfg = Release|Any CPU + {FC5A722A-7B12-459E-AB9F-0A724797783E}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE EndGlobalSection GlobalSection(NestedProjects) = preSolution - {16F8CA9D-341F-47B8-8E34-F4C5B3E72F3C} = {0FE6558F-2157-47F2-A835-558416CE0E2B} + {16F8CA9D-341F-47B8-8E34-F4C5B3E72F3C} = {201E8E89-A2E2-44FD-BF43-7F24B6CACA52} + {B0A3EAB7-759A-448A-A906-52DF75A70016} = {201E8E89-A2E2-44FD-BF43-7F24B6CACA52} {A63E1C1A-4A78-49F4-9F5C-D43783294861} = {0FE6558F-2157-47F2-A835-558416CE0E2B} {C40F5025-B0A6-4B25-B4A2-7EA568E06C40} = {0FE6558F-2157-47F2-A835-558416CE0E2B} - {D09DA1C2-3DC5-48E7-9F5B-739CA41174F1} = {0FE6558F-2157-47F2-A835-558416CE0E2B} - {FB5EAA9E-1B83-41E4-A3BC-F4B7D1AA0769} = {0FE6558F-2157-47F2-A835-558416CE0E2B} - {6A1ADA81-28E9-4A64-A32D-0755876D5EB7} = {0FE6558F-2157-47F2-A835-558416CE0E2B} - {185A8BB0-344A-4856-AEB4-213866EB2EE7} = {0FE6558F-2157-47F2-A835-558416CE0E2B} + {D09DA1C2-3DC5-48E7-9F5B-739CA41174F1} = {A9B8C7D6-E5F4-4321-ABCD-FEDCBA987654} + {FB5EAA9E-1B83-41E4-A3BC-F4B7D1AA0769} = {A9B8C7D6-E5F4-4321-ABCD-FEDCBA987654} + {6A1ADA81-28E9-4A64-A32D-0755876D5EB7} = {A9B8C7D6-E5F4-4321-ABCD-FEDCBA987654} + {185A8BB0-344A-4856-AEB4-213866EB2EE7} = {A9B8C7D6-E5F4-4321-ABCD-FEDCBA987654} {7446D193-8636-4E58-96E4-0C8CB8790679} = {43E9CD21-25B6-4CB4-B94E-5B953B2E1284} {4EDDEE9A-E9C7-4972-9C4A-3177611CCFE3} = {43E9CD21-25B6-4CB4-B94E-5B953B2E1284} {E7F21C94-6D88-4E9B-A12F-9C3E8D5B7A41} = {F1A2B3C4-D5E6-4789-ABCD-EF0123456789} + {C3D4E5F6-A7B8-4901-CDEF-012345678901} = {F1A2B3C4-D5E6-4789-ABCD-EF0123456789} {A19D2776-B935-BD35-4AB1-3FCE2092805A} = {F1A2B3C4-D5E6-4789-ABCD-EF0123456789} + {1B66E492-1830-4229-A8EF-135714BEADA2} = {D4E5F6A7-B8C9-4012-3456-789ABCDEF012} + {9582CD83-0B49-4255-9BA6-BC045C3984AD} = {D4E5F6A7-B8C9-4012-3456-789ABCDEF012} + {CFC1AED5-72BF-4E84-92B6-65819A5AC961} = {D4E5F6A7-B8C9-4012-3456-789ABCDEF012} + {31D58517-29D8-46E9-AEAC-F43FDE540590} = {D4E5F6A7-B8C9-4012-3456-789ABCDEF012} + {92CA82EB-E558-44E7-9185-6FF8B8299C2A} = {D4E5F6A7-B8C9-4012-3456-789ABCDEF012} + {02DE69CD-19E6-43C0-8916-DB98E5B5CA89} = {A9B8C7D6-E5F4-4321-ABCD-FEDCBA987654} + {069365DB-1916-4C38-A90D-5E909BD9EDD0} = {A9B8C7D6-E5F4-4321-ABCD-FEDCBA987654} + {BE44B4EB-9AB9-4D81-A9BF-5CF2832BEEE5} = {A9B8C7D6-E5F4-4321-ABCD-FEDCBA987654} + {434119EA-2FFC-4433-9B8E-1E6D94006413} = {0FE6558F-2157-47F2-A835-558416CE0E2B} + {C1D2E3F4-A5B6-4789-CDEF-012345678ABC} = {D4E5F6A7-B8C9-4012-3456-789ABCDEF012} + {4F1A669E-C8AF-428F-87E7-3E0A213DD20B} = {A9B8C7D6-E5F4-4321-ABCD-FEDCBA987654} + {06F803CD-329D-40C2-B62D-0F14E137D3C7} = {D4E5F6A7-B8C9-4012-3456-789ABCDEF012} + {FC5A722A-7B12-459E-AB9F-0A724797783E} = {0FE6558F-2157-47F2-A835-558416CE0E2B} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {6246A67B-299E-4E64-8DBE-1A66771E7C67} diff --git a/web/Dockerfile b/web/Dockerfile index 49d964d..f38dff5 100644 --- a/web/Dockerfile +++ b/web/Dockerfile @@ -1,6 +1,7 @@ FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build ARG BUILD_CONFIGURATION=Release WORKDIR /src +COPY Directory.Packages.props ./ COPY web/web.csproj web/ RUN dotnet restore web/web.csproj diff --git a/web/web.csproj b/web/web.csproj index eea6815..2e28118 100644 --- a/web/web.csproj +++ b/web/web.csproj @@ -9,7 +9,7 @@ - - + + diff --git a/web/wwwroot/css/bootstrap.min.css b/web/wwwroot/css/bootstrap.min.css deleted file mode 100644 index 27d4921..0000000 --- a/web/wwwroot/css/bootstrap.min.css +++ /dev/null @@ -1,6 +0,0 @@ -@charset "UTF-8";/*! - * Bootstrap v5.3.6 (https://getbootstrap.com/) - * Copyright 2011-2025 The Bootstrap Authors - * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE) - */:root,[data-bs-theme=light]{--bs-blue:#0d6efd;--bs-indigo:#6610f2;--bs-purple:#6f42c1;--bs-pink:#d63384;--bs-red:#dc3545;--bs-orange:#fd7e14;--bs-yellow:#ffc107;--bs-green:#198754;--bs-teal:#20c997;--bs-cyan:#0dcaf0;--bs-black:#000;--bs-white:#fff;--bs-gray:#6c757d;--bs-gray-dark:#343a40;--bs-gray-100:#f8f9fa;--bs-gray-200:#e9ecef;--bs-gray-300:#dee2e6;--bs-gray-400:#ced4da;--bs-gray-500:#adb5bd;--bs-gray-600:#6c757d;--bs-gray-700:#495057;--bs-gray-800:#343a40;--bs-gray-900:#212529;--bs-primary:#0d6efd;--bs-secondary:#6c757d;--bs-success:#198754;--bs-info:#0dcaf0;--bs-warning:#ffc107;--bs-danger:#dc3545;--bs-light:#f8f9fa;--bs-dark:#212529;--bs-primary-rgb:13,110,253;--bs-secondary-rgb:108,117,125;--bs-success-rgb:25,135,84;--bs-info-rgb:13,202,240;--bs-warning-rgb:255,193,7;--bs-danger-rgb:220,53,69;--bs-light-rgb:248,249,250;--bs-dark-rgb:33,37,41;--bs-primary-text-emphasis:#052c65;--bs-secondary-text-emphasis:#2b2f32;--bs-success-text-emphasis:#0a3622;--bs-info-text-emphasis:#055160;--bs-warning-text-emphasis:#664d03;--bs-danger-text-emphasis:#58151c;--bs-light-text-emphasis:#495057;--bs-dark-text-emphasis:#495057;--bs-primary-bg-subtle:#cfe2ff;--bs-secondary-bg-subtle:#e2e3e5;--bs-success-bg-subtle:#d1e7dd;--bs-info-bg-subtle:#cff4fc;--bs-warning-bg-subtle:#fff3cd;--bs-danger-bg-subtle:#f8d7da;--bs-light-bg-subtle:#fcfcfd;--bs-dark-bg-subtle:#ced4da;--bs-primary-border-subtle:#9ec5fe;--bs-secondary-border-subtle:#c4c8cb;--bs-success-border-subtle:#a3cfbb;--bs-info-border-subtle:#9eeaf9;--bs-warning-border-subtle:#ffe69c;--bs-danger-border-subtle:#f1aeb5;--bs-light-border-subtle:#e9ecef;--bs-dark-border-subtle:#adb5bd;--bs-white-rgb:255,255,255;--bs-black-rgb:0,0,0;--bs-font-sans-serif:system-ui,-apple-system,"Segoe UI",Roboto,"Helvetica Neue","Noto Sans","Liberation Sans",Arial,sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji";--bs-font-monospace:SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace;--bs-gradient:linear-gradient(180deg, rgba(255, 255, 255, 0.15), rgba(255, 255, 255, 0));--bs-body-font-family:var(--bs-font-sans-serif);--bs-body-font-size:1rem;--bs-body-font-weight:400;--bs-body-line-height:1.5;--bs-body-color:#212529;--bs-body-color-rgb:33,37,41;--bs-body-bg:#fff;--bs-body-bg-rgb:255,255,255;--bs-emphasis-color:#000;--bs-emphasis-color-rgb:0,0,0;--bs-secondary-color:rgba(33, 37, 41, 0.75);--bs-secondary-color-rgb:33,37,41;--bs-secondary-bg:#e9ecef;--bs-secondary-bg-rgb:233,236,239;--bs-tertiary-color:rgba(33, 37, 41, 0.5);--bs-tertiary-color-rgb:33,37,41;--bs-tertiary-bg:#f8f9fa;--bs-tertiary-bg-rgb:248,249,250;--bs-heading-color:inherit;--bs-link-color:#0d6efd;--bs-link-color-rgb:13,110,253;--bs-link-decoration:underline;--bs-link-hover-color:#0a58ca;--bs-link-hover-color-rgb:10,88,202;--bs-code-color:#d63384;--bs-highlight-color:#212529;--bs-highlight-bg:#fff3cd;--bs-border-width:1px;--bs-border-style:solid;--bs-border-color:#dee2e6;--bs-border-color-translucent:rgba(0, 0, 0, 0.175);--bs-border-radius:0.375rem;--bs-border-radius-sm:0.25rem;--bs-border-radius-lg:0.5rem;--bs-border-radius-xl:1rem;--bs-border-radius-xxl:2rem;--bs-border-radius-2xl:var(--bs-border-radius-xxl);--bs-border-radius-pill:50rem;--bs-box-shadow:0 0.5rem 1rem rgba(0, 0, 0, 0.15);--bs-box-shadow-sm:0 0.125rem 0.25rem rgba(0, 0, 0, 0.075);--bs-box-shadow-lg:0 1rem 3rem rgba(0, 0, 0, 0.175);--bs-box-shadow-inset:inset 0 1px 2px rgba(0, 0, 0, 0.075);--bs-focus-ring-width:0.25rem;--bs-focus-ring-opacity:0.25;--bs-focus-ring-color:rgba(13, 110, 253, 0.25);--bs-form-valid-color:#198754;--bs-form-valid-border-color:#198754;--bs-form-invalid-color:#dc3545;--bs-form-invalid-border-color:#dc3545}[data-bs-theme=dark]{color-scheme:dark;--bs-body-color:#dee2e6;--bs-body-color-rgb:222,226,230;--bs-body-bg:#212529;--bs-body-bg-rgb:33,37,41;--bs-emphasis-color:#fff;--bs-emphasis-color-rgb:255,255,255;--bs-secondary-color:rgba(222, 226, 230, 0.75);--bs-secondary-color-rgb:222,226,230;--bs-secondary-bg:#343a40;--bs-secondary-bg-rgb:52,58,64;--bs-tertiary-color:rgba(222, 226, 230, 0.5);--bs-tertiary-color-rgb:222,226,230;--bs-tertiary-bg:#2b3035;--bs-tertiary-bg-rgb:43,48,53;--bs-primary-text-emphasis:#6ea8fe;--bs-secondary-text-emphasis:#a7acb1;--bs-success-text-emphasis:#75b798;--bs-info-text-emphasis:#6edff6;--bs-warning-text-emphasis:#ffda6a;--bs-danger-text-emphasis:#ea868f;--bs-light-text-emphasis:#f8f9fa;--bs-dark-text-emphasis:#dee2e6;--bs-primary-bg-subtle:#031633;--bs-secondary-bg-subtle:#161719;--bs-success-bg-subtle:#051b11;--bs-info-bg-subtle:#032830;--bs-warning-bg-subtle:#332701;--bs-danger-bg-subtle:#2c0b0e;--bs-light-bg-subtle:#343a40;--bs-dark-bg-subtle:#1a1d20;--bs-primary-border-subtle:#084298;--bs-secondary-border-subtle:#41464b;--bs-success-border-subtle:#0f5132;--bs-info-border-subtle:#087990;--bs-warning-border-subtle:#997404;--bs-danger-border-subtle:#842029;--bs-light-border-subtle:#495057;--bs-dark-border-subtle:#343a40;--bs-heading-color:inherit;--bs-link-color:#6ea8fe;--bs-link-hover-color:#8bb9fe;--bs-link-color-rgb:110,168,254;--bs-link-hover-color-rgb:139,185,254;--bs-code-color:#e685b5;--bs-highlight-color:#dee2e6;--bs-highlight-bg:#664d03;--bs-border-color:#495057;--bs-border-color-translucent:rgba(255, 255, 255, 0.15);--bs-form-valid-color:#75b798;--bs-form-valid-border-color:#75b798;--bs-form-invalid-color:#ea868f;--bs-form-invalid-border-color:#ea868f}*,::after,::before{box-sizing:border-box}@media (prefers-reduced-motion:no-preference){:root{scroll-behavior:smooth}}body{margin:0;font-family:var(--bs-body-font-family);font-size:var(--bs-body-font-size);font-weight:var(--bs-body-font-weight);line-height:var(--bs-body-line-height);color:var(--bs-body-color);text-align:var(--bs-body-text-align);background-color:var(--bs-body-bg);-webkit-text-size-adjust:100%;-webkit-tap-highlight-color:transparent}hr{margin:1rem 0;color:inherit;border:0;border-top:var(--bs-border-width) solid;opacity:.25}.h1,.h2,.h3,.h4,.h5,.h6,h1,h2,h3,h4,h5,h6{margin-top:0;margin-bottom:.5rem;font-weight:500;line-height:1.2;color:var(--bs-heading-color)}.h1,h1{font-size:calc(1.375rem + 1.5vw)}@media (min-width:1200px){.h1,h1{font-size:2.5rem}}.h2,h2{font-size:calc(1.325rem + .9vw)}@media (min-width:1200px){.h2,h2{font-size:2rem}}.h3,h3{font-size:calc(1.3rem + .6vw)}@media (min-width:1200px){.h3,h3{font-size:1.75rem}}.h4,h4{font-size:calc(1.275rem + .3vw)}@media (min-width:1200px){.h4,h4{font-size:1.5rem}}.h5,h5{font-size:1.25rem}.h6,h6{font-size:1rem}p{margin-top:0;margin-bottom:1rem}abbr[title]{-webkit-text-decoration:underline dotted;text-decoration:underline dotted;cursor:help;-webkit-text-decoration-skip-ink:none;text-decoration-skip-ink:none}address{margin-bottom:1rem;font-style:normal;line-height:inherit}ol,ul{padding-left:2rem}dl,ol,ul{margin-top:0;margin-bottom:1rem}ol ol,ol ul,ul ol,ul ul{margin-bottom:0}dt{font-weight:700}dd{margin-bottom:.5rem;margin-left:0}blockquote{margin:0 0 1rem}b,strong{font-weight:bolder}.small,small{font-size:.875em}.mark,mark{padding:.1875em;color:var(--bs-highlight-color);background-color:var(--bs-highlight-bg)}sub,sup{position:relative;font-size:.75em;line-height:0;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}a{color:rgba(var(--bs-link-color-rgb),var(--bs-link-opacity,1));text-decoration:underline}a:hover{--bs-link-color-rgb:var(--bs-link-hover-color-rgb)}a:not([href]):not([class]),a:not([href]):not([class]):hover{color:inherit;text-decoration:none}code,kbd,pre,samp{font-family:var(--bs-font-monospace);font-size:1em}pre{display:block;margin-top:0;margin-bottom:1rem;overflow:auto;font-size:.875em}pre code{font-size:inherit;color:inherit;word-break:normal}code{font-size:.875em;color:var(--bs-code-color);word-wrap:break-word}a>code{color:inherit}kbd{padding:.1875rem .375rem;font-size:.875em;color:var(--bs-body-bg);background-color:var(--bs-body-color);border-radius:.25rem}kbd kbd{padding:0;font-size:1em}figure{margin:0 0 1rem}img,svg{vertical-align:middle}table{caption-side:bottom;border-collapse:collapse}caption{padding-top:.5rem;padding-bottom:.5rem;color:var(--bs-secondary-color);text-align:left}th{text-align:inherit;text-align:-webkit-match-parent}tbody,td,tfoot,th,thead,tr{border-color:inherit;border-style:solid;border-width:0}label{display:inline-block}button{border-radius:0}button:focus:not(:focus-visible){outline:0}button,input,optgroup,select,textarea{margin:0;font-family:inherit;font-size:inherit;line-height:inherit}button,select{text-transform:none}[role=button]{cursor:pointer}select{word-wrap:normal}select:disabled{opacity:1}[list]:not([type=date]):not([type=datetime-local]):not([type=month]):not([type=week]):not([type=time])::-webkit-calendar-picker-indicator{display:none!important}[type=button],[type=reset],[type=submit],button{-webkit-appearance:button}[type=button]:not(:disabled),[type=reset]:not(:disabled),[type=submit]:not(:disabled),button:not(:disabled){cursor:pointer}::-moz-focus-inner{padding:0;border-style:none}textarea{resize:vertical}fieldset{min-width:0;padding:0;margin:0;border:0}legend{float:left;width:100%;padding:0;margin-bottom:.5rem;line-height:inherit;font-size:calc(1.275rem + .3vw)}@media (min-width:1200px){legend{font-size:1.5rem}}legend+*{clear:left}::-webkit-datetime-edit-day-field,::-webkit-datetime-edit-fields-wrapper,::-webkit-datetime-edit-hour-field,::-webkit-datetime-edit-minute,::-webkit-datetime-edit-month-field,::-webkit-datetime-edit-text,::-webkit-datetime-edit-year-field{padding:0}::-webkit-inner-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-color-swatch-wrapper{padding:0}::-webkit-file-upload-button{font:inherit;-webkit-appearance:button}::file-selector-button{font:inherit;-webkit-appearance:button}output{display:inline-block}iframe{border:0}summary{display:list-item;cursor:pointer}progress{vertical-align:baseline}[hidden]{display:none!important}.lead{font-size:1.25rem;font-weight:300}.display-1{font-weight:300;line-height:1.2;font-size:calc(1.625rem + 4.5vw)}@media (min-width:1200px){.display-1{font-size:5rem}}.display-2{font-weight:300;line-height:1.2;font-size:calc(1.575rem + 3.9vw)}@media (min-width:1200px){.display-2{font-size:4.5rem}}.display-3{font-weight:300;line-height:1.2;font-size:calc(1.525rem + 3.3vw)}@media (min-width:1200px){.display-3{font-size:4rem}}.display-4{font-weight:300;line-height:1.2;font-size:calc(1.475rem + 2.7vw)}@media (min-width:1200px){.display-4{font-size:3.5rem}}.display-5{font-weight:300;line-height:1.2;font-size:calc(1.425rem + 2.1vw)}@media (min-width:1200px){.display-5{font-size:3rem}}.display-6{font-weight:300;line-height:1.2;font-size:calc(1.375rem + 1.5vw)}@media (min-width:1200px){.display-6{font-size:2.5rem}}.list-unstyled{padding-left:0;list-style:none}.list-inline{padding-left:0;list-style:none}.list-inline-item{display:inline-block}.list-inline-item:not(:last-child){margin-right:.5rem}.initialism{font-size:.875em;text-transform:uppercase}.blockquote{margin-bottom:1rem;font-size:1.25rem}.blockquote>:last-child{margin-bottom:0}.blockquote-footer{margin-top:-1rem;margin-bottom:1rem;font-size:.875em;color:#6c757d}.blockquote-footer::before{content:"— "}.img-fluid{max-width:100%;height:auto}.img-thumbnail{padding:.25rem;background-color:var(--bs-body-bg);border:var(--bs-border-width) solid var(--bs-border-color);border-radius:var(--bs-border-radius);max-width:100%;height:auto}.figure{display:inline-block}.figure-img{margin-bottom:.5rem;line-height:1}.figure-caption{font-size:.875em;color:var(--bs-secondary-color)}.container,.container-fluid,.container-lg,.container-md,.container-sm,.container-xl,.container-xxl{--bs-gutter-x:1.5rem;--bs-gutter-y:0;width:100%;padding-right:calc(var(--bs-gutter-x) * .5);padding-left:calc(var(--bs-gutter-x) * .5);margin-right:auto;margin-left:auto}@media (min-width:576px){.container,.container-sm{max-width:540px}}@media (min-width:768px){.container,.container-md,.container-sm{max-width:720px}}@media (min-width:992px){.container,.container-lg,.container-md,.container-sm{max-width:960px}}@media (min-width:1200px){.container,.container-lg,.container-md,.container-sm,.container-xl{max-width:1140px}}@media (min-width:1400px){.container,.container-lg,.container-md,.container-sm,.container-xl,.container-xxl{max-width:1320px}}:root{--bs-breakpoint-xs:0;--bs-breakpoint-sm:576px;--bs-breakpoint-md:768px;--bs-breakpoint-lg:992px;--bs-breakpoint-xl:1200px;--bs-breakpoint-xxl:1400px}.row{--bs-gutter-x:1.5rem;--bs-gutter-y:0;display:flex;flex-wrap:wrap;margin-top:calc(-1 * var(--bs-gutter-y));margin-right:calc(-.5 * var(--bs-gutter-x));margin-left:calc(-.5 * var(--bs-gutter-x))}.row>*{flex-shrink:0;width:100%;max-width:100%;padding-right:calc(var(--bs-gutter-x) * .5);padding-left:calc(var(--bs-gutter-x) * .5);margin-top:var(--bs-gutter-y)}.col{flex:1 0 0}.row-cols-auto>*{flex:0 0 auto;width:auto}.row-cols-1>*{flex:0 0 auto;width:100%}.row-cols-2>*{flex:0 0 auto;width:50%}.row-cols-3>*{flex:0 0 auto;width:33.33333333%}.row-cols-4>*{flex:0 0 auto;width:25%}.row-cols-5>*{flex:0 0 auto;width:20%}.row-cols-6>*{flex:0 0 auto;width:16.66666667%}.col-auto{flex:0 0 auto;width:auto}.col-1{flex:0 0 auto;width:8.33333333%}.col-2{flex:0 0 auto;width:16.66666667%}.col-3{flex:0 0 auto;width:25%}.col-4{flex:0 0 auto;width:33.33333333%}.col-5{flex:0 0 auto;width:41.66666667%}.col-6{flex:0 0 auto;width:50%}.col-7{flex:0 0 auto;width:58.33333333%}.col-8{flex:0 0 auto;width:66.66666667%}.col-9{flex:0 0 auto;width:75%}.col-10{flex:0 0 auto;width:83.33333333%}.col-11{flex:0 0 auto;width:91.66666667%}.col-12{flex:0 0 auto;width:100%}.offset-1{margin-left:8.33333333%}.offset-2{margin-left:16.66666667%}.offset-3{margin-left:25%}.offset-4{margin-left:33.33333333%}.offset-5{margin-left:41.66666667%}.offset-6{margin-left:50%}.offset-7{margin-left:58.33333333%}.offset-8{margin-left:66.66666667%}.offset-9{margin-left:75%}.offset-10{margin-left:83.33333333%}.offset-11{margin-left:91.66666667%}.g-0,.gx-0{--bs-gutter-x:0}.g-0,.gy-0{--bs-gutter-y:0}.g-1,.gx-1{--bs-gutter-x:0.25rem}.g-1,.gy-1{--bs-gutter-y:0.25rem}.g-2,.gx-2{--bs-gutter-x:0.5rem}.g-2,.gy-2{--bs-gutter-y:0.5rem}.g-3,.gx-3{--bs-gutter-x:1rem}.g-3,.gy-3{--bs-gutter-y:1rem}.g-4,.gx-4{--bs-gutter-x:1.5rem}.g-4,.gy-4{--bs-gutter-y:1.5rem}.g-5,.gx-5{--bs-gutter-x:3rem}.g-5,.gy-5{--bs-gutter-y:3rem}@media (min-width:576px){.col-sm{flex:1 0 0}.row-cols-sm-auto>*{flex:0 0 auto;width:auto}.row-cols-sm-1>*{flex:0 0 auto;width:100%}.row-cols-sm-2>*{flex:0 0 auto;width:50%}.row-cols-sm-3>*{flex:0 0 auto;width:33.33333333%}.row-cols-sm-4>*{flex:0 0 auto;width:25%}.row-cols-sm-5>*{flex:0 0 auto;width:20%}.row-cols-sm-6>*{flex:0 0 auto;width:16.66666667%}.col-sm-auto{flex:0 0 auto;width:auto}.col-sm-1{flex:0 0 auto;width:8.33333333%}.col-sm-2{flex:0 0 auto;width:16.66666667%}.col-sm-3{flex:0 0 auto;width:25%}.col-sm-4{flex:0 0 auto;width:33.33333333%}.col-sm-5{flex:0 0 auto;width:41.66666667%}.col-sm-6{flex:0 0 auto;width:50%}.col-sm-7{flex:0 0 auto;width:58.33333333%}.col-sm-8{flex:0 0 auto;width:66.66666667%}.col-sm-9{flex:0 0 auto;width:75%}.col-sm-10{flex:0 0 auto;width:83.33333333%}.col-sm-11{flex:0 0 auto;width:91.66666667%}.col-sm-12{flex:0 0 auto;width:100%}.offset-sm-0{margin-left:0}.offset-sm-1{margin-left:8.33333333%}.offset-sm-2{margin-left:16.66666667%}.offset-sm-3{margin-left:25%}.offset-sm-4{margin-left:33.33333333%}.offset-sm-5{margin-left:41.66666667%}.offset-sm-6{margin-left:50%}.offset-sm-7{margin-left:58.33333333%}.offset-sm-8{margin-left:66.66666667%}.offset-sm-9{margin-left:75%}.offset-sm-10{margin-left:83.33333333%}.offset-sm-11{margin-left:91.66666667%}.g-sm-0,.gx-sm-0{--bs-gutter-x:0}.g-sm-0,.gy-sm-0{--bs-gutter-y:0}.g-sm-1,.gx-sm-1{--bs-gutter-x:0.25rem}.g-sm-1,.gy-sm-1{--bs-gutter-y:0.25rem}.g-sm-2,.gx-sm-2{--bs-gutter-x:0.5rem}.g-sm-2,.gy-sm-2{--bs-gutter-y:0.5rem}.g-sm-3,.gx-sm-3{--bs-gutter-x:1rem}.g-sm-3,.gy-sm-3{--bs-gutter-y:1rem}.g-sm-4,.gx-sm-4{--bs-gutter-x:1.5rem}.g-sm-4,.gy-sm-4{--bs-gutter-y:1.5rem}.g-sm-5,.gx-sm-5{--bs-gutter-x:3rem}.g-sm-5,.gy-sm-5{--bs-gutter-y:3rem}}@media (min-width:768px){.col-md{flex:1 0 0}.row-cols-md-auto>*{flex:0 0 auto;width:auto}.row-cols-md-1>*{flex:0 0 auto;width:100%}.row-cols-md-2>*{flex:0 0 auto;width:50%}.row-cols-md-3>*{flex:0 0 auto;width:33.33333333%}.row-cols-md-4>*{flex:0 0 auto;width:25%}.row-cols-md-5>*{flex:0 0 auto;width:20%}.row-cols-md-6>*{flex:0 0 auto;width:16.66666667%}.col-md-auto{flex:0 0 auto;width:auto}.col-md-1{flex:0 0 auto;width:8.33333333%}.col-md-2{flex:0 0 auto;width:16.66666667%}.col-md-3{flex:0 0 auto;width:25%}.col-md-4{flex:0 0 auto;width:33.33333333%}.col-md-5{flex:0 0 auto;width:41.66666667%}.col-md-6{flex:0 0 auto;width:50%}.col-md-7{flex:0 0 auto;width:58.33333333%}.col-md-8{flex:0 0 auto;width:66.66666667%}.col-md-9{flex:0 0 auto;width:75%}.col-md-10{flex:0 0 auto;width:83.33333333%}.col-md-11{flex:0 0 auto;width:91.66666667%}.col-md-12{flex:0 0 auto;width:100%}.offset-md-0{margin-left:0}.offset-md-1{margin-left:8.33333333%}.offset-md-2{margin-left:16.66666667%}.offset-md-3{margin-left:25%}.offset-md-4{margin-left:33.33333333%}.offset-md-5{margin-left:41.66666667%}.offset-md-6{margin-left:50%}.offset-md-7{margin-left:58.33333333%}.offset-md-8{margin-left:66.66666667%}.offset-md-9{margin-left:75%}.offset-md-10{margin-left:83.33333333%}.offset-md-11{margin-left:91.66666667%}.g-md-0,.gx-md-0{--bs-gutter-x:0}.g-md-0,.gy-md-0{--bs-gutter-y:0}.g-md-1,.gx-md-1{--bs-gutter-x:0.25rem}.g-md-1,.gy-md-1{--bs-gutter-y:0.25rem}.g-md-2,.gx-md-2{--bs-gutter-x:0.5rem}.g-md-2,.gy-md-2{--bs-gutter-y:0.5rem}.g-md-3,.gx-md-3{--bs-gutter-x:1rem}.g-md-3,.gy-md-3{--bs-gutter-y:1rem}.g-md-4,.gx-md-4{--bs-gutter-x:1.5rem}.g-md-4,.gy-md-4{--bs-gutter-y:1.5rem}.g-md-5,.gx-md-5{--bs-gutter-x:3rem}.g-md-5,.gy-md-5{--bs-gutter-y:3rem}}@media (min-width:992px){.col-lg{flex:1 0 0}.row-cols-lg-auto>*{flex:0 0 auto;width:auto}.row-cols-lg-1>*{flex:0 0 auto;width:100%}.row-cols-lg-2>*{flex:0 0 auto;width:50%}.row-cols-lg-3>*{flex:0 0 auto;width:33.33333333%}.row-cols-lg-4>*{flex:0 0 auto;width:25%}.row-cols-lg-5>*{flex:0 0 auto;width:20%}.row-cols-lg-6>*{flex:0 0 auto;width:16.66666667%}.col-lg-auto{flex:0 0 auto;width:auto}.col-lg-1{flex:0 0 auto;width:8.33333333%}.col-lg-2{flex:0 0 auto;width:16.66666667%}.col-lg-3{flex:0 0 auto;width:25%}.col-lg-4{flex:0 0 auto;width:33.33333333%}.col-lg-5{flex:0 0 auto;width:41.66666667%}.col-lg-6{flex:0 0 auto;width:50%}.col-lg-7{flex:0 0 auto;width:58.33333333%}.col-lg-8{flex:0 0 auto;width:66.66666667%}.col-lg-9{flex:0 0 auto;width:75%}.col-lg-10{flex:0 0 auto;width:83.33333333%}.col-lg-11{flex:0 0 auto;width:91.66666667%}.col-lg-12{flex:0 0 auto;width:100%}.offset-lg-0{margin-left:0}.offset-lg-1{margin-left:8.33333333%}.offset-lg-2{margin-left:16.66666667%}.offset-lg-3{margin-left:25%}.offset-lg-4{margin-left:33.33333333%}.offset-lg-5{margin-left:41.66666667%}.offset-lg-6{margin-left:50%}.offset-lg-7{margin-left:58.33333333%}.offset-lg-8{margin-left:66.66666667%}.offset-lg-9{margin-left:75%}.offset-lg-10{margin-left:83.33333333%}.offset-lg-11{margin-left:91.66666667%}.g-lg-0,.gx-lg-0{--bs-gutter-x:0}.g-lg-0,.gy-lg-0{--bs-gutter-y:0}.g-lg-1,.gx-lg-1{--bs-gutter-x:0.25rem}.g-lg-1,.gy-lg-1{--bs-gutter-y:0.25rem}.g-lg-2,.gx-lg-2{--bs-gutter-x:0.5rem}.g-lg-2,.gy-lg-2{--bs-gutter-y:0.5rem}.g-lg-3,.gx-lg-3{--bs-gutter-x:1rem}.g-lg-3,.gy-lg-3{--bs-gutter-y:1rem}.g-lg-4,.gx-lg-4{--bs-gutter-x:1.5rem}.g-lg-4,.gy-lg-4{--bs-gutter-y:1.5rem}.g-lg-5,.gx-lg-5{--bs-gutter-x:3rem}.g-lg-5,.gy-lg-5{--bs-gutter-y:3rem}}@media (min-width:1200px){.col-xl{flex:1 0 0}.row-cols-xl-auto>*{flex:0 0 auto;width:auto}.row-cols-xl-1>*{flex:0 0 auto;width:100%}.row-cols-xl-2>*{flex:0 0 auto;width:50%}.row-cols-xl-3>*{flex:0 0 auto;width:33.33333333%}.row-cols-xl-4>*{flex:0 0 auto;width:25%}.row-cols-xl-5>*{flex:0 0 auto;width:20%}.row-cols-xl-6>*{flex:0 0 auto;width:16.66666667%}.col-xl-auto{flex:0 0 auto;width:auto}.col-xl-1{flex:0 0 auto;width:8.33333333%}.col-xl-2{flex:0 0 auto;width:16.66666667%}.col-xl-3{flex:0 0 auto;width:25%}.col-xl-4{flex:0 0 auto;width:33.33333333%}.col-xl-5{flex:0 0 auto;width:41.66666667%}.col-xl-6{flex:0 0 auto;width:50%}.col-xl-7{flex:0 0 auto;width:58.33333333%}.col-xl-8{flex:0 0 auto;width:66.66666667%}.col-xl-9{flex:0 0 auto;width:75%}.col-xl-10{flex:0 0 auto;width:83.33333333%}.col-xl-11{flex:0 0 auto;width:91.66666667%}.col-xl-12{flex:0 0 auto;width:100%}.offset-xl-0{margin-left:0}.offset-xl-1{margin-left:8.33333333%}.offset-xl-2{margin-left:16.66666667%}.offset-xl-3{margin-left:25%}.offset-xl-4{margin-left:33.33333333%}.offset-xl-5{margin-left:41.66666667%}.offset-xl-6{margin-left:50%}.offset-xl-7{margin-left:58.33333333%}.offset-xl-8{margin-left:66.66666667%}.offset-xl-9{margin-left:75%}.offset-xl-10{margin-left:83.33333333%}.offset-xl-11{margin-left:91.66666667%}.g-xl-0,.gx-xl-0{--bs-gutter-x:0}.g-xl-0,.gy-xl-0{--bs-gutter-y:0}.g-xl-1,.gx-xl-1{--bs-gutter-x:0.25rem}.g-xl-1,.gy-xl-1{--bs-gutter-y:0.25rem}.g-xl-2,.gx-xl-2{--bs-gutter-x:0.5rem}.g-xl-2,.gy-xl-2{--bs-gutter-y:0.5rem}.g-xl-3,.gx-xl-3{--bs-gutter-x:1rem}.g-xl-3,.gy-xl-3{--bs-gutter-y:1rem}.g-xl-4,.gx-xl-4{--bs-gutter-x:1.5rem}.g-xl-4,.gy-xl-4{--bs-gutter-y:1.5rem}.g-xl-5,.gx-xl-5{--bs-gutter-x:3rem}.g-xl-5,.gy-xl-5{--bs-gutter-y:3rem}}@media (min-width:1400px){.col-xxl{flex:1 0 0}.row-cols-xxl-auto>*{flex:0 0 auto;width:auto}.row-cols-xxl-1>*{flex:0 0 auto;width:100%}.row-cols-xxl-2>*{flex:0 0 auto;width:50%}.row-cols-xxl-3>*{flex:0 0 auto;width:33.33333333%}.row-cols-xxl-4>*{flex:0 0 auto;width:25%}.row-cols-xxl-5>*{flex:0 0 auto;width:20%}.row-cols-xxl-6>*{flex:0 0 auto;width:16.66666667%}.col-xxl-auto{flex:0 0 auto;width:auto}.col-xxl-1{flex:0 0 auto;width:8.33333333%}.col-xxl-2{flex:0 0 auto;width:16.66666667%}.col-xxl-3{flex:0 0 auto;width:25%}.col-xxl-4{flex:0 0 auto;width:33.33333333%}.col-xxl-5{flex:0 0 auto;width:41.66666667%}.col-xxl-6{flex:0 0 auto;width:50%}.col-xxl-7{flex:0 0 auto;width:58.33333333%}.col-xxl-8{flex:0 0 auto;width:66.66666667%}.col-xxl-9{flex:0 0 auto;width:75%}.col-xxl-10{flex:0 0 auto;width:83.33333333%}.col-xxl-11{flex:0 0 auto;width:91.66666667%}.col-xxl-12{flex:0 0 auto;width:100%}.offset-xxl-0{margin-left:0}.offset-xxl-1{margin-left:8.33333333%}.offset-xxl-2{margin-left:16.66666667%}.offset-xxl-3{margin-left:25%}.offset-xxl-4{margin-left:33.33333333%}.offset-xxl-5{margin-left:41.66666667%}.offset-xxl-6{margin-left:50%}.offset-xxl-7{margin-left:58.33333333%}.offset-xxl-8{margin-left:66.66666667%}.offset-xxl-9{margin-left:75%}.offset-xxl-10{margin-left:83.33333333%}.offset-xxl-11{margin-left:91.66666667%}.g-xxl-0,.gx-xxl-0{--bs-gutter-x:0}.g-xxl-0,.gy-xxl-0{--bs-gutter-y:0}.g-xxl-1,.gx-xxl-1{--bs-gutter-x:0.25rem}.g-xxl-1,.gy-xxl-1{--bs-gutter-y:0.25rem}.g-xxl-2,.gx-xxl-2{--bs-gutter-x:0.5rem}.g-xxl-2,.gy-xxl-2{--bs-gutter-y:0.5rem}.g-xxl-3,.gx-xxl-3{--bs-gutter-x:1rem}.g-xxl-3,.gy-xxl-3{--bs-gutter-y:1rem}.g-xxl-4,.gx-xxl-4{--bs-gutter-x:1.5rem}.g-xxl-4,.gy-xxl-4{--bs-gutter-y:1.5rem}.g-xxl-5,.gx-xxl-5{--bs-gutter-x:3rem}.g-xxl-5,.gy-xxl-5{--bs-gutter-y:3rem}}.table{--bs-table-color-type:initial;--bs-table-bg-type:initial;--bs-table-color-state:initial;--bs-table-bg-state:initial;--bs-table-color:var(--bs-emphasis-color);--bs-table-bg:var(--bs-body-bg);--bs-table-border-color:var(--bs-border-color);--bs-table-accent-bg:transparent;--bs-table-striped-color:var(--bs-emphasis-color);--bs-table-striped-bg:rgba(var(--bs-emphasis-color-rgb), 0.05);--bs-table-active-color:var(--bs-emphasis-color);--bs-table-active-bg:rgba(var(--bs-emphasis-color-rgb), 0.1);--bs-table-hover-color:var(--bs-emphasis-color);--bs-table-hover-bg:rgba(var(--bs-emphasis-color-rgb), 0.075);width:100%;margin-bottom:1rem;vertical-align:top;border-color:var(--bs-table-border-color)}.table>:not(caption)>*>*{padding:.5rem .5rem;color:var(--bs-table-color-state,var(--bs-table-color-type,var(--bs-table-color)));background-color:var(--bs-table-bg);border-bottom-width:var(--bs-border-width);box-shadow:inset 0 0 0 9999px var(--bs-table-bg-state,var(--bs-table-bg-type,var(--bs-table-accent-bg)))}.table>tbody{vertical-align:inherit}.table>thead{vertical-align:bottom}.table-group-divider{border-top:calc(var(--bs-border-width) * 2) solid currentcolor}.caption-top{caption-side:top}.table-sm>:not(caption)>*>*{padding:.25rem .25rem}.table-bordered>:not(caption)>*{border-width:var(--bs-border-width) 0}.table-bordered>:not(caption)>*>*{border-width:0 var(--bs-border-width)}.table-borderless>:not(caption)>*>*{border-bottom-width:0}.table-borderless>:not(:first-child){border-top-width:0}.table-striped>tbody>tr:nth-of-type(odd)>*{--bs-table-color-type:var(--bs-table-striped-color);--bs-table-bg-type:var(--bs-table-striped-bg)}.table-striped-columns>:not(caption)>tr>:nth-child(2n){--bs-table-color-type:var(--bs-table-striped-color);--bs-table-bg-type:var(--bs-table-striped-bg)}.table-active{--bs-table-color-state:var(--bs-table-active-color);--bs-table-bg-state:var(--bs-table-active-bg)}.table-hover>tbody>tr:hover>*{--bs-table-color-state:var(--bs-table-hover-color);--bs-table-bg-state:var(--bs-table-hover-bg)}.table-primary{--bs-table-color:#000;--bs-table-bg:#cfe2ff;--bs-table-border-color:#a6b5cc;--bs-table-striped-bg:#c5d7f2;--bs-table-striped-color:#000;--bs-table-active-bg:#bacbe6;--bs-table-active-color:#000;--bs-table-hover-bg:#bfd1ec;--bs-table-hover-color:#000;color:var(--bs-table-color);border-color:var(--bs-table-border-color)}.table-secondary{--bs-table-color:#000;--bs-table-bg:#e2e3e5;--bs-table-border-color:#b5b6b7;--bs-table-striped-bg:#d7d8da;--bs-table-striped-color:#000;--bs-table-active-bg:#cbccce;--bs-table-active-color:#000;--bs-table-hover-bg:#d1d2d4;--bs-table-hover-color:#000;color:var(--bs-table-color);border-color:var(--bs-table-border-color)}.table-success{--bs-table-color:#000;--bs-table-bg:#d1e7dd;--bs-table-border-color:#a7b9b1;--bs-table-striped-bg:#c7dbd2;--bs-table-striped-color:#000;--bs-table-active-bg:#bcd0c7;--bs-table-active-color:#000;--bs-table-hover-bg:#c1d6cc;--bs-table-hover-color:#000;color:var(--bs-table-color);border-color:var(--bs-table-border-color)}.table-info{--bs-table-color:#000;--bs-table-bg:#cff4fc;--bs-table-border-color:#a6c3ca;--bs-table-striped-bg:#c5e8ef;--bs-table-striped-color:#000;--bs-table-active-bg:#badce3;--bs-table-active-color:#000;--bs-table-hover-bg:#bfe2e9;--bs-table-hover-color:#000;color:var(--bs-table-color);border-color:var(--bs-table-border-color)}.table-warning{--bs-table-color:#000;--bs-table-bg:#fff3cd;--bs-table-border-color:#ccc2a4;--bs-table-striped-bg:#f2e7c3;--bs-table-striped-color:#000;--bs-table-active-bg:#e6dbb9;--bs-table-active-color:#000;--bs-table-hover-bg:#ece1be;--bs-table-hover-color:#000;color:var(--bs-table-color);border-color:var(--bs-table-border-color)}.table-danger{--bs-table-color:#000;--bs-table-bg:#f8d7da;--bs-table-border-color:#c6acae;--bs-table-striped-bg:#eccccf;--bs-table-striped-color:#000;--bs-table-active-bg:#dfc2c4;--bs-table-active-color:#000;--bs-table-hover-bg:#e5c7ca;--bs-table-hover-color:#000;color:var(--bs-table-color);border-color:var(--bs-table-border-color)}.table-light{--bs-table-color:#000;--bs-table-bg:#f8f9fa;--bs-table-border-color:#c6c7c8;--bs-table-striped-bg:#ecedee;--bs-table-striped-color:#000;--bs-table-active-bg:#dfe0e1;--bs-table-active-color:#000;--bs-table-hover-bg:#e5e6e7;--bs-table-hover-color:#000;color:var(--bs-table-color);border-color:var(--bs-table-border-color)}.table-dark{--bs-table-color:#fff;--bs-table-bg:#212529;--bs-table-border-color:#4d5154;--bs-table-striped-bg:#2c3034;--bs-table-striped-color:#fff;--bs-table-active-bg:#373b3e;--bs-table-active-color:#fff;--bs-table-hover-bg:#323539;--bs-table-hover-color:#fff;color:var(--bs-table-color);border-color:var(--bs-table-border-color)}.table-responsive{overflow-x:auto;-webkit-overflow-scrolling:touch}@media (max-width:575.98px){.table-responsive-sm{overflow-x:auto;-webkit-overflow-scrolling:touch}}@media (max-width:767.98px){.table-responsive-md{overflow-x:auto;-webkit-overflow-scrolling:touch}}@media (max-width:991.98px){.table-responsive-lg{overflow-x:auto;-webkit-overflow-scrolling:touch}}@media (max-width:1199.98px){.table-responsive-xl{overflow-x:auto;-webkit-overflow-scrolling:touch}}@media (max-width:1399.98px){.table-responsive-xxl{overflow-x:auto;-webkit-overflow-scrolling:touch}}.form-label{margin-bottom:.5rem}.col-form-label{padding-top:calc(.375rem + var(--bs-border-width));padding-bottom:calc(.375rem + var(--bs-border-width));margin-bottom:0;font-size:inherit;line-height:1.5}.col-form-label-lg{padding-top:calc(.5rem + var(--bs-border-width));padding-bottom:calc(.5rem + var(--bs-border-width));font-size:1.25rem}.col-form-label-sm{padding-top:calc(.25rem + var(--bs-border-width));padding-bottom:calc(.25rem + var(--bs-border-width));font-size:.875rem}.form-text{margin-top:.25rem;font-size:.875em;color:var(--bs-secondary-color)}.form-control{display:block;width:100%;padding:.375rem .75rem;font-size:1rem;font-weight:400;line-height:1.5;color:var(--bs-body-color);-webkit-appearance:none;-moz-appearance:none;appearance:none;background-color:var(--bs-body-bg);background-clip:padding-box;border:var(--bs-border-width) solid var(--bs-border-color);border-radius:var(--bs-border-radius);transition:border-color .15s ease-in-out,box-shadow .15s ease-in-out}@media (prefers-reduced-motion:reduce){.form-control{transition:none}}.form-control[type=file]{overflow:hidden}.form-control[type=file]:not(:disabled):not([readonly]){cursor:pointer}.form-control:focus{color:var(--bs-body-color);background-color:var(--bs-body-bg);border-color:#86b7fe;outline:0;box-shadow:0 0 0 .25rem rgba(13,110,253,.25)}.form-control::-webkit-date-and-time-value{min-width:85px;height:1.5em;margin:0}.form-control::-webkit-datetime-edit{display:block;padding:0}.form-control::placeholder{color:var(--bs-secondary-color);opacity:1}.form-control:disabled{background-color:var(--bs-secondary-bg);opacity:1}.form-control::-webkit-file-upload-button{padding:.375rem .75rem;margin:-.375rem -.75rem;-webkit-margin-end:.75rem;margin-inline-end:.75rem;color:var(--bs-body-color);background-color:var(--bs-tertiary-bg);pointer-events:none;border-color:inherit;border-style:solid;border-width:0;border-inline-end-width:var(--bs-border-width);border-radius:0;-webkit-transition:color .15s ease-in-out,background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out;transition:color .15s ease-in-out,background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out}.form-control::file-selector-button{padding:.375rem .75rem;margin:-.375rem -.75rem;-webkit-margin-end:.75rem;margin-inline-end:.75rem;color:var(--bs-body-color);background-color:var(--bs-tertiary-bg);pointer-events:none;border-color:inherit;border-style:solid;border-width:0;border-inline-end-width:var(--bs-border-width);border-radius:0;transition:color .15s ease-in-out,background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out}@media (prefers-reduced-motion:reduce){.form-control::-webkit-file-upload-button{-webkit-transition:none;transition:none}.form-control::file-selector-button{transition:none}}.form-control:hover:not(:disabled):not([readonly])::-webkit-file-upload-button{background-color:var(--bs-secondary-bg)}.form-control:hover:not(:disabled):not([readonly])::file-selector-button{background-color:var(--bs-secondary-bg)}.form-control-plaintext{display:block;width:100%;padding:.375rem 0;margin-bottom:0;line-height:1.5;color:var(--bs-body-color);background-color:transparent;border:solid transparent;border-width:var(--bs-border-width) 0}.form-control-plaintext:focus{outline:0}.form-control-plaintext.form-control-lg,.form-control-plaintext.form-control-sm{padding-right:0;padding-left:0}.form-control-sm{min-height:calc(1.5em + .5rem + calc(var(--bs-border-width) * 2));padding:.25rem .5rem;font-size:.875rem;border-radius:var(--bs-border-radius-sm)}.form-control-sm::-webkit-file-upload-button{padding:.25rem .5rem;margin:-.25rem -.5rem;-webkit-margin-end:.5rem;margin-inline-end:.5rem}.form-control-sm::file-selector-button{padding:.25rem .5rem;margin:-.25rem -.5rem;-webkit-margin-end:.5rem;margin-inline-end:.5rem}.form-control-lg{min-height:calc(1.5em + 1rem + calc(var(--bs-border-width) * 2));padding:.5rem 1rem;font-size:1.25rem;border-radius:var(--bs-border-radius-lg)}.form-control-lg::-webkit-file-upload-button{padding:.5rem 1rem;margin:-.5rem -1rem;-webkit-margin-end:1rem;margin-inline-end:1rem}.form-control-lg::file-selector-button{padding:.5rem 1rem;margin:-.5rem -1rem;-webkit-margin-end:1rem;margin-inline-end:1rem}textarea.form-control{min-height:calc(1.5em + .75rem + calc(var(--bs-border-width) * 2))}textarea.form-control-sm{min-height:calc(1.5em + .5rem + calc(var(--bs-border-width) * 2))}textarea.form-control-lg{min-height:calc(1.5em + 1rem + calc(var(--bs-border-width) * 2))}.form-control-color{width:3rem;height:calc(1.5em + .75rem + calc(var(--bs-border-width) * 2));padding:.375rem}.form-control-color:not(:disabled):not([readonly]){cursor:pointer}.form-control-color::-moz-color-swatch{border:0!important;border-radius:var(--bs-border-radius)}.form-control-color::-webkit-color-swatch{border:0!important;border-radius:var(--bs-border-radius)}.form-control-color.form-control-sm{height:calc(1.5em + .5rem + calc(var(--bs-border-width) * 2))}.form-control-color.form-control-lg{height:calc(1.5em + 1rem + calc(var(--bs-border-width) * 2))}.form-select{--bs-form-select-bg-img:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3e%3cpath fill='none' stroke='%23343a40' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='m2 5 6 6 6-6'/%3e%3c/svg%3e");display:block;width:100%;padding:.375rem 2.25rem .375rem .75rem;font-size:1rem;font-weight:400;line-height:1.5;color:var(--bs-body-color);-webkit-appearance:none;-moz-appearance:none;appearance:none;background-color:var(--bs-body-bg);background-image:var(--bs-form-select-bg-img),var(--bs-form-select-bg-icon,none);background-repeat:no-repeat;background-position:right .75rem center;background-size:16px 12px;border:var(--bs-border-width) solid var(--bs-border-color);border-radius:var(--bs-border-radius);transition:border-color .15s ease-in-out,box-shadow .15s ease-in-out}@media (prefers-reduced-motion:reduce){.form-select{transition:none}}.form-select:focus{border-color:#86b7fe;outline:0;box-shadow:0 0 0 .25rem rgba(13,110,253,.25)}.form-select[multiple],.form-select[size]:not([size="1"]){padding-right:.75rem;background-image:none}.form-select:disabled{background-color:var(--bs-secondary-bg)}.form-select:-moz-focusring{color:transparent;text-shadow:0 0 0 var(--bs-body-color)}.form-select-sm{padding-top:.25rem;padding-bottom:.25rem;padding-left:.5rem;font-size:.875rem;border-radius:var(--bs-border-radius-sm)}.form-select-lg{padding-top:.5rem;padding-bottom:.5rem;padding-left:1rem;font-size:1.25rem;border-radius:var(--bs-border-radius-lg)}[data-bs-theme=dark] .form-select{--bs-form-select-bg-img:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3e%3cpath fill='none' stroke='%23dee2e6' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='m2 5 6 6 6-6'/%3e%3c/svg%3e")}.form-check{display:block;min-height:1.5rem;padding-left:1.5em;margin-bottom:.125rem}.form-check .form-check-input{float:left;margin-left:-1.5em}.form-check-reverse{padding-right:1.5em;padding-left:0;text-align:right}.form-check-reverse .form-check-input{float:right;margin-right:-1.5em;margin-left:0}.form-check-input{--bs-form-check-bg:var(--bs-body-bg);flex-shrink:0;width:1em;height:1em;margin-top:.25em;vertical-align:top;-webkit-appearance:none;-moz-appearance:none;appearance:none;background-color:var(--bs-form-check-bg);background-image:var(--bs-form-check-bg-image);background-repeat:no-repeat;background-position:center;background-size:contain;border:var(--bs-border-width) solid var(--bs-border-color);-webkit-print-color-adjust:exact;color-adjust:exact;print-color-adjust:exact}.form-check-input[type=checkbox]{border-radius:.25em}.form-check-input[type=radio]{border-radius:50%}.form-check-input:active{filter:brightness(90%)}.form-check-input:focus{border-color:#86b7fe;outline:0;box-shadow:0 0 0 .25rem rgba(13,110,253,.25)}.form-check-input:checked{background-color:#0d6efd;border-color:#0d6efd}.form-check-input:checked[type=checkbox]{--bs-form-check-bg-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'%3e%3cpath fill='none' stroke='%23fff' stroke-linecap='round' stroke-linejoin='round' stroke-width='3' d='m6 10 3 3 6-6'/%3e%3c/svg%3e")}.form-check-input:checked[type=radio]{--bs-form-check-bg-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='-4 -4 8 8'%3e%3ccircle r='2' fill='%23fff'/%3e%3c/svg%3e")}.form-check-input[type=checkbox]:indeterminate{background-color:#0d6efd;border-color:#0d6efd;--bs-form-check-bg-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'%3e%3cpath fill='none' stroke='%23fff' stroke-linecap='round' stroke-linejoin='round' stroke-width='3' d='M6 10h8'/%3e%3c/svg%3e")}.form-check-input:disabled{pointer-events:none;filter:none;opacity:.5}.form-check-input:disabled~.form-check-label,.form-check-input[disabled]~.form-check-label{cursor:default;opacity:.5}.form-switch{padding-left:2.5em}.form-switch .form-check-input{--bs-form-switch-bg:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='-4 -4 8 8'%3e%3ccircle r='3' fill='rgba%280, 0, 0, 0.25%29'/%3e%3c/svg%3e");width:2em;margin-left:-2.5em;background-image:var(--bs-form-switch-bg);background-position:left center;border-radius:2em;transition:background-position .15s ease-in-out}@media (prefers-reduced-motion:reduce){.form-switch .form-check-input{transition:none}}.form-switch .form-check-input:focus{--bs-form-switch-bg:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='-4 -4 8 8'%3e%3ccircle r='3' fill='%2386b7fe'/%3e%3c/svg%3e")}.form-switch .form-check-input:checked{background-position:right center;--bs-form-switch-bg:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='-4 -4 8 8'%3e%3ccircle r='3' fill='%23fff'/%3e%3c/svg%3e")}.form-switch.form-check-reverse{padding-right:2.5em;padding-left:0}.form-switch.form-check-reverse .form-check-input{margin-right:-2.5em;margin-left:0}.form-check-inline{display:inline-block;margin-right:1rem}.btn-check{position:absolute;clip:rect(0,0,0,0);pointer-events:none}.btn-check:disabled+.btn,.btn-check[disabled]+.btn{pointer-events:none;filter:none;opacity:.65}[data-bs-theme=dark] .form-switch .form-check-input:not(:checked):not(:focus){--bs-form-switch-bg:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='-4 -4 8 8'%3e%3ccircle r='3' fill='rgba%28255, 255, 255, 0.25%29'/%3e%3c/svg%3e")}.form-range{width:100%;height:1.5rem;padding:0;-webkit-appearance:none;-moz-appearance:none;appearance:none;background-color:transparent}.form-range:focus{outline:0}.form-range:focus::-webkit-slider-thumb{box-shadow:0 0 0 1px #fff,0 0 0 .25rem rgba(13,110,253,.25)}.form-range:focus::-moz-range-thumb{box-shadow:0 0 0 1px #fff,0 0 0 .25rem rgba(13,110,253,.25)}.form-range::-moz-focus-outer{border:0}.form-range::-webkit-slider-thumb{width:1rem;height:1rem;margin-top:-.25rem;-webkit-appearance:none;appearance:none;background-color:#0d6efd;border:0;border-radius:1rem;-webkit-transition:background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out;transition:background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out}@media (prefers-reduced-motion:reduce){.form-range::-webkit-slider-thumb{-webkit-transition:none;transition:none}}.form-range::-webkit-slider-thumb:active{background-color:#b6d4fe}.form-range::-webkit-slider-runnable-track{width:100%;height:.5rem;color:transparent;cursor:pointer;background-color:var(--bs-secondary-bg);border-color:transparent;border-radius:1rem}.form-range::-moz-range-thumb{width:1rem;height:1rem;-moz-appearance:none;appearance:none;background-color:#0d6efd;border:0;border-radius:1rem;-moz-transition:background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out;transition:background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out}@media (prefers-reduced-motion:reduce){.form-range::-moz-range-thumb{-moz-transition:none;transition:none}}.form-range::-moz-range-thumb:active{background-color:#b6d4fe}.form-range::-moz-range-track{width:100%;height:.5rem;color:transparent;cursor:pointer;background-color:var(--bs-secondary-bg);border-color:transparent;border-radius:1rem}.form-range:disabled{pointer-events:none}.form-range:disabled::-webkit-slider-thumb{background-color:var(--bs-secondary-color)}.form-range:disabled::-moz-range-thumb{background-color:var(--bs-secondary-color)}.form-floating{position:relative}.form-floating>.form-control,.form-floating>.form-control-plaintext,.form-floating>.form-select{height:calc(3.5rem + calc(var(--bs-border-width) * 2));min-height:calc(3.5rem + calc(var(--bs-border-width) * 2));line-height:1.25}.form-floating>label{position:absolute;top:0;left:0;z-index:2;max-width:100%;height:100%;padding:1rem .75rem;overflow:hidden;color:rgba(var(--bs-body-color-rgb),.65);text-align:start;text-overflow:ellipsis;white-space:nowrap;pointer-events:none;border:var(--bs-border-width) solid transparent;transform-origin:0 0;transition:opacity .1s ease-in-out,transform .1s ease-in-out}@media (prefers-reduced-motion:reduce){.form-floating>label{transition:none}}.form-floating>.form-control,.form-floating>.form-control-plaintext{padding:1rem .75rem}.form-floating>.form-control-plaintext::placeholder,.form-floating>.form-control::placeholder{color:transparent}.form-floating>.form-control-plaintext:focus,.form-floating>.form-control-plaintext:not(:placeholder-shown),.form-floating>.form-control:focus,.form-floating>.form-control:not(:placeholder-shown){padding-top:1.625rem;padding-bottom:.625rem}.form-floating>.form-control-plaintext:-webkit-autofill,.form-floating>.form-control:-webkit-autofill{padding-top:1.625rem;padding-bottom:.625rem}.form-floating>.form-select{padding-top:1.625rem;padding-bottom:.625rem;padding-left:.75rem}.form-floating>.form-control-plaintext~label,.form-floating>.form-control:focus~label,.form-floating>.form-control:not(:placeholder-shown)~label,.form-floating>.form-select~label{transform:scale(.85) translateY(-.5rem) translateX(.15rem)}.form-floating>.form-control:-webkit-autofill~label{transform:scale(.85) translateY(-.5rem) translateX(.15rem)}.form-floating>textarea:focus~label::after,.form-floating>textarea:not(:placeholder-shown)~label::after{position:absolute;inset:1rem 0.375rem;z-index:-1;height:1.5em;content:"";background-color:var(--bs-body-bg);border-radius:var(--bs-border-radius)}.form-floating>textarea:disabled~label::after{background-color:var(--bs-secondary-bg)}.form-floating>.form-control-plaintext~label{border-width:var(--bs-border-width) 0}.form-floating>.form-control:disabled~label,.form-floating>:disabled~label{color:#6c757d}.input-group{position:relative;display:flex;flex-wrap:wrap;align-items:stretch;width:100%}.input-group>.form-control,.input-group>.form-floating,.input-group>.form-select{position:relative;flex:1 1 auto;width:1%;min-width:0}.input-group>.form-control:focus,.input-group>.form-floating:focus-within,.input-group>.form-select:focus{z-index:5}.input-group .btn{position:relative;z-index:2}.input-group .btn:focus{z-index:5}.input-group-text{display:flex;align-items:center;padding:.375rem .75rem;font-size:1rem;font-weight:400;line-height:1.5;color:var(--bs-body-color);text-align:center;white-space:nowrap;background-color:var(--bs-tertiary-bg);border:var(--bs-border-width) solid var(--bs-border-color);border-radius:var(--bs-border-radius)}.input-group-lg>.btn,.input-group-lg>.form-control,.input-group-lg>.form-select,.input-group-lg>.input-group-text{padding:.5rem 1rem;font-size:1.25rem;border-radius:var(--bs-border-radius-lg)}.input-group-sm>.btn,.input-group-sm>.form-control,.input-group-sm>.form-select,.input-group-sm>.input-group-text{padding:.25rem .5rem;font-size:.875rem;border-radius:var(--bs-border-radius-sm)}.input-group-lg>.form-select,.input-group-sm>.form-select{padding-right:3rem}.input-group:not(.has-validation)>.dropdown-toggle:nth-last-child(n+3),.input-group:not(.has-validation)>.form-floating:not(:last-child)>.form-control,.input-group:not(.has-validation)>.form-floating:not(:last-child)>.form-select,.input-group:not(.has-validation)>:not(:last-child):not(.dropdown-toggle):not(.dropdown-menu):not(.form-floating){border-top-right-radius:0;border-bottom-right-radius:0}.input-group.has-validation>.dropdown-toggle:nth-last-child(n+4),.input-group.has-validation>.form-floating:nth-last-child(n+3)>.form-control,.input-group.has-validation>.form-floating:nth-last-child(n+3)>.form-select,.input-group.has-validation>:nth-last-child(n+3):not(.dropdown-toggle):not(.dropdown-menu):not(.form-floating){border-top-right-radius:0;border-bottom-right-radius:0}.input-group>:not(:first-child):not(.dropdown-menu):not(.valid-tooltip):not(.valid-feedback):not(.invalid-tooltip):not(.invalid-feedback){margin-left:calc(-1 * var(--bs-border-width));border-top-left-radius:0;border-bottom-left-radius:0}.input-group>.form-floating:not(:first-child)>.form-control,.input-group>.form-floating:not(:first-child)>.form-select{border-top-left-radius:0;border-bottom-left-radius:0}.valid-feedback{display:none;width:100%;margin-top:.25rem;font-size:.875em;color:var(--bs-form-valid-color)}.valid-tooltip{position:absolute;top:100%;z-index:5;display:none;max-width:100%;padding:.25rem .5rem;margin-top:.1rem;font-size:.875rem;color:#fff;background-color:var(--bs-success);border-radius:var(--bs-border-radius)}.is-valid~.valid-feedback,.is-valid~.valid-tooltip,.was-validated :valid~.valid-feedback,.was-validated :valid~.valid-tooltip{display:block}.form-control.is-valid,.was-validated .form-control:valid{border-color:var(--bs-form-valid-border-color);padding-right:calc(1.5em + .75rem);background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 8 8'%3e%3cpath fill='%23198754' d='M2.3 6.73.6 4.53c-.4-1.04.46-1.4 1.1-.8l1.1 1.4 3.4-3.8c.6-.63 1.6-.27 1.2.7l-4 4.6c-.43.5-.8.4-1.1.1'/%3e%3c/svg%3e");background-repeat:no-repeat;background-position:right calc(.375em + .1875rem) center;background-size:calc(.75em + .375rem) calc(.75em + .375rem)}.form-control.is-valid:focus,.was-validated .form-control:valid:focus{border-color:var(--bs-form-valid-border-color);box-shadow:0 0 0 .25rem rgba(var(--bs-success-rgb),.25)}.was-validated textarea.form-control:valid,textarea.form-control.is-valid{padding-right:calc(1.5em + .75rem);background-position:top calc(.375em + .1875rem) right calc(.375em + .1875rem)}.form-select.is-valid,.was-validated .form-select:valid{border-color:var(--bs-form-valid-border-color)}.form-select.is-valid:not([multiple]):not([size]),.form-select.is-valid:not([multiple])[size="1"],.was-validated .form-select:valid:not([multiple]):not([size]),.was-validated .form-select:valid:not([multiple])[size="1"]{--bs-form-select-bg-icon:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 8 8'%3e%3cpath fill='%23198754' d='M2.3 6.73.6 4.53c-.4-1.04.46-1.4 1.1-.8l1.1 1.4 3.4-3.8c.6-.63 1.6-.27 1.2.7l-4 4.6c-.43.5-.8.4-1.1.1'/%3e%3c/svg%3e");padding-right:4.125rem;background-position:right .75rem center,center right 2.25rem;background-size:16px 12px,calc(.75em + .375rem) calc(.75em + .375rem)}.form-select.is-valid:focus,.was-validated .form-select:valid:focus{border-color:var(--bs-form-valid-border-color);box-shadow:0 0 0 .25rem rgba(var(--bs-success-rgb),.25)}.form-control-color.is-valid,.was-validated .form-control-color:valid{width:calc(3rem + calc(1.5em + .75rem))}.form-check-input.is-valid,.was-validated .form-check-input:valid{border-color:var(--bs-form-valid-border-color)}.form-check-input.is-valid:checked,.was-validated .form-check-input:valid:checked{background-color:var(--bs-form-valid-color)}.form-check-input.is-valid:focus,.was-validated .form-check-input:valid:focus{box-shadow:0 0 0 .25rem rgba(var(--bs-success-rgb),.25)}.form-check-input.is-valid~.form-check-label,.was-validated .form-check-input:valid~.form-check-label{color:var(--bs-form-valid-color)}.form-check-inline .form-check-input~.valid-feedback{margin-left:.5em}.input-group>.form-control:not(:focus).is-valid,.input-group>.form-floating:not(:focus-within).is-valid,.input-group>.form-select:not(:focus).is-valid,.was-validated .input-group>.form-control:not(:focus):valid,.was-validated .input-group>.form-floating:not(:focus-within):valid,.was-validated .input-group>.form-select:not(:focus):valid{z-index:3}.invalid-feedback{display:none;width:100%;margin-top:.25rem;font-size:.875em;color:var(--bs-form-invalid-color)}.invalid-tooltip{position:absolute;top:100%;z-index:5;display:none;max-width:100%;padding:.25rem .5rem;margin-top:.1rem;font-size:.875rem;color:#fff;background-color:var(--bs-danger);border-radius:var(--bs-border-radius)}.is-invalid~.invalid-feedback,.is-invalid~.invalid-tooltip,.was-validated :invalid~.invalid-feedback,.was-validated :invalid~.invalid-tooltip{display:block}.form-control.is-invalid,.was-validated .form-control:invalid{border-color:var(--bs-form-invalid-border-color);padding-right:calc(1.5em + .75rem);background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 12' width='12' height='12' fill='none' stroke='%23dc3545'%3e%3ccircle cx='6' cy='6' r='4.5'/%3e%3cpath stroke-linejoin='round' d='M5.8 3.6h.4L6 6.5z'/%3e%3ccircle cx='6' cy='8.2' r='.6' fill='%23dc3545' stroke='none'/%3e%3c/svg%3e");background-repeat:no-repeat;background-position:right calc(.375em + .1875rem) center;background-size:calc(.75em + .375rem) calc(.75em + .375rem)}.form-control.is-invalid:focus,.was-validated .form-control:invalid:focus{border-color:var(--bs-form-invalid-border-color);box-shadow:0 0 0 .25rem rgba(var(--bs-danger-rgb),.25)}.was-validated textarea.form-control:invalid,textarea.form-control.is-invalid{padding-right:calc(1.5em + .75rem);background-position:top calc(.375em + .1875rem) right calc(.375em + .1875rem)}.form-select.is-invalid,.was-validated .form-select:invalid{border-color:var(--bs-form-invalid-border-color)}.form-select.is-invalid:not([multiple]):not([size]),.form-select.is-invalid:not([multiple])[size="1"],.was-validated .form-select:invalid:not([multiple]):not([size]),.was-validated .form-select:invalid:not([multiple])[size="1"]{--bs-form-select-bg-icon:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 12' width='12' height='12' fill='none' stroke='%23dc3545'%3e%3ccircle cx='6' cy='6' r='4.5'/%3e%3cpath stroke-linejoin='round' d='M5.8 3.6h.4L6 6.5z'/%3e%3ccircle cx='6' cy='8.2' r='.6' fill='%23dc3545' stroke='none'/%3e%3c/svg%3e");padding-right:4.125rem;background-position:right .75rem center,center right 2.25rem;background-size:16px 12px,calc(.75em + .375rem) calc(.75em + .375rem)}.form-select.is-invalid:focus,.was-validated .form-select:invalid:focus{border-color:var(--bs-form-invalid-border-color);box-shadow:0 0 0 .25rem rgba(var(--bs-danger-rgb),.25)}.form-control-color.is-invalid,.was-validated .form-control-color:invalid{width:calc(3rem + calc(1.5em + .75rem))}.form-check-input.is-invalid,.was-validated .form-check-input:invalid{border-color:var(--bs-form-invalid-border-color)}.form-check-input.is-invalid:checked,.was-validated .form-check-input:invalid:checked{background-color:var(--bs-form-invalid-color)}.form-check-input.is-invalid:focus,.was-validated .form-check-input:invalid:focus{box-shadow:0 0 0 .25rem rgba(var(--bs-danger-rgb),.25)}.form-check-input.is-invalid~.form-check-label,.was-validated .form-check-input:invalid~.form-check-label{color:var(--bs-form-invalid-color)}.form-check-inline .form-check-input~.invalid-feedback{margin-left:.5em}.input-group>.form-control:not(:focus).is-invalid,.input-group>.form-floating:not(:focus-within).is-invalid,.input-group>.form-select:not(:focus).is-invalid,.was-validated .input-group>.form-control:not(:focus):invalid,.was-validated .input-group>.form-floating:not(:focus-within):invalid,.was-validated .input-group>.form-select:not(:focus):invalid{z-index:4}.btn{--bs-btn-padding-x:0.75rem;--bs-btn-padding-y:0.375rem;--bs-btn-font-family: ;--bs-btn-font-size:1rem;--bs-btn-font-weight:400;--bs-btn-line-height:1.5;--bs-btn-color:var(--bs-body-color);--bs-btn-bg:transparent;--bs-btn-border-width:var(--bs-border-width);--bs-btn-border-color:transparent;--bs-btn-border-radius:var(--bs-border-radius);--bs-btn-hover-border-color:transparent;--bs-btn-box-shadow:inset 0 1px 0 rgba(255, 255, 255, 0.15),0 1px 1px rgba(0, 0, 0, 0.075);--bs-btn-disabled-opacity:0.65;--bs-btn-focus-box-shadow:0 0 0 0.25rem rgba(var(--bs-btn-focus-shadow-rgb), .5);display:inline-block;padding:var(--bs-btn-padding-y) var(--bs-btn-padding-x);font-family:var(--bs-btn-font-family);font-size:var(--bs-btn-font-size);font-weight:var(--bs-btn-font-weight);line-height:var(--bs-btn-line-height);color:var(--bs-btn-color);text-align:center;text-decoration:none;vertical-align:middle;cursor:pointer;-webkit-user-select:none;-moz-user-select:none;user-select:none;border:var(--bs-btn-border-width) solid var(--bs-btn-border-color);border-radius:var(--bs-btn-border-radius);background-color:var(--bs-btn-bg);transition:color .15s ease-in-out,background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out}@media (prefers-reduced-motion:reduce){.btn{transition:none}}.btn:hover{color:var(--bs-btn-hover-color);background-color:var(--bs-btn-hover-bg);border-color:var(--bs-btn-hover-border-color)}.btn-check+.btn:hover{color:var(--bs-btn-color);background-color:var(--bs-btn-bg);border-color:var(--bs-btn-border-color)}.btn:focus-visible{color:var(--bs-btn-hover-color);background-color:var(--bs-btn-hover-bg);border-color:var(--bs-btn-hover-border-color);outline:0;box-shadow:var(--bs-btn-focus-box-shadow)}.btn-check:focus-visible+.btn{border-color:var(--bs-btn-hover-border-color);outline:0;box-shadow:var(--bs-btn-focus-box-shadow)}.btn-check:checked+.btn,.btn.active,.btn.show,.btn:first-child:active,:not(.btn-check)+.btn:active{color:var(--bs-btn-active-color);background-color:var(--bs-btn-active-bg);border-color:var(--bs-btn-active-border-color)}.btn-check:checked+.btn:focus-visible,.btn.active:focus-visible,.btn.show:focus-visible,.btn:first-child:active:focus-visible,:not(.btn-check)+.btn:active:focus-visible{box-shadow:var(--bs-btn-focus-box-shadow)}.btn-check:checked:focus-visible+.btn{box-shadow:var(--bs-btn-focus-box-shadow)}.btn.disabled,.btn:disabled,fieldset:disabled .btn{color:var(--bs-btn-disabled-color);pointer-events:none;background-color:var(--bs-btn-disabled-bg);border-color:var(--bs-btn-disabled-border-color);opacity:var(--bs-btn-disabled-opacity)}.btn-primary{--bs-btn-color:#fff;--bs-btn-bg:#0d6efd;--bs-btn-border-color:#0d6efd;--bs-btn-hover-color:#fff;--bs-btn-hover-bg:#0b5ed7;--bs-btn-hover-border-color:#0a58ca;--bs-btn-focus-shadow-rgb:49,132,253;--bs-btn-active-color:#fff;--bs-btn-active-bg:#0a58ca;--bs-btn-active-border-color:#0a53be;--bs-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color:#fff;--bs-btn-disabled-bg:#0d6efd;--bs-btn-disabled-border-color:#0d6efd}.btn-secondary{--bs-btn-color:#fff;--bs-btn-bg:#6c757d;--bs-btn-border-color:#6c757d;--bs-btn-hover-color:#fff;--bs-btn-hover-bg:#5c636a;--bs-btn-hover-border-color:#565e64;--bs-btn-focus-shadow-rgb:130,138,145;--bs-btn-active-color:#fff;--bs-btn-active-bg:#565e64;--bs-btn-active-border-color:#51585e;--bs-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color:#fff;--bs-btn-disabled-bg:#6c757d;--bs-btn-disabled-border-color:#6c757d}.btn-success{--bs-btn-color:#fff;--bs-btn-bg:#198754;--bs-btn-border-color:#198754;--bs-btn-hover-color:#fff;--bs-btn-hover-bg:#157347;--bs-btn-hover-border-color:#146c43;--bs-btn-focus-shadow-rgb:60,153,110;--bs-btn-active-color:#fff;--bs-btn-active-bg:#146c43;--bs-btn-active-border-color:#13653f;--bs-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color:#fff;--bs-btn-disabled-bg:#198754;--bs-btn-disabled-border-color:#198754}.btn-info{--bs-btn-color:#000;--bs-btn-bg:#0dcaf0;--bs-btn-border-color:#0dcaf0;--bs-btn-hover-color:#000;--bs-btn-hover-bg:#31d2f2;--bs-btn-hover-border-color:#25cff2;--bs-btn-focus-shadow-rgb:11,172,204;--bs-btn-active-color:#000;--bs-btn-active-bg:#3dd5f3;--bs-btn-active-border-color:#25cff2;--bs-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color:#000;--bs-btn-disabled-bg:#0dcaf0;--bs-btn-disabled-border-color:#0dcaf0}.btn-warning{--bs-btn-color:#000;--bs-btn-bg:#ffc107;--bs-btn-border-color:#ffc107;--bs-btn-hover-color:#000;--bs-btn-hover-bg:#ffca2c;--bs-btn-hover-border-color:#ffc720;--bs-btn-focus-shadow-rgb:217,164,6;--bs-btn-active-color:#000;--bs-btn-active-bg:#ffcd39;--bs-btn-active-border-color:#ffc720;--bs-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color:#000;--bs-btn-disabled-bg:#ffc107;--bs-btn-disabled-border-color:#ffc107}.btn-danger{--bs-btn-color:#fff;--bs-btn-bg:#dc3545;--bs-btn-border-color:#dc3545;--bs-btn-hover-color:#fff;--bs-btn-hover-bg:#bb2d3b;--bs-btn-hover-border-color:#b02a37;--bs-btn-focus-shadow-rgb:225,83,97;--bs-btn-active-color:#fff;--bs-btn-active-bg:#b02a37;--bs-btn-active-border-color:#a52834;--bs-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color:#fff;--bs-btn-disabled-bg:#dc3545;--bs-btn-disabled-border-color:#dc3545}.btn-light{--bs-btn-color:#000;--bs-btn-bg:#f8f9fa;--bs-btn-border-color:#f8f9fa;--bs-btn-hover-color:#000;--bs-btn-hover-bg:#d3d4d5;--bs-btn-hover-border-color:#c6c7c8;--bs-btn-focus-shadow-rgb:211,212,213;--bs-btn-active-color:#000;--bs-btn-active-bg:#c6c7c8;--bs-btn-active-border-color:#babbbc;--bs-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color:#000;--bs-btn-disabled-bg:#f8f9fa;--bs-btn-disabled-border-color:#f8f9fa}.btn-dark{--bs-btn-color:#fff;--bs-btn-bg:#212529;--bs-btn-border-color:#212529;--bs-btn-hover-color:#fff;--bs-btn-hover-bg:#424649;--bs-btn-hover-border-color:#373b3e;--bs-btn-focus-shadow-rgb:66,70,73;--bs-btn-active-color:#fff;--bs-btn-active-bg:#4d5154;--bs-btn-active-border-color:#373b3e;--bs-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color:#fff;--bs-btn-disabled-bg:#212529;--bs-btn-disabled-border-color:#212529}.btn-outline-primary{--bs-btn-color:#0d6efd;--bs-btn-border-color:#0d6efd;--bs-btn-hover-color:#fff;--bs-btn-hover-bg:#0d6efd;--bs-btn-hover-border-color:#0d6efd;--bs-btn-focus-shadow-rgb:13,110,253;--bs-btn-active-color:#fff;--bs-btn-active-bg:#0d6efd;--bs-btn-active-border-color:#0d6efd;--bs-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color:#0d6efd;--bs-btn-disabled-bg:transparent;--bs-btn-disabled-border-color:#0d6efd;--bs-gradient:none}.btn-outline-secondary{--bs-btn-color:#6c757d;--bs-btn-border-color:#6c757d;--bs-btn-hover-color:#fff;--bs-btn-hover-bg:#6c757d;--bs-btn-hover-border-color:#6c757d;--bs-btn-focus-shadow-rgb:108,117,125;--bs-btn-active-color:#fff;--bs-btn-active-bg:#6c757d;--bs-btn-active-border-color:#6c757d;--bs-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color:#6c757d;--bs-btn-disabled-bg:transparent;--bs-btn-disabled-border-color:#6c757d;--bs-gradient:none}.btn-outline-success{--bs-btn-color:#198754;--bs-btn-border-color:#198754;--bs-btn-hover-color:#fff;--bs-btn-hover-bg:#198754;--bs-btn-hover-border-color:#198754;--bs-btn-focus-shadow-rgb:25,135,84;--bs-btn-active-color:#fff;--bs-btn-active-bg:#198754;--bs-btn-active-border-color:#198754;--bs-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color:#198754;--bs-btn-disabled-bg:transparent;--bs-btn-disabled-border-color:#198754;--bs-gradient:none}.btn-outline-info{--bs-btn-color:#0dcaf0;--bs-btn-border-color:#0dcaf0;--bs-btn-hover-color:#000;--bs-btn-hover-bg:#0dcaf0;--bs-btn-hover-border-color:#0dcaf0;--bs-btn-focus-shadow-rgb:13,202,240;--bs-btn-active-color:#000;--bs-btn-active-bg:#0dcaf0;--bs-btn-active-border-color:#0dcaf0;--bs-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color:#0dcaf0;--bs-btn-disabled-bg:transparent;--bs-btn-disabled-border-color:#0dcaf0;--bs-gradient:none}.btn-outline-warning{--bs-btn-color:#ffc107;--bs-btn-border-color:#ffc107;--bs-btn-hover-color:#000;--bs-btn-hover-bg:#ffc107;--bs-btn-hover-border-color:#ffc107;--bs-btn-focus-shadow-rgb:255,193,7;--bs-btn-active-color:#000;--bs-btn-active-bg:#ffc107;--bs-btn-active-border-color:#ffc107;--bs-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color:#ffc107;--bs-btn-disabled-bg:transparent;--bs-btn-disabled-border-color:#ffc107;--bs-gradient:none}.btn-outline-danger{--bs-btn-color:#dc3545;--bs-btn-border-color:#dc3545;--bs-btn-hover-color:#fff;--bs-btn-hover-bg:#dc3545;--bs-btn-hover-border-color:#dc3545;--bs-btn-focus-shadow-rgb:220,53,69;--bs-btn-active-color:#fff;--bs-btn-active-bg:#dc3545;--bs-btn-active-border-color:#dc3545;--bs-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color:#dc3545;--bs-btn-disabled-bg:transparent;--bs-btn-disabled-border-color:#dc3545;--bs-gradient:none}.btn-outline-light{--bs-btn-color:#f8f9fa;--bs-btn-border-color:#f8f9fa;--bs-btn-hover-color:#000;--bs-btn-hover-bg:#f8f9fa;--bs-btn-hover-border-color:#f8f9fa;--bs-btn-focus-shadow-rgb:248,249,250;--bs-btn-active-color:#000;--bs-btn-active-bg:#f8f9fa;--bs-btn-active-border-color:#f8f9fa;--bs-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color:#f8f9fa;--bs-btn-disabled-bg:transparent;--bs-btn-disabled-border-color:#f8f9fa;--bs-gradient:none}.btn-outline-dark{--bs-btn-color:#212529;--bs-btn-border-color:#212529;--bs-btn-hover-color:#fff;--bs-btn-hover-bg:#212529;--bs-btn-hover-border-color:#212529;--bs-btn-focus-shadow-rgb:33,37,41;--bs-btn-active-color:#fff;--bs-btn-active-bg:#212529;--bs-btn-active-border-color:#212529;--bs-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color:#212529;--bs-btn-disabled-bg:transparent;--bs-btn-disabled-border-color:#212529;--bs-gradient:none}.btn-link{--bs-btn-font-weight:400;--bs-btn-color:var(--bs-link-color);--bs-btn-bg:transparent;--bs-btn-border-color:transparent;--bs-btn-hover-color:var(--bs-link-hover-color);--bs-btn-hover-border-color:transparent;--bs-btn-active-color:var(--bs-link-hover-color);--bs-btn-active-border-color:transparent;--bs-btn-disabled-color:#6c757d;--bs-btn-disabled-border-color:transparent;--bs-btn-box-shadow:0 0 0 #000;--bs-btn-focus-shadow-rgb:49,132,253;text-decoration:underline}.btn-link:focus-visible{color:var(--bs-btn-color)}.btn-link:hover{color:var(--bs-btn-hover-color)}.btn-group-lg>.btn,.btn-lg{--bs-btn-padding-y:0.5rem;--bs-btn-padding-x:1rem;--bs-btn-font-size:1.25rem;--bs-btn-border-radius:var(--bs-border-radius-lg)}.btn-group-sm>.btn,.btn-sm{--bs-btn-padding-y:0.25rem;--bs-btn-padding-x:0.5rem;--bs-btn-font-size:0.875rem;--bs-btn-border-radius:var(--bs-border-radius-sm)}.fade{transition:opacity .15s linear}@media (prefers-reduced-motion:reduce){.fade{transition:none}}.fade:not(.show){opacity:0}.collapse:not(.show){display:none}.collapsing{height:0;overflow:hidden;transition:height .35s ease}@media (prefers-reduced-motion:reduce){.collapsing{transition:none}}.collapsing.collapse-horizontal{width:0;height:auto;transition:width .35s ease}@media (prefers-reduced-motion:reduce){.collapsing.collapse-horizontal{transition:none}}.dropdown,.dropdown-center,.dropend,.dropstart,.dropup,.dropup-center{position:relative}.dropdown-toggle{white-space:nowrap}.dropdown-toggle::after{display:inline-block;margin-left:.255em;vertical-align:.255em;content:"";border-top:.3em solid;border-right:.3em solid transparent;border-bottom:0;border-left:.3em solid transparent}.dropdown-toggle:empty::after{margin-left:0}.dropdown-menu{--bs-dropdown-zindex:1000;--bs-dropdown-min-width:10rem;--bs-dropdown-padding-x:0;--bs-dropdown-padding-y:0.5rem;--bs-dropdown-spacer:0.125rem;--bs-dropdown-font-size:1rem;--bs-dropdown-color:var(--bs-body-color);--bs-dropdown-bg:var(--bs-body-bg);--bs-dropdown-border-color:var(--bs-border-color-translucent);--bs-dropdown-border-radius:var(--bs-border-radius);--bs-dropdown-border-width:var(--bs-border-width);--bs-dropdown-inner-border-radius:calc(var(--bs-border-radius) - var(--bs-border-width));--bs-dropdown-divider-bg:var(--bs-border-color-translucent);--bs-dropdown-divider-margin-y:0.5rem;--bs-dropdown-box-shadow:var(--bs-box-shadow);--bs-dropdown-link-color:var(--bs-body-color);--bs-dropdown-link-hover-color:var(--bs-body-color);--bs-dropdown-link-hover-bg:var(--bs-tertiary-bg);--bs-dropdown-link-active-color:#fff;--bs-dropdown-link-active-bg:#0d6efd;--bs-dropdown-link-disabled-color:var(--bs-tertiary-color);--bs-dropdown-item-padding-x:1rem;--bs-dropdown-item-padding-y:0.25rem;--bs-dropdown-header-color:#6c757d;--bs-dropdown-header-padding-x:1rem;--bs-dropdown-header-padding-y:0.5rem;position:absolute;z-index:var(--bs-dropdown-zindex);display:none;min-width:var(--bs-dropdown-min-width);padding:var(--bs-dropdown-padding-y) var(--bs-dropdown-padding-x);margin:0;font-size:var(--bs-dropdown-font-size);color:var(--bs-dropdown-color);text-align:left;list-style:none;background-color:var(--bs-dropdown-bg);background-clip:padding-box;border:var(--bs-dropdown-border-width) solid var(--bs-dropdown-border-color);border-radius:var(--bs-dropdown-border-radius)}.dropdown-menu[data-bs-popper]{top:100%;left:0;margin-top:var(--bs-dropdown-spacer)}.dropdown-menu-start{--bs-position:start}.dropdown-menu-start[data-bs-popper]{right:auto;left:0}.dropdown-menu-end{--bs-position:end}.dropdown-menu-end[data-bs-popper]{right:0;left:auto}@media (min-width:576px){.dropdown-menu-sm-start{--bs-position:start}.dropdown-menu-sm-start[data-bs-popper]{right:auto;left:0}.dropdown-menu-sm-end{--bs-position:end}.dropdown-menu-sm-end[data-bs-popper]{right:0;left:auto}}@media (min-width:768px){.dropdown-menu-md-start{--bs-position:start}.dropdown-menu-md-start[data-bs-popper]{right:auto;left:0}.dropdown-menu-md-end{--bs-position:end}.dropdown-menu-md-end[data-bs-popper]{right:0;left:auto}}@media (min-width:992px){.dropdown-menu-lg-start{--bs-position:start}.dropdown-menu-lg-start[data-bs-popper]{right:auto;left:0}.dropdown-menu-lg-end{--bs-position:end}.dropdown-menu-lg-end[data-bs-popper]{right:0;left:auto}}@media (min-width:1200px){.dropdown-menu-xl-start{--bs-position:start}.dropdown-menu-xl-start[data-bs-popper]{right:auto;left:0}.dropdown-menu-xl-end{--bs-position:end}.dropdown-menu-xl-end[data-bs-popper]{right:0;left:auto}}@media (min-width:1400px){.dropdown-menu-xxl-start{--bs-position:start}.dropdown-menu-xxl-start[data-bs-popper]{right:auto;left:0}.dropdown-menu-xxl-end{--bs-position:end}.dropdown-menu-xxl-end[data-bs-popper]{right:0;left:auto}}.dropup .dropdown-menu[data-bs-popper]{top:auto;bottom:100%;margin-top:0;margin-bottom:var(--bs-dropdown-spacer)}.dropup .dropdown-toggle::after{display:inline-block;margin-left:.255em;vertical-align:.255em;content:"";border-top:0;border-right:.3em solid transparent;border-bottom:.3em solid;border-left:.3em solid transparent}.dropup .dropdown-toggle:empty::after{margin-left:0}.dropend .dropdown-menu[data-bs-popper]{top:0;right:auto;left:100%;margin-top:0;margin-left:var(--bs-dropdown-spacer)}.dropend .dropdown-toggle::after{display:inline-block;margin-left:.255em;vertical-align:.255em;content:"";border-top:.3em solid transparent;border-right:0;border-bottom:.3em solid transparent;border-left:.3em solid}.dropend .dropdown-toggle:empty::after{margin-left:0}.dropend .dropdown-toggle::after{vertical-align:0}.dropstart .dropdown-menu[data-bs-popper]{top:0;right:100%;left:auto;margin-top:0;margin-right:var(--bs-dropdown-spacer)}.dropstart .dropdown-toggle::after{display:inline-block;margin-left:.255em;vertical-align:.255em;content:""}.dropstart .dropdown-toggle::after{display:none}.dropstart .dropdown-toggle::before{display:inline-block;margin-right:.255em;vertical-align:.255em;content:"";border-top:.3em solid transparent;border-right:.3em solid;border-bottom:.3em solid transparent}.dropstart .dropdown-toggle:empty::after{margin-left:0}.dropstart .dropdown-toggle::before{vertical-align:0}.dropdown-divider{height:0;margin:var(--bs-dropdown-divider-margin-y) 0;overflow:hidden;border-top:1px solid var(--bs-dropdown-divider-bg);opacity:1}.dropdown-item{display:block;width:100%;padding:var(--bs-dropdown-item-padding-y) var(--bs-dropdown-item-padding-x);clear:both;font-weight:400;color:var(--bs-dropdown-link-color);text-align:inherit;text-decoration:none;white-space:nowrap;background-color:transparent;border:0;border-radius:var(--bs-dropdown-item-border-radius,0)}.dropdown-item:focus,.dropdown-item:hover{color:var(--bs-dropdown-link-hover-color);background-color:var(--bs-dropdown-link-hover-bg)}.dropdown-item.active,.dropdown-item:active{color:var(--bs-dropdown-link-active-color);text-decoration:none;background-color:var(--bs-dropdown-link-active-bg)}.dropdown-item.disabled,.dropdown-item:disabled{color:var(--bs-dropdown-link-disabled-color);pointer-events:none;background-color:transparent}.dropdown-menu.show{display:block}.dropdown-header{display:block;padding:var(--bs-dropdown-header-padding-y) var(--bs-dropdown-header-padding-x);margin-bottom:0;font-size:.875rem;color:var(--bs-dropdown-header-color);white-space:nowrap}.dropdown-item-text{display:block;padding:var(--bs-dropdown-item-padding-y) var(--bs-dropdown-item-padding-x);color:var(--bs-dropdown-link-color)}.dropdown-menu-dark{--bs-dropdown-color:#dee2e6;--bs-dropdown-bg:#343a40;--bs-dropdown-border-color:var(--bs-border-color-translucent);--bs-dropdown-box-shadow: ;--bs-dropdown-link-color:#dee2e6;--bs-dropdown-link-hover-color:#fff;--bs-dropdown-divider-bg:var(--bs-border-color-translucent);--bs-dropdown-link-hover-bg:rgba(255, 255, 255, 0.15);--bs-dropdown-link-active-color:#fff;--bs-dropdown-link-active-bg:#0d6efd;--bs-dropdown-link-disabled-color:#adb5bd;--bs-dropdown-header-color:#adb5bd}.btn-group,.btn-group-vertical{position:relative;display:inline-flex;vertical-align:middle}.btn-group-vertical>.btn,.btn-group>.btn{position:relative;flex:1 1 auto}.btn-group-vertical>.btn-check:checked+.btn,.btn-group-vertical>.btn-check:focus+.btn,.btn-group-vertical>.btn.active,.btn-group-vertical>.btn:active,.btn-group-vertical>.btn:focus,.btn-group-vertical>.btn:hover,.btn-group>.btn-check:checked+.btn,.btn-group>.btn-check:focus+.btn,.btn-group>.btn.active,.btn-group>.btn:active,.btn-group>.btn:focus,.btn-group>.btn:hover{z-index:1}.btn-toolbar{display:flex;flex-wrap:wrap;justify-content:flex-start}.btn-toolbar .input-group{width:auto}.btn-group{border-radius:var(--bs-border-radius)}.btn-group>.btn-group:not(:first-child),.btn-group>:not(.btn-check:first-child)+.btn{margin-left:calc(-1 * var(--bs-border-width))}.btn-group>.btn-group:not(:last-child)>.btn,.btn-group>.btn.dropdown-toggle-split:first-child,.btn-group>.btn:not(:last-child):not(.dropdown-toggle){border-top-right-radius:0;border-bottom-right-radius:0}.btn-group>.btn-group:not(:first-child)>.btn,.btn-group>.btn:nth-child(n+3),.btn-group>:not(.btn-check)+.btn{border-top-left-radius:0;border-bottom-left-radius:0}.dropdown-toggle-split{padding-right:.5625rem;padding-left:.5625rem}.dropdown-toggle-split::after,.dropend .dropdown-toggle-split::after,.dropup .dropdown-toggle-split::after{margin-left:0}.dropstart .dropdown-toggle-split::before{margin-right:0}.btn-group-sm>.btn+.dropdown-toggle-split,.btn-sm+.dropdown-toggle-split{padding-right:.375rem;padding-left:.375rem}.btn-group-lg>.btn+.dropdown-toggle-split,.btn-lg+.dropdown-toggle-split{padding-right:.75rem;padding-left:.75rem}.btn-group-vertical{flex-direction:column;align-items:flex-start;justify-content:center}.btn-group-vertical>.btn,.btn-group-vertical>.btn-group{width:100%}.btn-group-vertical>.btn-group:not(:first-child),.btn-group-vertical>.btn:not(:first-child){margin-top:calc(-1 * var(--bs-border-width))}.btn-group-vertical>.btn-group:not(:last-child)>.btn,.btn-group-vertical>.btn:not(:last-child):not(.dropdown-toggle){border-bottom-right-radius:0;border-bottom-left-radius:0}.btn-group-vertical>.btn-group:not(:first-child)>.btn,.btn-group-vertical>.btn:nth-child(n+3),.btn-group-vertical>:not(.btn-check)+.btn{border-top-left-radius:0;border-top-right-radius:0}.nav{--bs-nav-link-padding-x:1rem;--bs-nav-link-padding-y:0.5rem;--bs-nav-link-font-weight: ;--bs-nav-link-color:var(--bs-link-color);--bs-nav-link-hover-color:var(--bs-link-hover-color);--bs-nav-link-disabled-color:var(--bs-secondary-color);display:flex;flex-wrap:wrap;padding-left:0;margin-bottom:0;list-style:none}.nav-link{display:block;padding:var(--bs-nav-link-padding-y) var(--bs-nav-link-padding-x);font-size:var(--bs-nav-link-font-size);font-weight:var(--bs-nav-link-font-weight);color:var(--bs-nav-link-color);text-decoration:none;background:0 0;border:0;transition:color .15s ease-in-out,background-color .15s ease-in-out,border-color .15s ease-in-out}@media (prefers-reduced-motion:reduce){.nav-link{transition:none}}.nav-link:focus,.nav-link:hover{color:var(--bs-nav-link-hover-color)}.nav-link:focus-visible{outline:0;box-shadow:0 0 0 .25rem rgba(13,110,253,.25)}.nav-link.disabled,.nav-link:disabled{color:var(--bs-nav-link-disabled-color);pointer-events:none;cursor:default}.nav-tabs{--bs-nav-tabs-border-width:var(--bs-border-width);--bs-nav-tabs-border-color:var(--bs-border-color);--bs-nav-tabs-border-radius:var(--bs-border-radius);--bs-nav-tabs-link-hover-border-color:var(--bs-secondary-bg) var(--bs-secondary-bg) var(--bs-border-color);--bs-nav-tabs-link-active-color:var(--bs-emphasis-color);--bs-nav-tabs-link-active-bg:var(--bs-body-bg);--bs-nav-tabs-link-active-border-color:var(--bs-border-color) var(--bs-border-color) var(--bs-body-bg);border-bottom:var(--bs-nav-tabs-border-width) solid var(--bs-nav-tabs-border-color)}.nav-tabs .nav-link{margin-bottom:calc(-1 * var(--bs-nav-tabs-border-width));border:var(--bs-nav-tabs-border-width) solid transparent;border-top-left-radius:var(--bs-nav-tabs-border-radius);border-top-right-radius:var(--bs-nav-tabs-border-radius)}.nav-tabs .nav-link:focus,.nav-tabs .nav-link:hover{isolation:isolate;border-color:var(--bs-nav-tabs-link-hover-border-color)}.nav-tabs .nav-item.show .nav-link,.nav-tabs .nav-link.active{color:var(--bs-nav-tabs-link-active-color);background-color:var(--bs-nav-tabs-link-active-bg);border-color:var(--bs-nav-tabs-link-active-border-color)}.nav-tabs .dropdown-menu{margin-top:calc(-1 * var(--bs-nav-tabs-border-width));border-top-left-radius:0;border-top-right-radius:0}.nav-pills{--bs-nav-pills-border-radius:var(--bs-border-radius);--bs-nav-pills-link-active-color:#fff;--bs-nav-pills-link-active-bg:#0d6efd}.nav-pills .nav-link{border-radius:var(--bs-nav-pills-border-radius)}.nav-pills .nav-link.active,.nav-pills .show>.nav-link{color:var(--bs-nav-pills-link-active-color);background-color:var(--bs-nav-pills-link-active-bg)}.nav-underline{--bs-nav-underline-gap:1rem;--bs-nav-underline-border-width:0.125rem;--bs-nav-underline-link-active-color:var(--bs-emphasis-color);gap:var(--bs-nav-underline-gap)}.nav-underline .nav-link{padding-right:0;padding-left:0;border-bottom:var(--bs-nav-underline-border-width) solid transparent}.nav-underline .nav-link:focus,.nav-underline .nav-link:hover{border-bottom-color:currentcolor}.nav-underline .nav-link.active,.nav-underline .show>.nav-link{font-weight:700;color:var(--bs-nav-underline-link-active-color);border-bottom-color:currentcolor}.nav-fill .nav-item,.nav-fill>.nav-link{flex:1 1 auto;text-align:center}.nav-justified .nav-item,.nav-justified>.nav-link{flex-grow:1;flex-basis:0;text-align:center}.nav-fill .nav-item .nav-link,.nav-justified .nav-item .nav-link{width:100%}.tab-content>.tab-pane{display:none}.tab-content>.active{display:block}.navbar{--bs-navbar-padding-x:0;--bs-navbar-padding-y:0.5rem;--bs-navbar-color:rgba(var(--bs-emphasis-color-rgb), 0.65);--bs-navbar-hover-color:rgba(var(--bs-emphasis-color-rgb), 0.8);--bs-navbar-disabled-color:rgba(var(--bs-emphasis-color-rgb), 0.3);--bs-navbar-active-color:rgba(var(--bs-emphasis-color-rgb), 1);--bs-navbar-brand-padding-y:0.3125rem;--bs-navbar-brand-margin-end:1rem;--bs-navbar-brand-font-size:1.25rem;--bs-navbar-brand-color:rgba(var(--bs-emphasis-color-rgb), 1);--bs-navbar-brand-hover-color:rgba(var(--bs-emphasis-color-rgb), 1);--bs-navbar-nav-link-padding-x:0.5rem;--bs-navbar-toggler-padding-y:0.25rem;--bs-navbar-toggler-padding-x:0.75rem;--bs-navbar-toggler-font-size:1.25rem;--bs-navbar-toggler-icon-bg:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 30 30'%3e%3cpath stroke='rgba%2833, 37, 41, 0.75%29' stroke-linecap='round' stroke-miterlimit='10' stroke-width='2' d='M4 7h22M4 15h22M4 23h22'/%3e%3c/svg%3e");--bs-navbar-toggler-border-color:rgba(var(--bs-emphasis-color-rgb), 0.15);--bs-navbar-toggler-border-radius:var(--bs-border-radius);--bs-navbar-toggler-focus-width:0.25rem;--bs-navbar-toggler-transition:box-shadow 0.15s ease-in-out;position:relative;display:flex;flex-wrap:wrap;align-items:center;justify-content:space-between;padding:var(--bs-navbar-padding-y) var(--bs-navbar-padding-x)}.navbar>.container,.navbar>.container-fluid,.navbar>.container-lg,.navbar>.container-md,.navbar>.container-sm,.navbar>.container-xl,.navbar>.container-xxl{display:flex;flex-wrap:inherit;align-items:center;justify-content:space-between}.navbar-brand{padding-top:var(--bs-navbar-brand-padding-y);padding-bottom:var(--bs-navbar-brand-padding-y);margin-right:var(--bs-navbar-brand-margin-end);font-size:var(--bs-navbar-brand-font-size);color:var(--bs-navbar-brand-color);text-decoration:none;white-space:nowrap}.navbar-brand:focus,.navbar-brand:hover{color:var(--bs-navbar-brand-hover-color)}.navbar-nav{--bs-nav-link-padding-x:0;--bs-nav-link-padding-y:0.5rem;--bs-nav-link-font-weight: ;--bs-nav-link-color:var(--bs-navbar-color);--bs-nav-link-hover-color:var(--bs-navbar-hover-color);--bs-nav-link-disabled-color:var(--bs-navbar-disabled-color);display:flex;flex-direction:column;padding-left:0;margin-bottom:0;list-style:none}.navbar-nav .nav-link.active,.navbar-nav .nav-link.show{color:var(--bs-navbar-active-color)}.navbar-nav .dropdown-menu{position:static}.navbar-text{padding-top:.5rem;padding-bottom:.5rem;color:var(--bs-navbar-color)}.navbar-text a,.navbar-text a:focus,.navbar-text a:hover{color:var(--bs-navbar-active-color)}.navbar-collapse{flex-grow:1;flex-basis:100%;align-items:center}.navbar-toggler{padding:var(--bs-navbar-toggler-padding-y) var(--bs-navbar-toggler-padding-x);font-size:var(--bs-navbar-toggler-font-size);line-height:1;color:var(--bs-navbar-color);background-color:transparent;border:var(--bs-border-width) solid var(--bs-navbar-toggler-border-color);border-radius:var(--bs-navbar-toggler-border-radius);transition:var(--bs-navbar-toggler-transition)}@media (prefers-reduced-motion:reduce){.navbar-toggler{transition:none}}.navbar-toggler:hover{text-decoration:none}.navbar-toggler:focus{text-decoration:none;outline:0;box-shadow:0 0 0 var(--bs-navbar-toggler-focus-width)}.navbar-toggler-icon{display:inline-block;width:1.5em;height:1.5em;vertical-align:middle;background-image:var(--bs-navbar-toggler-icon-bg);background-repeat:no-repeat;background-position:center;background-size:100%}.navbar-nav-scroll{max-height:var(--bs-scroll-height,75vh);overflow-y:auto}@media (min-width:576px){.navbar-expand-sm{flex-wrap:nowrap;justify-content:flex-start}.navbar-expand-sm .navbar-nav{flex-direction:row}.navbar-expand-sm .navbar-nav .dropdown-menu{position:absolute}.navbar-expand-sm .navbar-nav .nav-link{padding-right:var(--bs-navbar-nav-link-padding-x);padding-left:var(--bs-navbar-nav-link-padding-x)}.navbar-expand-sm .navbar-nav-scroll{overflow:visible}.navbar-expand-sm .navbar-collapse{display:flex!important;flex-basis:auto}.navbar-expand-sm .navbar-toggler{display:none}.navbar-expand-sm .offcanvas{position:static;z-index:auto;flex-grow:1;width:auto!important;height:auto!important;visibility:visible!important;background-color:transparent!important;border:0!important;transform:none!important;transition:none}.navbar-expand-sm .offcanvas .offcanvas-header{display:none}.navbar-expand-sm .offcanvas .offcanvas-body{display:flex;flex-grow:0;padding:0;overflow-y:visible}}@media (min-width:768px){.navbar-expand-md{flex-wrap:nowrap;justify-content:flex-start}.navbar-expand-md .navbar-nav{flex-direction:row}.navbar-expand-md .navbar-nav .dropdown-menu{position:absolute}.navbar-expand-md .navbar-nav .nav-link{padding-right:var(--bs-navbar-nav-link-padding-x);padding-left:var(--bs-navbar-nav-link-padding-x)}.navbar-expand-md .navbar-nav-scroll{overflow:visible}.navbar-expand-md .navbar-collapse{display:flex!important;flex-basis:auto}.navbar-expand-md .navbar-toggler{display:none}.navbar-expand-md .offcanvas{position:static;z-index:auto;flex-grow:1;width:auto!important;height:auto!important;visibility:visible!important;background-color:transparent!important;border:0!important;transform:none!important;transition:none}.navbar-expand-md .offcanvas .offcanvas-header{display:none}.navbar-expand-md .offcanvas .offcanvas-body{display:flex;flex-grow:0;padding:0;overflow-y:visible}}@media (min-width:992px){.navbar-expand-lg{flex-wrap:nowrap;justify-content:flex-start}.navbar-expand-lg .navbar-nav{flex-direction:row}.navbar-expand-lg .navbar-nav .dropdown-menu{position:absolute}.navbar-expand-lg .navbar-nav .nav-link{padding-right:var(--bs-navbar-nav-link-padding-x);padding-left:var(--bs-navbar-nav-link-padding-x)}.navbar-expand-lg .navbar-nav-scroll{overflow:visible}.navbar-expand-lg .navbar-collapse{display:flex!important;flex-basis:auto}.navbar-expand-lg .navbar-toggler{display:none}.navbar-expand-lg .offcanvas{position:static;z-index:auto;flex-grow:1;width:auto!important;height:auto!important;visibility:visible!important;background-color:transparent!important;border:0!important;transform:none!important;transition:none}.navbar-expand-lg .offcanvas .offcanvas-header{display:none}.navbar-expand-lg .offcanvas .offcanvas-body{display:flex;flex-grow:0;padding:0;overflow-y:visible}}@media (min-width:1200px){.navbar-expand-xl{flex-wrap:nowrap;justify-content:flex-start}.navbar-expand-xl .navbar-nav{flex-direction:row}.navbar-expand-xl .navbar-nav .dropdown-menu{position:absolute}.navbar-expand-xl .navbar-nav .nav-link{padding-right:var(--bs-navbar-nav-link-padding-x);padding-left:var(--bs-navbar-nav-link-padding-x)}.navbar-expand-xl .navbar-nav-scroll{overflow:visible}.navbar-expand-xl .navbar-collapse{display:flex!important;flex-basis:auto}.navbar-expand-xl .navbar-toggler{display:none}.navbar-expand-xl .offcanvas{position:static;z-index:auto;flex-grow:1;width:auto!important;height:auto!important;visibility:visible!important;background-color:transparent!important;border:0!important;transform:none!important;transition:none}.navbar-expand-xl .offcanvas .offcanvas-header{display:none}.navbar-expand-xl .offcanvas .offcanvas-body{display:flex;flex-grow:0;padding:0;overflow-y:visible}}@media (min-width:1400px){.navbar-expand-xxl{flex-wrap:nowrap;justify-content:flex-start}.navbar-expand-xxl .navbar-nav{flex-direction:row}.navbar-expand-xxl .navbar-nav .dropdown-menu{position:absolute}.navbar-expand-xxl .navbar-nav .nav-link{padding-right:var(--bs-navbar-nav-link-padding-x);padding-left:var(--bs-navbar-nav-link-padding-x)}.navbar-expand-xxl .navbar-nav-scroll{overflow:visible}.navbar-expand-xxl .navbar-collapse{display:flex!important;flex-basis:auto}.navbar-expand-xxl .navbar-toggler{display:none}.navbar-expand-xxl .offcanvas{position:static;z-index:auto;flex-grow:1;width:auto!important;height:auto!important;visibility:visible!important;background-color:transparent!important;border:0!important;transform:none!important;transition:none}.navbar-expand-xxl .offcanvas .offcanvas-header{display:none}.navbar-expand-xxl .offcanvas .offcanvas-body{display:flex;flex-grow:0;padding:0;overflow-y:visible}}.navbar-expand{flex-wrap:nowrap;justify-content:flex-start}.navbar-expand .navbar-nav{flex-direction:row}.navbar-expand .navbar-nav .dropdown-menu{position:absolute}.navbar-expand .navbar-nav .nav-link{padding-right:var(--bs-navbar-nav-link-padding-x);padding-left:var(--bs-navbar-nav-link-padding-x)}.navbar-expand .navbar-nav-scroll{overflow:visible}.navbar-expand .navbar-collapse{display:flex!important;flex-basis:auto}.navbar-expand .navbar-toggler{display:none}.navbar-expand .offcanvas{position:static;z-index:auto;flex-grow:1;width:auto!important;height:auto!important;visibility:visible!important;background-color:transparent!important;border:0!important;transform:none!important;transition:none}.navbar-expand .offcanvas .offcanvas-header{display:none}.navbar-expand .offcanvas .offcanvas-body{display:flex;flex-grow:0;padding:0;overflow-y:visible}.navbar-dark,.navbar[data-bs-theme=dark]{--bs-navbar-color:rgba(255, 255, 255, 0.55);--bs-navbar-hover-color:rgba(255, 255, 255, 0.75);--bs-navbar-disabled-color:rgba(255, 255, 255, 0.25);--bs-navbar-active-color:#fff;--bs-navbar-brand-color:#fff;--bs-navbar-brand-hover-color:#fff;--bs-navbar-toggler-border-color:rgba(255, 255, 255, 0.1);--bs-navbar-toggler-icon-bg:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 30 30'%3e%3cpath stroke='rgba%28255, 255, 255, 0.55%29' stroke-linecap='round' stroke-miterlimit='10' stroke-width='2' d='M4 7h22M4 15h22M4 23h22'/%3e%3c/svg%3e")}[data-bs-theme=dark] .navbar-toggler-icon{--bs-navbar-toggler-icon-bg:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 30 30'%3e%3cpath stroke='rgba%28255, 255, 255, 0.55%29' stroke-linecap='round' stroke-miterlimit='10' stroke-width='2' d='M4 7h22M4 15h22M4 23h22'/%3e%3c/svg%3e")}.card{--bs-card-spacer-y:1rem;--bs-card-spacer-x:1rem;--bs-card-title-spacer-y:0.5rem;--bs-card-title-color: ;--bs-card-subtitle-color: ;--bs-card-border-width:var(--bs-border-width);--bs-card-border-color:var(--bs-border-color-translucent);--bs-card-border-radius:var(--bs-border-radius);--bs-card-box-shadow: ;--bs-card-inner-border-radius:calc(var(--bs-border-radius) - (var(--bs-border-width)));--bs-card-cap-padding-y:0.5rem;--bs-card-cap-padding-x:1rem;--bs-card-cap-bg:rgba(var(--bs-body-color-rgb), 0.03);--bs-card-cap-color: ;--bs-card-height: ;--bs-card-color: ;--bs-card-bg:var(--bs-body-bg);--bs-card-img-overlay-padding:1rem;--bs-card-group-margin:0.75rem;position:relative;display:flex;flex-direction:column;min-width:0;height:var(--bs-card-height);color:var(--bs-body-color);word-wrap:break-word;background-color:var(--bs-card-bg);background-clip:border-box;border:var(--bs-card-border-width) solid var(--bs-card-border-color);border-radius:var(--bs-card-border-radius)}.card>hr{margin-right:0;margin-left:0}.card>.list-group{border-top:inherit;border-bottom:inherit}.card>.list-group:first-child{border-top-width:0;border-top-left-radius:var(--bs-card-inner-border-radius);border-top-right-radius:var(--bs-card-inner-border-radius)}.card>.list-group:last-child{border-bottom-width:0;border-bottom-right-radius:var(--bs-card-inner-border-radius);border-bottom-left-radius:var(--bs-card-inner-border-radius)}.card>.card-header+.list-group,.card>.list-group+.card-footer{border-top:0}.card-body{flex:1 1 auto;padding:var(--bs-card-spacer-y) var(--bs-card-spacer-x);color:var(--bs-card-color)}.card-title{margin-bottom:var(--bs-card-title-spacer-y);color:var(--bs-card-title-color)}.card-subtitle{margin-top:calc(-.5 * var(--bs-card-title-spacer-y));margin-bottom:0;color:var(--bs-card-subtitle-color)}.card-text:last-child{margin-bottom:0}.card-link+.card-link{margin-left:var(--bs-card-spacer-x)}.card-header{padding:var(--bs-card-cap-padding-y) var(--bs-card-cap-padding-x);margin-bottom:0;color:var(--bs-card-cap-color);background-color:var(--bs-card-cap-bg);border-bottom:var(--bs-card-border-width) solid var(--bs-card-border-color)}.card-header:first-child{border-radius:var(--bs-card-inner-border-radius) var(--bs-card-inner-border-radius) 0 0}.card-footer{padding:var(--bs-card-cap-padding-y) var(--bs-card-cap-padding-x);color:var(--bs-card-cap-color);background-color:var(--bs-card-cap-bg);border-top:var(--bs-card-border-width) solid var(--bs-card-border-color)}.card-footer:last-child{border-radius:0 0 var(--bs-card-inner-border-radius) var(--bs-card-inner-border-radius)}.card-header-tabs{margin-right:calc(-.5 * var(--bs-card-cap-padding-x));margin-bottom:calc(-1 * var(--bs-card-cap-padding-y));margin-left:calc(-.5 * var(--bs-card-cap-padding-x));border-bottom:0}.card-header-tabs .nav-link.active{background-color:var(--bs-card-bg);border-bottom-color:var(--bs-card-bg)}.card-header-pills{margin-right:calc(-.5 * var(--bs-card-cap-padding-x));margin-left:calc(-.5 * var(--bs-card-cap-padding-x))}.card-img-overlay{position:absolute;top:0;right:0;bottom:0;left:0;padding:var(--bs-card-img-overlay-padding);border-radius:var(--bs-card-inner-border-radius)}.card-img,.card-img-bottom,.card-img-top{width:100%}.card-img,.card-img-top{border-top-left-radius:var(--bs-card-inner-border-radius);border-top-right-radius:var(--bs-card-inner-border-radius)}.card-img,.card-img-bottom{border-bottom-right-radius:var(--bs-card-inner-border-radius);border-bottom-left-radius:var(--bs-card-inner-border-radius)}.card-group>.card{margin-bottom:var(--bs-card-group-margin)}@media (min-width:576px){.card-group{display:flex;flex-flow:row wrap}.card-group>.card{flex:1 0 0;margin-bottom:0}.card-group>.card+.card{margin-left:0;border-left:0}.card-group>.card:not(:last-child){border-top-right-radius:0;border-bottom-right-radius:0}.card-group>.card:not(:last-child)>.card-header,.card-group>.card:not(:last-child)>.card-img-top{border-top-right-radius:0}.card-group>.card:not(:last-child)>.card-footer,.card-group>.card:not(:last-child)>.card-img-bottom{border-bottom-right-radius:0}.card-group>.card:not(:first-child){border-top-left-radius:0;border-bottom-left-radius:0}.card-group>.card:not(:first-child)>.card-header,.card-group>.card:not(:first-child)>.card-img-top{border-top-left-radius:0}.card-group>.card:not(:first-child)>.card-footer,.card-group>.card:not(:first-child)>.card-img-bottom{border-bottom-left-radius:0}}.accordion{--bs-accordion-color:var(--bs-body-color);--bs-accordion-bg:var(--bs-body-bg);--bs-accordion-transition:color 0.15s ease-in-out,background-color 0.15s ease-in-out,border-color 0.15s ease-in-out,box-shadow 0.15s ease-in-out,border-radius 0.15s ease;--bs-accordion-border-color:var(--bs-border-color);--bs-accordion-border-width:var(--bs-border-width);--bs-accordion-border-radius:var(--bs-border-radius);--bs-accordion-inner-border-radius:calc(var(--bs-border-radius) - (var(--bs-border-width)));--bs-accordion-btn-padding-x:1.25rem;--bs-accordion-btn-padding-y:1rem;--bs-accordion-btn-color:var(--bs-body-color);--bs-accordion-btn-bg:var(--bs-accordion-bg);--bs-accordion-btn-icon:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='none' stroke='%23212529' stroke-linecap='round' stroke-linejoin='round'%3e%3cpath d='m2 5 6 6 6-6'/%3e%3c/svg%3e");--bs-accordion-btn-icon-width:1.25rem;--bs-accordion-btn-icon-transform:rotate(-180deg);--bs-accordion-btn-icon-transition:transform 0.2s ease-in-out;--bs-accordion-btn-active-icon:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='none' stroke='%23052c65' stroke-linecap='round' stroke-linejoin='round'%3e%3cpath d='m2 5 6 6 6-6'/%3e%3c/svg%3e");--bs-accordion-btn-focus-box-shadow:0 0 0 0.25rem rgba(13, 110, 253, 0.25);--bs-accordion-body-padding-x:1.25rem;--bs-accordion-body-padding-y:1rem;--bs-accordion-active-color:var(--bs-primary-text-emphasis);--bs-accordion-active-bg:var(--bs-primary-bg-subtle)}.accordion-button{position:relative;display:flex;align-items:center;width:100%;padding:var(--bs-accordion-btn-padding-y) var(--bs-accordion-btn-padding-x);font-size:1rem;color:var(--bs-accordion-btn-color);text-align:left;background-color:var(--bs-accordion-btn-bg);border:0;border-radius:0;overflow-anchor:none;transition:var(--bs-accordion-transition)}@media (prefers-reduced-motion:reduce){.accordion-button{transition:none}}.accordion-button:not(.collapsed){color:var(--bs-accordion-active-color);background-color:var(--bs-accordion-active-bg);box-shadow:inset 0 calc(-1 * var(--bs-accordion-border-width)) 0 var(--bs-accordion-border-color)}.accordion-button:not(.collapsed)::after{background-image:var(--bs-accordion-btn-active-icon);transform:var(--bs-accordion-btn-icon-transform)}.accordion-button::after{flex-shrink:0;width:var(--bs-accordion-btn-icon-width);height:var(--bs-accordion-btn-icon-width);margin-left:auto;content:"";background-image:var(--bs-accordion-btn-icon);background-repeat:no-repeat;background-size:var(--bs-accordion-btn-icon-width);transition:var(--bs-accordion-btn-icon-transition)}@media (prefers-reduced-motion:reduce){.accordion-button::after{transition:none}}.accordion-button:hover{z-index:2}.accordion-button:focus{z-index:3;outline:0;box-shadow:var(--bs-accordion-btn-focus-box-shadow)}.accordion-header{margin-bottom:0}.accordion-item{color:var(--bs-accordion-color);background-color:var(--bs-accordion-bg);border:var(--bs-accordion-border-width) solid var(--bs-accordion-border-color)}.accordion-item:first-of-type{border-top-left-radius:var(--bs-accordion-border-radius);border-top-right-radius:var(--bs-accordion-border-radius)}.accordion-item:first-of-type>.accordion-header .accordion-button{border-top-left-radius:var(--bs-accordion-inner-border-radius);border-top-right-radius:var(--bs-accordion-inner-border-radius)}.accordion-item:not(:first-of-type){border-top:0}.accordion-item:last-of-type{border-bottom-right-radius:var(--bs-accordion-border-radius);border-bottom-left-radius:var(--bs-accordion-border-radius)}.accordion-item:last-of-type>.accordion-header .accordion-button.collapsed{border-bottom-right-radius:var(--bs-accordion-inner-border-radius);border-bottom-left-radius:var(--bs-accordion-inner-border-radius)}.accordion-item:last-of-type>.accordion-collapse{border-bottom-right-radius:var(--bs-accordion-border-radius);border-bottom-left-radius:var(--bs-accordion-border-radius)}.accordion-body{padding:var(--bs-accordion-body-padding-y) var(--bs-accordion-body-padding-x)}.accordion-flush>.accordion-item{border-right:0;border-left:0;border-radius:0}.accordion-flush>.accordion-item:first-child{border-top:0}.accordion-flush>.accordion-item:last-child{border-bottom:0}.accordion-flush>.accordion-item>.accordion-collapse,.accordion-flush>.accordion-item>.accordion-header .accordion-button,.accordion-flush>.accordion-item>.accordion-header .accordion-button.collapsed{border-radius:0}[data-bs-theme=dark] .accordion-button::after{--bs-accordion-btn-icon:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='%236ea8fe'%3e%3cpath fill-rule='evenodd' d='M1.646 4.646a.5.5 0 0 1 .708 0L8 10.293l5.646-5.647a.5.5 0 0 1 .708.708l-6 6a.5.5 0 0 1-.708 0l-6-6a.5.5 0 0 1 0-.708'/%3e%3c/svg%3e");--bs-accordion-btn-active-icon:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='%236ea8fe'%3e%3cpath fill-rule='evenodd' d='M1.646 4.646a.5.5 0 0 1 .708 0L8 10.293l5.646-5.647a.5.5 0 0 1 .708.708l-6 6a.5.5 0 0 1-.708 0l-6-6a.5.5 0 0 1 0-.708'/%3e%3c/svg%3e")}.breadcrumb{--bs-breadcrumb-padding-x:0;--bs-breadcrumb-padding-y:0;--bs-breadcrumb-margin-bottom:1rem;--bs-breadcrumb-bg: ;--bs-breadcrumb-border-radius: ;--bs-breadcrumb-divider-color:var(--bs-secondary-color);--bs-breadcrumb-item-padding-x:0.5rem;--bs-breadcrumb-item-active-color:var(--bs-secondary-color);display:flex;flex-wrap:wrap;padding:var(--bs-breadcrumb-padding-y) var(--bs-breadcrumb-padding-x);margin-bottom:var(--bs-breadcrumb-margin-bottom);font-size:var(--bs-breadcrumb-font-size);list-style:none;background-color:var(--bs-breadcrumb-bg);border-radius:var(--bs-breadcrumb-border-radius)}.breadcrumb-item+.breadcrumb-item{padding-left:var(--bs-breadcrumb-item-padding-x)}.breadcrumb-item+.breadcrumb-item::before{float:left;padding-right:var(--bs-breadcrumb-item-padding-x);color:var(--bs-breadcrumb-divider-color);content:var(--bs-breadcrumb-divider, "/")}.breadcrumb-item.active{color:var(--bs-breadcrumb-item-active-color)}.pagination{--bs-pagination-padding-x:0.75rem;--bs-pagination-padding-y:0.375rem;--bs-pagination-font-size:1rem;--bs-pagination-color:var(--bs-link-color);--bs-pagination-bg:var(--bs-body-bg);--bs-pagination-border-width:var(--bs-border-width);--bs-pagination-border-color:var(--bs-border-color);--bs-pagination-border-radius:var(--bs-border-radius);--bs-pagination-hover-color:var(--bs-link-hover-color);--bs-pagination-hover-bg:var(--bs-tertiary-bg);--bs-pagination-hover-border-color:var(--bs-border-color);--bs-pagination-focus-color:var(--bs-link-hover-color);--bs-pagination-focus-bg:var(--bs-secondary-bg);--bs-pagination-focus-box-shadow:0 0 0 0.25rem rgba(13, 110, 253, 0.25);--bs-pagination-active-color:#fff;--bs-pagination-active-bg:#0d6efd;--bs-pagination-active-border-color:#0d6efd;--bs-pagination-disabled-color:var(--bs-secondary-color);--bs-pagination-disabled-bg:var(--bs-secondary-bg);--bs-pagination-disabled-border-color:var(--bs-border-color);display:flex;padding-left:0;list-style:none}.page-link{position:relative;display:block;padding:var(--bs-pagination-padding-y) var(--bs-pagination-padding-x);font-size:var(--bs-pagination-font-size);color:var(--bs-pagination-color);text-decoration:none;background-color:var(--bs-pagination-bg);border:var(--bs-pagination-border-width) solid var(--bs-pagination-border-color);transition:color .15s ease-in-out,background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out}@media (prefers-reduced-motion:reduce){.page-link{transition:none}}.page-link:hover{z-index:2;color:var(--bs-pagination-hover-color);background-color:var(--bs-pagination-hover-bg);border-color:var(--bs-pagination-hover-border-color)}.page-link:focus{z-index:3;color:var(--bs-pagination-focus-color);background-color:var(--bs-pagination-focus-bg);outline:0;box-shadow:var(--bs-pagination-focus-box-shadow)}.active>.page-link,.page-link.active{z-index:3;color:var(--bs-pagination-active-color);background-color:var(--bs-pagination-active-bg);border-color:var(--bs-pagination-active-border-color)}.disabled>.page-link,.page-link.disabled{color:var(--bs-pagination-disabled-color);pointer-events:none;background-color:var(--bs-pagination-disabled-bg);border-color:var(--bs-pagination-disabled-border-color)}.page-item:not(:first-child) .page-link{margin-left:calc(-1 * var(--bs-border-width))}.page-item:first-child .page-link{border-top-left-radius:var(--bs-pagination-border-radius);border-bottom-left-radius:var(--bs-pagination-border-radius)}.page-item:last-child .page-link{border-top-right-radius:var(--bs-pagination-border-radius);border-bottom-right-radius:var(--bs-pagination-border-radius)}.pagination-lg{--bs-pagination-padding-x:1.5rem;--bs-pagination-padding-y:0.75rem;--bs-pagination-font-size:1.25rem;--bs-pagination-border-radius:var(--bs-border-radius-lg)}.pagination-sm{--bs-pagination-padding-x:0.5rem;--bs-pagination-padding-y:0.25rem;--bs-pagination-font-size:0.875rem;--bs-pagination-border-radius:var(--bs-border-radius-sm)}.badge{--bs-badge-padding-x:0.65em;--bs-badge-padding-y:0.35em;--bs-badge-font-size:0.75em;--bs-badge-font-weight:700;--bs-badge-color:#fff;--bs-badge-border-radius:var(--bs-border-radius);display:inline-block;padding:var(--bs-badge-padding-y) var(--bs-badge-padding-x);font-size:var(--bs-badge-font-size);font-weight:var(--bs-badge-font-weight);line-height:1;color:var(--bs-badge-color);text-align:center;white-space:nowrap;vertical-align:baseline;border-radius:var(--bs-badge-border-radius)}.badge:empty{display:none}.btn .badge{position:relative;top:-1px}.alert{--bs-alert-bg:transparent;--bs-alert-padding-x:1rem;--bs-alert-padding-y:1rem;--bs-alert-margin-bottom:1rem;--bs-alert-color:inherit;--bs-alert-border-color:transparent;--bs-alert-border:var(--bs-border-width) solid var(--bs-alert-border-color);--bs-alert-border-radius:var(--bs-border-radius);--bs-alert-link-color:inherit;position:relative;padding:var(--bs-alert-padding-y) var(--bs-alert-padding-x);margin-bottom:var(--bs-alert-margin-bottom);color:var(--bs-alert-color);background-color:var(--bs-alert-bg);border:var(--bs-alert-border);border-radius:var(--bs-alert-border-radius)}.alert-heading{color:inherit}.alert-link{font-weight:700;color:var(--bs-alert-link-color)}.alert-dismissible{padding-right:3rem}.alert-dismissible .btn-close{position:absolute;top:0;right:0;z-index:2;padding:1.25rem 1rem}.alert-primary{--bs-alert-color:var(--bs-primary-text-emphasis);--bs-alert-bg:var(--bs-primary-bg-subtle);--bs-alert-border-color:var(--bs-primary-border-subtle);--bs-alert-link-color:var(--bs-primary-text-emphasis)}.alert-secondary{--bs-alert-color:var(--bs-secondary-text-emphasis);--bs-alert-bg:var(--bs-secondary-bg-subtle);--bs-alert-border-color:var(--bs-secondary-border-subtle);--bs-alert-link-color:var(--bs-secondary-text-emphasis)}.alert-success{--bs-alert-color:var(--bs-success-text-emphasis);--bs-alert-bg:var(--bs-success-bg-subtle);--bs-alert-border-color:var(--bs-success-border-subtle);--bs-alert-link-color:var(--bs-success-text-emphasis)}.alert-info{--bs-alert-color:var(--bs-info-text-emphasis);--bs-alert-bg:var(--bs-info-bg-subtle);--bs-alert-border-color:var(--bs-info-border-subtle);--bs-alert-link-color:var(--bs-info-text-emphasis)}.alert-warning{--bs-alert-color:var(--bs-warning-text-emphasis);--bs-alert-bg:var(--bs-warning-bg-subtle);--bs-alert-border-color:var(--bs-warning-border-subtle);--bs-alert-link-color:var(--bs-warning-text-emphasis)}.alert-danger{--bs-alert-color:var(--bs-danger-text-emphasis);--bs-alert-bg:var(--bs-danger-bg-subtle);--bs-alert-border-color:var(--bs-danger-border-subtle);--bs-alert-link-color:var(--bs-danger-text-emphasis)}.alert-light{--bs-alert-color:var(--bs-light-text-emphasis);--bs-alert-bg:var(--bs-light-bg-subtle);--bs-alert-border-color:var(--bs-light-border-subtle);--bs-alert-link-color:var(--bs-light-text-emphasis)}.alert-dark{--bs-alert-color:var(--bs-dark-text-emphasis);--bs-alert-bg:var(--bs-dark-bg-subtle);--bs-alert-border-color:var(--bs-dark-border-subtle);--bs-alert-link-color:var(--bs-dark-text-emphasis)}@keyframes progress-bar-stripes{0%{background-position-x:var(--bs-progress-height)}}.progress,.progress-stacked{--bs-progress-height:1rem;--bs-progress-font-size:0.75rem;--bs-progress-bg:var(--bs-secondary-bg);--bs-progress-border-radius:var(--bs-border-radius);--bs-progress-box-shadow:var(--bs-box-shadow-inset);--bs-progress-bar-color:#fff;--bs-progress-bar-bg:#0d6efd;--bs-progress-bar-transition:width 0.6s ease;display:flex;height:var(--bs-progress-height);overflow:hidden;font-size:var(--bs-progress-font-size);background-color:var(--bs-progress-bg);border-radius:var(--bs-progress-border-radius)}.progress-bar{display:flex;flex-direction:column;justify-content:center;overflow:hidden;color:var(--bs-progress-bar-color);text-align:center;white-space:nowrap;background-color:var(--bs-progress-bar-bg);transition:var(--bs-progress-bar-transition)}@media (prefers-reduced-motion:reduce){.progress-bar{transition:none}}.progress-bar-striped{background-image:linear-gradient(45deg,rgba(255,255,255,.15) 25%,transparent 25%,transparent 50%,rgba(255,255,255,.15) 50%,rgba(255,255,255,.15) 75%,transparent 75%,transparent);background-size:var(--bs-progress-height) var(--bs-progress-height)}.progress-stacked>.progress{overflow:visible}.progress-stacked>.progress>.progress-bar{width:100%}.progress-bar-animated{animation:1s linear infinite progress-bar-stripes}@media (prefers-reduced-motion:reduce){.progress-bar-animated{animation:none}}.list-group{--bs-list-group-color:var(--bs-body-color);--bs-list-group-bg:var(--bs-body-bg);--bs-list-group-border-color:var(--bs-border-color);--bs-list-group-border-width:var(--bs-border-width);--bs-list-group-border-radius:var(--bs-border-radius);--bs-list-group-item-padding-x:1rem;--bs-list-group-item-padding-y:0.5rem;--bs-list-group-action-color:var(--bs-secondary-color);--bs-list-group-action-hover-color:var(--bs-emphasis-color);--bs-list-group-action-hover-bg:var(--bs-tertiary-bg);--bs-list-group-action-active-color:var(--bs-body-color);--bs-list-group-action-active-bg:var(--bs-secondary-bg);--bs-list-group-disabled-color:var(--bs-secondary-color);--bs-list-group-disabled-bg:var(--bs-body-bg);--bs-list-group-active-color:#fff;--bs-list-group-active-bg:#0d6efd;--bs-list-group-active-border-color:#0d6efd;display:flex;flex-direction:column;padding-left:0;margin-bottom:0;border-radius:var(--bs-list-group-border-radius)}.list-group-numbered{list-style-type:none;counter-reset:section}.list-group-numbered>.list-group-item::before{content:counters(section, ".") ". ";counter-increment:section}.list-group-item{position:relative;display:block;padding:var(--bs-list-group-item-padding-y) var(--bs-list-group-item-padding-x);color:var(--bs-list-group-color);text-decoration:none;background-color:var(--bs-list-group-bg);border:var(--bs-list-group-border-width) solid var(--bs-list-group-border-color)}.list-group-item:first-child{border-top-left-radius:inherit;border-top-right-radius:inherit}.list-group-item:last-child{border-bottom-right-radius:inherit;border-bottom-left-radius:inherit}.list-group-item.disabled,.list-group-item:disabled{color:var(--bs-list-group-disabled-color);pointer-events:none;background-color:var(--bs-list-group-disabled-bg)}.list-group-item.active{z-index:2;color:var(--bs-list-group-active-color);background-color:var(--bs-list-group-active-bg);border-color:var(--bs-list-group-active-border-color)}.list-group-item+.list-group-item{border-top-width:0}.list-group-item+.list-group-item.active{margin-top:calc(-1 * var(--bs-list-group-border-width));border-top-width:var(--bs-list-group-border-width)}.list-group-item-action{width:100%;color:var(--bs-list-group-action-color);text-align:inherit}.list-group-item-action:not(.active):focus,.list-group-item-action:not(.active):hover{z-index:1;color:var(--bs-list-group-action-hover-color);text-decoration:none;background-color:var(--bs-list-group-action-hover-bg)}.list-group-item-action:not(.active):active{color:var(--bs-list-group-action-active-color);background-color:var(--bs-list-group-action-active-bg)}.list-group-horizontal{flex-direction:row}.list-group-horizontal>.list-group-item:first-child:not(:last-child){border-bottom-left-radius:var(--bs-list-group-border-radius);border-top-right-radius:0}.list-group-horizontal>.list-group-item:last-child:not(:first-child){border-top-right-radius:var(--bs-list-group-border-radius);border-bottom-left-radius:0}.list-group-horizontal>.list-group-item.active{margin-top:0}.list-group-horizontal>.list-group-item+.list-group-item{border-top-width:var(--bs-list-group-border-width);border-left-width:0}.list-group-horizontal>.list-group-item+.list-group-item.active{margin-left:calc(-1 * var(--bs-list-group-border-width));border-left-width:var(--bs-list-group-border-width)}@media (min-width:576px){.list-group-horizontal-sm{flex-direction:row}.list-group-horizontal-sm>.list-group-item:first-child:not(:last-child){border-bottom-left-radius:var(--bs-list-group-border-radius);border-top-right-radius:0}.list-group-horizontal-sm>.list-group-item:last-child:not(:first-child){border-top-right-radius:var(--bs-list-group-border-radius);border-bottom-left-radius:0}.list-group-horizontal-sm>.list-group-item.active{margin-top:0}.list-group-horizontal-sm>.list-group-item+.list-group-item{border-top-width:var(--bs-list-group-border-width);border-left-width:0}.list-group-horizontal-sm>.list-group-item+.list-group-item.active{margin-left:calc(-1 * var(--bs-list-group-border-width));border-left-width:var(--bs-list-group-border-width)}}@media (min-width:768px){.list-group-horizontal-md{flex-direction:row}.list-group-horizontal-md>.list-group-item:first-child:not(:last-child){border-bottom-left-radius:var(--bs-list-group-border-radius);border-top-right-radius:0}.list-group-horizontal-md>.list-group-item:last-child:not(:first-child){border-top-right-radius:var(--bs-list-group-border-radius);border-bottom-left-radius:0}.list-group-horizontal-md>.list-group-item.active{margin-top:0}.list-group-horizontal-md>.list-group-item+.list-group-item{border-top-width:var(--bs-list-group-border-width);border-left-width:0}.list-group-horizontal-md>.list-group-item+.list-group-item.active{margin-left:calc(-1 * var(--bs-list-group-border-width));border-left-width:var(--bs-list-group-border-width)}}@media (min-width:992px){.list-group-horizontal-lg{flex-direction:row}.list-group-horizontal-lg>.list-group-item:first-child:not(:last-child){border-bottom-left-radius:var(--bs-list-group-border-radius);border-top-right-radius:0}.list-group-horizontal-lg>.list-group-item:last-child:not(:first-child){border-top-right-radius:var(--bs-list-group-border-radius);border-bottom-left-radius:0}.list-group-horizontal-lg>.list-group-item.active{margin-top:0}.list-group-horizontal-lg>.list-group-item+.list-group-item{border-top-width:var(--bs-list-group-border-width);border-left-width:0}.list-group-horizontal-lg>.list-group-item+.list-group-item.active{margin-left:calc(-1 * var(--bs-list-group-border-width));border-left-width:var(--bs-list-group-border-width)}}@media (min-width:1200px){.list-group-horizontal-xl{flex-direction:row}.list-group-horizontal-xl>.list-group-item:first-child:not(:last-child){border-bottom-left-radius:var(--bs-list-group-border-radius);border-top-right-radius:0}.list-group-horizontal-xl>.list-group-item:last-child:not(:first-child){border-top-right-radius:var(--bs-list-group-border-radius);border-bottom-left-radius:0}.list-group-horizontal-xl>.list-group-item.active{margin-top:0}.list-group-horizontal-xl>.list-group-item+.list-group-item{border-top-width:var(--bs-list-group-border-width);border-left-width:0}.list-group-horizontal-xl>.list-group-item+.list-group-item.active{margin-left:calc(-1 * var(--bs-list-group-border-width));border-left-width:var(--bs-list-group-border-width)}}@media (min-width:1400px){.list-group-horizontal-xxl{flex-direction:row}.list-group-horizontal-xxl>.list-group-item:first-child:not(:last-child){border-bottom-left-radius:var(--bs-list-group-border-radius);border-top-right-radius:0}.list-group-horizontal-xxl>.list-group-item:last-child:not(:first-child){border-top-right-radius:var(--bs-list-group-border-radius);border-bottom-left-radius:0}.list-group-horizontal-xxl>.list-group-item.active{margin-top:0}.list-group-horizontal-xxl>.list-group-item+.list-group-item{border-top-width:var(--bs-list-group-border-width);border-left-width:0}.list-group-horizontal-xxl>.list-group-item+.list-group-item.active{margin-left:calc(-1 * var(--bs-list-group-border-width));border-left-width:var(--bs-list-group-border-width)}}.list-group-flush{border-radius:0}.list-group-flush>.list-group-item{border-width:0 0 var(--bs-list-group-border-width)}.list-group-flush>.list-group-item:last-child{border-bottom-width:0}.list-group-item-primary{--bs-list-group-color:var(--bs-primary-text-emphasis);--bs-list-group-bg:var(--bs-primary-bg-subtle);--bs-list-group-border-color:var(--bs-primary-border-subtle);--bs-list-group-action-hover-color:var(--bs-emphasis-color);--bs-list-group-action-hover-bg:var(--bs-primary-border-subtle);--bs-list-group-action-active-color:var(--bs-emphasis-color);--bs-list-group-action-active-bg:var(--bs-primary-border-subtle);--bs-list-group-active-color:var(--bs-primary-bg-subtle);--bs-list-group-active-bg:var(--bs-primary-text-emphasis);--bs-list-group-active-border-color:var(--bs-primary-text-emphasis)}.list-group-item-secondary{--bs-list-group-color:var(--bs-secondary-text-emphasis);--bs-list-group-bg:var(--bs-secondary-bg-subtle);--bs-list-group-border-color:var(--bs-secondary-border-subtle);--bs-list-group-action-hover-color:var(--bs-emphasis-color);--bs-list-group-action-hover-bg:var(--bs-secondary-border-subtle);--bs-list-group-action-active-color:var(--bs-emphasis-color);--bs-list-group-action-active-bg:var(--bs-secondary-border-subtle);--bs-list-group-active-color:var(--bs-secondary-bg-subtle);--bs-list-group-active-bg:var(--bs-secondary-text-emphasis);--bs-list-group-active-border-color:var(--bs-secondary-text-emphasis)}.list-group-item-success{--bs-list-group-color:var(--bs-success-text-emphasis);--bs-list-group-bg:var(--bs-success-bg-subtle);--bs-list-group-border-color:var(--bs-success-border-subtle);--bs-list-group-action-hover-color:var(--bs-emphasis-color);--bs-list-group-action-hover-bg:var(--bs-success-border-subtle);--bs-list-group-action-active-color:var(--bs-emphasis-color);--bs-list-group-action-active-bg:var(--bs-success-border-subtle);--bs-list-group-active-color:var(--bs-success-bg-subtle);--bs-list-group-active-bg:var(--bs-success-text-emphasis);--bs-list-group-active-border-color:var(--bs-success-text-emphasis)}.list-group-item-info{--bs-list-group-color:var(--bs-info-text-emphasis);--bs-list-group-bg:var(--bs-info-bg-subtle);--bs-list-group-border-color:var(--bs-info-border-subtle);--bs-list-group-action-hover-color:var(--bs-emphasis-color);--bs-list-group-action-hover-bg:var(--bs-info-border-subtle);--bs-list-group-action-active-color:var(--bs-emphasis-color);--bs-list-group-action-active-bg:var(--bs-info-border-subtle);--bs-list-group-active-color:var(--bs-info-bg-subtle);--bs-list-group-active-bg:var(--bs-info-text-emphasis);--bs-list-group-active-border-color:var(--bs-info-text-emphasis)}.list-group-item-warning{--bs-list-group-color:var(--bs-warning-text-emphasis);--bs-list-group-bg:var(--bs-warning-bg-subtle);--bs-list-group-border-color:var(--bs-warning-border-subtle);--bs-list-group-action-hover-color:var(--bs-emphasis-color);--bs-list-group-action-hover-bg:var(--bs-warning-border-subtle);--bs-list-group-action-active-color:var(--bs-emphasis-color);--bs-list-group-action-active-bg:var(--bs-warning-border-subtle);--bs-list-group-active-color:var(--bs-warning-bg-subtle);--bs-list-group-active-bg:var(--bs-warning-text-emphasis);--bs-list-group-active-border-color:var(--bs-warning-text-emphasis)}.list-group-item-danger{--bs-list-group-color:var(--bs-danger-text-emphasis);--bs-list-group-bg:var(--bs-danger-bg-subtle);--bs-list-group-border-color:var(--bs-danger-border-subtle);--bs-list-group-action-hover-color:var(--bs-emphasis-color);--bs-list-group-action-hover-bg:var(--bs-danger-border-subtle);--bs-list-group-action-active-color:var(--bs-emphasis-color);--bs-list-group-action-active-bg:var(--bs-danger-border-subtle);--bs-list-group-active-color:var(--bs-danger-bg-subtle);--bs-list-group-active-bg:var(--bs-danger-text-emphasis);--bs-list-group-active-border-color:var(--bs-danger-text-emphasis)}.list-group-item-light{--bs-list-group-color:var(--bs-light-text-emphasis);--bs-list-group-bg:var(--bs-light-bg-subtle);--bs-list-group-border-color:var(--bs-light-border-subtle);--bs-list-group-action-hover-color:var(--bs-emphasis-color);--bs-list-group-action-hover-bg:var(--bs-light-border-subtle);--bs-list-group-action-active-color:var(--bs-emphasis-color);--bs-list-group-action-active-bg:var(--bs-light-border-subtle);--bs-list-group-active-color:var(--bs-light-bg-subtle);--bs-list-group-active-bg:var(--bs-light-text-emphasis);--bs-list-group-active-border-color:var(--bs-light-text-emphasis)}.list-group-item-dark{--bs-list-group-color:var(--bs-dark-text-emphasis);--bs-list-group-bg:var(--bs-dark-bg-subtle);--bs-list-group-border-color:var(--bs-dark-border-subtle);--bs-list-group-action-hover-color:var(--bs-emphasis-color);--bs-list-group-action-hover-bg:var(--bs-dark-border-subtle);--bs-list-group-action-active-color:var(--bs-emphasis-color);--bs-list-group-action-active-bg:var(--bs-dark-border-subtle);--bs-list-group-active-color:var(--bs-dark-bg-subtle);--bs-list-group-active-bg:var(--bs-dark-text-emphasis);--bs-list-group-active-border-color:var(--bs-dark-text-emphasis)}.btn-close{--bs-btn-close-color:#000;--bs-btn-close-bg:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='%23000'%3e%3cpath d='M.293.293a1 1 0 0 1 1.414 0L8 6.586 14.293.293a1 1 0 1 1 1.414 1.414L9.414 8l6.293 6.293a1 1 0 0 1-1.414 1.414L8 9.414l-6.293 6.293a1 1 0 0 1-1.414-1.414L6.586 8 .293 1.707a1 1 0 0 1 0-1.414'/%3e%3c/svg%3e");--bs-btn-close-opacity:0.5;--bs-btn-close-hover-opacity:0.75;--bs-btn-close-focus-shadow:0 0 0 0.25rem rgba(13, 110, 253, 0.25);--bs-btn-close-focus-opacity:1;--bs-btn-close-disabled-opacity:0.25;box-sizing:content-box;width:1em;height:1em;padding:.25em .25em;color:var(--bs-btn-close-color);background:transparent var(--bs-btn-close-bg) center/1em auto no-repeat;filter:var(--bs-btn-close-filter);border:0;border-radius:.375rem;opacity:var(--bs-btn-close-opacity)}.btn-close:hover{color:var(--bs-btn-close-color);text-decoration:none;opacity:var(--bs-btn-close-hover-opacity)}.btn-close:focus{outline:0;box-shadow:var(--bs-btn-close-focus-shadow);opacity:var(--bs-btn-close-focus-opacity)}.btn-close.disabled,.btn-close:disabled{pointer-events:none;-webkit-user-select:none;-moz-user-select:none;user-select:none;opacity:var(--bs-btn-close-disabled-opacity)}.btn-close-white{--bs-btn-close-filter:invert(1) grayscale(100%) brightness(200%)}:root,[data-bs-theme=light]{--bs-btn-close-filter: }[data-bs-theme=dark]{--bs-btn-close-filter:invert(1) grayscale(100%) brightness(200%)}.toast{--bs-toast-zindex:1090;--bs-toast-padding-x:0.75rem;--bs-toast-padding-y:0.5rem;--bs-toast-spacing:1.5rem;--bs-toast-max-width:350px;--bs-toast-font-size:0.875rem;--bs-toast-color: ;--bs-toast-bg:rgba(var(--bs-body-bg-rgb), 0.85);--bs-toast-border-width:var(--bs-border-width);--bs-toast-border-color:var(--bs-border-color-translucent);--bs-toast-border-radius:var(--bs-border-radius);--bs-toast-box-shadow:var(--bs-box-shadow);--bs-toast-header-color:var(--bs-secondary-color);--bs-toast-header-bg:rgba(var(--bs-body-bg-rgb), 0.85);--bs-toast-header-border-color:var(--bs-border-color-translucent);width:var(--bs-toast-max-width);max-width:100%;font-size:var(--bs-toast-font-size);color:var(--bs-toast-color);pointer-events:auto;background-color:var(--bs-toast-bg);background-clip:padding-box;border:var(--bs-toast-border-width) solid var(--bs-toast-border-color);box-shadow:var(--bs-toast-box-shadow);border-radius:var(--bs-toast-border-radius)}.toast.showing{opacity:0}.toast:not(.show){display:none}.toast-container{--bs-toast-zindex:1090;position:absolute;z-index:var(--bs-toast-zindex);width:-webkit-max-content;width:-moz-max-content;width:max-content;max-width:100%;pointer-events:none}.toast-container>:not(:last-child){margin-bottom:var(--bs-toast-spacing)}.toast-header{display:flex;align-items:center;padding:var(--bs-toast-padding-y) var(--bs-toast-padding-x);color:var(--bs-toast-header-color);background-color:var(--bs-toast-header-bg);background-clip:padding-box;border-bottom:var(--bs-toast-border-width) solid var(--bs-toast-header-border-color);border-top-left-radius:calc(var(--bs-toast-border-radius) - var(--bs-toast-border-width));border-top-right-radius:calc(var(--bs-toast-border-radius) - var(--bs-toast-border-width))}.toast-header .btn-close{margin-right:calc(-.5 * var(--bs-toast-padding-x));margin-left:var(--bs-toast-padding-x)}.toast-body{padding:var(--bs-toast-padding-x);word-wrap:break-word}.modal{--bs-modal-zindex:1055;--bs-modal-width:500px;--bs-modal-padding:1rem;--bs-modal-margin:0.5rem;--bs-modal-color:var(--bs-body-color);--bs-modal-bg:var(--bs-body-bg);--bs-modal-border-color:var(--bs-border-color-translucent);--bs-modal-border-width:var(--bs-border-width);--bs-modal-border-radius:var(--bs-border-radius-lg);--bs-modal-box-shadow:var(--bs-box-shadow-sm);--bs-modal-inner-border-radius:calc(var(--bs-border-radius-lg) - (var(--bs-border-width)));--bs-modal-header-padding-x:1rem;--bs-modal-header-padding-y:1rem;--bs-modal-header-padding:1rem 1rem;--bs-modal-header-border-color:var(--bs-border-color);--bs-modal-header-border-width:var(--bs-border-width);--bs-modal-title-line-height:1.5;--bs-modal-footer-gap:0.5rem;--bs-modal-footer-bg: ;--bs-modal-footer-border-color:var(--bs-border-color);--bs-modal-footer-border-width:var(--bs-border-width);position:fixed;top:0;left:0;z-index:var(--bs-modal-zindex);display:none;width:100%;height:100%;overflow-x:hidden;overflow-y:auto;outline:0}.modal-dialog{position:relative;width:auto;margin:var(--bs-modal-margin);pointer-events:none}.modal.fade .modal-dialog{transform:translate(0,-50px);transition:transform .3s ease-out}@media (prefers-reduced-motion:reduce){.modal.fade .modal-dialog{transition:none}}.modal.show .modal-dialog{transform:none}.modal.modal-static .modal-dialog{transform:scale(1.02)}.modal-dialog-scrollable{height:calc(100% - var(--bs-modal-margin) * 2)}.modal-dialog-scrollable .modal-content{max-height:100%;overflow:hidden}.modal-dialog-scrollable .modal-body{overflow-y:auto}.modal-dialog-centered{display:flex;align-items:center;min-height:calc(100% - var(--bs-modal-margin) * 2)}.modal-content{position:relative;display:flex;flex-direction:column;width:100%;color:var(--bs-modal-color);pointer-events:auto;background-color:var(--bs-modal-bg);background-clip:padding-box;border:var(--bs-modal-border-width) solid var(--bs-modal-border-color);border-radius:var(--bs-modal-border-radius);outline:0}.modal-backdrop{--bs-backdrop-zindex:1050;--bs-backdrop-bg:#000;--bs-backdrop-opacity:0.5;position:fixed;top:0;left:0;z-index:var(--bs-backdrop-zindex);width:100vw;height:100vh;background-color:var(--bs-backdrop-bg)}.modal-backdrop.fade{opacity:0}.modal-backdrop.show{opacity:var(--bs-backdrop-opacity)}.modal-header{display:flex;flex-shrink:0;align-items:center;padding:var(--bs-modal-header-padding);border-bottom:var(--bs-modal-header-border-width) solid var(--bs-modal-header-border-color);border-top-left-radius:var(--bs-modal-inner-border-radius);border-top-right-radius:var(--bs-modal-inner-border-radius)}.modal-header .btn-close{padding:calc(var(--bs-modal-header-padding-y) * .5) calc(var(--bs-modal-header-padding-x) * .5);margin-top:calc(-.5 * var(--bs-modal-header-padding-y));margin-right:calc(-.5 * var(--bs-modal-header-padding-x));margin-bottom:calc(-.5 * var(--bs-modal-header-padding-y));margin-left:auto}.modal-title{margin-bottom:0;line-height:var(--bs-modal-title-line-height)}.modal-body{position:relative;flex:1 1 auto;padding:var(--bs-modal-padding)}.modal-footer{display:flex;flex-shrink:0;flex-wrap:wrap;align-items:center;justify-content:flex-end;padding:calc(var(--bs-modal-padding) - var(--bs-modal-footer-gap) * .5);background-color:var(--bs-modal-footer-bg);border-top:var(--bs-modal-footer-border-width) solid var(--bs-modal-footer-border-color);border-bottom-right-radius:var(--bs-modal-inner-border-radius);border-bottom-left-radius:var(--bs-modal-inner-border-radius)}.modal-footer>*{margin:calc(var(--bs-modal-footer-gap) * .5)}@media (min-width:576px){.modal{--bs-modal-margin:1.75rem;--bs-modal-box-shadow:var(--bs-box-shadow)}.modal-dialog{max-width:var(--bs-modal-width);margin-right:auto;margin-left:auto}.modal-sm{--bs-modal-width:300px}}@media (min-width:992px){.modal-lg,.modal-xl{--bs-modal-width:800px}}@media (min-width:1200px){.modal-xl{--bs-modal-width:1140px}}.modal-fullscreen{width:100vw;max-width:none;height:100%;margin:0}.modal-fullscreen .modal-content{height:100%;border:0;border-radius:0}.modal-fullscreen .modal-footer,.modal-fullscreen .modal-header{border-radius:0}.modal-fullscreen .modal-body{overflow-y:auto}@media (max-width:575.98px){.modal-fullscreen-sm-down{width:100vw;max-width:none;height:100%;margin:0}.modal-fullscreen-sm-down .modal-content{height:100%;border:0;border-radius:0}.modal-fullscreen-sm-down .modal-footer,.modal-fullscreen-sm-down .modal-header{border-radius:0}.modal-fullscreen-sm-down .modal-body{overflow-y:auto}}@media (max-width:767.98px){.modal-fullscreen-md-down{width:100vw;max-width:none;height:100%;margin:0}.modal-fullscreen-md-down .modal-content{height:100%;border:0;border-radius:0}.modal-fullscreen-md-down .modal-footer,.modal-fullscreen-md-down .modal-header{border-radius:0}.modal-fullscreen-md-down .modal-body{overflow-y:auto}}@media (max-width:991.98px){.modal-fullscreen-lg-down{width:100vw;max-width:none;height:100%;margin:0}.modal-fullscreen-lg-down .modal-content{height:100%;border:0;border-radius:0}.modal-fullscreen-lg-down .modal-footer,.modal-fullscreen-lg-down .modal-header{border-radius:0}.modal-fullscreen-lg-down .modal-body{overflow-y:auto}}@media (max-width:1199.98px){.modal-fullscreen-xl-down{width:100vw;max-width:none;height:100%;margin:0}.modal-fullscreen-xl-down .modal-content{height:100%;border:0;border-radius:0}.modal-fullscreen-xl-down .modal-footer,.modal-fullscreen-xl-down .modal-header{border-radius:0}.modal-fullscreen-xl-down .modal-body{overflow-y:auto}}@media (max-width:1399.98px){.modal-fullscreen-xxl-down{width:100vw;max-width:none;height:100%;margin:0}.modal-fullscreen-xxl-down .modal-content{height:100%;border:0;border-radius:0}.modal-fullscreen-xxl-down .modal-footer,.modal-fullscreen-xxl-down .modal-header{border-radius:0}.modal-fullscreen-xxl-down .modal-body{overflow-y:auto}}.tooltip{--bs-tooltip-zindex:1080;--bs-tooltip-max-width:200px;--bs-tooltip-padding-x:0.5rem;--bs-tooltip-padding-y:0.25rem;--bs-tooltip-margin: ;--bs-tooltip-font-size:0.875rem;--bs-tooltip-color:var(--bs-body-bg);--bs-tooltip-bg:var(--bs-emphasis-color);--bs-tooltip-border-radius:var(--bs-border-radius);--bs-tooltip-opacity:0.9;--bs-tooltip-arrow-width:0.8rem;--bs-tooltip-arrow-height:0.4rem;z-index:var(--bs-tooltip-zindex);display:block;margin:var(--bs-tooltip-margin);font-family:var(--bs-font-sans-serif);font-style:normal;font-weight:400;line-height:1.5;text-align:left;text-align:start;text-decoration:none;text-shadow:none;text-transform:none;letter-spacing:normal;word-break:normal;white-space:normal;word-spacing:normal;line-break:auto;font-size:var(--bs-tooltip-font-size);word-wrap:break-word;opacity:0}.tooltip.show{opacity:var(--bs-tooltip-opacity)}.tooltip .tooltip-arrow{display:block;width:var(--bs-tooltip-arrow-width);height:var(--bs-tooltip-arrow-height)}.tooltip .tooltip-arrow::before{position:absolute;content:"";border-color:transparent;border-style:solid}.bs-tooltip-auto[data-popper-placement^=top] .tooltip-arrow,.bs-tooltip-top .tooltip-arrow{bottom:calc(-1 * var(--bs-tooltip-arrow-height))}.bs-tooltip-auto[data-popper-placement^=top] .tooltip-arrow::before,.bs-tooltip-top .tooltip-arrow::before{top:-1px;border-width:var(--bs-tooltip-arrow-height) calc(var(--bs-tooltip-arrow-width) * .5) 0;border-top-color:var(--bs-tooltip-bg)}.bs-tooltip-auto[data-popper-placement^=right] .tooltip-arrow,.bs-tooltip-end .tooltip-arrow{left:calc(-1 * var(--bs-tooltip-arrow-height));width:var(--bs-tooltip-arrow-height);height:var(--bs-tooltip-arrow-width)}.bs-tooltip-auto[data-popper-placement^=right] .tooltip-arrow::before,.bs-tooltip-end .tooltip-arrow::before{right:-1px;border-width:calc(var(--bs-tooltip-arrow-width) * .5) var(--bs-tooltip-arrow-height) calc(var(--bs-tooltip-arrow-width) * .5) 0;border-right-color:var(--bs-tooltip-bg)}.bs-tooltip-auto[data-popper-placement^=bottom] .tooltip-arrow,.bs-tooltip-bottom .tooltip-arrow{top:calc(-1 * var(--bs-tooltip-arrow-height))}.bs-tooltip-auto[data-popper-placement^=bottom] .tooltip-arrow::before,.bs-tooltip-bottom .tooltip-arrow::before{bottom:-1px;border-width:0 calc(var(--bs-tooltip-arrow-width) * .5) var(--bs-tooltip-arrow-height);border-bottom-color:var(--bs-tooltip-bg)}.bs-tooltip-auto[data-popper-placement^=left] .tooltip-arrow,.bs-tooltip-start .tooltip-arrow{right:calc(-1 * var(--bs-tooltip-arrow-height));width:var(--bs-tooltip-arrow-height);height:var(--bs-tooltip-arrow-width)}.bs-tooltip-auto[data-popper-placement^=left] .tooltip-arrow::before,.bs-tooltip-start .tooltip-arrow::before{left:-1px;border-width:calc(var(--bs-tooltip-arrow-width) * .5) 0 calc(var(--bs-tooltip-arrow-width) * .5) var(--bs-tooltip-arrow-height);border-left-color:var(--bs-tooltip-bg)}.tooltip-inner{max-width:var(--bs-tooltip-max-width);padding:var(--bs-tooltip-padding-y) var(--bs-tooltip-padding-x);color:var(--bs-tooltip-color);text-align:center;background-color:var(--bs-tooltip-bg);border-radius:var(--bs-tooltip-border-radius)}.popover{--bs-popover-zindex:1070;--bs-popover-max-width:276px;--bs-popover-font-size:0.875rem;--bs-popover-bg:var(--bs-body-bg);--bs-popover-border-width:var(--bs-border-width);--bs-popover-border-color:var(--bs-border-color-translucent);--bs-popover-border-radius:var(--bs-border-radius-lg);--bs-popover-inner-border-radius:calc(var(--bs-border-radius-lg) - var(--bs-border-width));--bs-popover-box-shadow:var(--bs-box-shadow);--bs-popover-header-padding-x:1rem;--bs-popover-header-padding-y:0.5rem;--bs-popover-header-font-size:1rem;--bs-popover-header-color:inherit;--bs-popover-header-bg:var(--bs-secondary-bg);--bs-popover-body-padding-x:1rem;--bs-popover-body-padding-y:1rem;--bs-popover-body-color:var(--bs-body-color);--bs-popover-arrow-width:1rem;--bs-popover-arrow-height:0.5rem;--bs-popover-arrow-border:var(--bs-popover-border-color);z-index:var(--bs-popover-zindex);display:block;max-width:var(--bs-popover-max-width);font-family:var(--bs-font-sans-serif);font-style:normal;font-weight:400;line-height:1.5;text-align:left;text-align:start;text-decoration:none;text-shadow:none;text-transform:none;letter-spacing:normal;word-break:normal;white-space:normal;word-spacing:normal;line-break:auto;font-size:var(--bs-popover-font-size);word-wrap:break-word;background-color:var(--bs-popover-bg);background-clip:padding-box;border:var(--bs-popover-border-width) solid var(--bs-popover-border-color);border-radius:var(--bs-popover-border-radius)}.popover .popover-arrow{display:block;width:var(--bs-popover-arrow-width);height:var(--bs-popover-arrow-height)}.popover .popover-arrow::after,.popover .popover-arrow::before{position:absolute;display:block;content:"";border-color:transparent;border-style:solid;border-width:0}.bs-popover-auto[data-popper-placement^=top]>.popover-arrow,.bs-popover-top>.popover-arrow{bottom:calc(-1 * (var(--bs-popover-arrow-height)) - var(--bs-popover-border-width))}.bs-popover-auto[data-popper-placement^=top]>.popover-arrow::after,.bs-popover-auto[data-popper-placement^=top]>.popover-arrow::before,.bs-popover-top>.popover-arrow::after,.bs-popover-top>.popover-arrow::before{border-width:var(--bs-popover-arrow-height) calc(var(--bs-popover-arrow-width) * .5) 0}.bs-popover-auto[data-popper-placement^=top]>.popover-arrow::before,.bs-popover-top>.popover-arrow::before{bottom:0;border-top-color:var(--bs-popover-arrow-border)}.bs-popover-auto[data-popper-placement^=top]>.popover-arrow::after,.bs-popover-top>.popover-arrow::after{bottom:var(--bs-popover-border-width);border-top-color:var(--bs-popover-bg)}.bs-popover-auto[data-popper-placement^=right]>.popover-arrow,.bs-popover-end>.popover-arrow{left:calc(-1 * (var(--bs-popover-arrow-height)) - var(--bs-popover-border-width));width:var(--bs-popover-arrow-height);height:var(--bs-popover-arrow-width)}.bs-popover-auto[data-popper-placement^=right]>.popover-arrow::after,.bs-popover-auto[data-popper-placement^=right]>.popover-arrow::before,.bs-popover-end>.popover-arrow::after,.bs-popover-end>.popover-arrow::before{border-width:calc(var(--bs-popover-arrow-width) * .5) var(--bs-popover-arrow-height) calc(var(--bs-popover-arrow-width) * .5) 0}.bs-popover-auto[data-popper-placement^=right]>.popover-arrow::before,.bs-popover-end>.popover-arrow::before{left:0;border-right-color:var(--bs-popover-arrow-border)}.bs-popover-auto[data-popper-placement^=right]>.popover-arrow::after,.bs-popover-end>.popover-arrow::after{left:var(--bs-popover-border-width);border-right-color:var(--bs-popover-bg)}.bs-popover-auto[data-popper-placement^=bottom]>.popover-arrow,.bs-popover-bottom>.popover-arrow{top:calc(-1 * (var(--bs-popover-arrow-height)) - var(--bs-popover-border-width))}.bs-popover-auto[data-popper-placement^=bottom]>.popover-arrow::after,.bs-popover-auto[data-popper-placement^=bottom]>.popover-arrow::before,.bs-popover-bottom>.popover-arrow::after,.bs-popover-bottom>.popover-arrow::before{border-width:0 calc(var(--bs-popover-arrow-width) * .5) var(--bs-popover-arrow-height)}.bs-popover-auto[data-popper-placement^=bottom]>.popover-arrow::before,.bs-popover-bottom>.popover-arrow::before{top:0;border-bottom-color:var(--bs-popover-arrow-border)}.bs-popover-auto[data-popper-placement^=bottom]>.popover-arrow::after,.bs-popover-bottom>.popover-arrow::after{top:var(--bs-popover-border-width);border-bottom-color:var(--bs-popover-bg)}.bs-popover-auto[data-popper-placement^=bottom] .popover-header::before,.bs-popover-bottom .popover-header::before{position:absolute;top:0;left:50%;display:block;width:var(--bs-popover-arrow-width);margin-left:calc(-.5 * var(--bs-popover-arrow-width));content:"";border-bottom:var(--bs-popover-border-width) solid var(--bs-popover-header-bg)}.bs-popover-auto[data-popper-placement^=left]>.popover-arrow,.bs-popover-start>.popover-arrow{right:calc(-1 * (var(--bs-popover-arrow-height)) - var(--bs-popover-border-width));width:var(--bs-popover-arrow-height);height:var(--bs-popover-arrow-width)}.bs-popover-auto[data-popper-placement^=left]>.popover-arrow::after,.bs-popover-auto[data-popper-placement^=left]>.popover-arrow::before,.bs-popover-start>.popover-arrow::after,.bs-popover-start>.popover-arrow::before{border-width:calc(var(--bs-popover-arrow-width) * .5) 0 calc(var(--bs-popover-arrow-width) * .5) var(--bs-popover-arrow-height)}.bs-popover-auto[data-popper-placement^=left]>.popover-arrow::before,.bs-popover-start>.popover-arrow::before{right:0;border-left-color:var(--bs-popover-arrow-border)}.bs-popover-auto[data-popper-placement^=left]>.popover-arrow::after,.bs-popover-start>.popover-arrow::after{right:var(--bs-popover-border-width);border-left-color:var(--bs-popover-bg)}.popover-header{padding:var(--bs-popover-header-padding-y) var(--bs-popover-header-padding-x);margin-bottom:0;font-size:var(--bs-popover-header-font-size);color:var(--bs-popover-header-color);background-color:var(--bs-popover-header-bg);border-bottom:var(--bs-popover-border-width) solid var(--bs-popover-border-color);border-top-left-radius:var(--bs-popover-inner-border-radius);border-top-right-radius:var(--bs-popover-inner-border-radius)}.popover-header:empty{display:none}.popover-body{padding:var(--bs-popover-body-padding-y) var(--bs-popover-body-padding-x);color:var(--bs-popover-body-color)}.carousel{position:relative}.carousel.pointer-event{touch-action:pan-y}.carousel-inner{position:relative;width:100%;overflow:hidden}.carousel-inner::after{display:block;clear:both;content:""}.carousel-item{position:relative;display:none;float:left;width:100%;margin-right:-100%;-webkit-backface-visibility:hidden;backface-visibility:hidden;transition:transform .6s ease-in-out}@media (prefers-reduced-motion:reduce){.carousel-item{transition:none}}.carousel-item-next,.carousel-item-prev,.carousel-item.active{display:block}.active.carousel-item-end,.carousel-item-next:not(.carousel-item-start){transform:translateX(100%)}.active.carousel-item-start,.carousel-item-prev:not(.carousel-item-end){transform:translateX(-100%)}.carousel-fade .carousel-item{opacity:0;transition-property:opacity;transform:none}.carousel-fade .carousel-item-next.carousel-item-start,.carousel-fade .carousel-item-prev.carousel-item-end,.carousel-fade .carousel-item.active{z-index:1;opacity:1}.carousel-fade .active.carousel-item-end,.carousel-fade .active.carousel-item-start{z-index:0;opacity:0;transition:opacity 0s .6s}@media (prefers-reduced-motion:reduce){.carousel-fade .active.carousel-item-end,.carousel-fade .active.carousel-item-start{transition:none}}.carousel-control-next,.carousel-control-prev{position:absolute;top:0;bottom:0;z-index:1;display:flex;align-items:center;justify-content:center;width:15%;padding:0;color:#fff;text-align:center;background:0 0;filter:var(--bs-carousel-control-icon-filter);border:0;opacity:.5;transition:opacity .15s ease}@media (prefers-reduced-motion:reduce){.carousel-control-next,.carousel-control-prev{transition:none}}.carousel-control-next:focus,.carousel-control-next:hover,.carousel-control-prev:focus,.carousel-control-prev:hover{color:#fff;text-decoration:none;outline:0;opacity:.9}.carousel-control-prev{left:0}.carousel-control-next{right:0}.carousel-control-next-icon,.carousel-control-prev-icon{display:inline-block;width:2rem;height:2rem;background-repeat:no-repeat;background-position:50%;background-size:100% 100%}.carousel-control-prev-icon{background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='%23fff'%3e%3cpath d='M11.354 1.646a.5.5 0 0 1 0 .708L5.707 8l5.647 5.646a.5.5 0 0 1-.708.708l-6-6a.5.5 0 0 1 0-.708l6-6a.5.5 0 0 1 .708 0'/%3e%3c/svg%3e")}.carousel-control-next-icon{background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='%23fff'%3e%3cpath d='M4.646 1.646a.5.5 0 0 1 .708 0l6 6a.5.5 0 0 1 0 .708l-6 6a.5.5 0 0 1-.708-.708L10.293 8 4.646 2.354a.5.5 0 0 1 0-.708'/%3e%3c/svg%3e")}.carousel-indicators{position:absolute;right:0;bottom:0;left:0;z-index:2;display:flex;justify-content:center;padding:0;margin-right:15%;margin-bottom:1rem;margin-left:15%}.carousel-indicators [data-bs-target]{box-sizing:content-box;flex:0 1 auto;width:30px;height:3px;padding:0;margin-right:3px;margin-left:3px;text-indent:-999px;cursor:pointer;background-color:var(--bs-carousel-indicator-active-bg);background-clip:padding-box;border:0;border-top:10px solid transparent;border-bottom:10px solid transparent;opacity:.5;transition:opacity .6s ease}@media (prefers-reduced-motion:reduce){.carousel-indicators [data-bs-target]{transition:none}}.carousel-indicators .active{opacity:1}.carousel-caption{position:absolute;right:15%;bottom:1.25rem;left:15%;padding-top:1.25rem;padding-bottom:1.25rem;color:var(--bs-carousel-caption-color);text-align:center}.carousel-dark{--bs-carousel-indicator-active-bg:#000;--bs-carousel-caption-color:#000;--bs-carousel-control-icon-filter:invert(1) grayscale(100)}:root,[data-bs-theme=light]{--bs-carousel-indicator-active-bg:#fff;--bs-carousel-caption-color:#fff;--bs-carousel-control-icon-filter: }[data-bs-theme=dark]{--bs-carousel-indicator-active-bg:#000;--bs-carousel-caption-color:#000;--bs-carousel-control-icon-filter:invert(1) grayscale(100)}.spinner-border,.spinner-grow{display:inline-block;width:var(--bs-spinner-width);height:var(--bs-spinner-height);vertical-align:var(--bs-spinner-vertical-align);border-radius:50%;animation:var(--bs-spinner-animation-speed) linear infinite var(--bs-spinner-animation-name)}@keyframes spinner-border{to{transform:rotate(360deg)}}.spinner-border{--bs-spinner-width:2rem;--bs-spinner-height:2rem;--bs-spinner-vertical-align:-0.125em;--bs-spinner-border-width:0.25em;--bs-spinner-animation-speed:0.75s;--bs-spinner-animation-name:spinner-border;border:var(--bs-spinner-border-width) solid currentcolor;border-right-color:transparent}.spinner-border-sm{--bs-spinner-width:1rem;--bs-spinner-height:1rem;--bs-spinner-border-width:0.2em}@keyframes spinner-grow{0%{transform:scale(0)}50%{opacity:1;transform:none}}.spinner-grow{--bs-spinner-width:2rem;--bs-spinner-height:2rem;--bs-spinner-vertical-align:-0.125em;--bs-spinner-animation-speed:0.75s;--bs-spinner-animation-name:spinner-grow;background-color:currentcolor;opacity:0}.spinner-grow-sm{--bs-spinner-width:1rem;--bs-spinner-height:1rem}@media (prefers-reduced-motion:reduce){.spinner-border,.spinner-grow{--bs-spinner-animation-speed:1.5s}}.offcanvas,.offcanvas-lg,.offcanvas-md,.offcanvas-sm,.offcanvas-xl,.offcanvas-xxl{--bs-offcanvas-zindex:1045;--bs-offcanvas-width:400px;--bs-offcanvas-height:30vh;--bs-offcanvas-padding-x:1rem;--bs-offcanvas-padding-y:1rem;--bs-offcanvas-color:var(--bs-body-color);--bs-offcanvas-bg:var(--bs-body-bg);--bs-offcanvas-border-width:var(--bs-border-width);--bs-offcanvas-border-color:var(--bs-border-color-translucent);--bs-offcanvas-box-shadow:var(--bs-box-shadow-sm);--bs-offcanvas-transition:transform 0.3s ease-in-out;--bs-offcanvas-title-line-height:1.5}@media (max-width:575.98px){.offcanvas-sm{position:fixed;bottom:0;z-index:var(--bs-offcanvas-zindex);display:flex;flex-direction:column;max-width:100%;color:var(--bs-offcanvas-color);visibility:hidden;background-color:var(--bs-offcanvas-bg);background-clip:padding-box;outline:0;transition:var(--bs-offcanvas-transition)}}@media (max-width:575.98px) and (prefers-reduced-motion:reduce){.offcanvas-sm{transition:none}}@media (max-width:575.98px){.offcanvas-sm.offcanvas-start{top:0;left:0;width:var(--bs-offcanvas-width);border-right:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateX(-100%)}.offcanvas-sm.offcanvas-end{top:0;right:0;width:var(--bs-offcanvas-width);border-left:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateX(100%)}.offcanvas-sm.offcanvas-top{top:0;right:0;left:0;height:var(--bs-offcanvas-height);max-height:100%;border-bottom:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateY(-100%)}.offcanvas-sm.offcanvas-bottom{right:0;left:0;height:var(--bs-offcanvas-height);max-height:100%;border-top:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateY(100%)}.offcanvas-sm.show:not(.hiding),.offcanvas-sm.showing{transform:none}.offcanvas-sm.hiding,.offcanvas-sm.show,.offcanvas-sm.showing{visibility:visible}}@media (min-width:576px){.offcanvas-sm{--bs-offcanvas-height:auto;--bs-offcanvas-border-width:0;background-color:transparent!important}.offcanvas-sm .offcanvas-header{display:none}.offcanvas-sm .offcanvas-body{display:flex;flex-grow:0;padding:0;overflow-y:visible;background-color:transparent!important}}@media (max-width:767.98px){.offcanvas-md{position:fixed;bottom:0;z-index:var(--bs-offcanvas-zindex);display:flex;flex-direction:column;max-width:100%;color:var(--bs-offcanvas-color);visibility:hidden;background-color:var(--bs-offcanvas-bg);background-clip:padding-box;outline:0;transition:var(--bs-offcanvas-transition)}}@media (max-width:767.98px) and (prefers-reduced-motion:reduce){.offcanvas-md{transition:none}}@media (max-width:767.98px){.offcanvas-md.offcanvas-start{top:0;left:0;width:var(--bs-offcanvas-width);border-right:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateX(-100%)}.offcanvas-md.offcanvas-end{top:0;right:0;width:var(--bs-offcanvas-width);border-left:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateX(100%)}.offcanvas-md.offcanvas-top{top:0;right:0;left:0;height:var(--bs-offcanvas-height);max-height:100%;border-bottom:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateY(-100%)}.offcanvas-md.offcanvas-bottom{right:0;left:0;height:var(--bs-offcanvas-height);max-height:100%;border-top:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateY(100%)}.offcanvas-md.show:not(.hiding),.offcanvas-md.showing{transform:none}.offcanvas-md.hiding,.offcanvas-md.show,.offcanvas-md.showing{visibility:visible}}@media (min-width:768px){.offcanvas-md{--bs-offcanvas-height:auto;--bs-offcanvas-border-width:0;background-color:transparent!important}.offcanvas-md .offcanvas-header{display:none}.offcanvas-md .offcanvas-body{display:flex;flex-grow:0;padding:0;overflow-y:visible;background-color:transparent!important}}@media (max-width:991.98px){.offcanvas-lg{position:fixed;bottom:0;z-index:var(--bs-offcanvas-zindex);display:flex;flex-direction:column;max-width:100%;color:var(--bs-offcanvas-color);visibility:hidden;background-color:var(--bs-offcanvas-bg);background-clip:padding-box;outline:0;transition:var(--bs-offcanvas-transition)}}@media (max-width:991.98px) and (prefers-reduced-motion:reduce){.offcanvas-lg{transition:none}}@media (max-width:991.98px){.offcanvas-lg.offcanvas-start{top:0;left:0;width:var(--bs-offcanvas-width);border-right:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateX(-100%)}.offcanvas-lg.offcanvas-end{top:0;right:0;width:var(--bs-offcanvas-width);border-left:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateX(100%)}.offcanvas-lg.offcanvas-top{top:0;right:0;left:0;height:var(--bs-offcanvas-height);max-height:100%;border-bottom:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateY(-100%)}.offcanvas-lg.offcanvas-bottom{right:0;left:0;height:var(--bs-offcanvas-height);max-height:100%;border-top:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateY(100%)}.offcanvas-lg.show:not(.hiding),.offcanvas-lg.showing{transform:none}.offcanvas-lg.hiding,.offcanvas-lg.show,.offcanvas-lg.showing{visibility:visible}}@media (min-width:992px){.offcanvas-lg{--bs-offcanvas-height:auto;--bs-offcanvas-border-width:0;background-color:transparent!important}.offcanvas-lg .offcanvas-header{display:none}.offcanvas-lg .offcanvas-body{display:flex;flex-grow:0;padding:0;overflow-y:visible;background-color:transparent!important}}@media (max-width:1199.98px){.offcanvas-xl{position:fixed;bottom:0;z-index:var(--bs-offcanvas-zindex);display:flex;flex-direction:column;max-width:100%;color:var(--bs-offcanvas-color);visibility:hidden;background-color:var(--bs-offcanvas-bg);background-clip:padding-box;outline:0;transition:var(--bs-offcanvas-transition)}}@media (max-width:1199.98px) and (prefers-reduced-motion:reduce){.offcanvas-xl{transition:none}}@media (max-width:1199.98px){.offcanvas-xl.offcanvas-start{top:0;left:0;width:var(--bs-offcanvas-width);border-right:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateX(-100%)}.offcanvas-xl.offcanvas-end{top:0;right:0;width:var(--bs-offcanvas-width);border-left:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateX(100%)}.offcanvas-xl.offcanvas-top{top:0;right:0;left:0;height:var(--bs-offcanvas-height);max-height:100%;border-bottom:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateY(-100%)}.offcanvas-xl.offcanvas-bottom{right:0;left:0;height:var(--bs-offcanvas-height);max-height:100%;border-top:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateY(100%)}.offcanvas-xl.show:not(.hiding),.offcanvas-xl.showing{transform:none}.offcanvas-xl.hiding,.offcanvas-xl.show,.offcanvas-xl.showing{visibility:visible}}@media (min-width:1200px){.offcanvas-xl{--bs-offcanvas-height:auto;--bs-offcanvas-border-width:0;background-color:transparent!important}.offcanvas-xl .offcanvas-header{display:none}.offcanvas-xl .offcanvas-body{display:flex;flex-grow:0;padding:0;overflow-y:visible;background-color:transparent!important}}@media (max-width:1399.98px){.offcanvas-xxl{position:fixed;bottom:0;z-index:var(--bs-offcanvas-zindex);display:flex;flex-direction:column;max-width:100%;color:var(--bs-offcanvas-color);visibility:hidden;background-color:var(--bs-offcanvas-bg);background-clip:padding-box;outline:0;transition:var(--bs-offcanvas-transition)}}@media (max-width:1399.98px) and (prefers-reduced-motion:reduce){.offcanvas-xxl{transition:none}}@media (max-width:1399.98px){.offcanvas-xxl.offcanvas-start{top:0;left:0;width:var(--bs-offcanvas-width);border-right:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateX(-100%)}.offcanvas-xxl.offcanvas-end{top:0;right:0;width:var(--bs-offcanvas-width);border-left:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateX(100%)}.offcanvas-xxl.offcanvas-top{top:0;right:0;left:0;height:var(--bs-offcanvas-height);max-height:100%;border-bottom:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateY(-100%)}.offcanvas-xxl.offcanvas-bottom{right:0;left:0;height:var(--bs-offcanvas-height);max-height:100%;border-top:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateY(100%)}.offcanvas-xxl.show:not(.hiding),.offcanvas-xxl.showing{transform:none}.offcanvas-xxl.hiding,.offcanvas-xxl.show,.offcanvas-xxl.showing{visibility:visible}}@media (min-width:1400px){.offcanvas-xxl{--bs-offcanvas-height:auto;--bs-offcanvas-border-width:0;background-color:transparent!important}.offcanvas-xxl .offcanvas-header{display:none}.offcanvas-xxl .offcanvas-body{display:flex;flex-grow:0;padding:0;overflow-y:visible;background-color:transparent!important}}.offcanvas{position:fixed;bottom:0;z-index:var(--bs-offcanvas-zindex);display:flex;flex-direction:column;max-width:100%;color:var(--bs-offcanvas-color);visibility:hidden;background-color:var(--bs-offcanvas-bg);background-clip:padding-box;outline:0;transition:var(--bs-offcanvas-transition)}@media (prefers-reduced-motion:reduce){.offcanvas{transition:none}}.offcanvas.offcanvas-start{top:0;left:0;width:var(--bs-offcanvas-width);border-right:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateX(-100%)}.offcanvas.offcanvas-end{top:0;right:0;width:var(--bs-offcanvas-width);border-left:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateX(100%)}.offcanvas.offcanvas-top{top:0;right:0;left:0;height:var(--bs-offcanvas-height);max-height:100%;border-bottom:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateY(-100%)}.offcanvas.offcanvas-bottom{right:0;left:0;height:var(--bs-offcanvas-height);max-height:100%;border-top:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateY(100%)}.offcanvas.show:not(.hiding),.offcanvas.showing{transform:none}.offcanvas.hiding,.offcanvas.show,.offcanvas.showing{visibility:visible}.offcanvas-backdrop{position:fixed;top:0;left:0;z-index:1040;width:100vw;height:100vh;background-color:#000}.offcanvas-backdrop.fade{opacity:0}.offcanvas-backdrop.show{opacity:.5}.offcanvas-header{display:flex;align-items:center;padding:var(--bs-offcanvas-padding-y) var(--bs-offcanvas-padding-x)}.offcanvas-header .btn-close{padding:calc(var(--bs-offcanvas-padding-y) * .5) calc(var(--bs-offcanvas-padding-x) * .5);margin-top:calc(-.5 * var(--bs-offcanvas-padding-y));margin-right:calc(-.5 * var(--bs-offcanvas-padding-x));margin-bottom:calc(-.5 * var(--bs-offcanvas-padding-y));margin-left:auto}.offcanvas-title{margin-bottom:0;line-height:var(--bs-offcanvas-title-line-height)}.offcanvas-body{flex-grow:1;padding:var(--bs-offcanvas-padding-y) var(--bs-offcanvas-padding-x);overflow-y:auto}.placeholder{display:inline-block;min-height:1em;vertical-align:middle;cursor:wait;background-color:currentcolor;opacity:.5}.placeholder.btn::before{display:inline-block;content:""}.placeholder-xs{min-height:.6em}.placeholder-sm{min-height:.8em}.placeholder-lg{min-height:1.2em}.placeholder-glow .placeholder{animation:placeholder-glow 2s ease-in-out infinite}@keyframes placeholder-glow{50%{opacity:.2}}.placeholder-wave{-webkit-mask-image:linear-gradient(130deg,#000 55%,rgba(0,0,0,0.8) 75%,#000 95%);mask-image:linear-gradient(130deg,#000 55%,rgba(0,0,0,0.8) 75%,#000 95%);-webkit-mask-size:200% 100%;mask-size:200% 100%;animation:placeholder-wave 2s linear infinite}@keyframes placeholder-wave{100%{-webkit-mask-position:-200% 0%;mask-position:-200% 0%}}.clearfix::after{display:block;clear:both;content:""}.text-bg-primary{color:#fff!important;background-color:RGBA(var(--bs-primary-rgb),var(--bs-bg-opacity,1))!important}.text-bg-secondary{color:#fff!important;background-color:RGBA(var(--bs-secondary-rgb),var(--bs-bg-opacity,1))!important}.text-bg-success{color:#fff!important;background-color:RGBA(var(--bs-success-rgb),var(--bs-bg-opacity,1))!important}.text-bg-info{color:#000!important;background-color:RGBA(var(--bs-info-rgb),var(--bs-bg-opacity,1))!important}.text-bg-warning{color:#000!important;background-color:RGBA(var(--bs-warning-rgb),var(--bs-bg-opacity,1))!important}.text-bg-danger{color:#fff!important;background-color:RGBA(var(--bs-danger-rgb),var(--bs-bg-opacity,1))!important}.text-bg-light{color:#000!important;background-color:RGBA(var(--bs-light-rgb),var(--bs-bg-opacity,1))!important}.text-bg-dark{color:#fff!important;background-color:RGBA(var(--bs-dark-rgb),var(--bs-bg-opacity,1))!important}.link-primary{color:RGBA(var(--bs-primary-rgb),var(--bs-link-opacity,1))!important;-webkit-text-decoration-color:RGBA(var(--bs-primary-rgb),var(--bs-link-underline-opacity,1))!important;text-decoration-color:RGBA(var(--bs-primary-rgb),var(--bs-link-underline-opacity,1))!important}.link-primary:focus,.link-primary:hover{color:RGBA(10,88,202,var(--bs-link-opacity,1))!important;-webkit-text-decoration-color:RGBA(10,88,202,var(--bs-link-underline-opacity,1))!important;text-decoration-color:RGBA(10,88,202,var(--bs-link-underline-opacity,1))!important}.link-secondary{color:RGBA(var(--bs-secondary-rgb),var(--bs-link-opacity,1))!important;-webkit-text-decoration-color:RGBA(var(--bs-secondary-rgb),var(--bs-link-underline-opacity,1))!important;text-decoration-color:RGBA(var(--bs-secondary-rgb),var(--bs-link-underline-opacity,1))!important}.link-secondary:focus,.link-secondary:hover{color:RGBA(86,94,100,var(--bs-link-opacity,1))!important;-webkit-text-decoration-color:RGBA(86,94,100,var(--bs-link-underline-opacity,1))!important;text-decoration-color:RGBA(86,94,100,var(--bs-link-underline-opacity,1))!important}.link-success{color:RGBA(var(--bs-success-rgb),var(--bs-link-opacity,1))!important;-webkit-text-decoration-color:RGBA(var(--bs-success-rgb),var(--bs-link-underline-opacity,1))!important;text-decoration-color:RGBA(var(--bs-success-rgb),var(--bs-link-underline-opacity,1))!important}.link-success:focus,.link-success:hover{color:RGBA(20,108,67,var(--bs-link-opacity,1))!important;-webkit-text-decoration-color:RGBA(20,108,67,var(--bs-link-underline-opacity,1))!important;text-decoration-color:RGBA(20,108,67,var(--bs-link-underline-opacity,1))!important}.link-info{color:RGBA(var(--bs-info-rgb),var(--bs-link-opacity,1))!important;-webkit-text-decoration-color:RGBA(var(--bs-info-rgb),var(--bs-link-underline-opacity,1))!important;text-decoration-color:RGBA(var(--bs-info-rgb),var(--bs-link-underline-opacity,1))!important}.link-info:focus,.link-info:hover{color:RGBA(61,213,243,var(--bs-link-opacity,1))!important;-webkit-text-decoration-color:RGBA(61,213,243,var(--bs-link-underline-opacity,1))!important;text-decoration-color:RGBA(61,213,243,var(--bs-link-underline-opacity,1))!important}.link-warning{color:RGBA(var(--bs-warning-rgb),var(--bs-link-opacity,1))!important;-webkit-text-decoration-color:RGBA(var(--bs-warning-rgb),var(--bs-link-underline-opacity,1))!important;text-decoration-color:RGBA(var(--bs-warning-rgb),var(--bs-link-underline-opacity,1))!important}.link-warning:focus,.link-warning:hover{color:RGBA(255,205,57,var(--bs-link-opacity,1))!important;-webkit-text-decoration-color:RGBA(255,205,57,var(--bs-link-underline-opacity,1))!important;text-decoration-color:RGBA(255,205,57,var(--bs-link-underline-opacity,1))!important}.link-danger{color:RGBA(var(--bs-danger-rgb),var(--bs-link-opacity,1))!important;-webkit-text-decoration-color:RGBA(var(--bs-danger-rgb),var(--bs-link-underline-opacity,1))!important;text-decoration-color:RGBA(var(--bs-danger-rgb),var(--bs-link-underline-opacity,1))!important}.link-danger:focus,.link-danger:hover{color:RGBA(176,42,55,var(--bs-link-opacity,1))!important;-webkit-text-decoration-color:RGBA(176,42,55,var(--bs-link-underline-opacity,1))!important;text-decoration-color:RGBA(176,42,55,var(--bs-link-underline-opacity,1))!important}.link-light{color:RGBA(var(--bs-light-rgb),var(--bs-link-opacity,1))!important;-webkit-text-decoration-color:RGBA(var(--bs-light-rgb),var(--bs-link-underline-opacity,1))!important;text-decoration-color:RGBA(var(--bs-light-rgb),var(--bs-link-underline-opacity,1))!important}.link-light:focus,.link-light:hover{color:RGBA(249,250,251,var(--bs-link-opacity,1))!important;-webkit-text-decoration-color:RGBA(249,250,251,var(--bs-link-underline-opacity,1))!important;text-decoration-color:RGBA(249,250,251,var(--bs-link-underline-opacity,1))!important}.link-dark{color:RGBA(var(--bs-dark-rgb),var(--bs-link-opacity,1))!important;-webkit-text-decoration-color:RGBA(var(--bs-dark-rgb),var(--bs-link-underline-opacity,1))!important;text-decoration-color:RGBA(var(--bs-dark-rgb),var(--bs-link-underline-opacity,1))!important}.link-dark:focus,.link-dark:hover{color:RGBA(26,30,33,var(--bs-link-opacity,1))!important;-webkit-text-decoration-color:RGBA(26,30,33,var(--bs-link-underline-opacity,1))!important;text-decoration-color:RGBA(26,30,33,var(--bs-link-underline-opacity,1))!important}.link-body-emphasis{color:RGBA(var(--bs-emphasis-color-rgb),var(--bs-link-opacity,1))!important;-webkit-text-decoration-color:RGBA(var(--bs-emphasis-color-rgb),var(--bs-link-underline-opacity,1))!important;text-decoration-color:RGBA(var(--bs-emphasis-color-rgb),var(--bs-link-underline-opacity,1))!important}.link-body-emphasis:focus,.link-body-emphasis:hover{color:RGBA(var(--bs-emphasis-color-rgb),var(--bs-link-opacity,.75))!important;-webkit-text-decoration-color:RGBA(var(--bs-emphasis-color-rgb),var(--bs-link-underline-opacity,0.75))!important;text-decoration-color:RGBA(var(--bs-emphasis-color-rgb),var(--bs-link-underline-opacity,0.75))!important}.focus-ring:focus{outline:0;box-shadow:var(--bs-focus-ring-x,0) var(--bs-focus-ring-y,0) var(--bs-focus-ring-blur,0) var(--bs-focus-ring-width) var(--bs-focus-ring-color)}.icon-link{display:inline-flex;gap:.375rem;align-items:center;-webkit-text-decoration-color:rgba(var(--bs-link-color-rgb),var(--bs-link-opacity,0.5));text-decoration-color:rgba(var(--bs-link-color-rgb),var(--bs-link-opacity,0.5));text-underline-offset:0.25em;-webkit-backface-visibility:hidden;backface-visibility:hidden}.icon-link>.bi{flex-shrink:0;width:1em;height:1em;fill:currentcolor;transition:.2s ease-in-out transform}@media (prefers-reduced-motion:reduce){.icon-link>.bi{transition:none}}.icon-link-hover:focus-visible>.bi,.icon-link-hover:hover>.bi{transform:var(--bs-icon-link-transform,translate3d(.25em,0,0))}.ratio{position:relative;width:100%}.ratio::before{display:block;padding-top:var(--bs-aspect-ratio);content:""}.ratio>*{position:absolute;top:0;left:0;width:100%;height:100%}.ratio-1x1{--bs-aspect-ratio:100%}.ratio-4x3{--bs-aspect-ratio:75%}.ratio-16x9{--bs-aspect-ratio:56.25%}.ratio-21x9{--bs-aspect-ratio:42.8571428571%}.fixed-top{position:fixed;top:0;right:0;left:0;z-index:1030}.fixed-bottom{position:fixed;right:0;bottom:0;left:0;z-index:1030}.sticky-top{position:-webkit-sticky;position:sticky;top:0;z-index:1020}.sticky-bottom{position:-webkit-sticky;position:sticky;bottom:0;z-index:1020}@media (min-width:576px){.sticky-sm-top{position:-webkit-sticky;position:sticky;top:0;z-index:1020}.sticky-sm-bottom{position:-webkit-sticky;position:sticky;bottom:0;z-index:1020}}@media (min-width:768px){.sticky-md-top{position:-webkit-sticky;position:sticky;top:0;z-index:1020}.sticky-md-bottom{position:-webkit-sticky;position:sticky;bottom:0;z-index:1020}}@media (min-width:992px){.sticky-lg-top{position:-webkit-sticky;position:sticky;top:0;z-index:1020}.sticky-lg-bottom{position:-webkit-sticky;position:sticky;bottom:0;z-index:1020}}@media (min-width:1200px){.sticky-xl-top{position:-webkit-sticky;position:sticky;top:0;z-index:1020}.sticky-xl-bottom{position:-webkit-sticky;position:sticky;bottom:0;z-index:1020}}@media (min-width:1400px){.sticky-xxl-top{position:-webkit-sticky;position:sticky;top:0;z-index:1020}.sticky-xxl-bottom{position:-webkit-sticky;position:sticky;bottom:0;z-index:1020}}.hstack{display:flex;flex-direction:row;align-items:center;align-self:stretch}.vstack{display:flex;flex:1 1 auto;flex-direction:column;align-self:stretch}.visually-hidden,.visually-hidden-focusable:not(:focus):not(:focus-within){width:1px!important;height:1px!important;padding:0!important;margin:-1px!important;overflow:hidden!important;clip:rect(0,0,0,0)!important;white-space:nowrap!important;border:0!important}.visually-hidden-focusable:not(:focus):not(:focus-within):not(caption),.visually-hidden:not(caption){position:absolute!important}.visually-hidden *,.visually-hidden-focusable:not(:focus):not(:focus-within) *{overflow:hidden!important}.stretched-link::after{position:absolute;top:0;right:0;bottom:0;left:0;z-index:1;content:""}.text-truncate{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.vr{display:inline-block;align-self:stretch;width:var(--bs-border-width);min-height:1em;background-color:currentcolor;opacity:.25}.align-baseline{vertical-align:baseline!important}.align-top{vertical-align:top!important}.align-middle{vertical-align:middle!important}.align-bottom{vertical-align:bottom!important}.align-text-bottom{vertical-align:text-bottom!important}.align-text-top{vertical-align:text-top!important}.float-start{float:left!important}.float-end{float:right!important}.float-none{float:none!important}.object-fit-contain{-o-object-fit:contain!important;object-fit:contain!important}.object-fit-cover{-o-object-fit:cover!important;object-fit:cover!important}.object-fit-fill{-o-object-fit:fill!important;object-fit:fill!important}.object-fit-scale{-o-object-fit:scale-down!important;object-fit:scale-down!important}.object-fit-none{-o-object-fit:none!important;object-fit:none!important}.opacity-0{opacity:0!important}.opacity-25{opacity:.25!important}.opacity-50{opacity:.5!important}.opacity-75{opacity:.75!important}.opacity-100{opacity:1!important}.overflow-auto{overflow:auto!important}.overflow-hidden{overflow:hidden!important}.overflow-visible{overflow:visible!important}.overflow-scroll{overflow:scroll!important}.overflow-x-auto{overflow-x:auto!important}.overflow-x-hidden{overflow-x:hidden!important}.overflow-x-visible{overflow-x:visible!important}.overflow-x-scroll{overflow-x:scroll!important}.overflow-y-auto{overflow-y:auto!important}.overflow-y-hidden{overflow-y:hidden!important}.overflow-y-visible{overflow-y:visible!important}.overflow-y-scroll{overflow-y:scroll!important}.d-inline{display:inline!important}.d-inline-block{display:inline-block!important}.d-block{display:block!important}.d-grid{display:grid!important}.d-inline-grid{display:inline-grid!important}.d-table{display:table!important}.d-table-row{display:table-row!important}.d-table-cell{display:table-cell!important}.d-flex{display:flex!important}.d-inline-flex{display:inline-flex!important}.d-none{display:none!important}.shadow{box-shadow:var(--bs-box-shadow)!important}.shadow-sm{box-shadow:var(--bs-box-shadow-sm)!important}.shadow-lg{box-shadow:var(--bs-box-shadow-lg)!important}.shadow-none{box-shadow:none!important}.focus-ring-primary{--bs-focus-ring-color:rgba(var(--bs-primary-rgb), var(--bs-focus-ring-opacity))}.focus-ring-secondary{--bs-focus-ring-color:rgba(var(--bs-secondary-rgb), var(--bs-focus-ring-opacity))}.focus-ring-success{--bs-focus-ring-color:rgba(var(--bs-success-rgb), var(--bs-focus-ring-opacity))}.focus-ring-info{--bs-focus-ring-color:rgba(var(--bs-info-rgb), var(--bs-focus-ring-opacity))}.focus-ring-warning{--bs-focus-ring-color:rgba(var(--bs-warning-rgb), var(--bs-focus-ring-opacity))}.focus-ring-danger{--bs-focus-ring-color:rgba(var(--bs-danger-rgb), var(--bs-focus-ring-opacity))}.focus-ring-light{--bs-focus-ring-color:rgba(var(--bs-light-rgb), var(--bs-focus-ring-opacity))}.focus-ring-dark{--bs-focus-ring-color:rgba(var(--bs-dark-rgb), var(--bs-focus-ring-opacity))}.position-static{position:static!important}.position-relative{position:relative!important}.position-absolute{position:absolute!important}.position-fixed{position:fixed!important}.position-sticky{position:-webkit-sticky!important;position:sticky!important}.top-0{top:0!important}.top-50{top:50%!important}.top-100{top:100%!important}.bottom-0{bottom:0!important}.bottom-50{bottom:50%!important}.bottom-100{bottom:100%!important}.start-0{left:0!important}.start-50{left:50%!important}.start-100{left:100%!important}.end-0{right:0!important}.end-50{right:50%!important}.end-100{right:100%!important}.translate-middle{transform:translate(-50%,-50%)!important}.translate-middle-x{transform:translateX(-50%)!important}.translate-middle-y{transform:translateY(-50%)!important}.border{border:var(--bs-border-width) var(--bs-border-style) var(--bs-border-color)!important}.border-0{border:0!important}.border-top{border-top:var(--bs-border-width) var(--bs-border-style) var(--bs-border-color)!important}.border-top-0{border-top:0!important}.border-end{border-right:var(--bs-border-width) var(--bs-border-style) var(--bs-border-color)!important}.border-end-0{border-right:0!important}.border-bottom{border-bottom:var(--bs-border-width) var(--bs-border-style) var(--bs-border-color)!important}.border-bottom-0{border-bottom:0!important}.border-start{border-left:var(--bs-border-width) var(--bs-border-style) var(--bs-border-color)!important}.border-start-0{border-left:0!important}.border-primary{--bs-border-opacity:1;border-color:rgba(var(--bs-primary-rgb),var(--bs-border-opacity))!important}.border-secondary{--bs-border-opacity:1;border-color:rgba(var(--bs-secondary-rgb),var(--bs-border-opacity))!important}.border-success{--bs-border-opacity:1;border-color:rgba(var(--bs-success-rgb),var(--bs-border-opacity))!important}.border-info{--bs-border-opacity:1;border-color:rgba(var(--bs-info-rgb),var(--bs-border-opacity))!important}.border-warning{--bs-border-opacity:1;border-color:rgba(var(--bs-warning-rgb),var(--bs-border-opacity))!important}.border-danger{--bs-border-opacity:1;border-color:rgba(var(--bs-danger-rgb),var(--bs-border-opacity))!important}.border-light{--bs-border-opacity:1;border-color:rgba(var(--bs-light-rgb),var(--bs-border-opacity))!important}.border-dark{--bs-border-opacity:1;border-color:rgba(var(--bs-dark-rgb),var(--bs-border-opacity))!important}.border-black{--bs-border-opacity:1;border-color:rgba(var(--bs-black-rgb),var(--bs-border-opacity))!important}.border-white{--bs-border-opacity:1;border-color:rgba(var(--bs-white-rgb),var(--bs-border-opacity))!important}.border-primary-subtle{border-color:var(--bs-primary-border-subtle)!important}.border-secondary-subtle{border-color:var(--bs-secondary-border-subtle)!important}.border-success-subtle{border-color:var(--bs-success-border-subtle)!important}.border-info-subtle{border-color:var(--bs-info-border-subtle)!important}.border-warning-subtle{border-color:var(--bs-warning-border-subtle)!important}.border-danger-subtle{border-color:var(--bs-danger-border-subtle)!important}.border-light-subtle{border-color:var(--bs-light-border-subtle)!important}.border-dark-subtle{border-color:var(--bs-dark-border-subtle)!important}.border-1{border-width:1px!important}.border-2{border-width:2px!important}.border-3{border-width:3px!important}.border-4{border-width:4px!important}.border-5{border-width:5px!important}.border-opacity-10{--bs-border-opacity:0.1}.border-opacity-25{--bs-border-opacity:0.25}.border-opacity-50{--bs-border-opacity:0.5}.border-opacity-75{--bs-border-opacity:0.75}.border-opacity-100{--bs-border-opacity:1}.w-25{width:25%!important}.w-50{width:50%!important}.w-75{width:75%!important}.w-100{width:100%!important}.w-auto{width:auto!important}.mw-100{max-width:100%!important}.vw-100{width:100vw!important}.min-vw-100{min-width:100vw!important}.h-25{height:25%!important}.h-50{height:50%!important}.h-75{height:75%!important}.h-100{height:100%!important}.h-auto{height:auto!important}.mh-100{max-height:100%!important}.vh-100{height:100vh!important}.min-vh-100{min-height:100vh!important}.flex-fill{flex:1 1 auto!important}.flex-row{flex-direction:row!important}.flex-column{flex-direction:column!important}.flex-row-reverse{flex-direction:row-reverse!important}.flex-column-reverse{flex-direction:column-reverse!important}.flex-grow-0{flex-grow:0!important}.flex-grow-1{flex-grow:1!important}.flex-shrink-0{flex-shrink:0!important}.flex-shrink-1{flex-shrink:1!important}.flex-wrap{flex-wrap:wrap!important}.flex-nowrap{flex-wrap:nowrap!important}.flex-wrap-reverse{flex-wrap:wrap-reverse!important}.justify-content-start{justify-content:flex-start!important}.justify-content-end{justify-content:flex-end!important}.justify-content-center{justify-content:center!important}.justify-content-between{justify-content:space-between!important}.justify-content-around{justify-content:space-around!important}.justify-content-evenly{justify-content:space-evenly!important}.align-items-start{align-items:flex-start!important}.align-items-end{align-items:flex-end!important}.align-items-center{align-items:center!important}.align-items-baseline{align-items:baseline!important}.align-items-stretch{align-items:stretch!important}.align-content-start{align-content:flex-start!important}.align-content-end{align-content:flex-end!important}.align-content-center{align-content:center!important}.align-content-between{align-content:space-between!important}.align-content-around{align-content:space-around!important}.align-content-stretch{align-content:stretch!important}.align-self-auto{align-self:auto!important}.align-self-start{align-self:flex-start!important}.align-self-end{align-self:flex-end!important}.align-self-center{align-self:center!important}.align-self-baseline{align-self:baseline!important}.align-self-stretch{align-self:stretch!important}.order-first{order:-1!important}.order-0{order:0!important}.order-1{order:1!important}.order-2{order:2!important}.order-3{order:3!important}.order-4{order:4!important}.order-5{order:5!important}.order-last{order:6!important}.m-0{margin:0!important}.m-1{margin:.25rem!important}.m-2{margin:.5rem!important}.m-3{margin:1rem!important}.m-4{margin:1.5rem!important}.m-5{margin:3rem!important}.m-auto{margin:auto!important}.mx-0{margin-right:0!important;margin-left:0!important}.mx-1{margin-right:.25rem!important;margin-left:.25rem!important}.mx-2{margin-right:.5rem!important;margin-left:.5rem!important}.mx-3{margin-right:1rem!important;margin-left:1rem!important}.mx-4{margin-right:1.5rem!important;margin-left:1.5rem!important}.mx-5{margin-right:3rem!important;margin-left:3rem!important}.mx-auto{margin-right:auto!important;margin-left:auto!important}.my-0{margin-top:0!important;margin-bottom:0!important}.my-1{margin-top:.25rem!important;margin-bottom:.25rem!important}.my-2{margin-top:.5rem!important;margin-bottom:.5rem!important}.my-3{margin-top:1rem!important;margin-bottom:1rem!important}.my-4{margin-top:1.5rem!important;margin-bottom:1.5rem!important}.my-5{margin-top:3rem!important;margin-bottom:3rem!important}.my-auto{margin-top:auto!important;margin-bottom:auto!important}.mt-0{margin-top:0!important}.mt-1{margin-top:.25rem!important}.mt-2{margin-top:.5rem!important}.mt-3{margin-top:1rem!important}.mt-4{margin-top:1.5rem!important}.mt-5{margin-top:3rem!important}.mt-auto{margin-top:auto!important}.me-0{margin-right:0!important}.me-1{margin-right:.25rem!important}.me-2{margin-right:.5rem!important}.me-3{margin-right:1rem!important}.me-4{margin-right:1.5rem!important}.me-5{margin-right:3rem!important}.me-auto{margin-right:auto!important}.mb-0{margin-bottom:0!important}.mb-1{margin-bottom:.25rem!important}.mb-2{margin-bottom:.5rem!important}.mb-3{margin-bottom:1rem!important}.mb-4{margin-bottom:1.5rem!important}.mb-5{margin-bottom:3rem!important}.mb-auto{margin-bottom:auto!important}.ms-0{margin-left:0!important}.ms-1{margin-left:.25rem!important}.ms-2{margin-left:.5rem!important}.ms-3{margin-left:1rem!important}.ms-4{margin-left:1.5rem!important}.ms-5{margin-left:3rem!important}.ms-auto{margin-left:auto!important}.p-0{padding:0!important}.p-1{padding:.25rem!important}.p-2{padding:.5rem!important}.p-3{padding:1rem!important}.p-4{padding:1.5rem!important}.p-5{padding:3rem!important}.px-0{padding-right:0!important;padding-left:0!important}.px-1{padding-right:.25rem!important;padding-left:.25rem!important}.px-2{padding-right:.5rem!important;padding-left:.5rem!important}.px-3{padding-right:1rem!important;padding-left:1rem!important}.px-4{padding-right:1.5rem!important;padding-left:1.5rem!important}.px-5{padding-right:3rem!important;padding-left:3rem!important}.py-0{padding-top:0!important;padding-bottom:0!important}.py-1{padding-top:.25rem!important;padding-bottom:.25rem!important}.py-2{padding-top:.5rem!important;padding-bottom:.5rem!important}.py-3{padding-top:1rem!important;padding-bottom:1rem!important}.py-4{padding-top:1.5rem!important;padding-bottom:1.5rem!important}.py-5{padding-top:3rem!important;padding-bottom:3rem!important}.pt-0{padding-top:0!important}.pt-1{padding-top:.25rem!important}.pt-2{padding-top:.5rem!important}.pt-3{padding-top:1rem!important}.pt-4{padding-top:1.5rem!important}.pt-5{padding-top:3rem!important}.pe-0{padding-right:0!important}.pe-1{padding-right:.25rem!important}.pe-2{padding-right:.5rem!important}.pe-3{padding-right:1rem!important}.pe-4{padding-right:1.5rem!important}.pe-5{padding-right:3rem!important}.pb-0{padding-bottom:0!important}.pb-1{padding-bottom:.25rem!important}.pb-2{padding-bottom:.5rem!important}.pb-3{padding-bottom:1rem!important}.pb-4{padding-bottom:1.5rem!important}.pb-5{padding-bottom:3rem!important}.ps-0{padding-left:0!important}.ps-1{padding-left:.25rem!important}.ps-2{padding-left:.5rem!important}.ps-3{padding-left:1rem!important}.ps-4{padding-left:1.5rem!important}.ps-5{padding-left:3rem!important}.gap-0{gap:0!important}.gap-1{gap:.25rem!important}.gap-2{gap:.5rem!important}.gap-3{gap:1rem!important}.gap-4{gap:1.5rem!important}.gap-5{gap:3rem!important}.row-gap-0{row-gap:0!important}.row-gap-1{row-gap:.25rem!important}.row-gap-2{row-gap:.5rem!important}.row-gap-3{row-gap:1rem!important}.row-gap-4{row-gap:1.5rem!important}.row-gap-5{row-gap:3rem!important}.column-gap-0{-moz-column-gap:0!important;column-gap:0!important}.column-gap-1{-moz-column-gap:0.25rem!important;column-gap:.25rem!important}.column-gap-2{-moz-column-gap:0.5rem!important;column-gap:.5rem!important}.column-gap-3{-moz-column-gap:1rem!important;column-gap:1rem!important}.column-gap-4{-moz-column-gap:1.5rem!important;column-gap:1.5rem!important}.column-gap-5{-moz-column-gap:3rem!important;column-gap:3rem!important}.font-monospace{font-family:var(--bs-font-monospace)!important}.fs-1{font-size:calc(1.375rem + 1.5vw)!important}.fs-2{font-size:calc(1.325rem + .9vw)!important}.fs-3{font-size:calc(1.3rem + .6vw)!important}.fs-4{font-size:calc(1.275rem + .3vw)!important}.fs-5{font-size:1.25rem!important}.fs-6{font-size:1rem!important}.fst-italic{font-style:italic!important}.fst-normal{font-style:normal!important}.fw-lighter{font-weight:lighter!important}.fw-light{font-weight:300!important}.fw-normal{font-weight:400!important}.fw-medium{font-weight:500!important}.fw-semibold{font-weight:600!important}.fw-bold{font-weight:700!important}.fw-bolder{font-weight:bolder!important}.lh-1{line-height:1!important}.lh-sm{line-height:1.25!important}.lh-base{line-height:1.5!important}.lh-lg{line-height:2!important}.text-start{text-align:left!important}.text-end{text-align:right!important}.text-center{text-align:center!important}.text-decoration-none{text-decoration:none!important}.text-decoration-underline{text-decoration:underline!important}.text-decoration-line-through{text-decoration:line-through!important}.text-lowercase{text-transform:lowercase!important}.text-uppercase{text-transform:uppercase!important}.text-capitalize{text-transform:capitalize!important}.text-wrap{white-space:normal!important}.text-nowrap{white-space:nowrap!important}.text-break{word-wrap:break-word!important;word-break:break-word!important}.text-primary{--bs-text-opacity:1;color:rgba(var(--bs-primary-rgb),var(--bs-text-opacity))!important}.text-secondary{--bs-text-opacity:1;color:rgba(var(--bs-secondary-rgb),var(--bs-text-opacity))!important}.text-success{--bs-text-opacity:1;color:rgba(var(--bs-success-rgb),var(--bs-text-opacity))!important}.text-info{--bs-text-opacity:1;color:rgba(var(--bs-info-rgb),var(--bs-text-opacity))!important}.text-warning{--bs-text-opacity:1;color:rgba(var(--bs-warning-rgb),var(--bs-text-opacity))!important}.text-danger{--bs-text-opacity:1;color:rgba(var(--bs-danger-rgb),var(--bs-text-opacity))!important}.text-light{--bs-text-opacity:1;color:rgba(var(--bs-light-rgb),var(--bs-text-opacity))!important}.text-dark{--bs-text-opacity:1;color:rgba(var(--bs-dark-rgb),var(--bs-text-opacity))!important}.text-black{--bs-text-opacity:1;color:rgba(var(--bs-black-rgb),var(--bs-text-opacity))!important}.text-white{--bs-text-opacity:1;color:rgba(var(--bs-white-rgb),var(--bs-text-opacity))!important}.text-body{--bs-text-opacity:1;color:rgba(var(--bs-body-color-rgb),var(--bs-text-opacity))!important}.text-muted{--bs-text-opacity:1;color:var(--bs-secondary-color)!important}.text-black-50{--bs-text-opacity:1;color:rgba(0,0,0,.5)!important}.text-white-50{--bs-text-opacity:1;color:rgba(255,255,255,.5)!important}.text-body-secondary{--bs-text-opacity:1;color:var(--bs-secondary-color)!important}.text-body-tertiary{--bs-text-opacity:1;color:var(--bs-tertiary-color)!important}.text-body-emphasis{--bs-text-opacity:1;color:var(--bs-emphasis-color)!important}.text-reset{--bs-text-opacity:1;color:inherit!important}.text-opacity-25{--bs-text-opacity:0.25}.text-opacity-50{--bs-text-opacity:0.5}.text-opacity-75{--bs-text-opacity:0.75}.text-opacity-100{--bs-text-opacity:1}.text-primary-emphasis{color:var(--bs-primary-text-emphasis)!important}.text-secondary-emphasis{color:var(--bs-secondary-text-emphasis)!important}.text-success-emphasis{color:var(--bs-success-text-emphasis)!important}.text-info-emphasis{color:var(--bs-info-text-emphasis)!important}.text-warning-emphasis{color:var(--bs-warning-text-emphasis)!important}.text-danger-emphasis{color:var(--bs-danger-text-emphasis)!important}.text-light-emphasis{color:var(--bs-light-text-emphasis)!important}.text-dark-emphasis{color:var(--bs-dark-text-emphasis)!important}.link-opacity-10{--bs-link-opacity:0.1}.link-opacity-10-hover:hover{--bs-link-opacity:0.1}.link-opacity-25{--bs-link-opacity:0.25}.link-opacity-25-hover:hover{--bs-link-opacity:0.25}.link-opacity-50{--bs-link-opacity:0.5}.link-opacity-50-hover:hover{--bs-link-opacity:0.5}.link-opacity-75{--bs-link-opacity:0.75}.link-opacity-75-hover:hover{--bs-link-opacity:0.75}.link-opacity-100{--bs-link-opacity:1}.link-opacity-100-hover:hover{--bs-link-opacity:1}.link-offset-1{text-underline-offset:0.125em!important}.link-offset-1-hover:hover{text-underline-offset:0.125em!important}.link-offset-2{text-underline-offset:0.25em!important}.link-offset-2-hover:hover{text-underline-offset:0.25em!important}.link-offset-3{text-underline-offset:0.375em!important}.link-offset-3-hover:hover{text-underline-offset:0.375em!important}.link-underline-primary{--bs-link-underline-opacity:1;-webkit-text-decoration-color:rgba(var(--bs-primary-rgb),var(--bs-link-underline-opacity))!important;text-decoration-color:rgba(var(--bs-primary-rgb),var(--bs-link-underline-opacity))!important}.link-underline-secondary{--bs-link-underline-opacity:1;-webkit-text-decoration-color:rgba(var(--bs-secondary-rgb),var(--bs-link-underline-opacity))!important;text-decoration-color:rgba(var(--bs-secondary-rgb),var(--bs-link-underline-opacity))!important}.link-underline-success{--bs-link-underline-opacity:1;-webkit-text-decoration-color:rgba(var(--bs-success-rgb),var(--bs-link-underline-opacity))!important;text-decoration-color:rgba(var(--bs-success-rgb),var(--bs-link-underline-opacity))!important}.link-underline-info{--bs-link-underline-opacity:1;-webkit-text-decoration-color:rgba(var(--bs-info-rgb),var(--bs-link-underline-opacity))!important;text-decoration-color:rgba(var(--bs-info-rgb),var(--bs-link-underline-opacity))!important}.link-underline-warning{--bs-link-underline-opacity:1;-webkit-text-decoration-color:rgba(var(--bs-warning-rgb),var(--bs-link-underline-opacity))!important;text-decoration-color:rgba(var(--bs-warning-rgb),var(--bs-link-underline-opacity))!important}.link-underline-danger{--bs-link-underline-opacity:1;-webkit-text-decoration-color:rgba(var(--bs-danger-rgb),var(--bs-link-underline-opacity))!important;text-decoration-color:rgba(var(--bs-danger-rgb),var(--bs-link-underline-opacity))!important}.link-underline-light{--bs-link-underline-opacity:1;-webkit-text-decoration-color:rgba(var(--bs-light-rgb),var(--bs-link-underline-opacity))!important;text-decoration-color:rgba(var(--bs-light-rgb),var(--bs-link-underline-opacity))!important}.link-underline-dark{--bs-link-underline-opacity:1;-webkit-text-decoration-color:rgba(var(--bs-dark-rgb),var(--bs-link-underline-opacity))!important;text-decoration-color:rgba(var(--bs-dark-rgb),var(--bs-link-underline-opacity))!important}.link-underline{--bs-link-underline-opacity:1;-webkit-text-decoration-color:rgba(var(--bs-link-color-rgb),var(--bs-link-underline-opacity,1))!important;text-decoration-color:rgba(var(--bs-link-color-rgb),var(--bs-link-underline-opacity,1))!important}.link-underline-opacity-0{--bs-link-underline-opacity:0}.link-underline-opacity-0-hover:hover{--bs-link-underline-opacity:0}.link-underline-opacity-10{--bs-link-underline-opacity:0.1}.link-underline-opacity-10-hover:hover{--bs-link-underline-opacity:0.1}.link-underline-opacity-25{--bs-link-underline-opacity:0.25}.link-underline-opacity-25-hover:hover{--bs-link-underline-opacity:0.25}.link-underline-opacity-50{--bs-link-underline-opacity:0.5}.link-underline-opacity-50-hover:hover{--bs-link-underline-opacity:0.5}.link-underline-opacity-75{--bs-link-underline-opacity:0.75}.link-underline-opacity-75-hover:hover{--bs-link-underline-opacity:0.75}.link-underline-opacity-100{--bs-link-underline-opacity:1}.link-underline-opacity-100-hover:hover{--bs-link-underline-opacity:1}.bg-primary{--bs-bg-opacity:1;background-color:rgba(var(--bs-primary-rgb),var(--bs-bg-opacity))!important}.bg-secondary{--bs-bg-opacity:1;background-color:rgba(var(--bs-secondary-rgb),var(--bs-bg-opacity))!important}.bg-success{--bs-bg-opacity:1;background-color:rgba(var(--bs-success-rgb),var(--bs-bg-opacity))!important}.bg-info{--bs-bg-opacity:1;background-color:rgba(var(--bs-info-rgb),var(--bs-bg-opacity))!important}.bg-warning{--bs-bg-opacity:1;background-color:rgba(var(--bs-warning-rgb),var(--bs-bg-opacity))!important}.bg-danger{--bs-bg-opacity:1;background-color:rgba(var(--bs-danger-rgb),var(--bs-bg-opacity))!important}.bg-light{--bs-bg-opacity:1;background-color:rgba(var(--bs-light-rgb),var(--bs-bg-opacity))!important}.bg-dark{--bs-bg-opacity:1;background-color:rgba(var(--bs-dark-rgb),var(--bs-bg-opacity))!important}.bg-black{--bs-bg-opacity:1;background-color:rgba(var(--bs-black-rgb),var(--bs-bg-opacity))!important}.bg-white{--bs-bg-opacity:1;background-color:rgba(var(--bs-white-rgb),var(--bs-bg-opacity))!important}.bg-body{--bs-bg-opacity:1;background-color:rgba(var(--bs-body-bg-rgb),var(--bs-bg-opacity))!important}.bg-transparent{--bs-bg-opacity:1;background-color:transparent!important}.bg-body-secondary{--bs-bg-opacity:1;background-color:rgba(var(--bs-secondary-bg-rgb),var(--bs-bg-opacity))!important}.bg-body-tertiary{--bs-bg-opacity:1;background-color:rgba(var(--bs-tertiary-bg-rgb),var(--bs-bg-opacity))!important}.bg-opacity-10{--bs-bg-opacity:0.1}.bg-opacity-25{--bs-bg-opacity:0.25}.bg-opacity-50{--bs-bg-opacity:0.5}.bg-opacity-75{--bs-bg-opacity:0.75}.bg-opacity-100{--bs-bg-opacity:1}.bg-primary-subtle{background-color:var(--bs-primary-bg-subtle)!important}.bg-secondary-subtle{background-color:var(--bs-secondary-bg-subtle)!important}.bg-success-subtle{background-color:var(--bs-success-bg-subtle)!important}.bg-info-subtle{background-color:var(--bs-info-bg-subtle)!important}.bg-warning-subtle{background-color:var(--bs-warning-bg-subtle)!important}.bg-danger-subtle{background-color:var(--bs-danger-bg-subtle)!important}.bg-light-subtle{background-color:var(--bs-light-bg-subtle)!important}.bg-dark-subtle{background-color:var(--bs-dark-bg-subtle)!important}.bg-gradient{background-image:var(--bs-gradient)!important}.user-select-all{-webkit-user-select:all!important;-moz-user-select:all!important;user-select:all!important}.user-select-auto{-webkit-user-select:auto!important;-moz-user-select:auto!important;user-select:auto!important}.user-select-none{-webkit-user-select:none!important;-moz-user-select:none!important;user-select:none!important}.pe-none{pointer-events:none!important}.pe-auto{pointer-events:auto!important}.rounded{border-radius:var(--bs-border-radius)!important}.rounded-0{border-radius:0!important}.rounded-1{border-radius:var(--bs-border-radius-sm)!important}.rounded-2{border-radius:var(--bs-border-radius)!important}.rounded-3{border-radius:var(--bs-border-radius-lg)!important}.rounded-4{border-radius:var(--bs-border-radius-xl)!important}.rounded-5{border-radius:var(--bs-border-radius-xxl)!important}.rounded-circle{border-radius:50%!important}.rounded-pill{border-radius:var(--bs-border-radius-pill)!important}.rounded-top{border-top-left-radius:var(--bs-border-radius)!important;border-top-right-radius:var(--bs-border-radius)!important}.rounded-top-0{border-top-left-radius:0!important;border-top-right-radius:0!important}.rounded-top-1{border-top-left-radius:var(--bs-border-radius-sm)!important;border-top-right-radius:var(--bs-border-radius-sm)!important}.rounded-top-2{border-top-left-radius:var(--bs-border-radius)!important;border-top-right-radius:var(--bs-border-radius)!important}.rounded-top-3{border-top-left-radius:var(--bs-border-radius-lg)!important;border-top-right-radius:var(--bs-border-radius-lg)!important}.rounded-top-4{border-top-left-radius:var(--bs-border-radius-xl)!important;border-top-right-radius:var(--bs-border-radius-xl)!important}.rounded-top-5{border-top-left-radius:var(--bs-border-radius-xxl)!important;border-top-right-radius:var(--bs-border-radius-xxl)!important}.rounded-top-circle{border-top-left-radius:50%!important;border-top-right-radius:50%!important}.rounded-top-pill{border-top-left-radius:var(--bs-border-radius-pill)!important;border-top-right-radius:var(--bs-border-radius-pill)!important}.rounded-end{border-top-right-radius:var(--bs-border-radius)!important;border-bottom-right-radius:var(--bs-border-radius)!important}.rounded-end-0{border-top-right-radius:0!important;border-bottom-right-radius:0!important}.rounded-end-1{border-top-right-radius:var(--bs-border-radius-sm)!important;border-bottom-right-radius:var(--bs-border-radius-sm)!important}.rounded-end-2{border-top-right-radius:var(--bs-border-radius)!important;border-bottom-right-radius:var(--bs-border-radius)!important}.rounded-end-3{border-top-right-radius:var(--bs-border-radius-lg)!important;border-bottom-right-radius:var(--bs-border-radius-lg)!important}.rounded-end-4{border-top-right-radius:var(--bs-border-radius-xl)!important;border-bottom-right-radius:var(--bs-border-radius-xl)!important}.rounded-end-5{border-top-right-radius:var(--bs-border-radius-xxl)!important;border-bottom-right-radius:var(--bs-border-radius-xxl)!important}.rounded-end-circle{border-top-right-radius:50%!important;border-bottom-right-radius:50%!important}.rounded-end-pill{border-top-right-radius:var(--bs-border-radius-pill)!important;border-bottom-right-radius:var(--bs-border-radius-pill)!important}.rounded-bottom{border-bottom-right-radius:var(--bs-border-radius)!important;border-bottom-left-radius:var(--bs-border-radius)!important}.rounded-bottom-0{border-bottom-right-radius:0!important;border-bottom-left-radius:0!important}.rounded-bottom-1{border-bottom-right-radius:var(--bs-border-radius-sm)!important;border-bottom-left-radius:var(--bs-border-radius-sm)!important}.rounded-bottom-2{border-bottom-right-radius:var(--bs-border-radius)!important;border-bottom-left-radius:var(--bs-border-radius)!important}.rounded-bottom-3{border-bottom-right-radius:var(--bs-border-radius-lg)!important;border-bottom-left-radius:var(--bs-border-radius-lg)!important}.rounded-bottom-4{border-bottom-right-radius:var(--bs-border-radius-xl)!important;border-bottom-left-radius:var(--bs-border-radius-xl)!important}.rounded-bottom-5{border-bottom-right-radius:var(--bs-border-radius-xxl)!important;border-bottom-left-radius:var(--bs-border-radius-xxl)!important}.rounded-bottom-circle{border-bottom-right-radius:50%!important;border-bottom-left-radius:50%!important}.rounded-bottom-pill{border-bottom-right-radius:var(--bs-border-radius-pill)!important;border-bottom-left-radius:var(--bs-border-radius-pill)!important}.rounded-start{border-bottom-left-radius:var(--bs-border-radius)!important;border-top-left-radius:var(--bs-border-radius)!important}.rounded-start-0{border-bottom-left-radius:0!important;border-top-left-radius:0!important}.rounded-start-1{border-bottom-left-radius:var(--bs-border-radius-sm)!important;border-top-left-radius:var(--bs-border-radius-sm)!important}.rounded-start-2{border-bottom-left-radius:var(--bs-border-radius)!important;border-top-left-radius:var(--bs-border-radius)!important}.rounded-start-3{border-bottom-left-radius:var(--bs-border-radius-lg)!important;border-top-left-radius:var(--bs-border-radius-lg)!important}.rounded-start-4{border-bottom-left-radius:var(--bs-border-radius-xl)!important;border-top-left-radius:var(--bs-border-radius-xl)!important}.rounded-start-5{border-bottom-left-radius:var(--bs-border-radius-xxl)!important;border-top-left-radius:var(--bs-border-radius-xxl)!important}.rounded-start-circle{border-bottom-left-radius:50%!important;border-top-left-radius:50%!important}.rounded-start-pill{border-bottom-left-radius:var(--bs-border-radius-pill)!important;border-top-left-radius:var(--bs-border-radius-pill)!important}.visible{visibility:visible!important}.invisible{visibility:hidden!important}.z-n1{z-index:-1!important}.z-0{z-index:0!important}.z-1{z-index:1!important}.z-2{z-index:2!important}.z-3{z-index:3!important}@media (min-width:576px){.float-sm-start{float:left!important}.float-sm-end{float:right!important}.float-sm-none{float:none!important}.object-fit-sm-contain{-o-object-fit:contain!important;object-fit:contain!important}.object-fit-sm-cover{-o-object-fit:cover!important;object-fit:cover!important}.object-fit-sm-fill{-o-object-fit:fill!important;object-fit:fill!important}.object-fit-sm-scale{-o-object-fit:scale-down!important;object-fit:scale-down!important}.object-fit-sm-none{-o-object-fit:none!important;object-fit:none!important}.d-sm-inline{display:inline!important}.d-sm-inline-block{display:inline-block!important}.d-sm-block{display:block!important}.d-sm-grid{display:grid!important}.d-sm-inline-grid{display:inline-grid!important}.d-sm-table{display:table!important}.d-sm-table-row{display:table-row!important}.d-sm-table-cell{display:table-cell!important}.d-sm-flex{display:flex!important}.d-sm-inline-flex{display:inline-flex!important}.d-sm-none{display:none!important}.flex-sm-fill{flex:1 1 auto!important}.flex-sm-row{flex-direction:row!important}.flex-sm-column{flex-direction:column!important}.flex-sm-row-reverse{flex-direction:row-reverse!important}.flex-sm-column-reverse{flex-direction:column-reverse!important}.flex-sm-grow-0{flex-grow:0!important}.flex-sm-grow-1{flex-grow:1!important}.flex-sm-shrink-0{flex-shrink:0!important}.flex-sm-shrink-1{flex-shrink:1!important}.flex-sm-wrap{flex-wrap:wrap!important}.flex-sm-nowrap{flex-wrap:nowrap!important}.flex-sm-wrap-reverse{flex-wrap:wrap-reverse!important}.justify-content-sm-start{justify-content:flex-start!important}.justify-content-sm-end{justify-content:flex-end!important}.justify-content-sm-center{justify-content:center!important}.justify-content-sm-between{justify-content:space-between!important}.justify-content-sm-around{justify-content:space-around!important}.justify-content-sm-evenly{justify-content:space-evenly!important}.align-items-sm-start{align-items:flex-start!important}.align-items-sm-end{align-items:flex-end!important}.align-items-sm-center{align-items:center!important}.align-items-sm-baseline{align-items:baseline!important}.align-items-sm-stretch{align-items:stretch!important}.align-content-sm-start{align-content:flex-start!important}.align-content-sm-end{align-content:flex-end!important}.align-content-sm-center{align-content:center!important}.align-content-sm-between{align-content:space-between!important}.align-content-sm-around{align-content:space-around!important}.align-content-sm-stretch{align-content:stretch!important}.align-self-sm-auto{align-self:auto!important}.align-self-sm-start{align-self:flex-start!important}.align-self-sm-end{align-self:flex-end!important}.align-self-sm-center{align-self:center!important}.align-self-sm-baseline{align-self:baseline!important}.align-self-sm-stretch{align-self:stretch!important}.order-sm-first{order:-1!important}.order-sm-0{order:0!important}.order-sm-1{order:1!important}.order-sm-2{order:2!important}.order-sm-3{order:3!important}.order-sm-4{order:4!important}.order-sm-5{order:5!important}.order-sm-last{order:6!important}.m-sm-0{margin:0!important}.m-sm-1{margin:.25rem!important}.m-sm-2{margin:.5rem!important}.m-sm-3{margin:1rem!important}.m-sm-4{margin:1.5rem!important}.m-sm-5{margin:3rem!important}.m-sm-auto{margin:auto!important}.mx-sm-0{margin-right:0!important;margin-left:0!important}.mx-sm-1{margin-right:.25rem!important;margin-left:.25rem!important}.mx-sm-2{margin-right:.5rem!important;margin-left:.5rem!important}.mx-sm-3{margin-right:1rem!important;margin-left:1rem!important}.mx-sm-4{margin-right:1.5rem!important;margin-left:1.5rem!important}.mx-sm-5{margin-right:3rem!important;margin-left:3rem!important}.mx-sm-auto{margin-right:auto!important;margin-left:auto!important}.my-sm-0{margin-top:0!important;margin-bottom:0!important}.my-sm-1{margin-top:.25rem!important;margin-bottom:.25rem!important}.my-sm-2{margin-top:.5rem!important;margin-bottom:.5rem!important}.my-sm-3{margin-top:1rem!important;margin-bottom:1rem!important}.my-sm-4{margin-top:1.5rem!important;margin-bottom:1.5rem!important}.my-sm-5{margin-top:3rem!important;margin-bottom:3rem!important}.my-sm-auto{margin-top:auto!important;margin-bottom:auto!important}.mt-sm-0{margin-top:0!important}.mt-sm-1{margin-top:.25rem!important}.mt-sm-2{margin-top:.5rem!important}.mt-sm-3{margin-top:1rem!important}.mt-sm-4{margin-top:1.5rem!important}.mt-sm-5{margin-top:3rem!important}.mt-sm-auto{margin-top:auto!important}.me-sm-0{margin-right:0!important}.me-sm-1{margin-right:.25rem!important}.me-sm-2{margin-right:.5rem!important}.me-sm-3{margin-right:1rem!important}.me-sm-4{margin-right:1.5rem!important}.me-sm-5{margin-right:3rem!important}.me-sm-auto{margin-right:auto!important}.mb-sm-0{margin-bottom:0!important}.mb-sm-1{margin-bottom:.25rem!important}.mb-sm-2{margin-bottom:.5rem!important}.mb-sm-3{margin-bottom:1rem!important}.mb-sm-4{margin-bottom:1.5rem!important}.mb-sm-5{margin-bottom:3rem!important}.mb-sm-auto{margin-bottom:auto!important}.ms-sm-0{margin-left:0!important}.ms-sm-1{margin-left:.25rem!important}.ms-sm-2{margin-left:.5rem!important}.ms-sm-3{margin-left:1rem!important}.ms-sm-4{margin-left:1.5rem!important}.ms-sm-5{margin-left:3rem!important}.ms-sm-auto{margin-left:auto!important}.p-sm-0{padding:0!important}.p-sm-1{padding:.25rem!important}.p-sm-2{padding:.5rem!important}.p-sm-3{padding:1rem!important}.p-sm-4{padding:1.5rem!important}.p-sm-5{padding:3rem!important}.px-sm-0{padding-right:0!important;padding-left:0!important}.px-sm-1{padding-right:.25rem!important;padding-left:.25rem!important}.px-sm-2{padding-right:.5rem!important;padding-left:.5rem!important}.px-sm-3{padding-right:1rem!important;padding-left:1rem!important}.px-sm-4{padding-right:1.5rem!important;padding-left:1.5rem!important}.px-sm-5{padding-right:3rem!important;padding-left:3rem!important}.py-sm-0{padding-top:0!important;padding-bottom:0!important}.py-sm-1{padding-top:.25rem!important;padding-bottom:.25rem!important}.py-sm-2{padding-top:.5rem!important;padding-bottom:.5rem!important}.py-sm-3{padding-top:1rem!important;padding-bottom:1rem!important}.py-sm-4{padding-top:1.5rem!important;padding-bottom:1.5rem!important}.py-sm-5{padding-top:3rem!important;padding-bottom:3rem!important}.pt-sm-0{padding-top:0!important}.pt-sm-1{padding-top:.25rem!important}.pt-sm-2{padding-top:.5rem!important}.pt-sm-3{padding-top:1rem!important}.pt-sm-4{padding-top:1.5rem!important}.pt-sm-5{padding-top:3rem!important}.pe-sm-0{padding-right:0!important}.pe-sm-1{padding-right:.25rem!important}.pe-sm-2{padding-right:.5rem!important}.pe-sm-3{padding-right:1rem!important}.pe-sm-4{padding-right:1.5rem!important}.pe-sm-5{padding-right:3rem!important}.pb-sm-0{padding-bottom:0!important}.pb-sm-1{padding-bottom:.25rem!important}.pb-sm-2{padding-bottom:.5rem!important}.pb-sm-3{padding-bottom:1rem!important}.pb-sm-4{padding-bottom:1.5rem!important}.pb-sm-5{padding-bottom:3rem!important}.ps-sm-0{padding-left:0!important}.ps-sm-1{padding-left:.25rem!important}.ps-sm-2{padding-left:.5rem!important}.ps-sm-3{padding-left:1rem!important}.ps-sm-4{padding-left:1.5rem!important}.ps-sm-5{padding-left:3rem!important}.gap-sm-0{gap:0!important}.gap-sm-1{gap:.25rem!important}.gap-sm-2{gap:.5rem!important}.gap-sm-3{gap:1rem!important}.gap-sm-4{gap:1.5rem!important}.gap-sm-5{gap:3rem!important}.row-gap-sm-0{row-gap:0!important}.row-gap-sm-1{row-gap:.25rem!important}.row-gap-sm-2{row-gap:.5rem!important}.row-gap-sm-3{row-gap:1rem!important}.row-gap-sm-4{row-gap:1.5rem!important}.row-gap-sm-5{row-gap:3rem!important}.column-gap-sm-0{-moz-column-gap:0!important;column-gap:0!important}.column-gap-sm-1{-moz-column-gap:0.25rem!important;column-gap:.25rem!important}.column-gap-sm-2{-moz-column-gap:0.5rem!important;column-gap:.5rem!important}.column-gap-sm-3{-moz-column-gap:1rem!important;column-gap:1rem!important}.column-gap-sm-4{-moz-column-gap:1.5rem!important;column-gap:1.5rem!important}.column-gap-sm-5{-moz-column-gap:3rem!important;column-gap:3rem!important}.text-sm-start{text-align:left!important}.text-sm-end{text-align:right!important}.text-sm-center{text-align:center!important}}@media (min-width:768px){.float-md-start{float:left!important}.float-md-end{float:right!important}.float-md-none{float:none!important}.object-fit-md-contain{-o-object-fit:contain!important;object-fit:contain!important}.object-fit-md-cover{-o-object-fit:cover!important;object-fit:cover!important}.object-fit-md-fill{-o-object-fit:fill!important;object-fit:fill!important}.object-fit-md-scale{-o-object-fit:scale-down!important;object-fit:scale-down!important}.object-fit-md-none{-o-object-fit:none!important;object-fit:none!important}.d-md-inline{display:inline!important}.d-md-inline-block{display:inline-block!important}.d-md-block{display:block!important}.d-md-grid{display:grid!important}.d-md-inline-grid{display:inline-grid!important}.d-md-table{display:table!important}.d-md-table-row{display:table-row!important}.d-md-table-cell{display:table-cell!important}.d-md-flex{display:flex!important}.d-md-inline-flex{display:inline-flex!important}.d-md-none{display:none!important}.flex-md-fill{flex:1 1 auto!important}.flex-md-row{flex-direction:row!important}.flex-md-column{flex-direction:column!important}.flex-md-row-reverse{flex-direction:row-reverse!important}.flex-md-column-reverse{flex-direction:column-reverse!important}.flex-md-grow-0{flex-grow:0!important}.flex-md-grow-1{flex-grow:1!important}.flex-md-shrink-0{flex-shrink:0!important}.flex-md-shrink-1{flex-shrink:1!important}.flex-md-wrap{flex-wrap:wrap!important}.flex-md-nowrap{flex-wrap:nowrap!important}.flex-md-wrap-reverse{flex-wrap:wrap-reverse!important}.justify-content-md-start{justify-content:flex-start!important}.justify-content-md-end{justify-content:flex-end!important}.justify-content-md-center{justify-content:center!important}.justify-content-md-between{justify-content:space-between!important}.justify-content-md-around{justify-content:space-around!important}.justify-content-md-evenly{justify-content:space-evenly!important}.align-items-md-start{align-items:flex-start!important}.align-items-md-end{align-items:flex-end!important}.align-items-md-center{align-items:center!important}.align-items-md-baseline{align-items:baseline!important}.align-items-md-stretch{align-items:stretch!important}.align-content-md-start{align-content:flex-start!important}.align-content-md-end{align-content:flex-end!important}.align-content-md-center{align-content:center!important}.align-content-md-between{align-content:space-between!important}.align-content-md-around{align-content:space-around!important}.align-content-md-stretch{align-content:stretch!important}.align-self-md-auto{align-self:auto!important}.align-self-md-start{align-self:flex-start!important}.align-self-md-end{align-self:flex-end!important}.align-self-md-center{align-self:center!important}.align-self-md-baseline{align-self:baseline!important}.align-self-md-stretch{align-self:stretch!important}.order-md-first{order:-1!important}.order-md-0{order:0!important}.order-md-1{order:1!important}.order-md-2{order:2!important}.order-md-3{order:3!important}.order-md-4{order:4!important}.order-md-5{order:5!important}.order-md-last{order:6!important}.m-md-0{margin:0!important}.m-md-1{margin:.25rem!important}.m-md-2{margin:.5rem!important}.m-md-3{margin:1rem!important}.m-md-4{margin:1.5rem!important}.m-md-5{margin:3rem!important}.m-md-auto{margin:auto!important}.mx-md-0{margin-right:0!important;margin-left:0!important}.mx-md-1{margin-right:.25rem!important;margin-left:.25rem!important}.mx-md-2{margin-right:.5rem!important;margin-left:.5rem!important}.mx-md-3{margin-right:1rem!important;margin-left:1rem!important}.mx-md-4{margin-right:1.5rem!important;margin-left:1.5rem!important}.mx-md-5{margin-right:3rem!important;margin-left:3rem!important}.mx-md-auto{margin-right:auto!important;margin-left:auto!important}.my-md-0{margin-top:0!important;margin-bottom:0!important}.my-md-1{margin-top:.25rem!important;margin-bottom:.25rem!important}.my-md-2{margin-top:.5rem!important;margin-bottom:.5rem!important}.my-md-3{margin-top:1rem!important;margin-bottom:1rem!important}.my-md-4{margin-top:1.5rem!important;margin-bottom:1.5rem!important}.my-md-5{margin-top:3rem!important;margin-bottom:3rem!important}.my-md-auto{margin-top:auto!important;margin-bottom:auto!important}.mt-md-0{margin-top:0!important}.mt-md-1{margin-top:.25rem!important}.mt-md-2{margin-top:.5rem!important}.mt-md-3{margin-top:1rem!important}.mt-md-4{margin-top:1.5rem!important}.mt-md-5{margin-top:3rem!important}.mt-md-auto{margin-top:auto!important}.me-md-0{margin-right:0!important}.me-md-1{margin-right:.25rem!important}.me-md-2{margin-right:.5rem!important}.me-md-3{margin-right:1rem!important}.me-md-4{margin-right:1.5rem!important}.me-md-5{margin-right:3rem!important}.me-md-auto{margin-right:auto!important}.mb-md-0{margin-bottom:0!important}.mb-md-1{margin-bottom:.25rem!important}.mb-md-2{margin-bottom:.5rem!important}.mb-md-3{margin-bottom:1rem!important}.mb-md-4{margin-bottom:1.5rem!important}.mb-md-5{margin-bottom:3rem!important}.mb-md-auto{margin-bottom:auto!important}.ms-md-0{margin-left:0!important}.ms-md-1{margin-left:.25rem!important}.ms-md-2{margin-left:.5rem!important}.ms-md-3{margin-left:1rem!important}.ms-md-4{margin-left:1.5rem!important}.ms-md-5{margin-left:3rem!important}.ms-md-auto{margin-left:auto!important}.p-md-0{padding:0!important}.p-md-1{padding:.25rem!important}.p-md-2{padding:.5rem!important}.p-md-3{padding:1rem!important}.p-md-4{padding:1.5rem!important}.p-md-5{padding:3rem!important}.px-md-0{padding-right:0!important;padding-left:0!important}.px-md-1{padding-right:.25rem!important;padding-left:.25rem!important}.px-md-2{padding-right:.5rem!important;padding-left:.5rem!important}.px-md-3{padding-right:1rem!important;padding-left:1rem!important}.px-md-4{padding-right:1.5rem!important;padding-left:1.5rem!important}.px-md-5{padding-right:3rem!important;padding-left:3rem!important}.py-md-0{padding-top:0!important;padding-bottom:0!important}.py-md-1{padding-top:.25rem!important;padding-bottom:.25rem!important}.py-md-2{padding-top:.5rem!important;padding-bottom:.5rem!important}.py-md-3{padding-top:1rem!important;padding-bottom:1rem!important}.py-md-4{padding-top:1.5rem!important;padding-bottom:1.5rem!important}.py-md-5{padding-top:3rem!important;padding-bottom:3rem!important}.pt-md-0{padding-top:0!important}.pt-md-1{padding-top:.25rem!important}.pt-md-2{padding-top:.5rem!important}.pt-md-3{padding-top:1rem!important}.pt-md-4{padding-top:1.5rem!important}.pt-md-5{padding-top:3rem!important}.pe-md-0{padding-right:0!important}.pe-md-1{padding-right:.25rem!important}.pe-md-2{padding-right:.5rem!important}.pe-md-3{padding-right:1rem!important}.pe-md-4{padding-right:1.5rem!important}.pe-md-5{padding-right:3rem!important}.pb-md-0{padding-bottom:0!important}.pb-md-1{padding-bottom:.25rem!important}.pb-md-2{padding-bottom:.5rem!important}.pb-md-3{padding-bottom:1rem!important}.pb-md-4{padding-bottom:1.5rem!important}.pb-md-5{padding-bottom:3rem!important}.ps-md-0{padding-left:0!important}.ps-md-1{padding-left:.25rem!important}.ps-md-2{padding-left:.5rem!important}.ps-md-3{padding-left:1rem!important}.ps-md-4{padding-left:1.5rem!important}.ps-md-5{padding-left:3rem!important}.gap-md-0{gap:0!important}.gap-md-1{gap:.25rem!important}.gap-md-2{gap:.5rem!important}.gap-md-3{gap:1rem!important}.gap-md-4{gap:1.5rem!important}.gap-md-5{gap:3rem!important}.row-gap-md-0{row-gap:0!important}.row-gap-md-1{row-gap:.25rem!important}.row-gap-md-2{row-gap:.5rem!important}.row-gap-md-3{row-gap:1rem!important}.row-gap-md-4{row-gap:1.5rem!important}.row-gap-md-5{row-gap:3rem!important}.column-gap-md-0{-moz-column-gap:0!important;column-gap:0!important}.column-gap-md-1{-moz-column-gap:0.25rem!important;column-gap:.25rem!important}.column-gap-md-2{-moz-column-gap:0.5rem!important;column-gap:.5rem!important}.column-gap-md-3{-moz-column-gap:1rem!important;column-gap:1rem!important}.column-gap-md-4{-moz-column-gap:1.5rem!important;column-gap:1.5rem!important}.column-gap-md-5{-moz-column-gap:3rem!important;column-gap:3rem!important}.text-md-start{text-align:left!important}.text-md-end{text-align:right!important}.text-md-center{text-align:center!important}}@media (min-width:992px){.float-lg-start{float:left!important}.float-lg-end{float:right!important}.float-lg-none{float:none!important}.object-fit-lg-contain{-o-object-fit:contain!important;object-fit:contain!important}.object-fit-lg-cover{-o-object-fit:cover!important;object-fit:cover!important}.object-fit-lg-fill{-o-object-fit:fill!important;object-fit:fill!important}.object-fit-lg-scale{-o-object-fit:scale-down!important;object-fit:scale-down!important}.object-fit-lg-none{-o-object-fit:none!important;object-fit:none!important}.d-lg-inline{display:inline!important}.d-lg-inline-block{display:inline-block!important}.d-lg-block{display:block!important}.d-lg-grid{display:grid!important}.d-lg-inline-grid{display:inline-grid!important}.d-lg-table{display:table!important}.d-lg-table-row{display:table-row!important}.d-lg-table-cell{display:table-cell!important}.d-lg-flex{display:flex!important}.d-lg-inline-flex{display:inline-flex!important}.d-lg-none{display:none!important}.flex-lg-fill{flex:1 1 auto!important}.flex-lg-row{flex-direction:row!important}.flex-lg-column{flex-direction:column!important}.flex-lg-row-reverse{flex-direction:row-reverse!important}.flex-lg-column-reverse{flex-direction:column-reverse!important}.flex-lg-grow-0{flex-grow:0!important}.flex-lg-grow-1{flex-grow:1!important}.flex-lg-shrink-0{flex-shrink:0!important}.flex-lg-shrink-1{flex-shrink:1!important}.flex-lg-wrap{flex-wrap:wrap!important}.flex-lg-nowrap{flex-wrap:nowrap!important}.flex-lg-wrap-reverse{flex-wrap:wrap-reverse!important}.justify-content-lg-start{justify-content:flex-start!important}.justify-content-lg-end{justify-content:flex-end!important}.justify-content-lg-center{justify-content:center!important}.justify-content-lg-between{justify-content:space-between!important}.justify-content-lg-around{justify-content:space-around!important}.justify-content-lg-evenly{justify-content:space-evenly!important}.align-items-lg-start{align-items:flex-start!important}.align-items-lg-end{align-items:flex-end!important}.align-items-lg-center{align-items:center!important}.align-items-lg-baseline{align-items:baseline!important}.align-items-lg-stretch{align-items:stretch!important}.align-content-lg-start{align-content:flex-start!important}.align-content-lg-end{align-content:flex-end!important}.align-content-lg-center{align-content:center!important}.align-content-lg-between{align-content:space-between!important}.align-content-lg-around{align-content:space-around!important}.align-content-lg-stretch{align-content:stretch!important}.align-self-lg-auto{align-self:auto!important}.align-self-lg-start{align-self:flex-start!important}.align-self-lg-end{align-self:flex-end!important}.align-self-lg-center{align-self:center!important}.align-self-lg-baseline{align-self:baseline!important}.align-self-lg-stretch{align-self:stretch!important}.order-lg-first{order:-1!important}.order-lg-0{order:0!important}.order-lg-1{order:1!important}.order-lg-2{order:2!important}.order-lg-3{order:3!important}.order-lg-4{order:4!important}.order-lg-5{order:5!important}.order-lg-last{order:6!important}.m-lg-0{margin:0!important}.m-lg-1{margin:.25rem!important}.m-lg-2{margin:.5rem!important}.m-lg-3{margin:1rem!important}.m-lg-4{margin:1.5rem!important}.m-lg-5{margin:3rem!important}.m-lg-auto{margin:auto!important}.mx-lg-0{margin-right:0!important;margin-left:0!important}.mx-lg-1{margin-right:.25rem!important;margin-left:.25rem!important}.mx-lg-2{margin-right:.5rem!important;margin-left:.5rem!important}.mx-lg-3{margin-right:1rem!important;margin-left:1rem!important}.mx-lg-4{margin-right:1.5rem!important;margin-left:1.5rem!important}.mx-lg-5{margin-right:3rem!important;margin-left:3rem!important}.mx-lg-auto{margin-right:auto!important;margin-left:auto!important}.my-lg-0{margin-top:0!important;margin-bottom:0!important}.my-lg-1{margin-top:.25rem!important;margin-bottom:.25rem!important}.my-lg-2{margin-top:.5rem!important;margin-bottom:.5rem!important}.my-lg-3{margin-top:1rem!important;margin-bottom:1rem!important}.my-lg-4{margin-top:1.5rem!important;margin-bottom:1.5rem!important}.my-lg-5{margin-top:3rem!important;margin-bottom:3rem!important}.my-lg-auto{margin-top:auto!important;margin-bottom:auto!important}.mt-lg-0{margin-top:0!important}.mt-lg-1{margin-top:.25rem!important}.mt-lg-2{margin-top:.5rem!important}.mt-lg-3{margin-top:1rem!important}.mt-lg-4{margin-top:1.5rem!important}.mt-lg-5{margin-top:3rem!important}.mt-lg-auto{margin-top:auto!important}.me-lg-0{margin-right:0!important}.me-lg-1{margin-right:.25rem!important}.me-lg-2{margin-right:.5rem!important}.me-lg-3{margin-right:1rem!important}.me-lg-4{margin-right:1.5rem!important}.me-lg-5{margin-right:3rem!important}.me-lg-auto{margin-right:auto!important}.mb-lg-0{margin-bottom:0!important}.mb-lg-1{margin-bottom:.25rem!important}.mb-lg-2{margin-bottom:.5rem!important}.mb-lg-3{margin-bottom:1rem!important}.mb-lg-4{margin-bottom:1.5rem!important}.mb-lg-5{margin-bottom:3rem!important}.mb-lg-auto{margin-bottom:auto!important}.ms-lg-0{margin-left:0!important}.ms-lg-1{margin-left:.25rem!important}.ms-lg-2{margin-left:.5rem!important}.ms-lg-3{margin-left:1rem!important}.ms-lg-4{margin-left:1.5rem!important}.ms-lg-5{margin-left:3rem!important}.ms-lg-auto{margin-left:auto!important}.p-lg-0{padding:0!important}.p-lg-1{padding:.25rem!important}.p-lg-2{padding:.5rem!important}.p-lg-3{padding:1rem!important}.p-lg-4{padding:1.5rem!important}.p-lg-5{padding:3rem!important}.px-lg-0{padding-right:0!important;padding-left:0!important}.px-lg-1{padding-right:.25rem!important;padding-left:.25rem!important}.px-lg-2{padding-right:.5rem!important;padding-left:.5rem!important}.px-lg-3{padding-right:1rem!important;padding-left:1rem!important}.px-lg-4{padding-right:1.5rem!important;padding-left:1.5rem!important}.px-lg-5{padding-right:3rem!important;padding-left:3rem!important}.py-lg-0{padding-top:0!important;padding-bottom:0!important}.py-lg-1{padding-top:.25rem!important;padding-bottom:.25rem!important}.py-lg-2{padding-top:.5rem!important;padding-bottom:.5rem!important}.py-lg-3{padding-top:1rem!important;padding-bottom:1rem!important}.py-lg-4{padding-top:1.5rem!important;padding-bottom:1.5rem!important}.py-lg-5{padding-top:3rem!important;padding-bottom:3rem!important}.pt-lg-0{padding-top:0!important}.pt-lg-1{padding-top:.25rem!important}.pt-lg-2{padding-top:.5rem!important}.pt-lg-3{padding-top:1rem!important}.pt-lg-4{padding-top:1.5rem!important}.pt-lg-5{padding-top:3rem!important}.pe-lg-0{padding-right:0!important}.pe-lg-1{padding-right:.25rem!important}.pe-lg-2{padding-right:.5rem!important}.pe-lg-3{padding-right:1rem!important}.pe-lg-4{padding-right:1.5rem!important}.pe-lg-5{padding-right:3rem!important}.pb-lg-0{padding-bottom:0!important}.pb-lg-1{padding-bottom:.25rem!important}.pb-lg-2{padding-bottom:.5rem!important}.pb-lg-3{padding-bottom:1rem!important}.pb-lg-4{padding-bottom:1.5rem!important}.pb-lg-5{padding-bottom:3rem!important}.ps-lg-0{padding-left:0!important}.ps-lg-1{padding-left:.25rem!important}.ps-lg-2{padding-left:.5rem!important}.ps-lg-3{padding-left:1rem!important}.ps-lg-4{padding-left:1.5rem!important}.ps-lg-5{padding-left:3rem!important}.gap-lg-0{gap:0!important}.gap-lg-1{gap:.25rem!important}.gap-lg-2{gap:.5rem!important}.gap-lg-3{gap:1rem!important}.gap-lg-4{gap:1.5rem!important}.gap-lg-5{gap:3rem!important}.row-gap-lg-0{row-gap:0!important}.row-gap-lg-1{row-gap:.25rem!important}.row-gap-lg-2{row-gap:.5rem!important}.row-gap-lg-3{row-gap:1rem!important}.row-gap-lg-4{row-gap:1.5rem!important}.row-gap-lg-5{row-gap:3rem!important}.column-gap-lg-0{-moz-column-gap:0!important;column-gap:0!important}.column-gap-lg-1{-moz-column-gap:0.25rem!important;column-gap:.25rem!important}.column-gap-lg-2{-moz-column-gap:0.5rem!important;column-gap:.5rem!important}.column-gap-lg-3{-moz-column-gap:1rem!important;column-gap:1rem!important}.column-gap-lg-4{-moz-column-gap:1.5rem!important;column-gap:1.5rem!important}.column-gap-lg-5{-moz-column-gap:3rem!important;column-gap:3rem!important}.text-lg-start{text-align:left!important}.text-lg-end{text-align:right!important}.text-lg-center{text-align:center!important}}@media (min-width:1200px){.float-xl-start{float:left!important}.float-xl-end{float:right!important}.float-xl-none{float:none!important}.object-fit-xl-contain{-o-object-fit:contain!important;object-fit:contain!important}.object-fit-xl-cover{-o-object-fit:cover!important;object-fit:cover!important}.object-fit-xl-fill{-o-object-fit:fill!important;object-fit:fill!important}.object-fit-xl-scale{-o-object-fit:scale-down!important;object-fit:scale-down!important}.object-fit-xl-none{-o-object-fit:none!important;object-fit:none!important}.d-xl-inline{display:inline!important}.d-xl-inline-block{display:inline-block!important}.d-xl-block{display:block!important}.d-xl-grid{display:grid!important}.d-xl-inline-grid{display:inline-grid!important}.d-xl-table{display:table!important}.d-xl-table-row{display:table-row!important}.d-xl-table-cell{display:table-cell!important}.d-xl-flex{display:flex!important}.d-xl-inline-flex{display:inline-flex!important}.d-xl-none{display:none!important}.flex-xl-fill{flex:1 1 auto!important}.flex-xl-row{flex-direction:row!important}.flex-xl-column{flex-direction:column!important}.flex-xl-row-reverse{flex-direction:row-reverse!important}.flex-xl-column-reverse{flex-direction:column-reverse!important}.flex-xl-grow-0{flex-grow:0!important}.flex-xl-grow-1{flex-grow:1!important}.flex-xl-shrink-0{flex-shrink:0!important}.flex-xl-shrink-1{flex-shrink:1!important}.flex-xl-wrap{flex-wrap:wrap!important}.flex-xl-nowrap{flex-wrap:nowrap!important}.flex-xl-wrap-reverse{flex-wrap:wrap-reverse!important}.justify-content-xl-start{justify-content:flex-start!important}.justify-content-xl-end{justify-content:flex-end!important}.justify-content-xl-center{justify-content:center!important}.justify-content-xl-between{justify-content:space-between!important}.justify-content-xl-around{justify-content:space-around!important}.justify-content-xl-evenly{justify-content:space-evenly!important}.align-items-xl-start{align-items:flex-start!important}.align-items-xl-end{align-items:flex-end!important}.align-items-xl-center{align-items:center!important}.align-items-xl-baseline{align-items:baseline!important}.align-items-xl-stretch{align-items:stretch!important}.align-content-xl-start{align-content:flex-start!important}.align-content-xl-end{align-content:flex-end!important}.align-content-xl-center{align-content:center!important}.align-content-xl-between{align-content:space-between!important}.align-content-xl-around{align-content:space-around!important}.align-content-xl-stretch{align-content:stretch!important}.align-self-xl-auto{align-self:auto!important}.align-self-xl-start{align-self:flex-start!important}.align-self-xl-end{align-self:flex-end!important}.align-self-xl-center{align-self:center!important}.align-self-xl-baseline{align-self:baseline!important}.align-self-xl-stretch{align-self:stretch!important}.order-xl-first{order:-1!important}.order-xl-0{order:0!important}.order-xl-1{order:1!important}.order-xl-2{order:2!important}.order-xl-3{order:3!important}.order-xl-4{order:4!important}.order-xl-5{order:5!important}.order-xl-last{order:6!important}.m-xl-0{margin:0!important}.m-xl-1{margin:.25rem!important}.m-xl-2{margin:.5rem!important}.m-xl-3{margin:1rem!important}.m-xl-4{margin:1.5rem!important}.m-xl-5{margin:3rem!important}.m-xl-auto{margin:auto!important}.mx-xl-0{margin-right:0!important;margin-left:0!important}.mx-xl-1{margin-right:.25rem!important;margin-left:.25rem!important}.mx-xl-2{margin-right:.5rem!important;margin-left:.5rem!important}.mx-xl-3{margin-right:1rem!important;margin-left:1rem!important}.mx-xl-4{margin-right:1.5rem!important;margin-left:1.5rem!important}.mx-xl-5{margin-right:3rem!important;margin-left:3rem!important}.mx-xl-auto{margin-right:auto!important;margin-left:auto!important}.my-xl-0{margin-top:0!important;margin-bottom:0!important}.my-xl-1{margin-top:.25rem!important;margin-bottom:.25rem!important}.my-xl-2{margin-top:.5rem!important;margin-bottom:.5rem!important}.my-xl-3{margin-top:1rem!important;margin-bottom:1rem!important}.my-xl-4{margin-top:1.5rem!important;margin-bottom:1.5rem!important}.my-xl-5{margin-top:3rem!important;margin-bottom:3rem!important}.my-xl-auto{margin-top:auto!important;margin-bottom:auto!important}.mt-xl-0{margin-top:0!important}.mt-xl-1{margin-top:.25rem!important}.mt-xl-2{margin-top:.5rem!important}.mt-xl-3{margin-top:1rem!important}.mt-xl-4{margin-top:1.5rem!important}.mt-xl-5{margin-top:3rem!important}.mt-xl-auto{margin-top:auto!important}.me-xl-0{margin-right:0!important}.me-xl-1{margin-right:.25rem!important}.me-xl-2{margin-right:.5rem!important}.me-xl-3{margin-right:1rem!important}.me-xl-4{margin-right:1.5rem!important}.me-xl-5{margin-right:3rem!important}.me-xl-auto{margin-right:auto!important}.mb-xl-0{margin-bottom:0!important}.mb-xl-1{margin-bottom:.25rem!important}.mb-xl-2{margin-bottom:.5rem!important}.mb-xl-3{margin-bottom:1rem!important}.mb-xl-4{margin-bottom:1.5rem!important}.mb-xl-5{margin-bottom:3rem!important}.mb-xl-auto{margin-bottom:auto!important}.ms-xl-0{margin-left:0!important}.ms-xl-1{margin-left:.25rem!important}.ms-xl-2{margin-left:.5rem!important}.ms-xl-3{margin-left:1rem!important}.ms-xl-4{margin-left:1.5rem!important}.ms-xl-5{margin-left:3rem!important}.ms-xl-auto{margin-left:auto!important}.p-xl-0{padding:0!important}.p-xl-1{padding:.25rem!important}.p-xl-2{padding:.5rem!important}.p-xl-3{padding:1rem!important}.p-xl-4{padding:1.5rem!important}.p-xl-5{padding:3rem!important}.px-xl-0{padding-right:0!important;padding-left:0!important}.px-xl-1{padding-right:.25rem!important;padding-left:.25rem!important}.px-xl-2{padding-right:.5rem!important;padding-left:.5rem!important}.px-xl-3{padding-right:1rem!important;padding-left:1rem!important}.px-xl-4{padding-right:1.5rem!important;padding-left:1.5rem!important}.px-xl-5{padding-right:3rem!important;padding-left:3rem!important}.py-xl-0{padding-top:0!important;padding-bottom:0!important}.py-xl-1{padding-top:.25rem!important;padding-bottom:.25rem!important}.py-xl-2{padding-top:.5rem!important;padding-bottom:.5rem!important}.py-xl-3{padding-top:1rem!important;padding-bottom:1rem!important}.py-xl-4{padding-top:1.5rem!important;padding-bottom:1.5rem!important}.py-xl-5{padding-top:3rem!important;padding-bottom:3rem!important}.pt-xl-0{padding-top:0!important}.pt-xl-1{padding-top:.25rem!important}.pt-xl-2{padding-top:.5rem!important}.pt-xl-3{padding-top:1rem!important}.pt-xl-4{padding-top:1.5rem!important}.pt-xl-5{padding-top:3rem!important}.pe-xl-0{padding-right:0!important}.pe-xl-1{padding-right:.25rem!important}.pe-xl-2{padding-right:.5rem!important}.pe-xl-3{padding-right:1rem!important}.pe-xl-4{padding-right:1.5rem!important}.pe-xl-5{padding-right:3rem!important}.pb-xl-0{padding-bottom:0!important}.pb-xl-1{padding-bottom:.25rem!important}.pb-xl-2{padding-bottom:.5rem!important}.pb-xl-3{padding-bottom:1rem!important}.pb-xl-4{padding-bottom:1.5rem!important}.pb-xl-5{padding-bottom:3rem!important}.ps-xl-0{padding-left:0!important}.ps-xl-1{padding-left:.25rem!important}.ps-xl-2{padding-left:.5rem!important}.ps-xl-3{padding-left:1rem!important}.ps-xl-4{padding-left:1.5rem!important}.ps-xl-5{padding-left:3rem!important}.gap-xl-0{gap:0!important}.gap-xl-1{gap:.25rem!important}.gap-xl-2{gap:.5rem!important}.gap-xl-3{gap:1rem!important}.gap-xl-4{gap:1.5rem!important}.gap-xl-5{gap:3rem!important}.row-gap-xl-0{row-gap:0!important}.row-gap-xl-1{row-gap:.25rem!important}.row-gap-xl-2{row-gap:.5rem!important}.row-gap-xl-3{row-gap:1rem!important}.row-gap-xl-4{row-gap:1.5rem!important}.row-gap-xl-5{row-gap:3rem!important}.column-gap-xl-0{-moz-column-gap:0!important;column-gap:0!important}.column-gap-xl-1{-moz-column-gap:0.25rem!important;column-gap:.25rem!important}.column-gap-xl-2{-moz-column-gap:0.5rem!important;column-gap:.5rem!important}.column-gap-xl-3{-moz-column-gap:1rem!important;column-gap:1rem!important}.column-gap-xl-4{-moz-column-gap:1.5rem!important;column-gap:1.5rem!important}.column-gap-xl-5{-moz-column-gap:3rem!important;column-gap:3rem!important}.text-xl-start{text-align:left!important}.text-xl-end{text-align:right!important}.text-xl-center{text-align:center!important}}@media (min-width:1400px){.float-xxl-start{float:left!important}.float-xxl-end{float:right!important}.float-xxl-none{float:none!important}.object-fit-xxl-contain{-o-object-fit:contain!important;object-fit:contain!important}.object-fit-xxl-cover{-o-object-fit:cover!important;object-fit:cover!important}.object-fit-xxl-fill{-o-object-fit:fill!important;object-fit:fill!important}.object-fit-xxl-scale{-o-object-fit:scale-down!important;object-fit:scale-down!important}.object-fit-xxl-none{-o-object-fit:none!important;object-fit:none!important}.d-xxl-inline{display:inline!important}.d-xxl-inline-block{display:inline-block!important}.d-xxl-block{display:block!important}.d-xxl-grid{display:grid!important}.d-xxl-inline-grid{display:inline-grid!important}.d-xxl-table{display:table!important}.d-xxl-table-row{display:table-row!important}.d-xxl-table-cell{display:table-cell!important}.d-xxl-flex{display:flex!important}.d-xxl-inline-flex{display:inline-flex!important}.d-xxl-none{display:none!important}.flex-xxl-fill{flex:1 1 auto!important}.flex-xxl-row{flex-direction:row!important}.flex-xxl-column{flex-direction:column!important}.flex-xxl-row-reverse{flex-direction:row-reverse!important}.flex-xxl-column-reverse{flex-direction:column-reverse!important}.flex-xxl-grow-0{flex-grow:0!important}.flex-xxl-grow-1{flex-grow:1!important}.flex-xxl-shrink-0{flex-shrink:0!important}.flex-xxl-shrink-1{flex-shrink:1!important}.flex-xxl-wrap{flex-wrap:wrap!important}.flex-xxl-nowrap{flex-wrap:nowrap!important}.flex-xxl-wrap-reverse{flex-wrap:wrap-reverse!important}.justify-content-xxl-start{justify-content:flex-start!important}.justify-content-xxl-end{justify-content:flex-end!important}.justify-content-xxl-center{justify-content:center!important}.justify-content-xxl-between{justify-content:space-between!important}.justify-content-xxl-around{justify-content:space-around!important}.justify-content-xxl-evenly{justify-content:space-evenly!important}.align-items-xxl-start{align-items:flex-start!important}.align-items-xxl-end{align-items:flex-end!important}.align-items-xxl-center{align-items:center!important}.align-items-xxl-baseline{align-items:baseline!important}.align-items-xxl-stretch{align-items:stretch!important}.align-content-xxl-start{align-content:flex-start!important}.align-content-xxl-end{align-content:flex-end!important}.align-content-xxl-center{align-content:center!important}.align-content-xxl-between{align-content:space-between!important}.align-content-xxl-around{align-content:space-around!important}.align-content-xxl-stretch{align-content:stretch!important}.align-self-xxl-auto{align-self:auto!important}.align-self-xxl-start{align-self:flex-start!important}.align-self-xxl-end{align-self:flex-end!important}.align-self-xxl-center{align-self:center!important}.align-self-xxl-baseline{align-self:baseline!important}.align-self-xxl-stretch{align-self:stretch!important}.order-xxl-first{order:-1!important}.order-xxl-0{order:0!important}.order-xxl-1{order:1!important}.order-xxl-2{order:2!important}.order-xxl-3{order:3!important}.order-xxl-4{order:4!important}.order-xxl-5{order:5!important}.order-xxl-last{order:6!important}.m-xxl-0{margin:0!important}.m-xxl-1{margin:.25rem!important}.m-xxl-2{margin:.5rem!important}.m-xxl-3{margin:1rem!important}.m-xxl-4{margin:1.5rem!important}.m-xxl-5{margin:3rem!important}.m-xxl-auto{margin:auto!important}.mx-xxl-0{margin-right:0!important;margin-left:0!important}.mx-xxl-1{margin-right:.25rem!important;margin-left:.25rem!important}.mx-xxl-2{margin-right:.5rem!important;margin-left:.5rem!important}.mx-xxl-3{margin-right:1rem!important;margin-left:1rem!important}.mx-xxl-4{margin-right:1.5rem!important;margin-left:1.5rem!important}.mx-xxl-5{margin-right:3rem!important;margin-left:3rem!important}.mx-xxl-auto{margin-right:auto!important;margin-left:auto!important}.my-xxl-0{margin-top:0!important;margin-bottom:0!important}.my-xxl-1{margin-top:.25rem!important;margin-bottom:.25rem!important}.my-xxl-2{margin-top:.5rem!important;margin-bottom:.5rem!important}.my-xxl-3{margin-top:1rem!important;margin-bottom:1rem!important}.my-xxl-4{margin-top:1.5rem!important;margin-bottom:1.5rem!important}.my-xxl-5{margin-top:3rem!important;margin-bottom:3rem!important}.my-xxl-auto{margin-top:auto!important;margin-bottom:auto!important}.mt-xxl-0{margin-top:0!important}.mt-xxl-1{margin-top:.25rem!important}.mt-xxl-2{margin-top:.5rem!important}.mt-xxl-3{margin-top:1rem!important}.mt-xxl-4{margin-top:1.5rem!important}.mt-xxl-5{margin-top:3rem!important}.mt-xxl-auto{margin-top:auto!important}.me-xxl-0{margin-right:0!important}.me-xxl-1{margin-right:.25rem!important}.me-xxl-2{margin-right:.5rem!important}.me-xxl-3{margin-right:1rem!important}.me-xxl-4{margin-right:1.5rem!important}.me-xxl-5{margin-right:3rem!important}.me-xxl-auto{margin-right:auto!important}.mb-xxl-0{margin-bottom:0!important}.mb-xxl-1{margin-bottom:.25rem!important}.mb-xxl-2{margin-bottom:.5rem!important}.mb-xxl-3{margin-bottom:1rem!important}.mb-xxl-4{margin-bottom:1.5rem!important}.mb-xxl-5{margin-bottom:3rem!important}.mb-xxl-auto{margin-bottom:auto!important}.ms-xxl-0{margin-left:0!important}.ms-xxl-1{margin-left:.25rem!important}.ms-xxl-2{margin-left:.5rem!important}.ms-xxl-3{margin-left:1rem!important}.ms-xxl-4{margin-left:1.5rem!important}.ms-xxl-5{margin-left:3rem!important}.ms-xxl-auto{margin-left:auto!important}.p-xxl-0{padding:0!important}.p-xxl-1{padding:.25rem!important}.p-xxl-2{padding:.5rem!important}.p-xxl-3{padding:1rem!important}.p-xxl-4{padding:1.5rem!important}.p-xxl-5{padding:3rem!important}.px-xxl-0{padding-right:0!important;padding-left:0!important}.px-xxl-1{padding-right:.25rem!important;padding-left:.25rem!important}.px-xxl-2{padding-right:.5rem!important;padding-left:.5rem!important}.px-xxl-3{padding-right:1rem!important;padding-left:1rem!important}.px-xxl-4{padding-right:1.5rem!important;padding-left:1.5rem!important}.px-xxl-5{padding-right:3rem!important;padding-left:3rem!important}.py-xxl-0{padding-top:0!important;padding-bottom:0!important}.py-xxl-1{padding-top:.25rem!important;padding-bottom:.25rem!important}.py-xxl-2{padding-top:.5rem!important;padding-bottom:.5rem!important}.py-xxl-3{padding-top:1rem!important;padding-bottom:1rem!important}.py-xxl-4{padding-top:1.5rem!important;padding-bottom:1.5rem!important}.py-xxl-5{padding-top:3rem!important;padding-bottom:3rem!important}.pt-xxl-0{padding-top:0!important}.pt-xxl-1{padding-top:.25rem!important}.pt-xxl-2{padding-top:.5rem!important}.pt-xxl-3{padding-top:1rem!important}.pt-xxl-4{padding-top:1.5rem!important}.pt-xxl-5{padding-top:3rem!important}.pe-xxl-0{padding-right:0!important}.pe-xxl-1{padding-right:.25rem!important}.pe-xxl-2{padding-right:.5rem!important}.pe-xxl-3{padding-right:1rem!important}.pe-xxl-4{padding-right:1.5rem!important}.pe-xxl-5{padding-right:3rem!important}.pb-xxl-0{padding-bottom:0!important}.pb-xxl-1{padding-bottom:.25rem!important}.pb-xxl-2{padding-bottom:.5rem!important}.pb-xxl-3{padding-bottom:1rem!important}.pb-xxl-4{padding-bottom:1.5rem!important}.pb-xxl-5{padding-bottom:3rem!important}.ps-xxl-0{padding-left:0!important}.ps-xxl-1{padding-left:.25rem!important}.ps-xxl-2{padding-left:.5rem!important}.ps-xxl-3{padding-left:1rem!important}.ps-xxl-4{padding-left:1.5rem!important}.ps-xxl-5{padding-left:3rem!important}.gap-xxl-0{gap:0!important}.gap-xxl-1{gap:.25rem!important}.gap-xxl-2{gap:.5rem!important}.gap-xxl-3{gap:1rem!important}.gap-xxl-4{gap:1.5rem!important}.gap-xxl-5{gap:3rem!important}.row-gap-xxl-0{row-gap:0!important}.row-gap-xxl-1{row-gap:.25rem!important}.row-gap-xxl-2{row-gap:.5rem!important}.row-gap-xxl-3{row-gap:1rem!important}.row-gap-xxl-4{row-gap:1.5rem!important}.row-gap-xxl-5{row-gap:3rem!important}.column-gap-xxl-0{-moz-column-gap:0!important;column-gap:0!important}.column-gap-xxl-1{-moz-column-gap:0.25rem!important;column-gap:.25rem!important}.column-gap-xxl-2{-moz-column-gap:0.5rem!important;column-gap:.5rem!important}.column-gap-xxl-3{-moz-column-gap:1rem!important;column-gap:1rem!important}.column-gap-xxl-4{-moz-column-gap:1.5rem!important;column-gap:1.5rem!important}.column-gap-xxl-5{-moz-column-gap:3rem!important;column-gap:3rem!important}.text-xxl-start{text-align:left!important}.text-xxl-end{text-align:right!important}.text-xxl-center{text-align:center!important}}@media (min-width:1200px){.fs-1{font-size:2.5rem!important}.fs-2{font-size:2rem!important}.fs-3{font-size:1.75rem!important}.fs-4{font-size:1.5rem!important}}@media print{.d-print-inline{display:inline!important}.d-print-inline-block{display:inline-block!important}.d-print-block{display:block!important}.d-print-grid{display:grid!important}.d-print-inline-grid{display:inline-grid!important}.d-print-table{display:table!important}.d-print-table-row{display:table-row!important}.d-print-table-cell{display:table-cell!important}.d-print-flex{display:flex!important}.d-print-inline-flex{display:inline-flex!important}.d-print-none{display:none!important}} -/*# sourceMappingURL=bootstrap.min.css.map */ \ No newline at end of file diff --git a/web/wwwroot/css/myai.css b/web/wwwroot/css/myai.css index 1f9b56b..92bf9b3 100644 --- a/web/wwwroot/css/myai.css +++ b/web/wwwroot/css/myai.css @@ -1,3 +1,6 @@ +/* ============================================================ + DESIGN TOKENS + ============================================================ */ :root { --bg: #041120; --bg-soft: #0a1c34; @@ -11,6 +14,9 @@ --shadow: 0 18px 60px rgba(0,0,0,.28) } +/* ============================================================ + RESET / BASE + ============================================================ */ * { box-sizing: border-box } @@ -36,6 +42,9 @@ img { display: block } +/* ============================================================ + LAYOUT HELPERS + ============================================================ */ .container { width: 100%; max-width: 1120px; @@ -48,6 +57,9 @@ img { overflow: hidden } +/* ============================================================ + STATUS PAGE (job-search redirect result) + ============================================================ */ .status-hero { min-height: 100vh; display: flex; @@ -75,6 +87,9 @@ img { line-height: 1.6 } +/* ============================================================ + HEADER / NAVIGATION + ============================================================ */ .header { position: sticky; top: 0; @@ -103,15 +118,11 @@ img { height: 48px } -.ai-mark { - display: inline-flex; - align-items: center; - justify-content: center; - border-radius: 16px; - background: linear-gradient(135deg,var(--primary),var(--primary-strong)); - font-weight: 900; - color: #fff; - box-shadow: 0 18px 40px rgba(95,160,255,.24) +.brand-mark img { + width: 100%; + height: 100%; + object-fit: contain; + filter: drop-shadow(0 16px 26px rgba(95,160,255,.24)) } .brand-text { @@ -141,6 +152,7 @@ img { color: #fff } +/* Hamburger menu button — hidden on desktop, shown in responsive via media query */ .menu-toggle { display: none; background: transparent; @@ -148,6 +160,7 @@ img { padding: 8px } + /* Three horizontal lines that make up the hamburger icon */ .menu-toggle span { display: block; width: 24px; @@ -156,634 +169,7 @@ img { margin: 5px 0 } -.hero { - padding: 72px 0 48px -} - -.hero-grid { - display: grid; - grid-template-columns: 1.05fr .95fr; - gap: 42px; - align-items: center -} - -.eyebrow { - display: inline-flex; - align-items: center; - gap: 8px; - margin-bottom: 14px; - color: #8fb8ff; - text-transform: uppercase; - letter-spacing: .18em; - font-size: .78rem; - font-weight: 800 -} - -.hero h1 { - font-size: clamp(2.3rem,5vw,4.8rem); - line-height: 1.02; - margin: 0 0 24px; - letter-spacing: -.05em -} - -.hero-text { - font-size: 1.12rem; - line-height: 1.8; - color: var(--muted); - max-width: 650px -} - -.hero-actions { - display: flex; - gap: 14px; - flex-wrap: wrap; - margin-top: 30px -} - -.btn { - border-radius: 999px; - padding: 13px 22px; - font-weight: 800; - border: 1px solid rgba(255,255,255,.12) -} - -.btn-primary { - background: linear-gradient(135deg,var(--primary),var(--primary-strong)); - border: 0; - color: #fff -} - -.btn-secondary { - background: rgba(255,255,255,.06); - color: #fff -} - -.section { - padding: 76px 0 -} - -.section-heading { - max-width: 720px; - margin-bottom: 34px -} - - .section-heading h2, .contact h2, .ai-panel h2 { - font-size: clamp(2rem,4vw,3.2rem); - line-height: 1.05; - letter-spacing: -.04em; - margin: 0 0 18px - } - - .section-heading p, .contact p { - color: var(--muted); - font-size: 1.05rem; - line-height: 1.8 - } - -.ai-console-card, .ai-panel, .demo-card, .contact-form { - background: var(--panel); - border: 1px solid var(--panel-border); - border-radius: var(--card-radius); - box-shadow: var(--shadow) -} - -.ai-console-card { - padding: 30px -} - -.console-line { - display: flex; - gap: 16px; - align-items: center; - margin: 12px 0; - padding: 16px 18px; - border-radius: 18px; - background: rgba(255,255,255,.05); - color: #dce8ff -} - - .console-line span { - min-width: 76px; - color: #8fb8ff; - font-weight: 900 - } - -.demo-grid { - display: grid; - grid-template-columns: repeat(3,1fr); - gap: 20px -} - -.demo-card { - display: block; - padding: 26px; - min-height: 260px; - transition: transform .2s ease,border-color .2s ease -} - - .demo-card:hover { - transform: translateY(-4px); - border-color: rgba(95,160,255,.45) - } - - .demo-card h3 { - font-size: 1.55rem; - margin: 18px 0 12px - } - - .demo-card p { - color: var(--muted); - line-height: 1.7 - } - -.muted-card { - opacity: .7 -} - -.product-tag { - display: inline-flex; - border-radius: 999px; - padding: 7px 12px; - background: rgba(95,160,255,.12); - color: #b9d4ff; - border: 1px solid rgba(95,160,255,.18); - font-weight: 800; - font-size: .82rem -} - -.matcher-grid { - display: grid; - grid-template-columns: 1fr 1fr; - gap: 24px; - align-items: start -} - -.ai-panel { - padding: 28px -} - - .ai-panel label, .contact-form label { - display: block; - margin-bottom: 18px - } - - .ai-panel label span, .contact-form label span { - display: block; - margin-bottom: 8px; - color: #c3d4f2; - font-weight: 700 - } - - .ai-panel input:not([type="checkbox"]):not([type="file"]), - .ai-panel textarea, - .contact-form input:not([type="checkbox"]):not([type="file"]), - .contact-form textarea, - .subscribe-form input[type="email"] { - width: 100%; - border: 1px solid #d9e1f0; - border-radius: 6px; - background: #fff; - color: #0e1e3a; - padding: 12px 14px; - outline: none; - font-family: inherit; - font-size: 1rem; - line-height: 1.4; - transition: border-color .15s ease, box-shadow .15s ease - } - - .ai-panel input:not([type="checkbox"]):not([type="file"])::placeholder, - .ai-panel textarea::placeholder, - .contact-form input:not([type="checkbox"]):not([type="file"])::placeholder, - .contact-form textarea::placeholder, - .subscribe-form input[type="email"]::placeholder { - color: #97a4b8 - } - - .ai-panel input:not([type="checkbox"]):not([type="file"]):focus, - .ai-panel textarea:focus, - .contact-form input:not([type="checkbox"]):not([type="file"]):focus, - .contact-form textarea:focus, - .subscribe-form input[type="email"]:focus { - border-color: #5fa0ff; - box-shadow: 0 0 0 3px rgba(95,160,255,.18) - } - - .ai-panel label.is-invalid input:not([type="checkbox"]):not([type="file"]), - .ai-panel label.is-invalid textarea, - .contact-form label.is-invalid input:not([type="checkbox"]):not([type="file"]), - .contact-form label.is-invalid textarea, - .subscribe-form .subscribe-row.is-invalid input[type="email"] { - border-color: #ff8a8a; - box-shadow: 0 0 0 3px rgba(255,138,138,.18) - } - -.field-error { - display: block; - margin-top: 6px; - color: #ff8a8a; - font-size: .85rem; - font-weight: 600; - line-height: 1.35 -} - -.field-error:empty { - display: none -} - -.consent-inline.is-invalid label { - color: #ff8a8a -} - -.file-drop { - display: block; - border: 1px dashed rgba(143,184,255,.45); - border-radius: 6px; - background: rgba(95,160,255,.07); - padding: 22px; - cursor: pointer -} - -.file-drop.is-invalid { - border-color: #ff8a8a; - background: rgba(255,138,138,.06) -} - - .file-drop input { - display: none - } - - .file-drop strong { - display: block; - font-size: 1.1rem - } - - .file-drop span { - color: var(--muted) !important; - margin-top: 8px - } - -.consent-inline { - display: flex; - gap: 12px; - align-items: flex-start; - margin: 18px 0 22px; - color: #b7c7e4; - line-height: 1.6 -} - - .consent-inline input { - width: auto; - margin-top: 5px - } - - .consent-inline label { - margin: 0 - } - -.result-panel { - position: sticky; - top: 110px -} - -.empty-result { - color: var(--muted); - line-height: 1.8; - padding: 20px; - border-radius: 18px; - background: rgba(0,0,0,.25) -} - -.score-badge { - display: inline-flex; - align-items: center; - justify-content: center; - width: 104px; - height: 104px; - border-radius: 50%; - background: linear-gradient(135deg,var(--primary),var(--primary-strong)); - font-size: 2rem; - font-weight: 900; - margin: 10px 0 20px -} - -.result-list { - padding-left: 18px; - color: #d7e3fb; - line-height: 1.8 -} - -.contact { - background: rgba(255,255,255,.03) -} - -.contact-grid { - display: grid; - grid-template-columns: .9fr 1.1fr; - gap: 32px; - align-items: start -} - -.contact-list { - display: grid; - gap: 14px; - margin-top: 24px -} - - .contact-list div { - padding: 18px; - border-radius: 20px; - background: rgba(255,255,255,.05); - border: 1px solid rgba(255,255,255,.08) - } - - .contact-list span { - display: block; - color: var(--muted); - font-size: .88rem - } - -.contact-form { - padding: 28px -} - -.form-message { - display: block; - margin-top: 14px -} - -.form-message:empty { - display: none -} - -.form-message:not(:empty) { - padding: 10px 14px; - border-radius: 12px; - border: 1px solid rgba(255,255,255,.08); - background: rgba(0,0,0,.18); - font-weight: 700; - line-height: 1.4 -} - -.form-message.text-success:not(:empty) { - background: rgba(126,242,167,.08); - border-color: rgba(126,242,167,.35) -} - -.form-message.text-danger:not(:empty) { - background: rgba(255,138,138,.08); - border-color: rgba(255,138,138,.35) -} - -.form-message.text-warning:not(:empty) { - background: rgba(247,212,136,.08); - border-color: rgba(247,212,136,.35) -} - -.text-success { - color: #7ef2a7 !important -} - -.text-danger { - color: #ff8a8a !important -} - -.text-warning { - color: #f7d488 !important -} - -.subscribe-form { - margin-top: 28px; - padding: 28px; - border-radius: 24px; - background: rgba(255,255,255,.03); - border: 1px solid rgba(255,255,255,.08) -} - -.subscribe-form h3 { - margin: 0 0 6px; - font-size: 1.1rem -} - -.subscribe-form p { - margin: 0 0 14px; - color: var(--muted); - font-size: .92rem -} - -.subscribe-form .subscribe-row { - display: flex; - gap: 10px; - flex-wrap: wrap -} - -.subscribe-form .subscribe-row input[type="email"] { - flex: 1 1 220px; - min-width: 0 -} - -.subscribe-form .consent-inline { - margin-top: 12px -} - -.footer { - padding: 26px 0; - border-top: 1px solid rgba(255,255,255,.08); - background: rgba(0,0,0,.18) -} - -.footer-wrap { - display: flex; - align-items: center; - justify-content: space-between; - gap: 20px; - flex-wrap: wrap; - color: var(--muted) -} - -.footer-links { - display: flex; - gap: 18px; - flex-wrap: wrap -} - -.cookie-overlay { - position: fixed; - left: 0; - right: 0; - bottom: 20px; - z-index: 50; - padding: 0 20px -} - -.cookie-box { - max-width: 980px; - margin: auto; - padding: 20px; - border-radius: 24px; - background: #071326; - border: 1px solid rgba(255,255,255,.16); - box-shadow: var(--shadow); - display: flex; - align-items: center; - justify-content: space-between; - gap: 20px -} - -.cookie-text { - color: #dce8ff; - line-height: 1.6 -} - - .cookie-text a { - color: #9cc5ff; - text-decoration: underline - } - -.cookie-actions { - display: flex; - gap: 10px; - flex-wrap: wrap -} - -.cookie-manage { - position: fixed; - bottom: 20px; - z-index: 40 -} - -.loader-overlay { - position: fixed; - inset: 0; - z-index: 80; - background: rgba(0,0,0,.55); - align-items: center; - justify-content: center; - backdrop-filter: blur(2px) -} - -.loader-box { - max-width: 360px; - padding: 22px 28px; - border-radius: 18px; - background: #071326; - border: 1px solid rgba(255,255,255,.16); - text-align: center; - box-shadow: var(--shadow) -} - -.loader-box strong { - display: block; - font-weight: 800; - margin-bottom: 6px -} - -.loader-box span { - display: block; - color: var(--muted); - font-weight: 500; - font-size: .92rem; - line-height: 1.5 -} - -.loader-spinner { - display: block; - width: 28px; - height: 28px; - margin: 0 auto 14px; - border-radius: 50%; - border: 3px solid rgba(255,255,255,.18); - border-top-color: #9cc5ff; - animation: loader-spin .8s linear infinite -} - -@keyframes loader-spin { - to { transform: rotate(360deg) } -} - -.shake { - animation: shake .35s -} - -@keyframes shake { - 25% { - transform: translateX(-5px) - } - - 50% { - transform: translateX(5px) - } - - 75% { - transform: translateX(-3px) - } -} - -@media (max-width:900px) { - .hero-grid, .matcher-grid, .contact-grid, .demo-grid { - grid-template-columns: 1fr - } - - .result-panel { - position: static - } - - .nav { - position: absolute; - top: 84px; - left: 20px; - right: 20px; - display: none; - flex-direction: column; - align-items: flex-start; - background: #071326; - border: 1px solid rgba(255,255,255,.12); - border-radius: 20px; - padding: 20px - } - - .nav.is-open { - display: flex - } - - .menu-toggle { - display: block - } - - .cookie-box { - align-items: flex-start; - flex-direction: column - } -} - -@media (max-width:560px) { - .hero { - padding-top: 46px - } - - .section { - padding: 56px 0 - } - - .footer-wrap { - align-items: flex-start; - flex-direction: column - } - - .hero-actions .btn { - width: 100%; - text-align: center - } -} - -/* MyAi brand + main-page language selector */ -.brand-mark img { - width: 100%; - height: 100%; - object-fit: contain; - filter: drop-shadow(0 16px 26px rgba(95,160,255,.24)) -} - +/* Language selector (right side of header) */ .nav-actions { display: flex; align-items: center; @@ -835,6 +221,54 @@ img { background: rgba(95,160,255,.1) } +/* ============================================================ + HERO SECTION + ============================================================ */ +.hero { + padding: 72px 0 48px +} + +.hero-grid { + display: grid; + grid-template-columns: 1.05fr .95fr; + gap: 42px; + align-items: center +} + +.eyebrow { + display: inline-flex; + align-items: center; + gap: 8px; + margin-bottom: 14px; + color: #8fb8ff; + text-transform: uppercase; + letter-spacing: .18em; + font-size: .78rem; + font-weight: 800 +} + +.hero h1 { + font-size: clamp(2.3rem,5vw,4.8rem); + line-height: 1.02; + margin: 0 0 24px; + letter-spacing: -.05em +} + +.hero-text { + font-size: 1.12rem; + line-height: 1.8; + color: var(--muted); + max-width: 650px +} + +.hero-actions { + display: flex; + gap: 14px; + flex-wrap: wrap; + margin-top: 30px +} + +/* Hero card / AI console */ .banner-card { overflow: hidden } @@ -847,12 +281,638 @@ img { box-shadow: 0 20px 55px rgba(0,0,0,.22) } -.console-line b { - font-weight: 700; +.console-line { + display: flex; + gap: 16px; + align-items: center; + margin: 12px 0; + padding: 16px 18px; + border-radius: 18px; + background: rgba(255,255,255,.05); color: #dce8ff } + .console-line span { + min-width: 76px; + color: #8fb8ff; + font-weight: 900 + } + + .console-line b { + font-weight: 700; + color: #dce8ff + } + +/* ============================================================ + BUTTONS + ============================================================ */ +.btn { + border-radius: 999px; + padding: 13px 22px; + font-weight: 800; + border: 1px solid rgba(255,255,255,.12) +} + +.btn-primary { + background: linear-gradient(135deg,var(--primary),var(--primary-strong)); + border: 0; + color: #fff +} + +.btn-secondary { + background: rgba(255,255,255,.06); + color: #fff +} + +/* Bootstrap replacement utilities */ +.btn-sm { padding: 5px 10px; font-size: .875rem } +.btn-dark { background: #1e2730; color: #fff; border-color: #1e2730 } +.btn-warning { background: #ffc107; color: #212529; border: 0 } +.shadow { box-shadow: 0 .5rem 1rem rgba(0,0,0,.15) !important } + +/* ============================================================ + SECTION / DEMO GRID + ============================================================ */ +.section { + padding: 76px 0 +} + +.section-heading { + max-width: 720px; + margin-bottom: 34px +} + + .section-heading h2, .contact h2, .ai-panel h2 { + font-size: clamp(2rem,4vw,3.2rem); + line-height: 1.05; + letter-spacing: -.04em; + margin: 0 0 18px + } + + .section-heading p, .contact p { + color: var(--muted); + font-size: 1.05rem; + line-height: 1.8 + } + +.demo-grid { + display: grid; + grid-template-columns: repeat(3,1fr); + gap: 20px +} + +.demo-card { + display: block; + padding: 26px; + min-height: 260px; + transition: transform .2s ease,border-color .2s ease +} + + .demo-card:hover { + transform: translateY(-4px); + border-color: rgba(95,160,255,.45) + } + + .demo-card h3 { + font-size: 1.55rem; + margin: 18px 0 12px + } + + .demo-card p { + color: var(--muted); + line-height: 1.7 + } + +.muted-card { + opacity: .7 +} + +.product-tag { + display: inline-flex; + border-radius: 999px; + padding: 7px 12px; + background: rgba(95,160,255,.12); + color: #b9d4ff; + border: 1px solid rgba(95,160,255,.18); + font-weight: 800; + font-size: .82rem +} + +/* ============================================================ + SHARED CARD STYLES + ============================================================ */ +.ai-console-card, .ai-panel, .demo-card, .contact-form { + background: var(--panel); + border: 1px solid var(--panel-border); + border-radius: var(--card-radius); + box-shadow: var(--shadow) +} + +.ai-console-card { + padding: 30px +} + +/* ============================================================ + CV MATCHER — INPUT / RESULT PANELS + ============================================================ */ +.matcher-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 24px; + align-items: start +} + +.ai-panel { + padding: 28px +} + + .ai-panel label, .contact-form label { + display: block; + margin-bottom: 18px + } + + /* Label text styling */ + .ai-panel label span, .contact-form label span { + display: block; + margin-bottom: 8px; + color: #c3d4f2; + font-weight: 700 + } + + /* Input fields: text, textarea, email. Excludes checkboxes and file inputs. */ + .ai-panel input:not([type="checkbox"]):not([type="file"]), + .ai-panel textarea, + .contact-form input:not([type="checkbox"]):not([type="file"]), + .contact-form textarea, + .subscribe-form input[type="email"] { + width: 100%; + border: 1px solid #d9e1f0; + border-radius: 6px; + background: #fff; + color: #0e1e3a; + padding: 12px 14px; + outline: none; + font-family: inherit; + font-size: 1rem; + line-height: 1.4; + transition: border-color .15s ease, box-shadow .15s ease + } + + /* Placeholder text color */ + .ai-panel input:not([type="checkbox"]):not([type="file"])::placeholder, + .ai-panel textarea::placeholder, + .contact-form input:not([type="checkbox"]):not([type="file"])::placeholder, + .contact-form textarea::placeholder, + .subscribe-form input[type="email"]::placeholder { + color: #97a4b8 + } + + /* Focus state: blue border and glow */ + .ai-panel input:not([type="checkbox"]):not([type="file"]):focus, + .ai-panel textarea:focus, + .contact-form input:not([type="checkbox"]):not([type="file"]):focus, + .contact-form textarea:focus, + .subscribe-form input[type="email"]:focus { + border-color: #5fa0ff; + box-shadow: 0 0 0 3px rgba(95,160,255,.18) + } + + /* Error state (is-invalid): red border and red glow when parent has .is-invalid class */ + .ai-panel label.is-invalid input:not([type="checkbox"]):not([type="file"]), + .ai-panel label.is-invalid textarea, + .contact-form label.is-invalid input:not([type="checkbox"]):not([type="file"]), + .contact-form label.is-invalid textarea, + .subscribe-form .subscribe-row.is-invalid input[type="email"] { + border-color: #ff8a8a; + box-shadow: 0 0 0 3px rgba(255,138,138,.18) + } + +.field-error { + display: block; + margin-top: 6px; + color: #ff8a8a; + font-size: .85rem; + font-weight: 600; + line-height: 1.35 +} + +.field-error:empty { + display: none +} + +.consent-inline.is-invalid label { + color: #ff8a8a +} + +/* File upload drop zone */ +.file-drop { + display: block; + border: 1px dashed rgba(143,184,255,.45); + border-radius: 6px; + background: rgba(95,160,255,.07); + padding: 22px; + cursor: pointer +} + +/* File drop zone error state */ +.file-drop.is-invalid { + border-color: #ff8a8a; + background: rgba(255,138,138,.06) +} + + .file-drop input { + display: none + } + + .file-drop strong { + display: block; + font-size: 1.1rem + } + + .file-drop span { + color: var(--muted) !important; + margin-top: 8px + } + +.consent-inline { + display: flex; + gap: 12px; + align-items: flex-start; + margin: 18px 0 22px; + color: #b7c7e4; + line-height: 1.6 +} + + .consent-inline input { + width: auto; + margin-top: 5px + } + + .consent-inline label { + margin: 0 + } + +/* Match result panel — sticky positioning keeps it visible while scrolling the form */ +.result-panel { + position: sticky; + top: 110px +} + +/* Empty state message (before results generated) */ +.empty-result { + color: var(--muted); + line-height: 1.8; + padding: 20px; + border-radius: 18px; + background: rgba(0,0,0,.25) +} + +/* Large circular match score percentage badge */ +.score-badge { + display: inline-flex; + align-items: center; + justify-content: center; + width: 104px; + height: 104px; + border-radius: 50%; + background: linear-gradient(135deg,var(--primary),var(--primary-strong)); + font-size: 2rem; + font-weight: 900; + margin: 10px 0 20px +} + +/* Bulleted lists for strengths, gaps, evidence */ +.result-list { + padding-left: 18px; + color: #d7e3fb; + line-height: 1.8 +} + +/* ============================================================ + CONTACT SECTION + ============================================================ */ +.contact { + background: rgba(255,255,255,.03) +} + +.contact-grid { + display: grid; + grid-template-columns: .9fr 1.1fr; + gap: 32px; + align-items: start +} + +.contact-list { + display: grid; + gap: 14px; + margin-top: 24px +} + + .contact-list div { + padding: 18px; + border-radius: 20px; + background: rgba(255,255,255,.05); + border: 1px solid rgba(255,255,255,.08) + } + + .contact-list span { + display: block; + color: var(--muted); + font-size: .88rem + } + +.contact-form { + padding: 28px +} + +/* ============================================================ + FORM MESSAGES & FEEDBACK + ============================================================ */ +.form-message { + display: block; + margin-top: 14px +} + +.form-message:empty { + display: none +} + +.form-message:not(:empty) { + padding: 10px 14px; + border-radius: 12px; + border: 1px solid rgba(255,255,255,.08); + background: rgba(0,0,0,.18); + font-weight: 700; + line-height: 1.4 +} + +.form-message.text-success:not(:empty) { + background: rgba(126,242,167,.08); + border-color: rgba(126,242,167,.35) +} + +.form-message.text-danger:not(:empty) { + background: rgba(255,138,138,.08); + border-color: rgba(255,138,138,.35) +} + +.form-message.text-warning:not(:empty) { + background: rgba(247,212,136,.08); + border-color: rgba(247,212,136,.35) +} + +.text-success { + color: #7ef2a7 !important +} + +.text-danger { + color: #ff8a8a !important +} + +.text-warning { + color: #f7d488 !important +} + +/* ============================================================ + SUBSCRIBE FORM + ============================================================ */ +.subscribe-form { + margin-top: 28px; + padding: 28px; + border-radius: 24px; + background: rgba(255,255,255,.03); + border: 1px solid rgba(255,255,255,.08) +} + +.subscribe-form h3 { + margin: 0 0 6px; + font-size: 1.1rem +} + +.subscribe-form p { + margin: 0 0 14px; + color: var(--muted); + font-size: .92rem +} + +.subscribe-form .subscribe-row { + display: flex; + gap: 10px; + flex-wrap: wrap +} + +.subscribe-form .subscribe-row input[type="email"] { + flex: 1 1 220px; + min-width: 0 +} + +.subscribe-form .consent-inline { + margin-top: 12px +} + +/* ============================================================ + FOOTER + ============================================================ */ +.footer { + padding: 26px 0; + border-top: 1px solid rgba(255,255,255,.08); + background: rgba(0,0,0,.18) +} + +.footer-wrap { + display: flex; + align-items: center; + justify-content: space-between; + gap: 20px; + flex-wrap: wrap; + color: var(--muted) +} + +.footer-wrap p { + margin: 0 +} + +.footer-links { + display: flex; + gap: 18px; + flex-wrap: wrap +} + +.app-version { + font-size: .7rem; + color: var(--muted); + opacity: .5; + font-family: monospace +} + +/* ============================================================ + COOKIE BANNER & MANAGE BUTTON + Initial display:none ensures they are hidden before JS runs; + JS calls .fadeIn() / .show() to reveal them. + ============================================================ */ +.cookie-overlay { + display: none; + position: fixed; + left: 0; + right: 0; + bottom: 20px; + z-index: 50; + padding: 0 20px +} + +.cookie-box { + max-width: 980px; + margin: auto; + padding: 20px; + border-radius: 24px; + background: #071326; + border: 1px solid rgba(255,255,255,.16); + box-shadow: var(--shadow); + display: flex; + align-items: center; + justify-content: space-between; + gap: 20px +} + +.cookie-text { + color: #dce8ff; + line-height: 1.6 +} + + .cookie-text a { + color: #9cc5ff; + text-decoration: underline + } + +.cookie-actions { + display: flex; + gap: 10px; + flex-wrap: wrap +} + +/* "Cookie settings" button — hidden until consent is given */ +.cookie-manage { + display: none; + position: fixed; + bottom: 20px; + z-index: 40 +} + +/* ============================================================ + LOADER OVERLAY + Hidden by default; JS adds .loader-visible to show it. + Two-class selector (0,2,0) wins over single-class (0,1,0) + so no !important is needed. + ============================================================ */ +.loader-overlay { + display: none; + position: fixed; + inset: 0; + z-index: 80; + background: rgba(0,0,0,.55); + align-items: center; + justify-content: center; + backdrop-filter: blur(2px) +} + +.loader-overlay.loader-visible { + display: flex +} + +.loader-box { + max-width: 360px; + padding: 22px 28px; + border-radius: 18px; + background: #071326; + border: 1px solid rgba(255,255,255,.16); + text-align: center; + box-shadow: var(--shadow) +} + +.loader-box strong { + display: block; + font-weight: 800; + margin-bottom: 6px +} + +.loader-box span { + display: block; + color: var(--muted); + font-weight: 500; + font-size: .92rem; + line-height: 1.5 +} + +.loader-spinner { + display: block; + width: 28px; + height: 28px; + margin: 0 auto 14px; + border-radius: 50%; + border: 3px solid rgba(255,255,255,.18); + border-top-color: #9cc5ff; + animation: loader-spin .8s linear infinite +} + +/* ============================================================ + ANIMATIONS + ============================================================ */ +@keyframes loader-spin { + to { transform: rotate(360deg) } +} + +.shake { + animation: shake .35s +} + +@keyframes shake { + 25% { transform: translateX(-5px) } + 50% { transform: translateX(5px) } + 75% { transform: translateX(-3px) } +} + +/* ============================================================ + RESPONSIVE — tablets and below (≤900px) + Changes: Single-column layouts, hamburger nav, adjusted spacing + ============================================================ */ @media (max-width:900px) { + /* All grids switch from multi-column to single column */ + .hero-grid, .matcher-grid, .contact-grid, .demo-grid { + grid-template-columns: 1fr + } + + /* Result panel no longer sticky on tablets (would interfere with form) */ + .result-panel { + position: static + } + + /* Navigation becomes a hidden dropdown, shown on hamburger click via .is-open class */ + .nav { + position: absolute; + top: 84px; + left: 20px; + right: 20px; + display: none; + flex-direction: column; + align-items: flex-start; + background: #071326; + border: 1px solid rgba(255,255,255,.12); + border-radius: 20px; + padding: 20px; + z-index: 30 + } + + /* .is-open class added by JS on hamburger click */ + .nav.is-open { + display: flex + } + + /* Show hamburger button on tablets */ + .menu-toggle { + display: block; + order: 4 + } + .nav-actions { margin-left: auto } @@ -861,14 +921,13 @@ img { position: relative } - .menu-toggle { - order: 4 - } - - .nav { - z-index: 30 + /* Cookie banner stacks vertically on tablets */ + .cookie-box { + align-items: flex-start; + flex-direction: column } + /* Language flags get smaller */ .lang-switch { padding: 4px } @@ -879,7 +938,33 @@ img { } } +/* ============================================================ + RESPONSIVE — mobile (≤560px) + Changes: Reduced padding, smaller text, full-width buttons, hide subtitle + ============================================================ */ @media (max-width:560px) { + /* Tighter padding on small screens */ + .hero { + padding-top: 46px + } + + .section { + padding: 56px 0 + } + + /* Footer goes vertical on mobile */ + .footer-wrap { + align-items: flex-start; + flex-direction: column + } + + /* Action buttons take full width */ + .hero-actions .btn { + width: 100%; + text-align: center + } + + /* Brand logo smaller, subtitle hidden */ .brand-text { font-size: 1.35rem } @@ -893,6 +978,7 @@ img { height: 42px } + /* Tighter gaps in nav and language selector */ .nav-actions { gap: 6px } @@ -906,6 +992,7 @@ img { height: 27px } + /* Slightly smaller banner border radius on mobile */ .showcase-banner { border-radius: 18px } diff --git a/web/wwwroot/cv-matcher/index.html b/web/wwwroot/cv-matcher/index.html index 2b43d29..e30f05b 100644 --- a/web/wwwroot/cv-matcher/index.html +++ b/web/wwwroot/cv-matcher/index.html @@ -16,7 +16,6 @@ - @@ -181,29 +180,30 @@ MyAi.ro · All rights reserved

- -