Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8db283e88b | |||
| 93ee0cc390 | |||
| 8f151763ae | |||
| 873576e2bf | |||
| eee3215302 | |||
| c553757db0 |
@@ -11,7 +11,6 @@ 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
|
||||
CV_SEARCH_JOB_IMAGE: apps/myai-cv-search-job
|
||||
@@ -46,10 +45,6 @@ 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}" .
|
||||
@@ -74,10 +69,6 @@ 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}"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using Common.Requests;
|
||||
using Shared.Models.Requests;
|
||||
|
||||
namespace Models.Requests
|
||||
{
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
@@ -8,11 +8,11 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<FrameworkReference Include="Microsoft.AspNetCore.App" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Http.Features" Version="5.0.17" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\common\common.csproj" />
|
||||
<ProjectReference Include="..\shared-models\shared-models.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -4,7 +4,7 @@ using Microsoft.Extensions.Options;
|
||||
using Models.Settings;
|
||||
using Swashbuckle.AspNetCore.Annotations;
|
||||
using Models.Requests;
|
||||
using Common.Responses;
|
||||
using Shared.Models.Responses;
|
||||
|
||||
namespace Api.Controllers
|
||||
{
|
||||
@@ -70,7 +70,7 @@ namespace Api.Controllers
|
||||
{
|
||||
Error = "Captcha verification failed.",
|
||||
Code = "captcha_verification_failed",
|
||||
Detail = verdict.Score.HasValue ? $"Score: {verdict.Score:0.00}" : null
|
||||
Score = verdict.Score
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ using Microsoft.Extensions.Options;
|
||||
using Models.Settings;
|
||||
using Models.Requests;
|
||||
using Swashbuckle.AspNetCore.Annotations;
|
||||
using Common.Responses;
|
||||
using Shared.Models.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 = "Could not process subscription.", Code = "subscription_failed" });
|
||||
return StatusCode(StatusCodes.Status500InternalServerError, new ErrorResponse { Error = "Failed.", Code = "subscription_failed" });
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -4,12 +4,13 @@ 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 Common.Responses;
|
||||
using MyAi.Data.Services;
|
||||
using Shared.Models.Responses;
|
||||
using MyAi.Models.Services;
|
||||
|
||||
namespace Api.Controllers;
|
||||
|
||||
@@ -112,16 +113,6 @@ 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<ErrorResponse>();
|
||||
_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.");
|
||||
@@ -206,16 +197,6 @@ 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<ErrorResponse>();
|
||||
_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.");
|
||||
|
||||
@@ -6,7 +6,7 @@ using Microsoft.AspNetCore.StaticFiles;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Microsoft.Net.Http.Headers;
|
||||
using Swashbuckle.AspNetCore.Annotations;
|
||||
using Common.Responses;
|
||||
using Shared.Models.Responses;
|
||||
|
||||
namespace Api.Controllers
|
||||
{
|
||||
@@ -44,6 +44,10 @@ namespace Api.Controllers
|
||||
/// </summary>
|
||||
/// <param name="fileName">The name of the file to download (optional - uses default from settings if not provided)</param>
|
||||
/// <returns>File stream with appropriate headers for resumable downloads</returns>
|
||||
/// <response code="200">Full file content</response>
|
||||
/// <response code="206">Partial file content (range request)</response>
|
||||
/// <response code="404">File not found</response>
|
||||
/// <response code="416">Requested range not satisfiable</response>
|
||||
[HttpGet("{fileName?}")]
|
||||
[SwaggerOperation(Summary = "Download file", Description = "Downloads a file with support for full and ranged (resumable) transfers.")]
|
||||
[SwaggerResponse(StatusCodes.Status200OK, "Full file content returned")]
|
||||
|
||||
+4
-11
@@ -2,27 +2,20 @@ 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-api-data/email-api-data.csproj Apis/email-api-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 Apis/myai-models/myai-models.csproj Apis/myai-models/
|
||||
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-api-data/ Apis/email-api-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 Apis/myai-models/ Apis/myai-models/
|
||||
COPY Helpers/startup-helpers/ Helpers/startup-helpers/
|
||||
|
||||
RUN dotnet publish Apis/api/api.csproj -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=false
|
||||
|
||||
+6
-39
@@ -1,19 +1,13 @@
|
||||
using System.Reflection;
|
||||
using Api.Services;
|
||||
using Api.Services.Contracts;
|
||||
using EmailApi.Data;
|
||||
using EmailApi.Data.Repositories;
|
||||
using EmailApi.Data.Repositories.Contracts;
|
||||
using EmailApi.Data.Services;
|
||||
using EmailApi.Models.Clients;
|
||||
using EmailApi.Models.Settings;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Models.Settings;
|
||||
using MyAi.Data;
|
||||
using MyAi.Data.Services;
|
||||
using MyAi.Models.Data;
|
||||
using MyAi.Models.Services;
|
||||
using Refit;
|
||||
using Serilog;
|
||||
using Common.Settings;
|
||||
using Shared.Models.Settings;
|
||||
using StartupHelpers;
|
||||
|
||||
StartupExtensions.LoadDotEnvFile();
|
||||
@@ -35,50 +29,26 @@ try
|
||||
builder.Services.Configure<GoogleSettings>(builder.Configuration.GetSection("Google"));
|
||||
builder.Services.Configure<ContactSettings>(builder.Configuration.GetSection("Contact"));
|
||||
builder.Services.Configure<SubscribeSettings>(builder.Configuration.GetSection("Subscribe"));
|
||||
builder.Services.Configure<SmtpSettings>(builder.Configuration.GetSection("Smtp"));
|
||||
builder.Services.Configure<CaptchaSettings>(builder.Configuration.GetSection("Captcha"));
|
||||
builder.Services.Configure<FileStorageSettings>(builder.Configuration.GetSection("FileStorage"));
|
||||
builder.Services.Configure<JobSearchLinkSettings>(builder.Configuration.GetSection("JobSearch"));
|
||||
builder.Services.Configure<EmailApiSettings>(builder.Configuration.GetSection("EmailApi"));
|
||||
|
||||
builder.Services.AddDbContext<MyAiDbContext>(options =>
|
||||
{
|
||||
var connectionString = builder.Services.GetConfiguredDbConnectionString(builder.Configuration);
|
||||
options.UseSqlServer(connectionString, sql =>
|
||||
{
|
||||
sql.MigrationsAssembly("myai-data");
|
||||
sql.MigrationsAssembly("myai-models");
|
||||
sql.MigrationsHistoryTable(MyAiDbContext.MigrationTableName, MyAiDbContext.SchemaName);
|
||||
});
|
||||
});
|
||||
builder.Services.AddSingleton<ITemplateService, DbTemplateService>();
|
||||
|
||||
builder.Services.AddDbContext<EmailApiDbContext>(options =>
|
||||
{
|
||||
var connectionString = builder.Services.GetConfiguredDbConnectionString(builder.Configuration);
|
||||
options.UseSqlServer(connectionString, sql =>
|
||||
{
|
||||
sql.MigrationsHistoryTable(EmailApiDbContext.MigrationTableName, EmailApiDbContext.SchemaName);
|
||||
sql.MigrationsAssembly("email-api-data");
|
||||
});
|
||||
});
|
||||
|
||||
builder.Services.AddScoped<IEmailTemplateRepository, EfEmailTemplateRepository>();
|
||||
builder.Services.AddSingleton<IEmailTemplateService, EmailTemplateService>();
|
||||
|
||||
builder.Services.AddHttpClient<ICaptchaVerifier, RecaptchaVerifier>();
|
||||
builder.Services.AddSingleton<IEmailSender, EmailApiEmailSender>();
|
||||
builder.Services.AddSingleton<IEmailSender, SmtpEmailSender>();
|
||||
builder.Services.AddSingleton<Microsoft.AspNetCore.StaticFiles.IContentTypeProvider, Microsoft.AspNetCore.StaticFiles.FileExtensionContentTypeProvider>();
|
||||
|
||||
static void ConfigureEmailApiClient(IServiceProvider sp, HttpClient client)
|
||||
{
|
||||
var config = sp.GetRequiredService<IConfiguration>();
|
||||
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);
|
||||
}
|
||||
|
||||
static void ConfigureCvMatcherApiClient(IServiceProvider sp, HttpClient client)
|
||||
{
|
||||
var config = sp.GetRequiredService<IConfiguration>();
|
||||
@@ -90,9 +60,6 @@ try
|
||||
client.DefaultRequestHeaders.Add("X-Internal-Api-Key", key);
|
||||
}
|
||||
|
||||
builder.Services.AddRefitClient<IEmailApiClient>()
|
||||
.ConfigureHttpClient(ConfigureEmailApiClient);
|
||||
|
||||
builder.Services.AddRefitClient<Api.Clients.Api.Contracts.ICvMatcherApi>()
|
||||
.ConfigureHttpClient(ConfigureCvMatcherApiClient);
|
||||
|
||||
|
||||
@@ -1,21 +1,9 @@
|
||||
using Api.Services.Contracts.Models;
|
||||
using Api.Services.Contracts.Models;
|
||||
|
||||
namespace Api.Services.Contracts
|
||||
{
|
||||
/// <summary>
|
||||
/// Verifies a reCAPTCHA token against the Google verification API.
|
||||
/// </summary>
|
||||
public interface ICaptchaVerifier
|
||||
{
|
||||
/// <summary>
|
||||
/// Sends the token to the Google reCAPTCHA verification endpoint and
|
||||
/// returns a verdict indicating success, score, and any failure reason.
|
||||
/// </summary>
|
||||
/// <param name="token">The reCAPTCHA token provided by the client.</param>
|
||||
/// <param name="userIp">Optional remote IP address passed to Google for additional risk analysis.</param>
|
||||
/// <param name="expectedAction">Optional action name to validate against the token's embedded action (v3 only).</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>A <see cref="CaptchaVerdictModel"/> with the verification outcome.</returns>
|
||||
Task<CaptchaVerdictModel> VerifyAsync(string token, string? userIp, string? expectedAction, CancellationToken ct);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,65 +1,15 @@
|
||||
using CvMatcher.Models.Responses;
|
||||
using CvMatcher.Models.Responses;
|
||||
using Models.Requests;
|
||||
|
||||
namespace Api.Services.Contracts
|
||||
{
|
||||
/// <summary>
|
||||
/// Abstraction for sending transactional emails from the public API.
|
||||
/// </summary>
|
||||
public interface IEmailSender
|
||||
{
|
||||
/// <summary>
|
||||
/// Sends a contact-form message to the configured operator address.
|
||||
/// </summary>
|
||||
/// <param name="req">Contact request containing name, email, subject, and message.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
Task SendContactAsync(ContactRequest req, CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Notifies the configured operator address that a new email subscription was received.
|
||||
/// </summary>
|
||||
/// <param name="req">Subscription request containing the subscriber's email address.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
Task SendSubscribeAsync(SubscribeRequest req, CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Sends a background notification when a file download is initiated.
|
||||
/// Does nothing when no notification address is configured.
|
||||
/// </summary>
|
||||
/// <param name="fileName">Name of the downloaded file.</param>
|
||||
/// <param name="userIp">Remote IP address of the downloader, or <c>null</c> if unavailable.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
Task SendFileDownloadNotificationAsync(string fileName, string? userIp, CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Sends a CV match results email to the user and the operator copy address.
|
||||
/// </summary>
|
||||
/// <param name="explicitTo">Primary recipient email address, or <c>null</c> to send only the operator copy.</param>
|
||||
/// <param name="subject">Email subject line.</param>
|
||||
/// <param name="body">Pre-built HTML body fragment.</param>
|
||||
/// <param name="attachmentPath">Full path to a CV PDF to attach, or <c>null</c> for no attachment.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
Task SendMatchAsync(string? explicitTo, string subject, string body, string? attachmentPath, CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Builds the localised subject line for a CV match email.
|
||||
/// </summary>
|
||||
/// <param name="score">Match score percentage (0–100).</param>
|
||||
/// <param name="jobLabel">Human-readable job title or label.</param>
|
||||
/// <param name="language">Two-letter language code (e.g. <c>"en"</c>, <c>"ro"</c>).</param>
|
||||
/// <returns>Rendered subject string.</returns>
|
||||
string BuildMatchEmailSubject(int score, string? jobLabel, string language);
|
||||
|
||||
/// <summary>
|
||||
/// Builds the full HTML body for a CV match email, including an optional job-search footer link.
|
||||
/// </summary>
|
||||
/// <param name="cvDocumentId">Identifier of the indexed CV document.</param>
|
||||
/// <param name="result">Structured match response from the CV matcher engine.</param>
|
||||
/// <param name="jobLabel">Human-readable job title or label.</param>
|
||||
/// <param name="language">Two-letter language code.</param>
|
||||
/// <param name="jobSearchLink">Optional one-click job-search URL to append as a footer CTA.</param>
|
||||
/// <param name="expiryDays">Number of days until the job-search link expires (shown in the footer copy).</param>
|
||||
/// <returns>Rendered HTML body string.</returns>
|
||||
string BuildMatchEmailBody(string cvDocumentId, JobMatchResponse result, string? jobLabel, string language, string? jobSearchLink = null, int expiryDays = 7);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,237 +0,0 @@
|
||||
using Api.Services.Contracts;
|
||||
using CvMatcher.Models.Responses;
|
||||
using EmailApi.Data.Services;
|
||||
using EmailApi.Models.Clients;
|
||||
using EmailApi.Models.Requests;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Models.Requests;
|
||||
using Models.Settings;
|
||||
|
||||
namespace Api.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Implements <see cref="IEmailSender"/> by delegating all email dispatch to the internal email-api service via Refit.
|
||||
/// </summary>
|
||||
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<EmailApiEmailSender> _log;
|
||||
|
||||
public EmailApiEmailSender(
|
||||
IEmailApiClient emailApi,
|
||||
IOptions<ContactSettings> contact,
|
||||
IOptions<SubscribeSettings> subscribe,
|
||||
IOptions<FileStorageSettings> fileStorage,
|
||||
IEmailTemplateService emailTemplates,
|
||||
ILogger<EmailApiEmailSender> log)
|
||||
{
|
||||
_emailApi = emailApi;
|
||||
_contact = contact.Value;
|
||||
_subscribe = subscribe.Value;
|
||||
_fileStorage = fileStorage.Value;
|
||||
_emailTemplates = emailTemplates;
|
||||
_log = log;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
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 = $"""
|
||||
<h2 style="color:#2c5282;margin:0 0 20px">New Contact Message</h2>
|
||||
<table cellpadding="10" cellspacing="0" style="width:100%;border-collapse:collapse;margin-bottom:24px">
|
||||
<tr style="background:#f8f9fa">
|
||||
<td style="font-weight:600;width:100px;border:1px solid #dee2e6;color:#495057">Name</td>
|
||||
<td style="border:1px solid #dee2e6">{req.Name}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="font-weight:600;border:1px solid #dee2e6;color:#495057">Email</td>
|
||||
<td style="border:1px solid #dee2e6">{req.Email}</td>
|
||||
</tr>
|
||||
<tr style="background:#f8f9fa">
|
||||
<td style="font-weight:600;border:1px solid #dee2e6;color:#495057">Subject</td>
|
||||
<td style="border:1px solid #dee2e6">{req.Subject}</td>
|
||||
</tr>
|
||||
</table>
|
||||
<h3 style="color:#2c5282;border-bottom:2px solid #e9ecef;padding-bottom:6px">Message</h3>
|
||||
<p style="color:#495057;line-height:1.7;white-space:pre-wrap">{req.Message}</p>
|
||||
""";
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
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 = $"""
|
||||
<h2 style="color:#2c5282;margin:0 0 20px">New Subscription Request</h2>
|
||||
<p style="color:#495057">A new user has subscribed:</p>
|
||||
<table cellpadding="10" cellspacing="0" style="border-collapse:collapse;margin-top:12px">
|
||||
<tr style="background:#f8f9fa">
|
||||
<td style="font-weight:600;border:1px solid #dee2e6;color:#495057;padding:10px 16px">Email</td>
|
||||
<td style="border:1px solid #dee2e6;padding:10px 16px">{req.Email}</td>
|
||||
</tr>
|
||||
</table>
|
||||
""";
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
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 = $"""
|
||||
<h2 style="color:#2c5282;margin:0 0 20px">File Download Notification</h2>
|
||||
<table cellpadding="10" cellspacing="0" style="width:100%;border-collapse:collapse">
|
||||
<tr style="background:#f8f9fa">
|
||||
<td style="font-weight:600;width:120px;border:1px solid #dee2e6;color:#495057">File</td>
|
||||
<td style="border:1px solid #dee2e6">{fileName}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="font-weight:600;border:1px solid #dee2e6;color:#495057">Downloaded at</td>
|
||||
<td style="border:1px solid #dee2e6">{DateTime.UtcNow:yyyy-MM-dd HH:mm:ss} UTC</td>
|
||||
</tr>
|
||||
<tr style="background:#f8f9fa">
|
||||
<td style="font-weight:600;border:1px solid #dee2e6;color:#495057">IP Address</td>
|
||||
<td style="border:1px solid #dee2e6">{userIp ?? "Unknown"}</td>
|
||||
</tr>
|
||||
</table>
|
||||
""";
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
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<string>();
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string BuildMatchEmailBody(string cvDocumentId, JobMatchResponse result, string? jobLabel, string language, string? jobSearchLink = null, int expiryDays = 7)
|
||||
{
|
||||
var strengths = result.Strengths?.Count > 0
|
||||
? "<ul style=\"color:#495057;padding-left:20px;line-height:1.8\">" +
|
||||
string.Join("", result.Strengths.Select(s => $"<li>{s}</li>")) + "</ul>"
|
||||
: "<p style=\"color:#6c757d\">—</p>";
|
||||
|
||||
var gaps = result.Gaps?.Count > 0
|
||||
? "<ul style=\"color:#495057;padding-left:20px;line-height:1.8\">" +
|
||||
string.Join("", result.Gaps.Select(g => $"<li>{g}</li>")) + "</ul>"
|
||||
: "<p style=\"color:#6c757d\">—</p>";
|
||||
|
||||
var recommendations = result.Recommendations?.Count > 0
|
||||
? "<ul style=\"color:#495057;padding-left:20px;line-height:1.8\">" +
|
||||
string.Join("", result.Recommendations.Select(r => $"<li>{r}</li>")) + "</ul>"
|
||||
: "<p style=\"color:#6c757d\">—</p>";
|
||||
|
||||
var body = _emailTemplates.Render("email.match.body", language,
|
||||
("cvDocumentId", cvDocumentId),
|
||||
("jobLabel", jobLabel ?? "N/A"),
|
||||
("jobUrl", result.JobUrl ?? "N/A"),
|
||||
("score", result.Score.ToString()),
|
||||
("summary", result.Summary ?? string.Empty),
|
||||
("strengths", strengths),
|
||||
("gaps", gaps),
|
||||
("recommendations", recommendations));
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(jobSearchLink))
|
||||
{
|
||||
body += _emailTemplates.Render("email.match.job-search-footer", language,
|
||||
("jobSearchLink", jobSearchLink),
|
||||
("expiryDays", expiryDays.ToString()));
|
||||
}
|
||||
|
||||
return body;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public string BuildMatchEmailSubject(int score, string? jobLabel, string language) =>
|
||||
_emailTemplates.Render("email.match.subject", language,
|
||||
("score", score.ToString()),
|
||||
("jobLabel", jobLabel ?? "Job"));
|
||||
}
|
||||
@@ -5,9 +5,6 @@ using Models.Settings;
|
||||
|
||||
namespace Api.Services
|
||||
{
|
||||
/// <summary>
|
||||
/// Verifies reCAPTCHA v2/v3 tokens by calling the Google site-verify API.
|
||||
/// </summary>
|
||||
public sealed class RecaptchaVerifier : ICaptchaVerifier
|
||||
{
|
||||
private readonly HttpClient _http;
|
||||
@@ -21,7 +18,6 @@ namespace Api.Services
|
||||
_log = log;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<CaptchaVerdictModel> VerifyAsync(string token, string? userIp, string? expectedAction, CancellationToken ct)
|
||||
{
|
||||
_log.LogDebug("Verifying captcha token for IP {Ip}", userIp ?? "unknown");
|
||||
|
||||
@@ -0,0 +1,253 @@
|
||||
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;
|
||||
using MyAi.Models.Services;
|
||||
|
||||
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 ITemplateService _templates;
|
||||
private readonly ILogger<SmtpEmailSender> _log;
|
||||
private readonly string _environmentName;
|
||||
|
||||
public SmtpEmailSender(
|
||||
IOptions<SmtpSettings> smtp,
|
||||
IOptions<ContactSettings> contact,
|
||||
IOptions<SubscribeSettings> subscribe,
|
||||
IOptions<FileStorageSettings> fileStorage,
|
||||
ITemplateService templates,
|
||||
ILogger<SmtpEmailSender> log)
|
||||
{
|
||||
_smtp = smtp.Value;
|
||||
_contact = contact.Value;
|
||||
_subscribe = subscribe.Value;
|
||||
_fileStorage = fileStorage.Value;
|
||||
_templates = templates;
|
||||
_log = log;
|
||||
_environmentName = Environment.GetEnvironmentVariable("APP_ENVIRONMENT_NAME") ?? "Development";
|
||||
}
|
||||
|
||||
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 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)
|
||||
{
|
||||
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)
|
||||
{
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Connects to the SMTP server and authenticates if credentials are configured.
|
||||
/// </summary>
|
||||
private async Task ConnectAndAuthenticateAsync(SmtpClient client, CancellationToken ct)
|
||||
{
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sends an email message using SMTP.
|
||||
/// </summary>
|
||||
/// <param name="message">The email message to send.</param>
|
||||
/// <param name="messageType">Description of the message type for logging purposes.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
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<string>();
|
||||
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 string BuildMatchEmailBody(string cvDocumentId, JobMatchResponse result, string? jobLabel, string language, string? jobSearchLink = null, int expiryDays = 7)
|
||||
{
|
||||
var strengths = result.Strengths?.Count > 0
|
||||
? "- " + string.Join("\n- ", result.Strengths)
|
||||
: string.Empty;
|
||||
var gaps = result.Gaps?.Count > 0
|
||||
? "- " + string.Join("\n- ", result.Gaps)
|
||||
: string.Empty;
|
||||
var recommendations = result.Recommendations?.Count > 0
|
||||
? "- " + string.Join("\n- ", result.Recommendations)
|
||||
: string.Empty;
|
||||
|
||||
var body = _templates.Render("email.match.body", language,
|
||||
("cvDocumentId", cvDocumentId),
|
||||
("jobLabel", jobLabel ?? "N/A"),
|
||||
("jobUrl", result.JobUrl ?? "N/A"),
|
||||
("score", result.Score.ToString()),
|
||||
("summary", result.Summary ?? string.Empty),
|
||||
("strengths", strengths),
|
||||
("gaps", gaps),
|
||||
("recommendations", recommendations));
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(jobSearchLink))
|
||||
{
|
||||
body += _templates.Render("email.match.job-search-footer", language,
|
||||
("jobSearchLink", jobSearchLink),
|
||||
("expiryDays", expiryDays.ToString()));
|
||||
}
|
||||
|
||||
return body;
|
||||
}
|
||||
|
||||
public string BuildMatchEmailSubject(int score, string? jobLabel, string language) =>
|
||||
_templates.Render("email.match.subject", language,
|
||||
("score", score.ToString()),
|
||||
("jobLabel", jobLabel ?? "Job"));
|
||||
}
|
||||
}
|
||||
+14
-16
@@ -16,18 +16,18 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Azure.Extensions.AspNetCore.Configuration.Secrets" />
|
||||
<PackageReference Include="Azure.Identity" />
|
||||
<PackageReference Include="DotNetEnv" />
|
||||
<!-- MailKit removed — email sending delegated to email-api service -->
|
||||
<PackageReference Include="Microsoft.VisualStudio.Azure.Containers.Tools.Targets" />
|
||||
<PackageReference Include="Serilog.AspNetCore" />
|
||||
<PackageReference Include="Serilog.Enrichers.Environment" />
|
||||
<PackageReference Include="Serilog.Sinks.Email" />
|
||||
<PackageReference Include="Serilog.Sinks.File" />
|
||||
<PackageReference Include="Swashbuckle.AspNetCore" />
|
||||
<PackageReference Include="Swashbuckle.AspNetCore.Annotations" />
|
||||
<PackageReference Include="Refit.HttpClientFactory" />
|
||||
<PackageReference Include="Azure.Extensions.AspNetCore.Configuration.Secrets" Version="1.5.1" />
|
||||
<PackageReference Include="Azure.Identity" Version="1.21.0" />
|
||||
<PackageReference Include="DotNetEnv" Version="3.2.0" />
|
||||
<PackageReference Include="MailKit" Version="4.16.0" />
|
||||
<PackageReference Include="Microsoft.VisualStudio.Azure.Containers.Tools.Targets" Version="1.23.0" />
|
||||
<PackageReference Include="Serilog.AspNetCore" Version="10.0.0" />
|
||||
<PackageReference Include="Serilog.Enrichers.Environment" Version="3.0.1" />
|
||||
<PackageReference Include="Serilog.Sinks.Email" Version="4.2.1" />
|
||||
<PackageReference Include="Serilog.Sinks.File" Version="7.0.0" />
|
||||
<PackageReference Include="Swashbuckle.AspNetCore" Version="10.1.7" />
|
||||
<PackageReference Include="Swashbuckle.AspNetCore.Annotations" Version="10.1.7" />
|
||||
<PackageReference Include="Refit.HttpClientFactory" Version="10.1.6" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
@@ -36,12 +36,10 @@
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\api-models\api-models.csproj" />
|
||||
<ProjectReference Include="..\email-api-data\email-api-data.csproj" />
|
||||
<ProjectReference Include="..\email-api-models\email-api-models.csproj" />
|
||||
<ProjectReference Include="..\cv-matcher-api-models\cv-matcher-api-models.csproj" />
|
||||
<ProjectReference Include="..\common\common.csproj" />
|
||||
<ProjectReference Include="..\shared-models\shared-models.csproj" />
|
||||
<ProjectReference Include="..\..\Helpers\startup-helpers\startup-helpers.csproj" />
|
||||
<ProjectReference Include="..\myai-data\myai-data.csproj" />
|
||||
<ProjectReference Include="..\myai-models\myai-models.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -110,10 +110,6 @@
|
||||
"BaseUrl": "",
|
||||
"InternalApiKey": ""
|
||||
},
|
||||
"EmailApi": {
|
||||
"BaseUrl": "",
|
||||
"InternalApiKey": ""
|
||||
},
|
||||
"RateLimiting": {
|
||||
"Global": {
|
||||
"PermitLimit": 120,
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
namespace Common.Responses;
|
||||
|
||||
/// <summary>
|
||||
/// Standard error body returned by all API endpoints on 4xx and 5xx responses.
|
||||
/// </summary>
|
||||
public sealed class ErrorResponse
|
||||
{
|
||||
/// <summary>Human-readable error message, safe to display directly to the end user for 4xx responses.</summary>
|
||||
public string Error { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>Machine-readable error code for programmatic handling (e.g. <c>"captcha_verification_failed"</c>).</summary>
|
||||
public string? Code { get; init; }
|
||||
|
||||
/// <summary>Optional additional detail for debugging (not shown in UI).</summary>
|
||||
public string? Detail { get; init; }
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
using Common.Settings;
|
||||
using Shared.Models.Settings;
|
||||
|
||||
namespace CvMatcher.Models.Settings;
|
||||
|
||||
public sealed class AiSettings : Common.Settings.AiSettings
|
||||
public sealed class AiSettings : Shared.Models.Settings.AiSettings
|
||||
{
|
||||
public OpenAiSettings OpenAI { get; set; } = new();
|
||||
public OllamaSettings Ollama { get; set; } = new();
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
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;
|
||||
public List<JobProviderConfig> Providers { get; set; } = [];
|
||||
}
|
||||
|
||||
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<string> InitialKeywords { get; set; } = [];
|
||||
public int MaxResults { get; set; } = 20;
|
||||
}
|
||||
@@ -8,7 +8,7 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\common\common.csproj" />
|
||||
<ProjectReference Include="..\shared-models\shared-models.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -36,8 +36,8 @@ Default model: `gpt-4o-mini`. Timeout: 90 s.
|
||||
|
||||
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/`)
|
||||
- `CvMatcherDbContext` — schema `cvMatcher`; migrations in `cv-matcher-api` assembly
|
||||
- `CvSearchDbContext` — schema `cvSearch`; migrations in `cv-search-models` assembly (MigrationsAssembly = "cv-search-models")
|
||||
|
||||
## Keyword extraction (JobTokenService.ExtractKeywords)
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
using Microsoft.Extensions.Options;
|
||||
using CvMatcher.Models.Settings;
|
||||
using CvMatcher.Data.Repositories.Contracts;
|
||||
using Api.Data.Repositories.Contracts;
|
||||
using Api.Clients.Ai.Contracts;
|
||||
using CommonHelpers;
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Api.Clients.Ai.Contracts;
|
||||
using CvMatcher.Data.Repositories.Contracts;
|
||||
using Api.Data.Repositories.Contracts;
|
||||
using CommonHelpers;
|
||||
using CvMatcher.Models.Settings;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
@@ -2,9 +2,9 @@ using CvMatcher.Models.Requests;
|
||||
using Api.Services.Contracts;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using CvMatcher.Models.Responses;
|
||||
using Common.Requests;
|
||||
using Shared.Models.Requests;
|
||||
using Swashbuckle.AspNetCore.Annotations;
|
||||
using Common.Responses;
|
||||
using Shared.Models.Responses;
|
||||
|
||||
namespace Api.Controllers;
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ using Api.Services.Contracts;
|
||||
using CvMatcher.Models.Requests;
|
||||
using CvMatcher.Models.Responses;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Common.Responses;
|
||||
using Shared.Models.Responses;
|
||||
using Swashbuckle.AspNetCore.Annotations;
|
||||
|
||||
namespace Api.Controllers;
|
||||
|
||||
+3
-14
@@ -1,7 +1,8 @@
|
||||
using CvMatcher.Data.Entities;
|
||||
using Api.Data.Entities;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace CvMatcher.Data;
|
||||
namespace Api.Data;
|
||||
|
||||
|
||||
public sealed class CvMatcherDbContext : DbContext
|
||||
{
|
||||
@@ -14,7 +15,6 @@ public sealed class CvMatcherDbContext : DbContext
|
||||
|
||||
public DbSet<CvMatchResultEntity> CvMatchResults => Set<CvMatchResultEntity>();
|
||||
public DbSet<CvMatcherChatCacheEntity> CvMatcherChatCache => Set<CvMatcherChatCacheEntity>();
|
||||
public DbSet<AiPromptEntity> AiPrompts => Set<AiPromptEntity>();
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
{
|
||||
@@ -42,16 +42,5 @@ public sealed class CvMatcherDbContext : DbContext
|
||||
entity.Property(x => x.ResponseText).IsRequired();
|
||||
entity.Property(x => x.CreatedAt).HasDefaultValueSql("SYSUTCDATETIME()");
|
||||
});
|
||||
|
||||
modelBuilder.Entity<AiPromptEntity>(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()");
|
||||
});
|
||||
}
|
||||
}
|
||||
+4
-4
@@ -1,12 +1,12 @@
|
||||
using Shared.Data.Entities;
|
||||
namespace Api.Data.Entities;
|
||||
|
||||
namespace CvMatcher.Data.Entities;
|
||||
|
||||
public sealed class CvMatchResultEntity : BaseEntity
|
||||
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 Language { get; set; } = "en";
|
||||
public string ResultJson { get; set; } = string.Empty;
|
||||
public int Score { get; set; }
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
+1
-2
@@ -1,6 +1,5 @@
|
||||
namespace CvMatcher.Data.Entities;
|
||||
namespace Api.Data.Entities;
|
||||
|
||||
// CacheKey PK — BaseEntity not applicable
|
||||
public sealed class CvMatcherChatCacheEntity
|
||||
{
|
||||
public string CacheKey { get; set; } = string.Empty;
|
||||
@@ -1,6 +0,0 @@
|
||||
namespace CvMatcher.Data.Repositories.Contracts;
|
||||
|
||||
public interface IAiPromptsRepository
|
||||
{
|
||||
Task<string?> GetAsync(string key, string language, CancellationToken ct);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
using CvMatcher.Models.Responses;
|
||||
|
||||
namespace CvMatcher.Data.Repositories.Contracts;
|
||||
namespace Api.Data.Repositories.Contracts;
|
||||
|
||||
public interface IMatcherRepository
|
||||
{
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
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<string?> 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);
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,11 @@
|
||||
using System.Text.Json;
|
||||
using CvMatcher.Data;
|
||||
using CvMatcher.Data.Entities;
|
||||
using CvMatcher.Data.Repositories.Contracts;
|
||||
using Api.Data;
|
||||
using Api.Data.Entities;
|
||||
using Api.Data.Repositories.Contracts;
|
||||
using CvMatcher.Models.Responses;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace CvMatcher.Data.Repositories;
|
||||
namespace Api.Data.Repositories;
|
||||
|
||||
public sealed class EfMatcherRepository : IMatcherRepository
|
||||
{
|
||||
|
||||
@@ -2,26 +2,21 @@ 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/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-search-models/cv-search-models.csproj Apis/cv-search-models/
|
||||
COPY Apis/shared-models/shared-models.csproj Apis/shared-models/
|
||||
COPY Apis/cv-matcher-api-models/cv-matcher-api-models.csproj Apis/cv-matcher-api-models/
|
||||
COPY Apis/myai-data/myai-data.csproj Apis/myai-data/
|
||||
COPY Apis/shared-data/shared-data.csproj Apis/shared-data/
|
||||
COPY Apis/myai-models/myai-models.csproj Apis/myai-models/
|
||||
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/cv-search-data/ Apis/cv-search-data/
|
||||
COPY Apis/cv-matcher-data/ Apis/cv-matcher-data/
|
||||
COPY Apis/common/ Apis/common/
|
||||
COPY Apis/cv-search-models/ Apis/cv-search-models/
|
||||
COPY Apis/shared-models/ Apis/shared-models/
|
||||
COPY Apis/cv-matcher-api-models/ Apis/cv-matcher-api-models/
|
||||
COPY Apis/myai-data/ Apis/myai-data/
|
||||
COPY Apis/shared-data/ Apis/shared-data/
|
||||
COPY Apis/myai-models/ Apis/myai-models/
|
||||
COPY Helpers/common-helpers/ Helpers/common-helpers/
|
||||
COPY Helpers/startup-helpers/ Helpers/startup-helpers/
|
||||
|
||||
|
||||
+5
-5
@@ -1,6 +1,6 @@
|
||||
// <auto-generated />
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using CvMatcher.Data;
|
||||
using Api.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Metadata;
|
||||
@@ -9,7 +9,7 @@ using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace CvMatcher.Data.Migrations
|
||||
namespace Api.Migrations
|
||||
{
|
||||
[DbContext(typeof(CvMatcherDbContext))]
|
||||
[Migration("20260507140442_InitialCvMatcherSchema")]
|
||||
@@ -26,7 +26,7 @@ namespace CvMatcher.Data.Migrations
|
||||
|
||||
SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder);
|
||||
|
||||
modelBuilder.Entity("CvMatcher.Data.Entities.CvMatchResultEntity", b =>
|
||||
modelBuilder.Entity("Api.Data.Entities.CvMatchResultEntity", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasMaxLength(64)
|
||||
@@ -62,7 +62,7 @@ namespace CvMatcher.Data.Migrations
|
||||
b.ToTable("Results", "cvMatcher");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("CvMatcher.Data.Entities.CvMatcherChatCacheEntity", b =>
|
||||
modelBuilder.Entity("Api.Data.Entities.CvMatcherChatCacheEntity", b =>
|
||||
{
|
||||
b.Property<string>("CacheKey")
|
||||
.HasMaxLength(64)
|
||||
+2
-2
@@ -1,9 +1,9 @@
|
||||
using System;
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace CvMatcher.Data.Migrations
|
||||
namespace Api.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class InitialCvMatcherSchema : Migration
|
||||
+5
-5
@@ -1,6 +1,6 @@
|
||||
// <auto-generated />
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using CvMatcher.Data;
|
||||
using Api.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Metadata;
|
||||
@@ -9,7 +9,7 @@ using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace CvMatcher.Data.Migrations
|
||||
namespace Api.Migrations
|
||||
{
|
||||
[DbContext(typeof(CvMatcherDbContext))]
|
||||
[Migration("20260524140335_AddLanguageToCvMatchResult")]
|
||||
@@ -26,7 +26,7 @@ namespace CvMatcher.Data.Migrations
|
||||
|
||||
SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder);
|
||||
|
||||
modelBuilder.Entity("CvMatcher.Data.Entities.CvMatchResultEntity", b =>
|
||||
modelBuilder.Entity("Api.Data.Entities.CvMatchResultEntity", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasMaxLength(64)
|
||||
@@ -66,7 +66,7 @@ namespace CvMatcher.Data.Migrations
|
||||
b.ToTable("Results", "cvMatcher");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("CvMatcher.Data.Entities.CvMatcherChatCacheEntity", b =>
|
||||
modelBuilder.Entity("Api.Data.Entities.CvMatcherChatCacheEntity", b =>
|
||||
{
|
||||
b.Property<string>("CacheKey")
|
||||
.HasMaxLength(64)
|
||||
+2
-2
@@ -1,8 +1,8 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace CvMatcher.Data.Migrations
|
||||
namespace Api.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddLanguageToCvMatchResult : Migration
|
||||
+5
-36
@@ -1,6 +1,6 @@
|
||||
// <auto-generated />
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using CvMatcher.Data;
|
||||
using Api.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Metadata;
|
||||
@@ -8,7 +8,7 @@ using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace CvMatcher.Data.Migrations
|
||||
namespace Api.Migrations
|
||||
{
|
||||
[DbContext(typeof(CvMatcherDbContext))]
|
||||
partial class CvMatcherDbContextModelSnapshot : ModelSnapshot
|
||||
@@ -23,38 +23,7 @@ namespace CvMatcher.Data.Migrations
|
||||
|
||||
SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder);
|
||||
|
||||
modelBuilder.Entity("CvMatcher.Data.Entities.AiPromptEntity", b =>
|
||||
{
|
||||
b.Property<string>("Key")
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("nvarchar(128)");
|
||||
|
||||
b.Property<string>("Language")
|
||||
.HasMaxLength(8)
|
||||
.HasColumnType("nvarchar(8)");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("nvarchar(500)")
|
||||
.HasDefaultValue("");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("datetime2")
|
||||
.HasDefaultValueSql("SYSUTCDATETIME()");
|
||||
|
||||
b.Property<string>("Value")
|
||||
.IsRequired()
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.HasKey("Key", "Language");
|
||||
|
||||
b.ToTable("AiPrompts", "cvMatcher");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("CvMatcher.Data.Entities.CvMatchResultEntity", b =>
|
||||
modelBuilder.Entity("Api.Data.Entities.CvMatchResultEntity", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasMaxLength(64)
|
||||
@@ -94,7 +63,7 @@ namespace CvMatcher.Data.Migrations
|
||||
b.ToTable("Results", "cvMatcher");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("CvMatcher.Data.Entities.CvMatcherChatCacheEntity", b =>
|
||||
modelBuilder.Entity("Api.Data.Entities.CvMatcherChatCacheEntity", b =>
|
||||
{
|
||||
b.Property<string>("CacheKey")
|
||||
.HasMaxLength(64)
|
||||
@@ -2,17 +2,20 @@ using Api.Clients.Ai;
|
||||
using Api.Clients.Ai.Contracts;
|
||||
using Api.Clients.Api;
|
||||
using Api.Clients.Api.Contracts;
|
||||
using CvMatcher.Data;
|
||||
using CvMatcher.Data.Repositories;
|
||||
using CvMatcher.Data.Repositories.Contracts;
|
||||
using Api.Data;
|
||||
using Api.Data.Repositories;
|
||||
using Api.Data.Repositories.Contracts;
|
||||
using Api.Services;
|
||||
using Api.Services.Contracts;
|
||||
using CvMatcher.Models.Settings;
|
||||
using CvSearch.Data;
|
||||
using CvSearch.Models.Data;
|
||||
using CvSearch.Models.Settings;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using MyAi.Models.Data;
|
||||
using MyAi.Models.Services;
|
||||
using Refit;
|
||||
using Serilog;
|
||||
using Common.Settings;
|
||||
using Shared.Models.Settings;
|
||||
using StartupHelpers;
|
||||
using System.Reflection;
|
||||
|
||||
@@ -60,7 +63,6 @@ try
|
||||
options.UseSqlServer(connectionString, sql =>
|
||||
{
|
||||
sql.MigrationsHistoryTable(CvMatcherDbContext.MigrationTableName, CvMatcherDbContext.SchemaName);
|
||||
sql.MigrationsAssembly("cv-matcher-data");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -69,13 +71,23 @@ try
|
||||
var connectionString = builder.Services.GetConfiguredDbConnectionString(builder.Configuration);
|
||||
options.UseSqlServer(connectionString, sql =>
|
||||
{
|
||||
sql.MigrationsAssembly("cv-search-data");
|
||||
sql.MigrationsAssembly("cv-search-models");
|
||||
sql.MigrationsHistoryTable(CvSearchDbContext.MigrationTableName, CvSearchDbContext.SchemaName);
|
||||
});
|
||||
});
|
||||
|
||||
builder.Services.AddDbContext<MyAiDbContext>(options =>
|
||||
{
|
||||
var connectionString = builder.Services.GetConfiguredDbConnectionString(builder.Configuration);
|
||||
options.UseSqlServer(connectionString, sql =>
|
||||
{
|
||||
sql.MigrationsAssembly("myai-models");
|
||||
sql.MigrationsHistoryTable(MyAiDbContext.MigrationTableName, MyAiDbContext.SchemaName);
|
||||
});
|
||||
});
|
||||
builder.Services.AddSingleton<ITemplateService, DbTemplateService>();
|
||||
|
||||
builder.Services.AddScoped<IMatcherRepository, EfMatcherRepository>();
|
||||
builder.Services.AddScoped<IAiPromptsRepository, EfAiPromptsRepository>();
|
||||
builder.Services.AddScoped<ICvMatcherService, CvMatcherService>();
|
||||
builder.Services.AddScoped<IJobTokenService, JobTokenService>();
|
||||
|
||||
@@ -110,6 +122,11 @@ try
|
||||
var db = scope.ServiceProvider.GetRequiredService<CvSearchDbContext>();
|
||||
db.Database.Migrate();
|
||||
}
|
||||
using (var scope = app.Services.CreateScope())
|
||||
{
|
||||
var db = scope.ServiceProvider.GetRequiredService<MyAiDbContext>();
|
||||
db.Database.Migrate();
|
||||
}
|
||||
|
||||
Log.Information("{Service} startup complete", ServiceName);
|
||||
app.Run();
|
||||
|
||||
@@ -3,34 +3,9 @@ using CvMatcher.Models.Responses;
|
||||
|
||||
namespace Api.Services.Contracts;
|
||||
|
||||
/// <summary>
|
||||
/// Orchestrates CV indexing, job matching, and job discovery operations.
|
||||
/// </summary>
|
||||
public interface ICvMatcherService
|
||||
{
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
/// <param name="file">Uploaded CV PDF file.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>Upload response with document ID, hash, and indexing statistics.</returns>
|
||||
Task<CvUploadResponse> UploadCvAsync(IFormFile file, CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
/// <param name="request">Match request containing CV document ID, job URL or description, and language preference.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>Structured match response with score, summary, strengths, gaps, and recommendations.</returns>
|
||||
Task<JobMatchResponse> MatchJobAsync(MatchJobRequest request, CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Searches the RAG index for job documents most similar to the given CV and scores the top candidates.
|
||||
/// </summary>
|
||||
/// <param name="request">Request containing the CV document ID and optional result count limit.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>Response with the CV document ID and a list of ranked match results.</returns>
|
||||
Task<FindJobsResponse> FindJobsAsync(FindJobsRequest request, CancellationToken ct);
|
||||
}
|
||||
|
||||
@@ -1,17 +1,6 @@
|
||||
namespace Api.Services.Contracts;
|
||||
|
||||
/// <summary>
|
||||
/// Extracts plain text from a job posting, either from a pasted description or by fetching and parsing a URL.
|
||||
/// </summary>
|
||||
public interface IJobTextExtractor
|
||||
{
|
||||
/// <summary>
|
||||
/// Returns normalised plain text for the job posting.
|
||||
/// Prefers <paramref name="jobDescription"/> when provided; otherwise fetches and strips HTML from <paramref name="jobUrl"/>.
|
||||
/// </summary>
|
||||
/// <param name="jobUrl">URL of the job posting page, used when no description is pasted.</param>
|
||||
/// <param name="jobDescription">Pasted job description text; takes priority over URL fetching.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>Normalised plain text, truncated to the configured maximum character limit.</returns>
|
||||
Task<string> ExtractAsync(string? jobUrl, string? jobDescription, CancellationToken ct);
|
||||
}
|
||||
|
||||
@@ -1,29 +1,7 @@
|
||||
namespace Api.Services.Contracts;
|
||||
|
||||
/// <summary>
|
||||
/// Manages one-time job search tokens and the sessions they trigger.
|
||||
/// </summary>
|
||||
public interface IJobTokenService
|
||||
{
|
||||
/// <summary>
|
||||
/// 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 <c>JobSearch:TokenExpiryDays</c>.
|
||||
/// </summary>
|
||||
/// <param name="cvDocumentId">Identifier of the indexed CV document.</param>
|
||||
/// <param name="email">Email address of the user who will receive the results.</param>
|
||||
/// <param name="language">Preferred language for result emails (e.g. <c>"en"</c>, <c>"ro"</c>).</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>The generated token ID, to be embedded in the one-click job search link.</returns>
|
||||
Task<string> CreateTokenAsync(string cvDocumentId, string email, string language, CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Validates the token and, if valid, marks it as used and creates a <c>Pending</c> job search session.
|
||||
/// </summary>
|
||||
/// <param name="tokenId">The token ID from the one-click link.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>
|
||||
/// One of the <c>StartJobSearchStatus</c> string constants:
|
||||
/// <c>Started</c>, <c>AlreadyUsed</c>, <c>Expired</c>, or <c>NotFound</c>.
|
||||
/// </returns>
|
||||
Task<string> TriggerStartAsync(string tokenId, CancellationToken ct);
|
||||
}
|
||||
|
||||
@@ -1,44 +1,41 @@
|
||||
using System.Text.Json;
|
||||
using Api.Clients.Ai.Contracts;
|
||||
using Api.Clients.Api.Contracts;
|
||||
using CvMatcher.Data.Repositories.Contracts;
|
||||
using Api.Data.Repositories.Contracts;
|
||||
using CvMatcher.Models.Requests;
|
||||
using CvMatcher.Models.Responses;
|
||||
using CvMatcher.Models.Settings;
|
||||
using Api.Services.Contracts;
|
||||
using Microsoft.Extensions.Options;
|
||||
using MyAi.Models.Services;
|
||||
|
||||
namespace Api.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Orchestrates CV upload, RAG indexing, job text extraction, LLM scoring, and result caching.
|
||||
/// </summary>
|
||||
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;
|
||||
private readonly ITemplateService _templates;
|
||||
|
||||
public CvMatcherService(
|
||||
IRagApiClient rag,
|
||||
IJobTextExtractor jobTextExtractor,
|
||||
IMatcherAiClient ai,
|
||||
IMatcherRepository repository,
|
||||
IAiPromptsRepository aiPrompts,
|
||||
IOptions<MatcherSettings> options)
|
||||
IOptions<MatcherSettings> options,
|
||||
ITemplateService templates)
|
||||
{
|
||||
_rag = rag;
|
||||
_jobTextExtractor = jobTextExtractor;
|
||||
_ai = ai;
|
||||
_repository = repository;
|
||||
_aiPrompts = aiPrompts;
|
||||
_settings = options.Value;
|
||||
_templates = templates;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<CvUploadResponse> UploadCvAsync(IFormFile file, CancellationToken ct)
|
||||
{
|
||||
var response = await _rag.IndexCvPdfAsync(file, ct);
|
||||
@@ -55,7 +52,6 @@ public sealed class CvMatcherService : ICvMatcherService
|
||||
};
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<FindJobsResponse> FindJobsAsync(FindJobsRequest request, CancellationToken ct)
|
||||
{
|
||||
var cv = await _rag.GetDocumentAsync(request.CvDocumentId, ct) ?? throw new InvalidOperationException("CV document not found.");
|
||||
@@ -83,7 +79,6 @@ public sealed class CvMatcherService : ICvMatcherService
|
||||
return new FindJobsResponse { CvDocumentId = request.CvDocumentId, Jobs = jobs };
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<JobMatchResponse> MatchJobAsync(MatchJobRequest request, CancellationToken ct)
|
||||
{
|
||||
if (!request.GdprConsent) throw new InvalidOperationException("GDPR consent is required.");
|
||||
@@ -110,11 +105,6 @@ public sealed class CvMatcherService : ICvMatcherService
|
||||
return await ScorePairAsync(cv, jobDocument, matchedChunks, request.Email, NormalizeLanguage(request.Language), ct);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
private async Task<JobMatchResponse> ScorePairAsync(RagDocumentDetails cv, RagDocumentDetails job, IReadOnlyList<string> evidenceChunks, string? email, string language, CancellationToken ct)
|
||||
{
|
||||
var cached = await _repository.GetMatchAsync(cv.Id, job.Id, language, ct);
|
||||
@@ -125,9 +115,8 @@ public sealed class CvMatcherService : ICvMatcherService
|
||||
var evidence = evidenceChunks.Count > 0 ? string.Join("\n\n", evidenceChunks.Take(4)) : Limit(job.Text, 4000);
|
||||
var languageName = LanguageName(language);
|
||||
|
||||
var promptTemplate = await _aiPrompts.GetAsync("ai.cv-match.system-prompt", "*", ct)
|
||||
?? "You are a strict CV-to-job matching engine. Return JSON only.";
|
||||
var systemPrompt = promptTemplate.Replace("{{languageName}}", languageName, StringComparison.OrdinalIgnoreCase);
|
||||
var systemPrompt = _templates.Render("ai.cv-match.system-prompt", "*",
|
||||
("languageName", languageName));
|
||||
|
||||
var userPrompt = $"""
|
||||
CV:
|
||||
@@ -146,13 +135,16 @@ public sealed class CvMatcherService : ICvMatcherService
|
||||
result.JobUrl = job.SourceUrl;
|
||||
result.Cached = false;
|
||||
await _repository.SaveMatchAsync(cv.Id, job.Id, language, result, ct);
|
||||
|
||||
//await _email.SendMatchAsync(
|
||||
// email,
|
||||
// $"MyAi.ro CV Match: {result.Score}% - {job.Title}",
|
||||
// BuildEmailBody(cv, job, result),
|
||||
// ct);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deserialises the LLM's JSON output into a <see cref="JobMatchResponse"/>.
|
||||
/// Returns a safe fallback response instead of throwing when the JSON cannot be parsed.
|
||||
/// </summary>
|
||||
private static JobMatchResponse ParseResult(string json)
|
||||
{
|
||||
try
|
||||
@@ -173,29 +165,21 @@ public sealed class CvMatcherService : ICvMatcherService
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds a descriptive search query from the CV text for use in vector similarity search.
|
||||
/// </summary>
|
||||
private static string BuildCvSearchProfile(string cvText)
|
||||
{
|
||||
var text = Limit(cvText, 10000);
|
||||
return $"Candidate profile, skills, technologies, seniority, industry experience, project experience: {text}";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extracts a short job title from the first sentence-like fragment of the job text.
|
||||
/// </summary>
|
||||
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";
|
||||
}
|
||||
|
||||
/// <summary>Returns the base language code, lower-cased, defaulting to <c>"en"</c>.</summary>
|
||||
private static string NormalizeLanguage(string? language) =>
|
||||
string.IsNullOrWhiteSpace(language) ? "en" : language.ToLowerInvariant().Split('-')[0].Trim();
|
||||
|
||||
/// <summary>Maps a language code to its full English name for use in the LLM system prompt.</summary>
|
||||
private static string LanguageName(string language) => language switch
|
||||
{
|
||||
"ro" => "Romanian",
|
||||
@@ -203,6 +187,26 @@ public sealed class CvMatcherService : ICvMatcherService
|
||||
_ => "English"
|
||||
};
|
||||
|
||||
/// <summary>Truncates <paramref name="value"/> to at most <paramref name="max"/> characters.</summary>
|
||||
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)}
|
||||
// """;
|
||||
}
|
||||
|
||||
@@ -6,10 +6,6 @@ using Microsoft.Extensions.Options;
|
||||
|
||||
namespace Api.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Extracts normalised plain text from a job posting, either from a pasted description or by
|
||||
/// fetching and stripping the HTML of the job page URL.
|
||||
/// </summary>
|
||||
public sealed class JobTextExtractor : IJobTextExtractor
|
||||
{
|
||||
private readonly HttpClient _http;
|
||||
@@ -23,7 +19,6 @@ public sealed class JobTextExtractor : IJobTextExtractor
|
||||
_http.DefaultRequestHeaders.UserAgent.ParseAdd("MyAi.ro CV Matcher/1.0");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<string> ExtractAsync(string? jobUrl, string? jobDescription, CancellationToken ct)
|
||||
{
|
||||
var pasted = Normalize(jobDescription ?? string.Empty);
|
||||
@@ -42,14 +37,12 @@ public sealed class JobTextExtractor : IJobTextExtractor
|
||||
return Limit(Normalize(WebUtility.HtmlDecode(html)));
|
||||
}
|
||||
|
||||
/// <summary>Truncates text to the configured maximum character count.</summary>
|
||||
private string Limit(string value)
|
||||
{
|
||||
var max = Math.Max(4000, _settings.MaxJobTextChars);
|
||||
return value.Length <= max ? value : value[..max];
|
||||
}
|
||||
|
||||
/// <summary>Collapses all whitespace runs to single spaces and trims the result.</summary>
|
||||
private static string Normalize(string value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value)) return string.Empty;
|
||||
|
||||
@@ -3,17 +3,14 @@ using System.Text.RegularExpressions;
|
||||
using Api.Clients.Api.Contracts;
|
||||
using Api.Services.Contracts;
|
||||
using CvMatcher.Models.Responses;
|
||||
using CvSearch.Data;
|
||||
using CvSearch.Data.Entities;
|
||||
using CvMatcher.Models.Settings;
|
||||
using CvSearch.Models.Data;
|
||||
using CvSearch.Models.Data.Entities;
|
||||
using CvSearch.Models.Settings;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace Api.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Creates and validates one-time job search tokens, and creates the corresponding search sessions.
|
||||
/// </summary>
|
||||
public sealed class JobTokenService : IJobTokenService
|
||||
{
|
||||
private readonly CvSearchDbContext _db;
|
||||
@@ -33,7 +30,6 @@ public sealed class JobTokenService : IJobTokenService
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<string> CreateTokenAsync(string cvDocumentId, string email, string language, CancellationToken ct)
|
||||
{
|
||||
var token = new JobSearchTokenEntity
|
||||
@@ -53,7 +49,6 @@ public sealed class JobTokenService : IJobTokenService
|
||||
return token.Id;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<string> TriggerStartAsync(string tokenId, CancellationToken ct)
|
||||
{
|
||||
var token = await _db.JobSearchTokens.FirstOrDefaultAsync(x => x.Id == tokenId, ct);
|
||||
@@ -91,17 +86,12 @@ public sealed class JobTokenService : IJobTokenService
|
||||
return StartJobSearchStatus.Started;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extracts up to 10 meaningful keywords from the CV text using simple heuristics (no LLM).
|
||||
/// Takes the first 5 usable lines, splits them into words, strips punctuation, and deduplicates.
|
||||
/// </summary>
|
||||
private static string ExtractKeywords(string cvText)
|
||||
{
|
||||
var lines = cvText
|
||||
.Split(['\n', '\r'], StringSplitOptions.RemoveEmptyEntries)
|
||||
.Select(l => l.Trim())
|
||||
.Where(l => l.Length > 5 && l.Length < 200)
|
||||
// Skip lines that are purely digits, spaces, and phone/contact punctuation (phone numbers, emails, etc.)
|
||||
.Where(l => !Regex.IsMatch(l, @"^[\d\s\+\-\(\)\@\.]+$"))
|
||||
.Take(5)
|
||||
.ToList();
|
||||
|
||||
@@ -58,30 +58,30 @@
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Azure.Extensions.AspNetCore.Configuration.Secrets" />
|
||||
<PackageReference Include="Azure.Identity" />
|
||||
<PackageReference Include="DotNetEnv" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design">
|
||||
<PackageReference Include="Azure.Extensions.AspNetCore.Configuration.Secrets" Version="1.5.1" />
|
||||
<PackageReference Include="Azure.Identity" Version="1.21.0" />
|
||||
<PackageReference Include="DotNetEnv" Version="3.2.0" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="10.0.7" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.7">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="MailKit" />
|
||||
<PackageReference Include="Serilog.AspNetCore" />
|
||||
<PackageReference Include="Serilog.Enrichers.Environment" />
|
||||
<PackageReference Include="Serilog.Sinks.Console" />
|
||||
<PackageReference Include="Serilog.Sinks.Email" />
|
||||
<PackageReference Include="Swashbuckle.AspNetCore" />
|
||||
<PackageReference Include="Swashbuckle.AspNetCore.Annotations" />
|
||||
<PackageReference Include="Refit.HttpClientFactory" />
|
||||
<PackageReference Include="MailKit" Version="4.16.0" />
|
||||
<PackageReference Include="Serilog.AspNetCore" Version="10.0.0" />
|
||||
<PackageReference Include="Serilog.Enrichers.Environment" Version="3.0.1" />
|
||||
<PackageReference Include="Serilog.Sinks.Console" Version="6.1.1" />
|
||||
<PackageReference Include="Serilog.Sinks.Email" Version="4.2.1" />
|
||||
<PackageReference Include="Swashbuckle.AspNetCore" Version="10.1.7" />
|
||||
<PackageReference Include="Swashbuckle.AspNetCore.Annotations" Version="10.1.7" />
|
||||
<PackageReference Include="Refit.HttpClientFactory" Version="10.1.6" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\Helpers\common-helpers\common-helpers.csproj" />
|
||||
<ProjectReference Include="..\cv-matcher-api-models\cv-matcher-api-models.csproj" />
|
||||
<ProjectReference Include="..\cv-search-data\cv-search-data.csproj" />
|
||||
<ProjectReference Include="..\cv-matcher-data\cv-matcher-data.csproj" />
|
||||
<ProjectReference Include="..\common\common.csproj" />
|
||||
<ProjectReference Include="..\cv-search-models\cv-search-models.csproj" />
|
||||
<ProjectReference Include="..\shared-models\shared-models.csproj" />
|
||||
<ProjectReference Include="..\..\Helpers\startup-helpers\startup-helpers.csproj" />
|
||||
<ProjectReference Include="..\myai-models\myai-models.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
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; }
|
||||
}
|
||||
@@ -1,130 +0,0 @@
|
||||
// <auto-generated />
|
||||
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("20260528110000_AddAiPrompts")]
|
||||
partial class AddAiPrompts
|
||||
{
|
||||
/// <inheritdoc />
|
||||
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<string>("Key")
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("nvarchar(128)");
|
||||
|
||||
b.Property<string>("Language")
|
||||
.HasMaxLength(8)
|
||||
.HasColumnType("nvarchar(8)");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("nvarchar(500)")
|
||||
.HasDefaultValue("");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("datetime2")
|
||||
.HasDefaultValueSql("SYSUTCDATETIME()");
|
||||
|
||||
b.Property<string>("Value")
|
||||
.IsRequired()
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.HasKey("Key", "Language");
|
||||
|
||||
b.ToTable("AiPrompts", "cvMatcher");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("CvMatcher.Data.Entities.CvMatchResultEntity", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("nvarchar(64)");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("datetime2")
|
||||
.HasDefaultValueSql("SYSUTCDATETIME()");
|
||||
|
||||
b.Property<string>("CvDocumentId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("nvarchar(64)");
|
||||
|
||||
b.Property<string>("JobDocumentId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("nvarchar(64)");
|
||||
|
||||
b.Property<string>("Language")
|
||||
.IsRequired()
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<string>("ResultJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<int>("Score")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("CvDocumentId", "JobDocumentId")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("Results", "cvMatcher");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("CvMatcher.Data.Entities.CvMatcherChatCacheEntity", b =>
|
||||
{
|
||||
b.Property<string>("CacheKey")
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("nvarchar(64)");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("datetime2")
|
||||
.HasDefaultValueSql("SYSUTCDATETIME()");
|
||||
|
||||
b.Property<string>("Model")
|
||||
.IsRequired()
|
||||
.HasMaxLength(120)
|
||||
.HasColumnType("nvarchar(120)");
|
||||
|
||||
b.Property<string>("ResponseText")
|
||||
.IsRequired()
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<decimal>("Temperature")
|
||||
.HasColumnType("decimal(4,2)");
|
||||
|
||||
b.HasKey("CacheKey");
|
||||
|
||||
b.ToTable("ChatCache", "cvMatcher");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace CvMatcher.Data.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddAiPrompts : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "AiPrompts",
|
||||
schema: "cvMatcher",
|
||||
columns: table => new
|
||||
{
|
||||
Key = table.Column<string>(type: "nvarchar(128)", maxLength: 128, nullable: false),
|
||||
Language = table.Column<string>(type: "nvarchar(8)", maxLength: 8, nullable: false),
|
||||
Value = table.Column<string>(type: "nvarchar(max)", nullable: false),
|
||||
Description = table.Column<string>(type: "nvarchar(500)", maxLength: 500, nullable: false, defaultValue: ""),
|
||||
UpdatedAt = table.Column<DateTime>(type: "datetime2", nullable: false, defaultValueSql: "SYSUTCDATETIME()")
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_AiPrompts", x => new { x.Key, x.Language });
|
||||
});
|
||||
|
||||
migrationBuilder.InsertData(
|
||||
schema: "cvMatcher",
|
||||
table: "AiPrompts",
|
||||
columns: ["Key", "Language", "Value", "Description"],
|
||||
values: new object[]
|
||||
{
|
||||
"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."
|
||||
});
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(name: "AiPrompts", schema: "cvMatcher");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<AssemblyName>cv-matcher-data</AssemblyName>
|
||||
<RootNamespace>CvMatcher.Data</RootNamespace>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\shared-data\shared-data.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -1,62 +0,0 @@
|
||||
using CvSearch.Data.Entities;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace CvSearch.Data;
|
||||
|
||||
public sealed class CvSearchDbContext : DbContext
|
||||
{
|
||||
public const string SchemaName = "cvSearch";
|
||||
public const string MigrationTableName = "_Migrations";
|
||||
|
||||
public CvSearchDbContext(DbContextOptions<CvSearchDbContext> options) : base(options) { }
|
||||
|
||||
public DbSet<JobSearchTokenEntity> JobSearchTokens => Set<JobSearchTokenEntity>();
|
||||
public DbSet<JobSearchSessionEntity> JobSearchSessions => Set<JobSearchSessionEntity>();
|
||||
public DbSet<JobSearchResultEntity> JobSearchResults => Set<JobSearchResultEntity>();
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
{
|
||||
modelBuilder.HasDefaultSchema(SchemaName);
|
||||
|
||||
modelBuilder.Entity<JobSearchTokenEntity>(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.Used).HasDefaultValue(false);
|
||||
entity.Property(x => x.CreatedAt).HasDefaultValueSql("SYSUTCDATETIME()");
|
||||
});
|
||||
|
||||
modelBuilder.Entity<JobSearchSessionEntity>(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.CreatedAt).HasDefaultValueSql("SYSUTCDATETIME()");
|
||||
entity.HasIndex(x => x.Status);
|
||||
});
|
||||
|
||||
modelBuilder.Entity<JobSearchResultEntity>(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.CreatedAt).HasDefaultValueSql("SYSUTCDATETIME()");
|
||||
entity.HasIndex(x => x.SessionId);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
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;
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
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? 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";
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
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; }
|
||||
}
|
||||
-160
@@ -1,160 +0,0 @@
|
||||
// <auto-generated />
|
||||
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
|
||||
{
|
||||
/// <inheritdoc />
|
||||
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<string>("Id")
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("nvarchar(64)");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("datetime2")
|
||||
.HasDefaultValueSql("SYSUTCDATETIME()");
|
||||
|
||||
b.Property<string>("JobText")
|
||||
.IsRequired()
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<string>("JobTitle")
|
||||
.IsRequired()
|
||||
.HasMaxLength(512)
|
||||
.HasColumnType("nvarchar(512)");
|
||||
|
||||
b.Property<string>("JobUrl")
|
||||
.IsRequired()
|
||||
.HasMaxLength(2048)
|
||||
.HasColumnType("nvarchar(2048)");
|
||||
|
||||
b.Property<string>("ProviderName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("nvarchar(128)");
|
||||
|
||||
b.Property<string>("ResultJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<int>("Score")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<string>("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<string>("Id")
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("nvarchar(64)");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("datetime2")
|
||||
.HasDefaultValueSql("SYSUTCDATETIME()");
|
||||
|
||||
b.Property<string>("CvDocumentId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("nvarchar(64)");
|
||||
|
||||
b.Property<string>("Email")
|
||||
.IsRequired()
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("nvarchar(256)");
|
||||
|
||||
b.Property<string>("Keywords")
|
||||
.IsRequired()
|
||||
.HasMaxLength(1000)
|
||||
.HasColumnType("nvarchar(1000)");
|
||||
|
||||
b.Property<string>("ProviderConfigJson")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<string>("Status")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("nvarchar(32)");
|
||||
|
||||
b.Property<string>("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<string>("Id")
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("nvarchar(64)");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("datetime2")
|
||||
.HasDefaultValueSql("SYSUTCDATETIME()");
|
||||
|
||||
b.Property<string>("CvDocumentId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("nvarchar(64)");
|
||||
|
||||
b.Property<string>("Email")
|
||||
.IsRequired()
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("nvarchar(256)");
|
||||
|
||||
b.Property<DateTime>("ExpiresAt")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<bool>("Used")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("bit")
|
||||
.HasDefaultValue(false);
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("JobSearchTokens", "cvSearch");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,102 +0,0 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace CvSearch.Data.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddJobSearchTables : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.EnsureSchema(
|
||||
name: "cvSearch");
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "JobSearchResults",
|
||||
schema: "cvSearch",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
|
||||
SessionId = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
|
||||
ProviderName = table.Column<string>(type: "nvarchar(128)", maxLength: 128, nullable: false),
|
||||
JobUrl = table.Column<string>(type: "nvarchar(2048)", maxLength: 2048, nullable: false),
|
||||
JobTitle = table.Column<string>(type: "nvarchar(512)", maxLength: 512, nullable: false),
|
||||
JobText = table.Column<string>(type: "nvarchar(max)", nullable: false),
|
||||
Score = table.Column<int>(type: "int", nullable: false),
|
||||
ResultJson = table.Column<string>(type: "nvarchar(max)", nullable: false),
|
||||
CreatedAt = table.Column<DateTime>(type: "datetime2", nullable: false, defaultValueSql: "SYSUTCDATETIME()")
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_JobSearchResults", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "JobSearchSessions",
|
||||
schema: "cvSearch",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
|
||||
TokenId = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
|
||||
CvDocumentId = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
|
||||
Email = table.Column<string>(type: "nvarchar(256)", maxLength: 256, nullable: false),
|
||||
Status = table.Column<string>(type: "nvarchar(32)", maxLength: 32, nullable: false),
|
||||
Keywords = table.Column<string>(type: "nvarchar(1000)", maxLength: 1000, nullable: false),
|
||||
ProviderConfigJson = table.Column<string>(type: "nvarchar(max)", nullable: true),
|
||||
CreatedAt = table.Column<DateTime>(type: "datetime2", nullable: false, defaultValueSql: "SYSUTCDATETIME()")
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_JobSearchSessions", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "JobSearchTokens",
|
||||
schema: "cvSearch",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
|
||||
CvDocumentId = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
|
||||
Email = table.Column<string>(type: "nvarchar(256)", maxLength: 256, nullable: false),
|
||||
ExpiresAt = table.Column<DateTime>(type: "datetime2", nullable: false),
|
||||
Used = table.Column<bool>(type: "bit", nullable: false, defaultValue: false),
|
||||
CreatedAt = table.Column<DateTime>(type: "datetime2", nullable: false, defaultValueSql: "SYSUTCDATETIME()")
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_JobSearchTokens", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_JobSearchResults_SessionId",
|
||||
schema: "cvSearch",
|
||||
table: "JobSearchResults",
|
||||
column: "SessionId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_JobSearchSessions_Status",
|
||||
schema: "cvSearch",
|
||||
table: "JobSearchSessions",
|
||||
column: "Status");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "JobSearchResults",
|
||||
schema: "cvSearch");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "JobSearchSessions",
|
||||
schema: "cvSearch");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "JobSearchTokens",
|
||||
schema: "cvSearch");
|
||||
}
|
||||
}
|
||||
}
|
||||
-174
@@ -1,174 +0,0 @@
|
||||
// <auto-generated />
|
||||
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
|
||||
{
|
||||
/// <inheritdoc />
|
||||
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<string>("Id")
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("nvarchar(64)");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("datetime2")
|
||||
.HasDefaultValueSql("SYSUTCDATETIME()");
|
||||
|
||||
b.Property<string>("JobText")
|
||||
.IsRequired()
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<string>("JobTitle")
|
||||
.IsRequired()
|
||||
.HasMaxLength(512)
|
||||
.HasColumnType("nvarchar(512)");
|
||||
|
||||
b.Property<string>("JobUrl")
|
||||
.IsRequired()
|
||||
.HasMaxLength(2048)
|
||||
.HasColumnType("nvarchar(2048)");
|
||||
|
||||
b.Property<string>("ProviderName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("nvarchar(128)");
|
||||
|
||||
b.Property<string>("ResultJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<int>("Score")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<string>("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<string>("Id")
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("nvarchar(64)");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("datetime2")
|
||||
.HasDefaultValueSql("SYSUTCDATETIME()");
|
||||
|
||||
b.Property<string>("CvDocumentId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("nvarchar(64)");
|
||||
|
||||
b.Property<string>("Email")
|
||||
.IsRequired()
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("nvarchar(256)");
|
||||
|
||||
b.Property<string>("Keywords")
|
||||
.IsRequired()
|
||||
.HasMaxLength(1000)
|
||||
.HasColumnType("nvarchar(1000)");
|
||||
|
||||
b.Property<string>("Language")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasMaxLength(8)
|
||||
.HasColumnType("nvarchar(8)")
|
||||
.HasDefaultValue("en");
|
||||
|
||||
b.Property<string>("ProviderConfigJson")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<string>("Status")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("nvarchar(32)");
|
||||
|
||||
b.Property<string>("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<string>("Id")
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("nvarchar(64)");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("datetime2")
|
||||
.HasDefaultValueSql("SYSUTCDATETIME()");
|
||||
|
||||
b.Property<string>("CvDocumentId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("nvarchar(64)");
|
||||
|
||||
b.Property<string>("Email")
|
||||
.IsRequired()
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("nvarchar(256)");
|
||||
|
||||
b.Property<DateTime>("ExpiresAt")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<string>("Language")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasMaxLength(8)
|
||||
.HasColumnType("nvarchar(8)")
|
||||
.HasDefaultValue("en");
|
||||
|
||||
b.Property<bool>("Used")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("bit")
|
||||
.HasDefaultValue(false);
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("JobSearchTokens", "cvSearch");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace CvSearch.Data.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddLanguageToJobSearchEntities : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "Language",
|
||||
schema: "cvSearch",
|
||||
table: "JobSearchTokens",
|
||||
type: "nvarchar(8)",
|
||||
maxLength: 8,
|
||||
nullable: false,
|
||||
defaultValue: "en");
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "Language",
|
||||
schema: "cvSearch",
|
||||
table: "JobSearchSessions",
|
||||
type: "nvarchar(8)",
|
||||
maxLength: 8,
|
||||
nullable: false,
|
||||
defaultValue: "en");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "Language",
|
||||
schema: "cvSearch",
|
||||
table: "JobSearchTokens");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "Language",
|
||||
schema: "cvSearch",
|
||||
table: "JobSearchSessions");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,171 +0,0 @@
|
||||
// <auto-generated />
|
||||
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.JobSearchResultEntity", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("nvarchar(64)");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("datetime2")
|
||||
.HasDefaultValueSql("SYSUTCDATETIME()");
|
||||
|
||||
b.Property<string>("JobText")
|
||||
.IsRequired()
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<string>("JobTitle")
|
||||
.IsRequired()
|
||||
.HasMaxLength(512)
|
||||
.HasColumnType("nvarchar(512)");
|
||||
|
||||
b.Property<string>("JobUrl")
|
||||
.IsRequired()
|
||||
.HasMaxLength(2048)
|
||||
.HasColumnType("nvarchar(2048)");
|
||||
|
||||
b.Property<string>("ProviderName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("nvarchar(128)");
|
||||
|
||||
b.Property<string>("ResultJson")
|
||||
.IsRequired()
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<int>("Score")
|
||||
.HasColumnType("int");
|
||||
|
||||
b.Property<string>("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<string>("Id")
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("nvarchar(64)");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("datetime2")
|
||||
.HasDefaultValueSql("SYSUTCDATETIME()");
|
||||
|
||||
b.Property<string>("CvDocumentId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("nvarchar(64)");
|
||||
|
||||
b.Property<string>("Email")
|
||||
.IsRequired()
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("nvarchar(256)");
|
||||
|
||||
b.Property<string>("Keywords")
|
||||
.IsRequired()
|
||||
.HasMaxLength(1000)
|
||||
.HasColumnType("nvarchar(1000)");
|
||||
|
||||
b.Property<string>("Language")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasMaxLength(8)
|
||||
.HasColumnType("nvarchar(8)")
|
||||
.HasDefaultValue("en");
|
||||
|
||||
b.Property<string>("ProviderConfigJson")
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.Property<string>("Status")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("nvarchar(32)");
|
||||
|
||||
b.Property<string>("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<string>("Id")
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("nvarchar(64)");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("datetime2")
|
||||
.HasDefaultValueSql("SYSUTCDATETIME()");
|
||||
|
||||
b.Property<string>("CvDocumentId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("nvarchar(64)");
|
||||
|
||||
b.Property<string>("Email")
|
||||
.IsRequired()
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("nvarchar(256)");
|
||||
|
||||
b.Property<DateTime>("ExpiresAt")
|
||||
.HasColumnType("datetime2");
|
||||
|
||||
b.Property<string>("Language")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasMaxLength(8)
|
||||
.HasColumnType("nvarchar(8)")
|
||||
.HasDefaultValue("en");
|
||||
|
||||
b.Property<bool>("Used")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("bit")
|
||||
.HasDefaultValue(false);
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("JobSearchTokens", "cvSearch");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<AssemblyName>cv-search-data</AssemblyName>
|
||||
<RootNamespace>CvSearch.Data</RootNamespace>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\shared-data\shared-data.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -1,18 +0,0 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<RootNamespace>CvSearch.Models</RootNamespace>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="10.0.7" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.7">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -1,31 +0,0 @@
|
||||
using EmailApi.Data.Entities;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace EmailApi.Data;
|
||||
|
||||
public sealed class EmailApiDbContext : DbContext
|
||||
{
|
||||
public const string SchemaName = "emailApi";
|
||||
public const string MigrationTableName = "_EmailApiMigrations";
|
||||
|
||||
public EmailApiDbContext(DbContextOptions<EmailApiDbContext> options) : base(options) { }
|
||||
|
||||
public DbSet<EmailTemplateEntity> EmailTemplates => Set<EmailTemplateEntity>();
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
{
|
||||
modelBuilder.HasDefaultSchema(SchemaName);
|
||||
|
||||
modelBuilder.Entity<EmailTemplateEntity>(entity =>
|
||||
{
|
||||
entity.ToTable("EmailTemplates");
|
||||
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);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
namespace EmailApi.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;
|
||||
}
|
||||
-69
@@ -1,69 +0,0 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using EmailApi.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Metadata;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace EmailApi.Data.Migrations
|
||||
{
|
||||
[DbContext(typeof(EmailApiDbContext))]
|
||||
[Migration("20260528100000_CreateEmailTemplates")]
|
||||
partial class CreateEmailTemplates
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder
|
||||
.HasDefaultSchema("emailApi")
|
||||
.HasAnnotation("ProductVersion", "10.0.7")
|
||||
.HasAnnotation("Relational:MaxIdentifierLength", 128);
|
||||
|
||||
SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder);
|
||||
|
||||
modelBuilder.Entity("EmailApi.Data.Entities.EmailTemplateEntity", b =>
|
||||
{
|
||||
b.Property<string>("Key")
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("nvarchar(128)");
|
||||
|
||||
b.Property<string>("Language")
|
||||
.HasMaxLength(8)
|
||||
.HasColumnType("nvarchar(8)");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("nvarchar(500)")
|
||||
.HasDefaultValue("");
|
||||
|
||||
b.Property<string>("OperatorCopy")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("nvarchar(256)")
|
||||
.HasDefaultValue("");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("datetime2")
|
||||
.HasDefaultValueSql("SYSUTCDATETIME()");
|
||||
|
||||
b.Property<string>("Value")
|
||||
.IsRequired()
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.HasKey("Key", "Language");
|
||||
|
||||
b.ToTable("EmailTemplates", "emailApi");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,192 +0,0 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace EmailApi.Data.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class CreateEmailTemplates : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.EnsureSchema(name: "emailApi");
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "EmailTemplates",
|
||||
schema: "emailApi",
|
||||
columns: table => new
|
||||
{
|
||||
Key = table.Column<string>(type: "nvarchar(128)", maxLength: 128, nullable: false),
|
||||
Language = table.Column<string>(type: "nvarchar(8)", maxLength: 8, nullable: false),
|
||||
Value = table.Column<string>(type: "nvarchar(max)", nullable: false),
|
||||
Description = table.Column<string>(type: "nvarchar(500)", maxLength: 500, nullable: false, defaultValue: ""),
|
||||
UpdatedAt = table.Column<DateTime>(type: "datetime2", nullable: false, defaultValueSql: "SYSUTCDATETIME()"),
|
||||
OperatorCopy = table.Column<string>(type: "nvarchar(256)", maxLength: 256, nullable: false, defaultValue: "")
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_EmailTemplates", x => new { x.Key, x.Language });
|
||||
});
|
||||
|
||||
Seed(migrationBuilder);
|
||||
}
|
||||
|
||||
private static void Seed(MigrationBuilder m)
|
||||
{
|
||||
const string op = "contact@myai.ro";
|
||||
|
||||
void Row(string key, string lang, string value, string description = "", string operatorCopy = "")
|
||||
=> m.InsertData("EmailTemplates",
|
||||
["Key", "Language", "Value", "Description", "OperatorCopy"],
|
||||
[key, lang, value, description, operatorCopy],
|
||||
"emailApi");
|
||||
|
||||
// ── HTML shell (no operator copy — these are layout fragments, not addressable emails) ──
|
||||
Row("email.html-shell.start", "*",
|
||||
"<!DOCTYPE html>\n<html>\n<head><meta charset=\"utf-8\"><meta name=\"viewport\" content=\"width=device-width,initial-scale=1\"></head>\n<body style=\"margin:0;padding:0;background:#f4f4f4;font-family:Arial,Helvetica,sans-serif\">\n <table width=\"100%\" cellpadding=\"0\" cellspacing=\"0\" style=\"padding:20px 0\">\n <tr><td align=\"center\">\n <table width=\"600\" cellpadding=\"0\" cellspacing=\"0\"\n style=\"background:#ffffff;border-radius:8px;max-width:600px\">\n <tr><td style=\"background:#2c5282;padding:24px 32px;border-radius:8px 8px 0 0\">\n <h1 style=\"margin:0;color:#ffffff;font-size:22px;font-weight:600\">myAi</h1>\n </td></tr>\n <tr><td style=\"padding:32px\">",
|
||||
"Opening HTML shell fragment — wrapped around every HtmlBody before sending");
|
||||
|
||||
Row("email.html-shell.end", "*",
|
||||
" </td></tr>\n <tr><td style=\"background:#f8f9fa;padding:16px 32px;text-align:center;\n color:#6c757d;font-size:12px;border-radius:0 0 8px 8px\">\n Automated message from myAi.\n </td></tr>\n </table>\n </td></tr>\n </table>\n</body>\n</html>",
|
||||
"Closing HTML shell fragment — appended after every HtmlBody before sending");
|
||||
|
||||
// ── CV match result email ──
|
||||
Row("email.match.subject", "en",
|
||||
"MyAi.ro CV Match: {{score}}% - {{jobLabel}}",
|
||||
"Subject for the CV match result email",
|
||||
op);
|
||||
|
||||
Row("email.match.subject", "ro",
|
||||
"MyAi.ro Potrivire CV: {{score}}% - {{jobLabel}}",
|
||||
"Subiect email rezultat potrivire CV",
|
||||
op);
|
||||
|
||||
Row("email.match.body", "en",
|
||||
"<h2 style=\"margin:0 0 20px;color:#2c5282;font-size:20px\">CV Match Report</h2>" +
|
||||
"<table cellpadding=\"10\" cellspacing=\"0\" style=\"width:100%;border-collapse:collapse;margin-bottom:24px\">" +
|
||||
"<tr style=\"background:#f8f9fa\">" +
|
||||
"<td style=\"font-weight:600;width:130px;border:1px solid #dee2e6;color:#495057\">CV ID</td>" +
|
||||
"<td style=\"border:1px solid #dee2e6;font-family:monospace;font-size:13px\">{{cvDocumentId}}</td>" +
|
||||
"</tr>" +
|
||||
"<tr>" +
|
||||
"<td style=\"font-weight:600;border:1px solid #dee2e6;color:#495057\">Job</td>" +
|
||||
"<td style=\"border:1px solid #dee2e6\">{{jobLabel}}</td>" +
|
||||
"</tr>" +
|
||||
"<tr style=\"background:#f8f9fa\">" +
|
||||
"<td style=\"font-weight:600;border:1px solid #dee2e6;color:#495057\">URL</td>" +
|
||||
"<td style=\"border:1px solid #dee2e6\"><a href=\"{{jobUrl}}\" style=\"color:#2c5282\">{{jobUrl}}</a></td>" +
|
||||
"</tr>" +
|
||||
"<tr>" +
|
||||
"<td style=\"font-weight:600;border:1px solid #dee2e6;color:#495057\">Score</td>" +
|
||||
"<td style=\"border:1px solid #dee2e6;font-size:26px;font-weight:700;color:#28a745\">{{score}}%</td>" +
|
||||
"</tr>" +
|
||||
"</table>" +
|
||||
"<h3 style=\"color:#2c5282;border-bottom:2px solid #e9ecef;padding-bottom:6px\">Summary</h3>" +
|
||||
"<p style=\"color:#495057;line-height:1.7\">{{summary}}</p>" +
|
||||
"<h3 style=\"color:#2c5282;border-bottom:2px solid #e9ecef;padding-bottom:6px\">Strengths</h3>{{strengths}}" +
|
||||
"<h3 style=\"color:#2c5282;border-bottom:2px solid #e9ecef;padding-bottom:6px\">Gaps</h3>{{gaps}}" +
|
||||
"<h3 style=\"color:#2c5282;border-bottom:2px solid #e9ecef;padding-bottom:6px\">Recommendations</h3>{{recommendations}}",
|
||||
"Body for the CV match result email",
|
||||
op);
|
||||
|
||||
Row("email.match.body", "ro",
|
||||
"<h2 style=\"margin:0 0 20px;color:#2c5282;font-size:20px\">Raport Potrivire CV</h2>" +
|
||||
"<table cellpadding=\"10\" cellspacing=\"0\" style=\"width:100%;border-collapse:collapse;margin-bottom:24px\">" +
|
||||
"<tr style=\"background:#f8f9fa\">" +
|
||||
"<td style=\"font-weight:600;width:130px;border:1px solid #dee2e6;color:#495057\">ID Document CV</td>" +
|
||||
"<td style=\"border:1px solid #dee2e6;font-family:monospace;font-size:13px\">{{cvDocumentId}}</td>" +
|
||||
"</tr>" +
|
||||
"<tr>" +
|
||||
"<td style=\"font-weight:600;border:1px solid #dee2e6;color:#495057\">Job</td>" +
|
||||
"<td style=\"border:1px solid #dee2e6\">{{jobLabel}}</td>" +
|
||||
"</tr>" +
|
||||
"<tr style=\"background:#f8f9fa\">" +
|
||||
"<td style=\"font-weight:600;border:1px solid #dee2e6;color:#495057\">URL</td>" +
|
||||
"<td style=\"border:1px solid #dee2e6\"><a href=\"{{jobUrl}}\" style=\"color:#2c5282\">{{jobUrl}}</a></td>" +
|
||||
"</tr>" +
|
||||
"<tr>" +
|
||||
"<td style=\"font-weight:600;border:1px solid #dee2e6;color:#495057\">Scor</td>" +
|
||||
"<td style=\"border:1px solid #dee2e6;font-size:26px;font-weight:700;color:#28a745\">{{score}}%</td>" +
|
||||
"</tr>" +
|
||||
"</table>" +
|
||||
"<h3 style=\"color:#2c5282;border-bottom:2px solid #e9ecef;padding-bottom:6px\">Rezumat</h3>" +
|
||||
"<p style=\"color:#495057;line-height:1.7\">{{summary}}</p>" +
|
||||
"<h3 style=\"color:#2c5282;border-bottom:2px solid #e9ecef;padding-bottom:6px\">Puncte forte</h3>{{strengths}}" +
|
||||
"<h3 style=\"color:#2c5282;border-bottom:2px solid #e9ecef;padding-bottom:6px\">Lipsuri</h3>{{gaps}}" +
|
||||
"<h3 style=\"color:#2c5282;border-bottom:2px solid #e9ecef;padding-bottom:6px\">Recomandări</h3>{{recommendations}}",
|
||||
"Corpul emailului pentru rezultatul potrivirii CV",
|
||||
op);
|
||||
|
||||
Row("email.match.job-search-footer", "en",
|
||||
"<div style=\"background:#f0f4f8;border-left:4px solid #2c5282;padding:16px;margin-top:24px;border-radius:4px\">" +
|
||||
"<p style=\"margin:0;color:#495057\">" +
|
||||
"Want to find matching jobs automatically? " +
|
||||
"<a href=\"{{jobSearchLink}}\" style=\"color:#2c5282;font-weight:600\">Start a job search →</a><br>" +
|
||||
"<small style=\"color:#6c757d\">Link valid for {{expiryDays}} days.</small>" +
|
||||
"</p>" +
|
||||
"</div>",
|
||||
"Job search CTA appended to match result email",
|
||||
op);
|
||||
|
||||
Row("email.match.job-search-footer", "ro",
|
||||
"<div style=\"background:#f0f4f8;border-left:4px solid #2c5282;padding:16px;margin-top:24px;border-radius:4px\">" +
|
||||
"<p style=\"margin:0;color:#495057\">" +
|
||||
"Vrei să găsești joburi potrivite automat? " +
|
||||
"<a href=\"{{jobSearchLink}}\" style=\"color:#2c5282;font-weight:600\">Pornește o căutare de joburi →</a><br>" +
|
||||
"<small style=\"color:#6c757d\">Link valabil {{expiryDays}} zile.</small>" +
|
||||
"</p>" +
|
||||
"</div>",
|
||||
"CTA cautare joburi adaugat la emailul de potrivire CV",
|
||||
op);
|
||||
|
||||
// ── Job search results email ──
|
||||
Row("email.search-results.subject", "en",
|
||||
"MyAi.ro: {{count}} jobs matching your CV",
|
||||
"Subject for job search results email",
|
||||
op);
|
||||
|
||||
Row("email.search-results.subject", "ro",
|
||||
"MyAi.ro: {{count}} joburi potrivite CV-ului tau",
|
||||
"Subiect email rezultate cautare joburi",
|
||||
op);
|
||||
|
||||
Row("email.search-results.body", "en",
|
||||
"<h2 style=\"margin:0 0 16px;color:#2c5282\">Job Search Results</h2>" +
|
||||
"<p style=\"color:#495057\">Found <strong>{{count}}</strong> matching job(s):</p>" +
|
||||
"{{items}}",
|
||||
"Body preamble for job search results email",
|
||||
op);
|
||||
|
||||
Row("email.search-results.body", "ro",
|
||||
"<h2 style=\"margin:0 0 16px;color:#2c5282\">Rezultate Căutare Joburi</h2>" +
|
||||
"<p style=\"color:#495057\">Am găsit <strong>{{count}}</strong> job(uri) potrivite:</p>" +
|
||||
"{{items}}",
|
||||
"Corpul emailului de rezultate cautare joburi",
|
||||
op);
|
||||
|
||||
Row("email.search-results.empty", "en",
|
||||
"<div style=\"text-align:center;padding:32px;color:#6c757d\">" +
|
||||
"<p style=\"font-size:18px;margin:0 0 8px;color:#495057\">No matching jobs found</p>" +
|
||||
"<p style=\"margin:0\">Your job search completed but no matching jobs were found. Try again later or adjust your CV.</p>" +
|
||||
"</div>",
|
||||
"No results message for job search results email",
|
||||
op);
|
||||
|
||||
Row("email.search-results.empty", "ro",
|
||||
"<div style=\"text-align:center;padding:32px;color:#6c757d\">" +
|
||||
"<p style=\"font-size:18px;margin:0 0 8px;color:#495057\">Niciun job potrivit găsit</p>" +
|
||||
"<p style=\"margin:0\">Căutarea s-a finalizat dar nu au fost găsite joburi potrivite. Încearcă mai târziu sau ajustează CV-ul.</p>" +
|
||||
"</div>",
|
||||
"Mesaj fara rezultate pentru emailul de cautare joburi",
|
||||
op);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(name: "EmailTemplates", schema: "emailApi");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,66 +0,0 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using EmailApi.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Metadata;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace EmailApi.Data.Migrations
|
||||
{
|
||||
[DbContext(typeof(EmailApiDbContext))]
|
||||
partial class EmailApiDbContextModelSnapshot : ModelSnapshot
|
||||
{
|
||||
protected override void BuildModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder
|
||||
.HasDefaultSchema("emailApi")
|
||||
.HasAnnotation("ProductVersion", "10.0.7")
|
||||
.HasAnnotation("Relational:MaxIdentifierLength", 128);
|
||||
|
||||
SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder);
|
||||
|
||||
modelBuilder.Entity("EmailApi.Data.Entities.EmailTemplateEntity", b =>
|
||||
{
|
||||
b.Property<string>("Key")
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("nvarchar(128)");
|
||||
|
||||
b.Property<string>("Language")
|
||||
.HasMaxLength(8)
|
||||
.HasColumnType("nvarchar(8)");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("nvarchar(500)")
|
||||
.HasDefaultValue("");
|
||||
|
||||
b.Property<string>("OperatorCopy")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("nvarchar(256)")
|
||||
.HasDefaultValue("");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("datetime2")
|
||||
.HasDefaultValueSql("SYSUTCDATETIME()");
|
||||
|
||||
b.Property<string>("Value")
|
||||
.IsRequired()
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.HasKey("Key", "Language");
|
||||
|
||||
b.ToTable("EmailTemplates", "emailApi");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
using EmailApi.Data.Entities;
|
||||
|
||||
namespace EmailApi.Data.Repositories.Contracts;
|
||||
|
||||
public interface IEmailTemplateRepository
|
||||
{
|
||||
Task<IReadOnlyList<EmailTemplateEntity>> GetAllAsync(CancellationToken ct);
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
using EmailApi.Data.Entities;
|
||||
using EmailApi.Data.Repositories.Contracts;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace EmailApi.Data.Repositories;
|
||||
|
||||
public sealed class EfEmailTemplateRepository : IEmailTemplateRepository
|
||||
{
|
||||
private readonly EmailApiDbContext _db;
|
||||
|
||||
public EfEmailTemplateRepository(EmailApiDbContext db)
|
||||
{
|
||||
_db = db;
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<EmailTemplateEntity>> GetAllAsync(CancellationToken ct)
|
||||
=> await _db.EmailTemplates.AsNoTracking().ToListAsync(ct);
|
||||
}
|
||||
@@ -1,108 +0,0 @@
|
||||
using System.Collections.Concurrent;
|
||||
using EmailApi.Data.Repositories.Contracts;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace EmailApi.Data.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Singleton implementation of <see cref="IEmailTemplateService"/> that caches all email templates
|
||||
/// from the database and refreshes them every 10 minutes.
|
||||
/// Uses <see cref="IServiceScopeFactory"/> to resolve the scoped repository from a singleton lifetime.
|
||||
/// </summary>
|
||||
public sealed class EmailTemplateService : IEmailTemplateService
|
||||
{
|
||||
private readonly IServiceScopeFactory _scopeFactory;
|
||||
private readonly ILogger<EmailTemplateService> _logger;
|
||||
private ConcurrentDictionary<string, string> _valueCache = new(StringComparer.OrdinalIgnoreCase);
|
||||
private ConcurrentDictionary<string, string> _operatorCache = new(StringComparer.OrdinalIgnoreCase);
|
||||
private DateTime _loadedAt = DateTime.MinValue;
|
||||
private static readonly TimeSpan CacheTtl = TimeSpan.FromMinutes(10);
|
||||
|
||||
public EmailTemplateService(IServiceScopeFactory scopeFactory, ILogger<EmailTemplateService> logger)
|
||||
{
|
||||
_scopeFactory = scopeFactory;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
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;
|
||||
|
||||
_logger.LogWarning("Email template not found: key={Key}, language={Language}", key, language);
|
||||
return key;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
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;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
private void EnsureCacheLoaded()
|
||||
{
|
||||
if (DateTime.UtcNow - _loadedAt < CacheTtl) return;
|
||||
|
||||
try
|
||||
{
|
||||
using var scope = _scopeFactory.CreateScope();
|
||||
var repo = scope.ServiceProvider.GetRequiredService<IEmailTemplateRepository>();
|
||||
var rows = repo.GetAllAsync(CancellationToken.None).GetAwaiter().GetResult();
|
||||
|
||||
var freshValues = new ConcurrentDictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
var freshOperator = new ConcurrentDictionary<string, string>(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.");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Builds the dictionary key used for both caches.</summary>
|
||||
private static string CacheKey(string key, string language) => $"{key}::{language}";
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
namespace EmailApi.Data.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Provides access to localised email templates stored in the <c>emailApi.EmailTemplates</c> table.
|
||||
/// Implementations are expected to cache templates and refresh periodically.
|
||||
/// </summary>
|
||||
public interface IEmailTemplateService
|
||||
{
|
||||
/// <summary>
|
||||
/// Returns the template value for the given key and language.
|
||||
/// Falls back to <c>"en"</c> when the requested language has no entry.
|
||||
/// Returns the raw key string when no matching template is found.
|
||||
/// </summary>
|
||||
/// <param name="key">Template key (e.g. <c>"email.match.subject"</c>).</param>
|
||||
/// <param name="language">Two-letter language code (e.g. <c>"en"</c>, <c>"ro"</c>).</param>
|
||||
/// <returns>Template value string.</returns>
|
||||
string Get(string key, string language = "en");
|
||||
|
||||
/// <summary>
|
||||
/// Retrieves the template and substitutes <c>{{placeholder}}</c> tokens with the provided values.
|
||||
/// </summary>
|
||||
/// <param name="key">Template key.</param>
|
||||
/// <param name="language">Two-letter language code.</param>
|
||||
/// <param name="placeholders">Named replacement pairs in the form <c>("name", value)</c>.</param>
|
||||
/// <returns>Rendered template string with all placeholders replaced.</returns>
|
||||
string Render(string key, string language, params (string Key, string Value)[] placeholders);
|
||||
|
||||
/// <summary>
|
||||
/// Returns the operator copy address for the given template key.
|
||||
/// Uses the specific row's <c>OperatorCopy</c> value when non-empty; otherwise falls back
|
||||
/// to the first non-empty <c>OperatorCopy</c> across all cached rows, so future template rows
|
||||
/// with an empty value automatically inherit the globally configured address.
|
||||
/// </summary>
|
||||
/// <param name="key">Template key used to look up the specific row (typically the subject key).</param>
|
||||
/// <param name="language">Two-letter language code.</param>
|
||||
/// <returns>Operator copy email address, or <c>null</c> when none is configured.</returns>
|
||||
string? GetOperatorCopy(string key, string language);
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<AssemblyName>email-api-data</AssemblyName>
|
||||
<RootNamespace>EmailApi.Data</RootNamespace>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -1,10 +0,0 @@
|
||||
using EmailApi.Models.Requests;
|
||||
using Refit;
|
||||
|
||||
namespace EmailApi.Models.Clients;
|
||||
|
||||
public interface IEmailApiClient
|
||||
{
|
||||
[Post("/api/email/send")]
|
||||
Task SendAsync(SendEmailRequest request, CancellationToken ct = default);
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
namespace EmailApi.Models.Requests;
|
||||
|
||||
public sealed class SendEmailRequest
|
||||
{
|
||||
public required List<string> 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; }
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
namespace EmailApi.Models.Settings;
|
||||
|
||||
public sealed class EmailApiSettings
|
||||
{
|
||||
public string BaseUrl { get; set; } = "";
|
||||
public string InternalApiKey { get; set; } = "";
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<AssemblyName>email-api-models</AssemblyName>
|
||||
<RootNamespace>EmailApi.Models</RootNamespace>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Refit.HttpClientFactory" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -1,40 +0,0 @@
|
||||
# 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 |
|
||||
@@ -1,47 +0,0 @@
|
||||
using EmailApi.Models.Requests;
|
||||
using EmailApi.Services;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Swashbuckle.AspNetCore.Annotations;
|
||||
|
||||
namespace EmailApi.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Route("api/email")]
|
||||
public sealed class EmailController : ControllerBase
|
||||
{
|
||||
private readonly SmtpEmailDispatcher _dispatcher;
|
||||
|
||||
public EmailController(SmtpEmailDispatcher dispatcher) => _dispatcher = dispatcher;
|
||||
|
||||
/// <summary>
|
||||
/// 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
|
||||
/// <see cref="SendEmailRequest.AttachmentPath"/>.
|
||||
/// </summary>
|
||||
/// <param name="request">Email payload: recipients, subject, HTML body fragment, optional attachment path.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
/// <returns>204 No Content on success.</returns>
|
||||
[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<IActionResult> Send([FromBody] SendEmailRequest request, CancellationToken ct)
|
||||
{
|
||||
await _dispatcher.SendAsync(request, ct);
|
||||
return NoContent();
|
||||
}
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
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-api-data/email-api-data.csproj Apis/email-api-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-api-data/ Apis/email-api-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"]
|
||||
@@ -1,79 +0,0 @@
|
||||
using System.Reflection;
|
||||
using EmailApi.Data;
|
||||
using EmailApi.Data.Repositories;
|
||||
using EmailApi.Data.Repositories.Contracts;
|
||||
using EmailApi.Data.Services;
|
||||
using EmailApi.Services;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
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<SmtpSettings>(builder.Configuration.GetSection("Smtp"));
|
||||
builder.Services.Configure<FileStorageSettings>(builder.Configuration.GetSection("FileStorage"));
|
||||
|
||||
builder.Services.AddDbContext<EmailApiDbContext>(options =>
|
||||
{
|
||||
var connectionString = builder.Services.GetConfiguredDbConnectionString(builder.Configuration);
|
||||
options.UseSqlServer(connectionString, sql =>
|
||||
{
|
||||
sql.MigrationsHistoryTable(EmailApiDbContext.MigrationTableName, EmailApiDbContext.SchemaName);
|
||||
sql.MigrationsAssembly("email-api-data");
|
||||
});
|
||||
});
|
||||
|
||||
builder.Services.AddScoped<IEmailTemplateRepository, EfEmailTemplateRepository>();
|
||||
builder.Services.AddSingleton<IEmailTemplateService, EmailTemplateService>();
|
||||
|
||||
builder.Services.AddScoped<SmtpEmailDispatcher>();
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
app.LogStartupDiagnostics(ServiceName);
|
||||
|
||||
app.UseDefaultSerilogRequestLogging();
|
||||
app.UseJsonExceptionHandler(ServiceName);
|
||||
app.UseSwaggerInDevelopment("Email API", "EmailAPI");
|
||||
|
||||
app.UseInternalApiKeyProtection();
|
||||
|
||||
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<EmailApiDbContext>();
|
||||
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();
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
{
|
||||
"profiles": {
|
||||
"email-api": {
|
||||
"commandName": "Project",
|
||||
"launchBrowser": true,
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||
},
|
||||
"applicationUrl": "https://localhost:61871;http://localhost:61872"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,101 +0,0 @@
|
||||
using EmailApi.Data.Services;
|
||||
using EmailApi.Models.Requests;
|
||||
using MailKit.Net.Smtp;
|
||||
using MailKit.Security;
|
||||
using Microsoft.Extensions.Options;
|
||||
using MimeKit;
|
||||
using Models.Settings;
|
||||
|
||||
namespace EmailApi.Services;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public sealed class SmtpEmailDispatcher
|
||||
{
|
||||
private readonly SmtpSettings _smtp;
|
||||
private readonly FileStorageSettings _fileStorage;
|
||||
private readonly IEmailTemplateService _templates;
|
||||
private readonly ILogger<SmtpEmailDispatcher> _log;
|
||||
private readonly string _environmentName;
|
||||
|
||||
public SmtpEmailDispatcher(
|
||||
IOptions<SmtpSettings> smtp,
|
||||
IOptions<FileStorageSettings> fileStorage,
|
||||
IEmailTemplateService templates,
|
||||
ILogger<SmtpEmailDispatcher> log)
|
||||
{
|
||||
_smtp = smtp.Value;
|
||||
_fileStorage = fileStorage.Value;
|
||||
_templates = templates;
|
||||
_log = log;
|
||||
_environmentName = Environment.GetEnvironmentVariable("APP_ENVIRONMENT_NAME") ?? "Development";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds a <see cref="MimeMessage"/> from <paramref name="req"/>, 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.
|
||||
/// </summary>
|
||||
/// <param name="req">Email payload containing recipients, subject, HTML body, and optional attachment path.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
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));
|
||||
}
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
namespace 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;
|
||||
}
|
||||
@@ -1,98 +0,0 @@
|
||||
{
|
||||
"Serilog": {
|
||||
"Using": [
|
||||
"Serilog.Sinks.Console",
|
||||
"Serilog.Sinks.File",
|
||||
"Serilog.Sinks.Email"
|
||||
],
|
||||
"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}"
|
||||
}
|
||||
},
|
||||
{
|
||||
"Name": "Email",
|
||||
"Args": {
|
||||
"restrictedToMinimumLevel": "Error",
|
||||
"fromEmail": "",
|
||||
"toEmail": "",
|
||||
"mailServer": "",
|
||||
"networkCredential": {
|
||||
"userName": "",
|
||||
"password": ""
|
||||
},
|
||||
"port": 587,
|
||||
"enableSsl": true,
|
||||
"emailSubject": "[myAi] Email 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": [
|
||||
"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"
|
||||
}
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<DockerDefaultTargetOS>Linux</DockerDefaultTargetOS>
|
||||
<RootNamespace>EmailApi</RootNamespace>
|
||||
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
||||
<NoWarn>$(NoWarn);1591</NoWarn>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="MailKit" />
|
||||
<PackageReference Include="Serilog.AspNetCore" />
|
||||
<PackageReference Include="Serilog.Enrichers.Environment" />
|
||||
<PackageReference Include="Serilog.Sinks.Console" />
|
||||
<PackageReference Include="Serilog.Sinks.Email" />
|
||||
<PackageReference Include="Swashbuckle.AspNetCore" />
|
||||
<PackageReference Include="Swashbuckle.AspNetCore.Annotations" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\Helpers\common-helpers\common-helpers.csproj" />
|
||||
<ProjectReference Include="..\..\Helpers\startup-helpers\startup-helpers.csproj" />
|
||||
<ProjectReference Include="..\api-models\api-models.csproj" />
|
||||
<ProjectReference Include="..\common\common.csproj" />
|
||||
<ProjectReference Include="..\email-api-data\email-api-data.csproj" />
|
||||
<ProjectReference Include="..\email-api-models\email-api-models.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -1,11 +0,0 @@
|
||||
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; }
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
using MyAi.Data.Entities;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace MyAi.Data;
|
||||
|
||||
public sealed class MyAiDbContext : DbContext
|
||||
{
|
||||
public const string SchemaName = "myAi";
|
||||
public const string MigrationTableName = "_MyAiMigrations";
|
||||
|
||||
public MyAiDbContext(DbContextOptions<MyAiDbContext> options) : base(options) { }
|
||||
|
||||
public DbSet<TemplateEntity> Templates => Set<TemplateEntity>();
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
{
|
||||
modelBuilder.HasDefaultSchema(SchemaName);
|
||||
|
||||
modelBuilder.Entity<TemplateEntity>(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()");
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
// <auto-generated />
|
||||
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
|
||||
{
|
||||
/// <inheritdoc />
|
||||
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<string>("Key")
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("nvarchar(128)");
|
||||
|
||||
b.Property<string>("Language")
|
||||
.HasMaxLength(8)
|
||||
.HasColumnType("nvarchar(8)");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("nvarchar(500)")
|
||||
.HasDefaultValue("");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("datetime2")
|
||||
.HasDefaultValueSql("SYSUTCDATETIME()");
|
||||
|
||||
b.Property<string>("Value")
|
||||
.IsRequired()
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.HasKey("Key", "Language");
|
||||
|
||||
b.ToTable("Templates", "myAi");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,113 +0,0 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace MyAi.Data.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddTemplates : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.EnsureSchema(
|
||||
name: "myAi");
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "Templates",
|
||||
schema: "myAi",
|
||||
columns: table => new
|
||||
{
|
||||
Key = table.Column<string>(type: "nvarchar(128)", maxLength: 128, nullable: false),
|
||||
Language = table.Column<string>(type: "nvarchar(8)", maxLength: 8, nullable: false),
|
||||
Value = table.Column<string>(type: "nvarchar(max)", nullable: false),
|
||||
Description = table.Column<string>(type: "nvarchar(500)", maxLength: 500, nullable: false, defaultValue: ""),
|
||||
UpdatedAt = table.Column<DateTime>(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], "myAi");
|
||||
|
||||
// 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 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.");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "Templates",
|
||||
schema: "myAi");
|
||||
}
|
||||
}
|
||||
}
|
||||
-62
@@ -1,62 +0,0 @@
|
||||
// <auto-generated />
|
||||
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
|
||||
{
|
||||
/// <inheritdoc />
|
||||
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<string>("Key")
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("nvarchar(128)");
|
||||
|
||||
b.Property<string>("Language")
|
||||
.HasMaxLength(8)
|
||||
.HasColumnType("nvarchar(8)");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("nvarchar(500)")
|
||||
.HasDefaultValue("");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("datetime2")
|
||||
.HasDefaultValueSql("SYSUTCDATETIME()");
|
||||
|
||||
b.Property<string>("Value")
|
||||
.IsRequired()
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.HasKey("Key", "Language");
|
||||
|
||||
b.ToTable("Templates", "myAi");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,143 +0,0 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace MyAi.Data.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class UpdateEmailTemplatesToHtml : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
void Update(string key, string lang, string value)
|
||||
=> migrationBuilder.UpdateData("Templates", ["Key", "Language"], [key, lang],
|
||||
["Value"], [value], "myAi");
|
||||
|
||||
// email.match.body — en
|
||||
Update("email.match.body", "en",
|
||||
"<h2 style=\"margin:0 0 20px;color:#2c5282;font-size:20px\">CV Match Report</h2>" +
|
||||
"<table cellpadding=\"10\" cellspacing=\"0\" style=\"width:100%;border-collapse:collapse;margin-bottom:24px\">" +
|
||||
"<tr style=\"background:#f8f9fa\">" +
|
||||
"<td style=\"font-weight:600;width:130px;border:1px solid #dee2e6;color:#495057\">CV ID</td>" +
|
||||
"<td style=\"border:1px solid #dee2e6;font-family:monospace;font-size:13px\">{{cvDocumentId}}</td>" +
|
||||
"</tr>" +
|
||||
"<tr>" +
|
||||
"<td style=\"font-weight:600;border:1px solid #dee2e6;color:#495057\">Job</td>" +
|
||||
"<td style=\"border:1px solid #dee2e6\">{{jobLabel}}</td>" +
|
||||
"</tr>" +
|
||||
"<tr style=\"background:#f8f9fa\">" +
|
||||
"<td style=\"font-weight:600;border:1px solid #dee2e6;color:#495057\">URL</td>" +
|
||||
"<td style=\"border:1px solid #dee2e6\"><a href=\"{{jobUrl}}\" style=\"color:#2c5282\">{{jobUrl}}</a></td>" +
|
||||
"</tr>" +
|
||||
"<tr>" +
|
||||
"<td style=\"font-weight:600;border:1px solid #dee2e6;color:#495057\">Score</td>" +
|
||||
"<td style=\"border:1px solid #dee2e6;font-size:26px;font-weight:700;color:#28a745\">{{score}}%</td>" +
|
||||
"</tr>" +
|
||||
"</table>" +
|
||||
"<h3 style=\"color:#2c5282;border-bottom:2px solid #e9ecef;padding-bottom:6px\">Summary</h3>" +
|
||||
"<p style=\"color:#495057;line-height:1.7\">{{summary}}</p>" +
|
||||
"<h3 style=\"color:#2c5282;border-bottom:2px solid #e9ecef;padding-bottom:6px\">Strengths</h3>{{strengths}}" +
|
||||
"<h3 style=\"color:#2c5282;border-bottom:2px solid #e9ecef;padding-bottom:6px\">Gaps</h3>{{gaps}}" +
|
||||
"<h3 style=\"color:#2c5282;border-bottom:2px solid #e9ecef;padding-bottom:6px\">Recommendations</h3>{{recommendations}}");
|
||||
|
||||
// email.match.body — ro
|
||||
Update("email.match.body", "ro",
|
||||
"<h2 style=\"margin:0 0 20px;color:#2c5282;font-size:20px\">Raport Potrivire CV</h2>" +
|
||||
"<table cellpadding=\"10\" cellspacing=\"0\" style=\"width:100%;border-collapse:collapse;margin-bottom:24px\">" +
|
||||
"<tr style=\"background:#f8f9fa\">" +
|
||||
"<td style=\"font-weight:600;width:130px;border:1px solid #dee2e6;color:#495057\">ID Document CV</td>" +
|
||||
"<td style=\"border:1px solid #dee2e6;font-family:monospace;font-size:13px\">{{cvDocumentId}}</td>" +
|
||||
"</tr>" +
|
||||
"<tr>" +
|
||||
"<td style=\"font-weight:600;border:1px solid #dee2e6;color:#495057\">Job</td>" +
|
||||
"<td style=\"border:1px solid #dee2e6\">{{jobLabel}}</td>" +
|
||||
"</tr>" +
|
||||
"<tr style=\"background:#f8f9fa\">" +
|
||||
"<td style=\"font-weight:600;border:1px solid #dee2e6;color:#495057\">URL</td>" +
|
||||
"<td style=\"border:1px solid #dee2e6\"><a href=\"{{jobUrl}}\" style=\"color:#2c5282\">{{jobUrl}}</a></td>" +
|
||||
"</tr>" +
|
||||
"<tr>" +
|
||||
"<td style=\"font-weight:600;border:1px solid #dee2e6;color:#495057\">Scor</td>" +
|
||||
"<td style=\"border:1px solid #dee2e6;font-size:26px;font-weight:700;color:#28a745\">{{score}}%</td>" +
|
||||
"</tr>" +
|
||||
"</table>" +
|
||||
"<h3 style=\"color:#2c5282;border-bottom:2px solid #e9ecef;padding-bottom:6px\">Rezumat</h3>" +
|
||||
"<p style=\"color:#495057;line-height:1.7\">{{summary}}</p>" +
|
||||
"<h3 style=\"color:#2c5282;border-bottom:2px solid #e9ecef;padding-bottom:6px\">Puncte forte</h3>{{strengths}}" +
|
||||
"<h3 style=\"color:#2c5282;border-bottom:2px solid #e9ecef;padding-bottom:6px\">Lipsuri</h3>{{gaps}}" +
|
||||
"<h3 style=\"color:#2c5282;border-bottom:2px solid #e9ecef;padding-bottom:6px\">Recomandări</h3>{{recommendations}}");
|
||||
|
||||
// email.match.job-search-footer — en
|
||||
Update("email.match.job-search-footer", "en",
|
||||
"<div style=\"background:#f0f4f8;border-left:4px solid #2c5282;padding:16px;margin-top:24px;border-radius:4px\">" +
|
||||
"<p style=\"margin:0;color:#495057\">" +
|
||||
"Want to find matching jobs automatically? " +
|
||||
"<a href=\"{{jobSearchLink}}\" style=\"color:#2c5282;font-weight:600\">Start a job search →</a><br>" +
|
||||
"<small style=\"color:#6c757d\">Link valid for {{expiryDays}} days.</small>" +
|
||||
"</p>" +
|
||||
"</div>");
|
||||
|
||||
// email.match.job-search-footer — ro
|
||||
Update("email.match.job-search-footer", "ro",
|
||||
"<div style=\"background:#f0f4f8;border-left:4px solid #2c5282;padding:16px;margin-top:24px;border-radius:4px\">" +
|
||||
"<p style=\"margin:0;color:#495057\">" +
|
||||
"Vrei să găsești joburi potrivite automat? " +
|
||||
"<a href=\"{{jobSearchLink}}\" style=\"color:#2c5282;font-weight:600\">Pornește o căutare de joburi →</a><br>" +
|
||||
"<small style=\"color:#6c757d\">Link valabil {{expiryDays}} zile.</small>" +
|
||||
"</p>" +
|
||||
"</div>");
|
||||
|
||||
// email.search-results.body — en
|
||||
Update("email.search-results.body", "en",
|
||||
"<h2 style=\"margin:0 0 16px;color:#2c5282\">Job Search Results</h2>" +
|
||||
"<p style=\"color:#495057\">Found <strong>{{count}}</strong> matching job(s):</p>" +
|
||||
"{{items}}");
|
||||
|
||||
// email.search-results.body — ro
|
||||
Update("email.search-results.body", "ro",
|
||||
"<h2 style=\"margin:0 0 16px;color:#2c5282\">Rezultate Căutare Joburi</h2>" +
|
||||
"<p style=\"color:#495057\">Am găsit <strong>{{count}}</strong> job(uri) potrivite:</p>" +
|
||||
"{{items}}");
|
||||
|
||||
// email.search-results.empty — en
|
||||
Update("email.search-results.empty", "en",
|
||||
"<div style=\"text-align:center;padding:32px;color:#6c757d\">" +
|
||||
"<p style=\"font-size:18px;margin:0 0 8px;color:#495057\">No matching jobs found</p>" +
|
||||
"<p style=\"margin:0\">Your job search completed but no matching jobs were found. Try again later or adjust your CV.</p>" +
|
||||
"</div>");
|
||||
|
||||
// email.search-results.empty — ro
|
||||
Update("email.search-results.empty", "ro",
|
||||
"<div style=\"text-align:center;padding:32px;color:#6c757d\">" +
|
||||
"<p style=\"font-size:18px;margin:0 0 8px;color:#495057\">Niciun job potrivit găsit</p>" +
|
||||
"<p style=\"margin:0\">Căutarea s-a finalizat dar nu au fost găsite joburi potrivite. Încearcă mai târziu sau ajustează CV-ul.</p>" +
|
||||
"</div>");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
void Update(string key, string lang, string value)
|
||||
=> migrationBuilder.UpdateData("Templates", ["Key", "Language"], [key, lang],
|
||||
["Value"], [value], "myAi");
|
||||
|
||||
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.");
|
||||
}
|
||||
}
|
||||
}
|
||||
-62
@@ -1,62 +0,0 @@
|
||||
// <auto-generated />
|
||||
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
|
||||
{
|
||||
/// <inheritdoc />
|
||||
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<string>("Key")
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("nvarchar(128)");
|
||||
|
||||
b.Property<string>("Language")
|
||||
.HasMaxLength(8)
|
||||
.HasColumnType("nvarchar(8)");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("nvarchar(500)")
|
||||
.HasDefaultValue("");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("datetime2")
|
||||
.HasDefaultValueSql("SYSUTCDATETIME()");
|
||||
|
||||
b.Property<string>("Value")
|
||||
.IsRequired()
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.HasKey("Key", "Language");
|
||||
|
||||
b.ToTable("Templates", "myAi");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace MyAi.Data.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class DeleteMigratedTemplates : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.Sql("DELETE FROM [myAi].[Templates] WHERE [Key] LIKE 'email.%'");
|
||||
migrationBuilder.Sql("DELETE FROM [myAi].[Templates] WHERE [Key] LIKE 'ai.%'");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
// Rows were migrated to emailApi.EmailTemplates and cvMatcher.AiPrompts.
|
||||
// Re-inserting them here is intentionally omitted.
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,59 +0,0 @@
|
||||
// <auto-generated />
|
||||
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<string>("Key")
|
||||
.HasMaxLength(128)
|
||||
.HasColumnType("nvarchar(128)");
|
||||
|
||||
b.Property<string>("Language")
|
||||
.HasMaxLength(8)
|
||||
.HasColumnType("nvarchar(8)");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("nvarchar(500)")
|
||||
.HasDefaultValue("");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("datetime2")
|
||||
.HasDefaultValueSql("SYSUTCDATETIME()");
|
||||
|
||||
b.Property<string>("Value")
|
||||
.IsRequired()
|
||||
.HasColumnType("nvarchar(max)");
|
||||
|
||||
b.HasKey("Key", "Language");
|
||||
|
||||
b.ToTable("Templates", "myAi");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,82 +0,0 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using MyAi.Data;
|
||||
using System.Collections.Concurrent;
|
||||
|
||||
namespace MyAi.Data.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Singleton implementation of <see cref="ITemplateService"/> that caches all templates from the
|
||||
/// <c>myAi.Templates</c> table and refreshes them every 10 minutes.
|
||||
/// Uses <see cref="IServiceScopeFactory"/> to resolve the scoped DbContext from a singleton lifetime.
|
||||
/// </summary>
|
||||
public sealed class DbTemplateService : ITemplateService
|
||||
{
|
||||
private readonly IServiceScopeFactory _scopeFactory;
|
||||
private readonly ILogger<DbTemplateService> _logger;
|
||||
private ConcurrentDictionary<string, string> _cache = new(StringComparer.OrdinalIgnoreCase);
|
||||
private DateTime _loadedAt = DateTime.MinValue;
|
||||
private static readonly TimeSpan CacheTtl = TimeSpan.FromMinutes(10);
|
||||
|
||||
public DbTemplateService(IServiceScopeFactory scopeFactory, ILogger<DbTemplateService> logger)
|
||||
{
|
||||
_scopeFactory = scopeFactory;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
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;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
private void EnsureCacheLoaded()
|
||||
{
|
||||
if (DateTime.UtcNow - _loadedAt < CacheTtl) return;
|
||||
|
||||
try
|
||||
{
|
||||
using var scope = _scopeFactory.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<MyAiDbContext>();
|
||||
var rows = db.Templates.AsNoTracking().ToList();
|
||||
var fresh = new ConcurrentDictionary<string, string>(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.");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Builds the dictionary key used in the cache.</summary>
|
||||
private static string CacheKey(string key, string language) => $"{key}::{language}";
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
namespace MyAi.Data.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Provides access to localised string templates stored in the <c>myAi.Templates</c> table.
|
||||
/// Implementations are expected to cache templates and refresh periodically.
|
||||
/// </summary>
|
||||
public interface ITemplateService
|
||||
{
|
||||
/// <summary>
|
||||
/// Returns the template value for the given key and language.
|
||||
/// Falls back to <c>"en"</c> when the requested language has no entry.
|
||||
/// Returns the raw key string when no matching template is found.
|
||||
/// </summary>
|
||||
/// <param name="key">Template key (e.g. <c>"html.job-search-start.title"</c>).</param>
|
||||
/// <param name="language">Two-letter language code (e.g. <c>"en"</c>, <c>"ro"</c>).</param>
|
||||
/// <returns>Template value string.</returns>
|
||||
string Get(string key, string language = "en");
|
||||
|
||||
/// <summary>
|
||||
/// Retrieves the template and substitutes <c>{{placeholder}}</c> tokens with the provided values.
|
||||
/// </summary>
|
||||
/// <param name="key">Template key.</param>
|
||||
/// <param name="language">Two-letter language code.</param>
|
||||
/// <param name="placeholders">Named replacement pairs in the form <c>("name", value)</c>.</param>
|
||||
/// <returns>Rendered template string with all placeholders replaced.</returns>
|
||||
string Render(string key, string language, params (string Key, string Value)[] placeholders);
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<AssemblyName>myai-data</AssemblyName>
|
||||
<RootNamespace>MyAi.Data</RootNamespace>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\shared-data\shared-data.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -1,18 +0,0 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<RootNamespace>MyAi.Models</RootNamespace>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="10.0.7" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.7">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -1,6 +1,6 @@
|
||||
namespace Rag.Models.Settings;
|
||||
|
||||
public sealed class OllamaSettings : Common.Settings.OllamaSettings
|
||||
public sealed class OllamaSettings : Shared.Models.Settings.OllamaSettings
|
||||
{
|
||||
public string EmbeddingModel { get; set; } = "nomic-embed-text";
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
namespace Rag.Models.Settings;
|
||||
|
||||
public sealed class OpenAiSettings : Common.Settings.OpenAiSettings
|
||||
public sealed class OpenAiSettings: Shared.Models.Settings.OpenAiSettings
|
||||
{
|
||||
public string EmbeddingModel { get; set; } = "text-embedding-3-small";
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user