From 19e73aca170693cd78edbc875df7fa08522fcf05 Mon Sep 17 00:00:00 2001 From: claude Date: Thu, 28 May 2026 08:39:15 +0300 Subject: [PATCH 1/6] feat: add email-api-data project with EmailTemplates repository and service New data project owning the emailApi schema: - EmailTemplateEntity with Key, Language, Value, Description, UpdatedAt, OperatorCopy - EmailApiDbContext (schema: emailApi, custom migration table _EmailApiMigrations) - IEmailTemplateRepository / EfEmailTemplateRepository (scoped) - IEmailTemplateService / EmailTemplateService (singleton, 10-min cache) - GetOperatorCopy falls back to first non-empty OperatorCopy across all rows - Initial migration CreateEmailTemplates: creates table + seeds all email.* templates (match + search-results in en/ro) and html-shell fragments with OperatorCopy = "contact@myai.ro" for addressable rows Co-Authored-By: Claude Sonnet 4.6 --- Apis/email-api-data/EmailApiDbContext.cs | 31 +++ .../Entities/EmailTemplateEntity.cs | 12 ++ ...528100000_CreateEmailTemplates.Designer.cs | 69 +++++++ .../20260528100000_CreateEmailTemplates.cs | 192 ++++++++++++++++++ .../EmailApiDbContextModelSnapshot.cs | 66 ++++++ .../Contracts/IEmailTemplateRepository.cs | 8 + .../Repositories/EfEmailTemplateRepository.cs | 18 ++ .../Services/EmailTemplateService.cs | 95 +++++++++ .../Services/IEmailTemplateService.cs | 8 + Apis/email-api-data/email-api-data.csproj | 16 ++ myAi.sln | 15 ++ 11 files changed, 530 insertions(+) create mode 100644 Apis/email-api-data/EmailApiDbContext.cs create mode 100644 Apis/email-api-data/Entities/EmailTemplateEntity.cs create mode 100644 Apis/email-api-data/Migrations/20260528100000_CreateEmailTemplates.Designer.cs create mode 100644 Apis/email-api-data/Migrations/20260528100000_CreateEmailTemplates.cs create mode 100644 Apis/email-api-data/Migrations/EmailApiDbContextModelSnapshot.cs create mode 100644 Apis/email-api-data/Repositories/Contracts/IEmailTemplateRepository.cs create mode 100644 Apis/email-api-data/Repositories/EfEmailTemplateRepository.cs create mode 100644 Apis/email-api-data/Services/EmailTemplateService.cs create mode 100644 Apis/email-api-data/Services/IEmailTemplateService.cs create mode 100644 Apis/email-api-data/email-api-data.csproj diff --git a/Apis/email-api-data/EmailApiDbContext.cs b/Apis/email-api-data/EmailApiDbContext.cs new file mode 100644 index 0000000..e0763a9 --- /dev/null +++ b/Apis/email-api-data/EmailApiDbContext.cs @@ -0,0 +1,31 @@ +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 options) : base(options) { } + + public DbSet EmailTemplates => Set(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.HasDefaultSchema(SchemaName); + + modelBuilder.Entity(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); + }); + } +} diff --git a/Apis/email-api-data/Entities/EmailTemplateEntity.cs b/Apis/email-api-data/Entities/EmailTemplateEntity.cs new file mode 100644 index 0000000..f26485c --- /dev/null +++ b/Apis/email-api-data/Entities/EmailTemplateEntity.cs @@ -0,0 +1,12 @@ +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; +} diff --git a/Apis/email-api-data/Migrations/20260528100000_CreateEmailTemplates.Designer.cs b/Apis/email-api-data/Migrations/20260528100000_CreateEmailTemplates.Designer.cs new file mode 100644 index 0000000..240240f --- /dev/null +++ b/Apis/email-api-data/Migrations/20260528100000_CreateEmailTemplates.Designer.cs @@ -0,0 +1,69 @@ +// +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 + { + /// + 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("Key") + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("Language") + .HasMaxLength(8) + .HasColumnType("nvarchar(8)"); + + b.Property("Description") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)") + .HasDefaultValue(""); + + b.Property("OperatorCopy") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)") + .HasDefaultValue(""); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("datetime2") + .HasDefaultValueSql("SYSUTCDATETIME()"); + + b.Property("Value") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.HasKey("Key", "Language"); + + b.ToTable("EmailTemplates", "emailApi"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Apis/email-api-data/Migrations/20260528100000_CreateEmailTemplates.cs b/Apis/email-api-data/Migrations/20260528100000_CreateEmailTemplates.cs new file mode 100644 index 0000000..c9a678a --- /dev/null +++ b/Apis/email-api-data/Migrations/20260528100000_CreateEmailTemplates.cs @@ -0,0 +1,192 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace EmailApi.Data.Migrations +{ + /// + public partial class CreateEmailTemplates : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.EnsureSchema(name: "emailApi"); + + migrationBuilder.CreateTable( + name: "EmailTemplates", + schema: "emailApi", + columns: table => new + { + Key = table.Column(type: "nvarchar(128)", maxLength: 128, nullable: false), + Language = table.Column(type: "nvarchar(8)", maxLength: 8, nullable: false), + Value = table.Column(type: "nvarchar(max)", nullable: false), + Description = table.Column(type: "nvarchar(500)", maxLength: 500, nullable: false, defaultValue: ""), + UpdatedAt = table.Column(type: "datetime2", nullable: false, defaultValueSql: "SYSUTCDATETIME()"), + OperatorCopy = table.Column(type: "nvarchar(256)", maxLength: 256, nullable: false, defaultValue: "") + }, + constraints: table => + { + table.PrimaryKey("PK_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", "*", + "\n\n\n\n \n \n
\n \n \n \n \n
\n

myAi

\n
", + "Opening HTML shell fragment — wrapped around every HtmlBody before sending"); + + Row("email.html-shell.end", "*", + "
\n Automated message from myAi.\n
\n
\n\n", + "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", + "

CV Match Report

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

Summary

" + + "

{{summary}}

" + + "

Strengths

{{strengths}}" + + "

Gaps

{{gaps}}" + + "

Recommendations

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

Raport Potrivire CV

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

Rezumat

" + + "

{{summary}}

" + + "

Puncte forte

{{strengths}}" + + "

Lipsuri

{{gaps}}" + + "

Recomandări

{{recommendations}}", + "Corpul emailului pentru rezultatul potrivirii CV", + op); + + Row("email.match.job-search-footer", "en", + "
" + + "

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

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

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

" + + "
", + "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", + "

Job Search Results

" + + "

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

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

Rezultate Căutare Joburi

" + + "

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

" + + "{{items}}", + "Corpul emailului de rezultate cautare joburi", + op); + + Row("email.search-results.empty", "en", + "
" + + "

No matching jobs found

" + + "

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

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

Niciun job potrivit găsit

" + + "

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

" + + "
", + "Mesaj fara rezultate pentru emailul de cautare joburi", + op); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable(name: "EmailTemplates", schema: "emailApi"); + } + } +} diff --git a/Apis/email-api-data/Migrations/EmailApiDbContextModelSnapshot.cs b/Apis/email-api-data/Migrations/EmailApiDbContextModelSnapshot.cs new file mode 100644 index 0000000..1f1a1f4 --- /dev/null +++ b/Apis/email-api-data/Migrations/EmailApiDbContextModelSnapshot.cs @@ -0,0 +1,66 @@ +// +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("Key") + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("Language") + .HasMaxLength(8) + .HasColumnType("nvarchar(8)"); + + b.Property("Description") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)") + .HasDefaultValue(""); + + b.Property("OperatorCopy") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)") + .HasDefaultValue(""); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("datetime2") + .HasDefaultValueSql("SYSUTCDATETIME()"); + + b.Property("Value") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.HasKey("Key", "Language"); + + b.ToTable("EmailTemplates", "emailApi"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Apis/email-api-data/Repositories/Contracts/IEmailTemplateRepository.cs b/Apis/email-api-data/Repositories/Contracts/IEmailTemplateRepository.cs new file mode 100644 index 0000000..a4e189b --- /dev/null +++ b/Apis/email-api-data/Repositories/Contracts/IEmailTemplateRepository.cs @@ -0,0 +1,8 @@ +using EmailApi.Data.Entities; + +namespace EmailApi.Data.Repositories.Contracts; + +public interface IEmailTemplateRepository +{ + Task> GetAllAsync(CancellationToken ct); +} diff --git a/Apis/email-api-data/Repositories/EfEmailTemplateRepository.cs b/Apis/email-api-data/Repositories/EfEmailTemplateRepository.cs new file mode 100644 index 0000000..25c6efc --- /dev/null +++ b/Apis/email-api-data/Repositories/EfEmailTemplateRepository.cs @@ -0,0 +1,18 @@ +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> GetAllAsync(CancellationToken ct) + => await _db.EmailTemplates.AsNoTracking().ToListAsync(ct); +} diff --git a/Apis/email-api-data/Services/EmailTemplateService.cs b/Apis/email-api-data/Services/EmailTemplateService.cs new file mode 100644 index 0000000..79cdd56 --- /dev/null +++ b/Apis/email-api-data/Services/EmailTemplateService.cs @@ -0,0 +1,95 @@ +using System.Collections.Concurrent; +using EmailApi.Data.Repositories.Contracts; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +namespace EmailApi.Data.Services; + +public sealed class EmailTemplateService : IEmailTemplateService +{ + private readonly IServiceScopeFactory _scopeFactory; + private readonly ILogger _logger; + private ConcurrentDictionary _valueCache = new(StringComparer.OrdinalIgnoreCase); + private ConcurrentDictionary _operatorCache = new(StringComparer.OrdinalIgnoreCase); + private DateTime _loadedAt = DateTime.MinValue; + private static readonly TimeSpan CacheTtl = TimeSpan.FromMinutes(10); + + public EmailTemplateService(IServiceScopeFactory scopeFactory, ILogger logger) + { + _scopeFactory = scopeFactory; + _logger = logger; + } + + public string Get(string key, string language = "en") + { + EnsureCacheLoaded(); + + if (_valueCache.TryGetValue(CacheKey(key, language), out var value)) + return value; + + if (!string.Equals(language, "en", StringComparison.OrdinalIgnoreCase) + && _valueCache.TryGetValue(CacheKey(key, "en"), out var fallback)) + return fallback; + + _logger.LogWarning("Email template not found: key={Key}, language={Language}", key, language); + return key; + } + + public string Render(string key, string language, params (string Key, string Value)[] placeholders) + { + var template = Get(key, language); + foreach (var (k, v) in placeholders) + template = template.Replace($"{{{{{k}}}}}", v, StringComparison.OrdinalIgnoreCase); + return template; + } + + 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; + } + + private void EnsureCacheLoaded() + { + if (DateTime.UtcNow - _loadedAt < CacheTtl) return; + + try + { + using var scope = _scopeFactory.CreateScope(); + var repo = scope.ServiceProvider.GetRequiredService(); + var rows = repo.GetAllAsync(CancellationToken.None).GetAwaiter().GetResult(); + + var freshValues = new ConcurrentDictionary(StringComparer.OrdinalIgnoreCase); + var freshOperator = new ConcurrentDictionary(StringComparer.OrdinalIgnoreCase); + + foreach (var row in rows) + { + freshValues[CacheKey(row.Key, row.Language)] = row.Value; + freshOperator[CacheKey(row.Key, row.Language)] = row.OperatorCopy; + } + + _valueCache = freshValues; + _operatorCache = freshOperator; + _loadedAt = DateTime.UtcNow; + _logger.LogDebug("Email template cache refreshed. {Count} templates loaded.", rows.Count); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to refresh email template cache. Serving stale cache."); + } + } + + private static string CacheKey(string key, string language) => $"{key}::{language}"; +} diff --git a/Apis/email-api-data/Services/IEmailTemplateService.cs b/Apis/email-api-data/Services/IEmailTemplateService.cs new file mode 100644 index 0000000..835e9eb --- /dev/null +++ b/Apis/email-api-data/Services/IEmailTemplateService.cs @@ -0,0 +1,8 @@ +namespace EmailApi.Data.Services; + +public interface IEmailTemplateService +{ + string Get(string key, string language = "en"); + string Render(string key, string language, params (string Key, string Value)[] placeholders); + string? GetOperatorCopy(string key, string language); +} diff --git a/Apis/email-api-data/email-api-data.csproj b/Apis/email-api-data/email-api-data.csproj new file mode 100644 index 0000000..0596a74 --- /dev/null +++ b/Apis/email-api-data/email-api-data.csproj @@ -0,0 +1,16 @@ + + + net10.0 + email-api-data + EmailApi.Data + enable + enable + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + diff --git a/myAi.sln b/myAi.sln index a937763..62d9c91 100644 --- a/myAi.sln +++ b/myAi.sln @@ -61,6 +61,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "email-api-models", "Apis\em EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "email-api", "Apis\email-api\email-api.csproj", "{434119EA-2FFC-4433-9B8E-1E6D94006413}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "email-api-data", "Apis\email-api-data\email-api-data.csproj", "{C1D2E3F4-A5B6-4789-CDEF-012345678ABC}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -343,6 +345,18 @@ Global {434119EA-2FFC-4433-9B8E-1E6D94006413}.Release|x64.Build.0 = Release|Any CPU {434119EA-2FFC-4433-9B8E-1E6D94006413}.Release|x86.ActiveCfg = Release|Any CPU {434119EA-2FFC-4433-9B8E-1E6D94006413}.Release|x86.Build.0 = Release|Any CPU + {C1D2E3F4-A5B6-4789-CDEF-012345678ABC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {C1D2E3F4-A5B6-4789-CDEF-012345678ABC}.Debug|Any CPU.Build.0 = Debug|Any CPU + {C1D2E3F4-A5B6-4789-CDEF-012345678ABC}.Debug|x64.ActiveCfg = Debug|Any CPU + {C1D2E3F4-A5B6-4789-CDEF-012345678ABC}.Debug|x64.Build.0 = Debug|Any CPU + {C1D2E3F4-A5B6-4789-CDEF-012345678ABC}.Debug|x86.ActiveCfg = Debug|Any CPU + {C1D2E3F4-A5B6-4789-CDEF-012345678ABC}.Debug|x86.Build.0 = Debug|Any CPU + {C1D2E3F4-A5B6-4789-CDEF-012345678ABC}.Release|Any CPU.ActiveCfg = Release|Any CPU + {C1D2E3F4-A5B6-4789-CDEF-012345678ABC}.Release|Any CPU.Build.0 = Release|Any CPU + {C1D2E3F4-A5B6-4789-CDEF-012345678ABC}.Release|x64.ActiveCfg = Release|Any CPU + {C1D2E3F4-A5B6-4789-CDEF-012345678ABC}.Release|x64.Build.0 = Release|Any CPU + {C1D2E3F4-A5B6-4789-CDEF-012345678ABC}.Release|x86.ActiveCfg = Release|Any CPU + {C1D2E3F4-A5B6-4789-CDEF-012345678ABC}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -370,6 +384,7 @@ Global {069365DB-1916-4C38-A90D-5E909BD9EDD0} = {A9B8C7D6-E5F4-4321-ABCD-FEDCBA987654} {BE44B4EB-9AB9-4D81-A9BF-5CF2832BEEE5} = {A9B8C7D6-E5F4-4321-ABCD-FEDCBA987654} {434119EA-2FFC-4433-9B8E-1E6D94006413} = {0FE6558F-2157-47F2-A835-558416CE0E2B} + {C1D2E3F4-A5B6-4789-CDEF-012345678ABC} = {D4E5F6A7-B8C9-4012-3456-789ABCDEF012} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {6246A67B-299E-4E64-8DBE-1A66771E7C67} From c415ab3957167dd89f01e5b708a88c63103b8a24 Mon Sep 17 00:00:00 2001 From: claude Date: Thu, 28 May 2026 08:40:40 +0300 Subject: [PATCH 2/6] feat(email-api): wire email-api-data; load html shell templates from DB - Add ProjectReference to email-api-data - Register EmailApiDbContext + run migrations on startup - Register IEmailTemplateRepository (scoped) and IEmailTemplateService (singleton) - SmtpEmailDispatcher: inject IEmailTemplateService; replace hardcoded HtmlShellStart/HtmlShellEnd string constants with DB template lookups (email.html-shell.start / email.html-shell.end, language "*") Co-Authored-By: Claude Sonnet 4.6 --- Apis/email-api/Program.cs | 25 +++++++++++++ .../email-api/Services/SmtpEmailDispatcher.cs | 37 ++++--------------- Apis/email-api/email-api.csproj | 1 + 3 files changed, 34 insertions(+), 29 deletions(-) diff --git a/Apis/email-api/Program.cs b/Apis/email-api/Program.cs index e174e42..f954459 100644 --- a/Apis/email-api/Program.cs +++ b/Apis/email-api/Program.cs @@ -1,5 +1,10 @@ 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; @@ -24,6 +29,19 @@ try builder.Services.Configure(builder.Configuration.GetSection("Smtp")); builder.Services.Configure(builder.Configuration.GetSection("FileStorage")); + builder.Services.AddDbContext(options => + { + var connectionString = builder.Services.GetConfiguredDbConnectionString(builder.Configuration); + options.UseSqlServer(connectionString, sql => + { + sql.MigrationsHistoryTable(EmailApiDbContext.MigrationTableName, EmailApiDbContext.SchemaName); + sql.MigrationsAssembly("email-api-data"); + }); + }); + + builder.Services.AddScoped(); + builder.Services.AddSingleton(); + builder.Services.AddScoped(); var app = builder.Build(); @@ -40,6 +58,13 @@ try app.UseAuthorization(); app.MapControllers(); + Log.Information("Running EF Core migrations if any"); + using (var scope = app.Services.CreateScope()) + { + var db = scope.ServiceProvider.GetRequiredService(); + db.Database.Migrate(); + } + Log.Information("{Service} startup complete. Listening for requests...", ServiceName); app.Run(); } diff --git a/Apis/email-api/Services/SmtpEmailDispatcher.cs b/Apis/email-api/Services/SmtpEmailDispatcher.cs index 3ca3eb0..d0adc1f 100644 --- a/Apis/email-api/Services/SmtpEmailDispatcher.cs +++ b/Apis/email-api/Services/SmtpEmailDispatcher.cs @@ -1,3 +1,4 @@ +using EmailApi.Data.Services; using EmailApi.Models.Requests; using MailKit.Net.Smtp; using MailKit.Security; @@ -11,44 +12,19 @@ public sealed class SmtpEmailDispatcher { private readonly SmtpSettings _smtp; private readonly FileStorageSettings _fileStorage; + private readonly IEmailTemplateService _templates; private readonly ILogger _log; private readonly string _environmentName; - private static readonly string HtmlShellStart = """ - - - - - - -
- - - - -
-

myAi

-
- """; - - private static readonly string HtmlShellEnd = """ -
- Automated message from myAi. -
-
- - - """; - public SmtpEmailDispatcher( IOptions smtp, IOptions fileStorage, + IEmailTemplateService templates, ILogger log) { _smtp = smtp.Value; _fileStorage = fileStorage.Value; + _templates = templates; _log = log; _environmentName = Environment.GetEnvironmentVariable("APP_ENVIRONMENT_NAME") ?? "Development"; } @@ -72,9 +48,12 @@ public sealed class SmtpEmailDispatcher 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 = HtmlShellStart + req.HtmlBody + HtmlShellEnd + HtmlBody = shellStart + req.HtmlBody + shellEnd }; if (!string.IsNullOrWhiteSpace(req.AttachmentPath)) diff --git a/Apis/email-api/email-api.csproj b/Apis/email-api/email-api.csproj index 111de58..489268c 100644 --- a/Apis/email-api/email-api.csproj +++ b/Apis/email-api/email-api.csproj @@ -22,6 +22,7 @@ + From e7ca6043b7988dc66cd317ba6656c17f23366140 Mon Sep 17 00:00:00 2001 From: claude Date: Thu, 28 May 2026 08:43:07 +0300 Subject: [PATCH 3/6] feat(api): wire IEmailTemplateService; replace Contact:ToEmail with OperatorCopy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add ProjectReference to email-api-data - Register EmailApiDbContext (no migrate — email-api owns migrations) - Register IEmailTemplateRepository (scoped) and IEmailTemplateService (singleton) - EmailApiEmailSender: replace ITemplateService with IEmailTemplateService for all email.* template rendering (match body/subject/footer) - SendMatchAsync: replace _contact.ToEmail operator copy with GetOperatorCopy("email.match.subject", "en") from DB template Co-Authored-By: Claude Sonnet 4.6 --- Apis/api/Program.cs | 17 +++++++++++++++++ Apis/api/Services/EmailApiEmailSender.cs | 22 ++++++++++++---------- Apis/api/api.csproj | 1 + 3 files changed, 30 insertions(+), 10 deletions(-) diff --git a/Apis/api/Program.cs b/Apis/api/Program.cs index 5cf8974..295c83f 100644 --- a/Apis/api/Program.cs +++ b/Apis/api/Program.cs @@ -1,6 +1,10 @@ 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; @@ -47,6 +51,19 @@ try }); builder.Services.AddSingleton(); + builder.Services.AddDbContext(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(); + builder.Services.AddSingleton(); + builder.Services.AddHttpClient(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); diff --git a/Apis/api/Services/EmailApiEmailSender.cs b/Apis/api/Services/EmailApiEmailSender.cs index 6a76466..7d2a5a3 100644 --- a/Apis/api/Services/EmailApiEmailSender.cs +++ b/Apis/api/Services/EmailApiEmailSender.cs @@ -1,11 +1,11 @@ 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; -using MyAi.Data.Services; namespace Api.Services; @@ -15,7 +15,7 @@ public sealed class EmailApiEmailSender : IEmailSender private readonly ContactSettings _contact; private readonly SubscribeSettings _subscribe; private readonly FileStorageSettings _fileStorage; - private readonly ITemplateService _templates; + private readonly IEmailTemplateService _emailTemplates; private readonly ILogger _log; public EmailApiEmailSender( @@ -23,14 +23,14 @@ public sealed class EmailApiEmailSender : IEmailSender IOptions contact, IOptions subscribe, IOptions fileStorage, - ITemplateService templates, + IEmailTemplateService emailTemplates, ILogger log) { _emailApi = emailApi; _contact = contact.Value; _subscribe = subscribe.Value; _fileStorage = fileStorage.Value; - _templates = templates; + _emailTemplates = emailTemplates; _log = log; } @@ -148,13 +148,15 @@ public sealed class EmailApiEmailSender : IEmailSender public async Task SendMatchAsync(string? explicitTo, string subject, string body, string? attachmentPath, CancellationToken ct) { + var operatorCopy = _emailTemplates.GetOperatorCopy("email.match.subject", "en"); + var recipients = new List(); if (!string.IsNullOrWhiteSpace(explicitTo)) recipients.Add(explicitTo); - if (!string.IsNullOrWhiteSpace(_contact.ToEmail) && - !recipients.Any(x => string.Equals(x, _contact.ToEmail, StringComparison.OrdinalIgnoreCase))) - recipients.Add(_contact.ToEmail); + if (!string.IsNullOrWhiteSpace(operatorCopy) && + !recipients.Any(x => string.Equals(x, operatorCopy, StringComparison.OrdinalIgnoreCase))) + recipients.Add(operatorCopy); if (recipients.Count == 0) { @@ -199,7 +201,7 @@ public sealed class EmailApiEmailSender : IEmailSender string.Join("", result.Recommendations.Select(r => $"
  • {r}
  • ")) + "" : "

    "; - var body = _templates.Render("email.match.body", language, + var body = _emailTemplates.Render("email.match.body", language, ("cvDocumentId", cvDocumentId), ("jobLabel", jobLabel ?? "N/A"), ("jobUrl", result.JobUrl ?? "N/A"), @@ -211,7 +213,7 @@ public sealed class EmailApiEmailSender : IEmailSender if (!string.IsNullOrWhiteSpace(jobSearchLink)) { - body += _templates.Render("email.match.job-search-footer", language, + body += _emailTemplates.Render("email.match.job-search-footer", language, ("jobSearchLink", jobSearchLink), ("expiryDays", expiryDays.ToString())); } @@ -220,7 +222,7 @@ public sealed class EmailApiEmailSender : IEmailSender } public string BuildMatchEmailSubject(int score, string? jobLabel, string language) => - _templates.Render("email.match.subject", language, + _emailTemplates.Render("email.match.subject", language, ("score", score.ToString()), ("jobLabel", jobLabel ?? "Job")); } diff --git a/Apis/api/api.csproj b/Apis/api/api.csproj index f91a051..96be1e8 100644 --- a/Apis/api/api.csproj +++ b/Apis/api/api.csproj @@ -36,6 +36,7 @@ + From e17f17b566570c7c6645f6aab9a1763fecc8c0c5 Mon Sep 17 00:00:00 2001 From: claude Date: Thu, 28 May 2026 08:45:18 +0300 Subject: [PATCH 4/6] feat(cv-search-job): replace MyAiDbContext+ITemplateService with IEmailTemplateService - Add ProjectReference to email-api-data; remove myai-data reference - Program.cs: register EmailApiDbContext (no migrate), IEmailTemplateRepository (scoped), IEmailTemplateService (singleton); remove MyAiDbContext + ITemplateService registrations and their migration call - CvSearchEmailSender: inject IEmailTemplateService; replace _config["Contact:ToEmail"] with GetOperatorCopy("email.search-results.subject") for operator copy logic; remove IConfiguration injection - docker-compose: remove Contact__ToEmail from cv-search-job service block; add Database__* env vars to email-api service (needed for EmailApiDbContext) Co-Authored-By: Claude Sonnet 4.6 --- Jobs/cv-search-job/Program.cs | 21 +++++++-------- .../Services/CvSearchEmailSender.cs | 26 ++++++++----------- Jobs/cv-search-job/cv-search-job.csproj | 2 +- docker-compose/docker-compose.yml | 9 +++++-- 4 files changed, 29 insertions(+), 29 deletions(-) diff --git a/Jobs/cv-search-job/Program.cs b/Jobs/cv-search-job/Program.cs index 59abf8d..c273d20 100644 --- a/Jobs/cv-search-job/Program.cs +++ b/Jobs/cv-search-job/Program.cs @@ -3,6 +3,10 @@ using CvMatcher.Models.Settings; using CvSearch.Data; using CvSearchJob.Clients; using CvSearchJob.Services; +using EmailApi.Data; +using EmailApi.Data.Repositories; +using EmailApi.Data.Repositories.Contracts; +using EmailApi.Data.Services; using EmailApi.Models.Clients; using CvSearchJob.Tasks; using JobScheduler.Scheduling; @@ -10,8 +14,6 @@ using JobScheduler.Tasks; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; -using MyAi.Data; -using MyAi.Data.Services; using Refit; using Serilog; using Common.Settings; @@ -54,16 +56,18 @@ try client.DefaultRequestHeaders.Add("X-Internal-Api-Key", key); }); - builder.Services.AddDbContext(options => + builder.Services.AddDbContext(options => { var connectionString = builder.Services.GetConfiguredDbConnectionString(builder.Configuration); options.UseSqlServer(connectionString, sql => { - sql.MigrationsAssembly("myai-data"); - sql.MigrationsHistoryTable(MyAiDbContext.MigrationTableName, MyAiDbContext.SchemaName); + sql.MigrationsHistoryTable(EmailApiDbContext.MigrationTableName, EmailApiDbContext.SchemaName); + sql.MigrationsAssembly("email-api-data"); }); }); - builder.Services.AddSingleton(); + + builder.Services.AddScoped(); + builder.Services.AddSingleton(); builder.Services.AddRefitClient() .ConfigureHttpClient((sp, client) => @@ -98,11 +102,6 @@ try var db = scope.ServiceProvider.GetRequiredService(); db.Database.Migrate(); } - using (var scope = host.Services.CreateScope()) - { - var db = scope.ServiceProvider.GetRequiredService(); - db.Database.Migrate(); - } Log.Information("{Service} startup complete. Background scheduler is running.", ServiceName); await host.RunAsync(); diff --git a/Jobs/cv-search-job/Services/CvSearchEmailSender.cs b/Jobs/cv-search-job/Services/CvSearchEmailSender.cs index 8eeedc6..89be54f 100644 --- a/Jobs/cv-search-job/Services/CvSearchEmailSender.cs +++ b/Jobs/cv-search-job/Services/CvSearchEmailSender.cs @@ -1,29 +1,25 @@ using CvMatcher.Models.Responses; using CvSearch.Data.Entities; +using EmailApi.Data.Services; using EmailApi.Models.Clients; using EmailApi.Models.Requests; -using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging; -using MyAi.Data.Services; namespace CvSearchJob.Services; public sealed class CvSearchEmailSender { private readonly IEmailApiClient _emailApi; - private readonly ITemplateService _templates; - private readonly IConfiguration _config; + private readonly IEmailTemplateService _emailTemplates; private readonly ILogger _logger; public CvSearchEmailSender( IEmailApiClient emailApi, - ITemplateService templates, - IConfiguration config, + IEmailTemplateService emailTemplates, ILogger logger) { _emailApi = emailApi; - _templates = templates; - _config = config; + _emailTemplates = emailTemplates; _logger = logger; } @@ -34,18 +30,18 @@ public sealed class CvSearchEmailSender string language, CancellationToken ct) { - var contactToEmail = _config["Contact:ToEmail"]; + var operatorCopy = _emailTemplates.GetOperatorCopy("email.search-results.subject", language); var recipients = new List(); if (!string.IsNullOrWhiteSpace(toEmail)) recipients.Add(toEmail); - if (!string.IsNullOrWhiteSpace(contactToEmail) && - !recipients.Any(r => string.Equals(r, contactToEmail, StringComparison.OrdinalIgnoreCase))) - recipients.Add(contactToEmail); + if (!string.IsNullOrWhiteSpace(operatorCopy) && + !recipients.Any(r => string.Equals(r, operatorCopy, StringComparison.OrdinalIgnoreCase))) + recipients.Add(operatorCopy); if (recipients.Count == 0) return; var htmlBody = BuildBody(results, language); - var subject = _templates.Render("email.search-results.subject", language, + var subject = _emailTemplates.Render("email.search-results.subject", language, ("count", results.Count.ToString())); try @@ -71,7 +67,7 @@ public sealed class CvSearchEmailSender private string BuildBody(IReadOnlyList results, string language) { if (results.Count == 0) - return _templates.Get("email.search-results.empty", language); + return _emailTemplates.Get("email.search-results.empty", language); var items = new System.Text.StringBuilder(); for (int i = 0; i < results.Count; i++) @@ -91,7 +87,7 @@ public sealed class CvSearchEmailSender """); } - return _templates.Render("email.search-results.body", language, + return _emailTemplates.Render("email.search-results.body", language, ("count", results.Count.ToString()), ("items", items.ToString())); } diff --git a/Jobs/cv-search-job/cv-search-job.csproj b/Jobs/cv-search-job/cv-search-job.csproj index 94657fc..c0bd6ca 100644 --- a/Jobs/cv-search-job/cv-search-job.csproj +++ b/Jobs/cv-search-job/cv-search-job.csproj @@ -21,12 +21,12 @@ + - diff --git a/docker-compose/docker-compose.yml b/docker-compose/docker-compose.yml index e873de2..a371f4f 100644 --- a/docker-compose/docker-compose.yml +++ b/docker-compose/docker-compose.yml @@ -108,6 +108,13 @@ services: - ASPNETCORE_URLS=${ASPNETCORE_URLS:-http://+:8080} - APP_ENVIRONMENT_NAME=${APP_ENVIRONMENT_NAME:-myai.staging} + - Database__Host=${Database__Host:-sqlserver} + - Database__Port=${Database__Port:-1433} + - Database__Name=${Database__Name:-MyAiDb} + - Database__User=${Database__User:-sa} + - Database__Password=${Database__Password:-} + - Database__TrustServerCertificate=${Database__TrustServerCertificate:-true} + - InternalApi__ApiKey=${EmailApi__InternalApiKey:-} - InternalApi__RequireApiKey=true @@ -261,8 +268,6 @@ services: - EmailApi__BaseUrl=${EmailApi__BaseUrl:-http://email-api:8080} - EmailApi__InternalApiKey=${EmailApi__InternalApiKey:-} - - Contact__ToEmail=${Contact__ToEmail:-} - - FileStorage__Path=${FileStorage__Path:-Files} - JobSearch__Enabled=${JobSearch__Enabled:-true} From a1c145e861f13b42571cc509532f4c41c0113cf6 Mon Sep 17 00:00:00 2001 From: claude Date: Thu, 28 May 2026 08:48:39 +0300 Subject: [PATCH 5/6] feat(cv-matcher): add AiPrompts table; remove MyAiDbContext dependency cv-matcher-data: - Add AiPromptEntity (Key, Language, Value, Description, UpdatedAt) - Add AiPrompts DbSet to CvMatcherDbContext with composite PK - Migration AddAiPrompts: create cvMatcher.AiPrompts table and seed ai.cv-match.system-prompt (language "*") with the current prompt value cv-matcher-api: - Add IAiPromptsRepository / EfAiPromptsRepository under Data/Repositories/ - CvMatcherService: inject IAiPromptsRepository; replace _templates.Render(...) with async DB lookup + simple string replacement - Program.cs: register IAiPromptsRepository (scoped); remove MyAiDbContext, ITemplateService/DbTemplateService registrations and MyAiDbContext migration call - Remove myai-data ProjectReference Co-Authored-By: Claude Sonnet 4.6 --- .../Contracts/IAiPromptsRepository.cs | 6 + .../Repositories/EfAiPromptsRepository.cs | 24 ++++ Apis/cv-matcher-api/Program.cs | 19 +-- .../Services/CvMatcherService.cs | 14 +- Apis/cv-matcher-api/cv-matcher-api.csproj | 1 - Apis/cv-matcher-data/CvMatcherDbContext.cs | 12 ++ .../Entities/AiPromptEntity.cs | 10 ++ .../20260528110000_AddAiPrompts.Designer.cs | 130 ++++++++++++++++++ .../Migrations/20260528110000_AddAiPrompts.cs | 49 +++++++ .../CvMatcherDbContextModelSnapshot.cs | 31 +++++ 10 files changed, 270 insertions(+), 26 deletions(-) create mode 100644 Apis/cv-matcher-api/Data/Repositories/Contracts/IAiPromptsRepository.cs create mode 100644 Apis/cv-matcher-api/Data/Repositories/EfAiPromptsRepository.cs create mode 100644 Apis/cv-matcher-data/Entities/AiPromptEntity.cs create mode 100644 Apis/cv-matcher-data/Migrations/20260528110000_AddAiPrompts.Designer.cs create mode 100644 Apis/cv-matcher-data/Migrations/20260528110000_AddAiPrompts.cs diff --git a/Apis/cv-matcher-api/Data/Repositories/Contracts/IAiPromptsRepository.cs b/Apis/cv-matcher-api/Data/Repositories/Contracts/IAiPromptsRepository.cs new file mode 100644 index 0000000..1fe2c08 --- /dev/null +++ b/Apis/cv-matcher-api/Data/Repositories/Contracts/IAiPromptsRepository.cs @@ -0,0 +1,6 @@ +namespace CvMatcher.Data.Repositories.Contracts; + +public interface IAiPromptsRepository +{ + Task GetAsync(string key, string language, CancellationToken ct); +} diff --git a/Apis/cv-matcher-api/Data/Repositories/EfAiPromptsRepository.cs b/Apis/cv-matcher-api/Data/Repositories/EfAiPromptsRepository.cs new file mode 100644 index 0000000..5cfff67 --- /dev/null +++ b/Apis/cv-matcher-api/Data/Repositories/EfAiPromptsRepository.cs @@ -0,0 +1,24 @@ +using CvMatcher.Data; +using CvMatcher.Data.Repositories.Contracts; +using Microsoft.EntityFrameworkCore; + +namespace CvMatcher.Data.Repositories; + +public sealed class EfAiPromptsRepository : IAiPromptsRepository +{ + private readonly CvMatcherDbContext _db; + + public EfAiPromptsRepository(CvMatcherDbContext db) + { + _db = db; + } + + public async Task GetAsync(string key, string language, CancellationToken ct) + { + return await _db.AiPrompts + .AsNoTracking() + .Where(x => x.Key == key && x.Language == language) + .Select(x => x.Value) + .FirstOrDefaultAsync(ct); + } +} diff --git a/Apis/cv-matcher-api/Program.cs b/Apis/cv-matcher-api/Program.cs index 0913aec..f247251 100644 --- a/Apis/cv-matcher-api/Program.cs +++ b/Apis/cv-matcher-api/Program.cs @@ -10,8 +10,6 @@ using Api.Services.Contracts; using CvMatcher.Models.Settings; using CvSearch.Data; using Microsoft.EntityFrameworkCore; -using MyAi.Data; -using MyAi.Data.Services; using Refit; using Serilog; using Common.Settings; @@ -76,18 +74,8 @@ try }); }); - builder.Services.AddDbContext(options => - { - var connectionString = builder.Services.GetConfiguredDbConnectionString(builder.Configuration); - options.UseSqlServer(connectionString, sql => - { - sql.MigrationsAssembly("myai-data"); - sql.MigrationsHistoryTable(MyAiDbContext.MigrationTableName, MyAiDbContext.SchemaName); - }); - }); - builder.Services.AddSingleton(); - builder.Services.AddScoped(); + builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); @@ -122,11 +110,6 @@ try var db = scope.ServiceProvider.GetRequiredService(); db.Database.Migrate(); } - using (var scope = app.Services.CreateScope()) - { - var db = scope.ServiceProvider.GetRequiredService(); - db.Database.Migrate(); - } Log.Information("{Service} startup complete", ServiceName); app.Run(); diff --git a/Apis/cv-matcher-api/Services/CvMatcherService.cs b/Apis/cv-matcher-api/Services/CvMatcherService.cs index 5f699fd..023de6d 100644 --- a/Apis/cv-matcher-api/Services/CvMatcherService.cs +++ b/Apis/cv-matcher-api/Services/CvMatcherService.cs @@ -7,7 +7,6 @@ using CvMatcher.Models.Responses; using CvMatcher.Models.Settings; using Api.Services.Contracts; using Microsoft.Extensions.Options; -using MyAi.Data.Services; namespace Api.Services; @@ -17,23 +16,23 @@ public sealed class CvMatcherService : ICvMatcherService 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, - IOptions options, - ITemplateService templates) + IAiPromptsRepository aiPrompts, + IOptions options) { _rag = rag; _jobTextExtractor = jobTextExtractor; _ai = ai; _repository = repository; + _aiPrompts = aiPrompts; _settings = options.Value; - _templates = templates; } public async Task UploadCvAsync(IFormFile file, CancellationToken ct) @@ -115,8 +114,9 @@ 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 systemPrompt = _templates.Render("ai.cv-match.system-prompt", "*", - ("languageName", languageName)); + 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 userPrompt = $""" CV: diff --git a/Apis/cv-matcher-api/cv-matcher-api.csproj b/Apis/cv-matcher-api/cv-matcher-api.csproj index 561c0ea..f56e350 100644 --- a/Apis/cv-matcher-api/cv-matcher-api.csproj +++ b/Apis/cv-matcher-api/cv-matcher-api.csproj @@ -83,6 +83,5 @@ - diff --git a/Apis/cv-matcher-data/CvMatcherDbContext.cs b/Apis/cv-matcher-data/CvMatcherDbContext.cs index 52e12ce..b6685f0 100644 --- a/Apis/cv-matcher-data/CvMatcherDbContext.cs +++ b/Apis/cv-matcher-data/CvMatcherDbContext.cs @@ -14,6 +14,7 @@ public sealed class CvMatcherDbContext : DbContext public DbSet CvMatchResults => Set(); public DbSet CvMatcherChatCache => Set(); + public DbSet AiPrompts => Set(); protected override void OnModelCreating(ModelBuilder modelBuilder) { @@ -41,5 +42,16 @@ public sealed class CvMatcherDbContext : DbContext entity.Property(x => x.ResponseText).IsRequired(); entity.Property(x => x.CreatedAt).HasDefaultValueSql("SYSUTCDATETIME()"); }); + + modelBuilder.Entity(entity => + { + entity.ToTable("AiPrompts"); + entity.HasKey(x => new { x.Key, x.Language }); + entity.Property(x => x.Key).HasMaxLength(128); + entity.Property(x => x.Language).HasMaxLength(8); + entity.Property(x => x.Value).IsRequired(); + entity.Property(x => x.Description).HasMaxLength(500).HasDefaultValue(string.Empty); + entity.Property(x => x.UpdatedAt).HasDefaultValueSql("SYSUTCDATETIME()"); + }); } } diff --git a/Apis/cv-matcher-data/Entities/AiPromptEntity.cs b/Apis/cv-matcher-data/Entities/AiPromptEntity.cs new file mode 100644 index 0000000..47bd670 --- /dev/null +++ b/Apis/cv-matcher-data/Entities/AiPromptEntity.cs @@ -0,0 +1,10 @@ +namespace CvMatcher.Data.Entities; + +public sealed class AiPromptEntity +{ + public string Key { get; set; } = string.Empty; + public string Language { get; set; } = string.Empty; + public string Value { get; set; } = string.Empty; + public string Description { get; set; } = string.Empty; + public DateTime UpdatedAt { get; set; } +} diff --git a/Apis/cv-matcher-data/Migrations/20260528110000_AddAiPrompts.Designer.cs b/Apis/cv-matcher-data/Migrations/20260528110000_AddAiPrompts.Designer.cs new file mode 100644 index 0000000..2b6bf0b --- /dev/null +++ b/Apis/cv-matcher-data/Migrations/20260528110000_AddAiPrompts.Designer.cs @@ -0,0 +1,130 @@ +// +using System; +using CvMatcher.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace CvMatcher.Data.Migrations +{ + [DbContext(typeof(CvMatcherDbContext))] + [Migration("20260528110000_AddAiPrompts")] + partial class AddAiPrompts + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("cvMatcher") + .HasAnnotation("ProductVersion", "10.0.7") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("CvMatcher.Data.Entities.AiPromptEntity", b => + { + b.Property("Key") + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("Language") + .HasMaxLength(8) + .HasColumnType("nvarchar(8)"); + + b.Property("Description") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)") + .HasDefaultValue(""); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("datetime2") + .HasDefaultValueSql("SYSUTCDATETIME()"); + + b.Property("Value") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.HasKey("Key", "Language"); + + b.ToTable("AiPrompts", "cvMatcher"); + }); + + modelBuilder.Entity("CvMatcher.Data.Entities.CvMatchResultEntity", b => + { + b.Property("Id") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("datetime2") + .HasDefaultValueSql("SYSUTCDATETIME()"); + + b.Property("CvDocumentId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("JobDocumentId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("Language") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("ResultJson") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Score") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("CvDocumentId", "JobDocumentId") + .IsUnique(); + + b.ToTable("Results", "cvMatcher"); + }); + + modelBuilder.Entity("CvMatcher.Data.Entities.CvMatcherChatCacheEntity", b => + { + b.Property("CacheKey") + .HasMaxLength(64) + .HasColumnType("nvarchar(64)"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("datetime2") + .HasDefaultValueSql("SYSUTCDATETIME()"); + + b.Property("Model") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("nvarchar(120)"); + + b.Property("ResponseText") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.Property("Temperature") + .HasColumnType("decimal(4,2)"); + + b.HasKey("CacheKey"); + + b.ToTable("ChatCache", "cvMatcher"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Apis/cv-matcher-data/Migrations/20260528110000_AddAiPrompts.cs b/Apis/cv-matcher-data/Migrations/20260528110000_AddAiPrompts.cs new file mode 100644 index 0000000..754ee6b --- /dev/null +++ b/Apis/cv-matcher-data/Migrations/20260528110000_AddAiPrompts.cs @@ -0,0 +1,49 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace CvMatcher.Data.Migrations +{ + /// + public partial class AddAiPrompts : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "AiPrompts", + schema: "cvMatcher", + columns: table => new + { + Key = table.Column(type: "nvarchar(128)", maxLength: 128, nullable: false), + Language = table.Column(type: "nvarchar(8)", maxLength: 8, nullable: false), + Value = table.Column(type: "nvarchar(max)", nullable: false), + Description = table.Column(type: "nvarchar(500)", maxLength: 500, nullable: false, defaultValue: ""), + UpdatedAt = table.Column(type: "datetime2", nullable: false, defaultValueSql: "SYSUTCDATETIME()") + }, + constraints: table => + { + table.PrimaryKey("PK_AiPrompts", x => new { x.Key, x.Language }); + }); + + migrationBuilder.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." + }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable(name: "AiPrompts", schema: "cvMatcher"); + } + } +} diff --git a/Apis/cv-matcher-data/Migrations/CvMatcherDbContextModelSnapshot.cs b/Apis/cv-matcher-data/Migrations/CvMatcherDbContextModelSnapshot.cs index 8e7ffff..33937c3 100644 --- a/Apis/cv-matcher-data/Migrations/CvMatcherDbContextModelSnapshot.cs +++ b/Apis/cv-matcher-data/Migrations/CvMatcherDbContextModelSnapshot.cs @@ -23,6 +23,37 @@ namespace CvMatcher.Data.Migrations SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + modelBuilder.Entity("CvMatcher.Data.Entities.AiPromptEntity", b => + { + b.Property("Key") + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("Language") + .HasMaxLength(8) + .HasColumnType("nvarchar(8)"); + + b.Property("Description") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)") + .HasDefaultValue(""); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("datetime2") + .HasDefaultValueSql("SYSUTCDATETIME()"); + + b.Property("Value") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.HasKey("Key", "Language"); + + b.ToTable("AiPrompts", "cvMatcher"); + }); + modelBuilder.Entity("CvMatcher.Data.Entities.CvMatchResultEntity", b => { b.Property("Id") From de7a3a3a2df9b28061270f0de76d45be3a5946fa Mon Sep 17 00:00:00 2001 From: claude Date: Thu, 28 May 2026 08:50:19 +0300 Subject: [PATCH 6/6] feat(myai-data): cleanup migration removes email.* and ai.* templates; update CLAUDE.md - Add DeleteMigratedTemplates migration: removes all email.* and ai.* rows from myAi.Templates (now owned by emailApi.EmailTemplates and cvMatcher.AiPrompts respectively) - CLAUDE.md: add email-api-data to solution layout; add emailApi schema to database schemas table; add email-api-data EF CLI migration command; note cv-matcher-api no longer runs MyAi migrations Co-Authored-By: Claude Sonnet 4.6 --- ...120000_DeleteMigratedTemplates.Designer.cs | 62 +++++++++++++++++++ .../20260528120000_DeleteMigratedTemplates.cs | 24 +++++++ CLAUDE.md | 16 ++++- 3 files changed, 99 insertions(+), 3 deletions(-) create mode 100644 Apis/myai-data/Migrations/20260528120000_DeleteMigratedTemplates.Designer.cs create mode 100644 Apis/myai-data/Migrations/20260528120000_DeleteMigratedTemplates.cs diff --git a/Apis/myai-data/Migrations/20260528120000_DeleteMigratedTemplates.Designer.cs b/Apis/myai-data/Migrations/20260528120000_DeleteMigratedTemplates.Designer.cs new file mode 100644 index 0000000..d41e5cf --- /dev/null +++ b/Apis/myai-data/Migrations/20260528120000_DeleteMigratedTemplates.Designer.cs @@ -0,0 +1,62 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using MyAi.Data; + +#nullable disable + +namespace MyAi.Data.Migrations +{ + [DbContext(typeof(MyAiDbContext))] + [Migration("20260528120000_DeleteMigratedTemplates")] + partial class DeleteMigratedTemplates + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("myAi") + .HasAnnotation("ProductVersion", "10.0.7") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("MyAi.Data.Entities.TemplateEntity", b => + { + b.Property("Key") + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("Language") + .HasMaxLength(8) + .HasColumnType("nvarchar(8)"); + + b.Property("Description") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)") + .HasDefaultValue(""); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("datetime2") + .HasDefaultValueSql("SYSUTCDATETIME()"); + + b.Property("Value") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.HasKey("Key", "Language"); + + b.ToTable("Templates", "myAi"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Apis/myai-data/Migrations/20260528120000_DeleteMigratedTemplates.cs b/Apis/myai-data/Migrations/20260528120000_DeleteMigratedTemplates.cs new file mode 100644 index 0000000..426c592 --- /dev/null +++ b/Apis/myai-data/Migrations/20260528120000_DeleteMigratedTemplates.cs @@ -0,0 +1,24 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace MyAi.Data.Migrations +{ + /// + public partial class DeleteMigratedTemplates : Migration + { + /// + 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.%'"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + // Rows were migrated to emailApi.EmailTemplates and cvMatcher.AiPrompts. + // Re-inserting them here is intentionally omitted. + } + } +} diff --git a/CLAUDE.md b/CLAUDE.md index 6443417..948be91 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -65,16 +65,17 @@ Apis/ api-models/ DTOs and settings for api only. email-api/ Internal SMTP email relay (no public port). All email sending goes here. email-api-models/ Refit client + SendEmailRequest + EmailApiSettings (shared by api and cv-search-job). - cv-matcher-api/ Internal CV match engine (port 8082). Runs CvMatcher + CvSearch + MyAi DB migrations. + cv-matcher-api/ Internal CV match engine (port 8082). Runs CvMatcher + CvSearch DB migrations. cv-matcher-api-models/ DTOs shared between api and cv-matcher-api (incl. JobSearchSettings). rag-api/ Internal RAG/vector-search service (port 8081). rag-api-models/ DTOs shared with rag-api. common/ Cross-service infrastructure primitives (DatabaseSettings, InternalApiSettings, etc.). shared-data/ Abstract BaseEntity base class. No DbContext. - cv-matcher-data/ CvMatcherDbContext + entities + migrations (schema: cvMatcher). + cv-matcher-data/ CvMatcherDbContext + entities + migrations (schema: cvMatcher). Owns AiPrompts table. cv-search-data/ CvSearchDbContext + entities + migrations (schema: cvSearch). + email-api-data/ EmailApiDbContext + entities + migrations (schema: emailApi). Owns EmailTemplates table. rag-data/ RagDbContext + entities + migrations (schema: rag). - myai-data/ MyAiDbContext + entities + migrations (schema: myAi). + myai-data/ MyAiDbContext + entities + migrations (schema: myAi). Keeps only html.* templates. Helpers/ startup-helpers/ Shared Program.cs bootstrap: Serilog, Swagger, .env loading, Azure Key Vault, middleware. common-helpers/ Utility helpers. @@ -110,12 +111,15 @@ Config lives in `docker-compose/.env`. All env vars use `${VAR:-default}` fallba | Schema | Owner DbContext | Migrations project | Startup project | |-------------|----------------------|-----------------------|-----------------------| | `cvMatcher` | `CvMatcherDbContext` | `cv-matcher-data` | `cv-matcher-api` | +| `emailApi` | `EmailApiDbContext` | `email-api-data` | `email-api` | | `rag` | `RagDbContext` | `rag-data` | `rag-api` | | `cvSearch` | `CvSearchDbContext` | `cv-search-data` | `cv-matcher-api` | | `myAi` | `MyAiDbContext` | `myai-data` | `api` | Both `cv-matcher-api` and `cv-search-job` register `CvSearchDbContext` and call `db.Database.Migrate()` on startup (idempotent — safe for both to run). +`api` and `cv-search-job` also register `EmailApiDbContext` (read-only — `email-api` is the sole migration owner). They use it to load email templates via `IEmailTemplateService` (10-min cache, singleton). + ## EF Core migrations ```powershell @@ -125,6 +129,12 @@ dotnet ef migrations add ` --project Apis/cv-matcher-data ` --startup-project Apis/cv-matcher-api +# email-api-data (schema: emailApi) +dotnet ef migrations add ` + --context EmailApiDbContext ` + --project Apis/email-api-data ` + --startup-project Apis/email-api + # rag-data (schema: rag) dotnet ef migrations add ` --context RagDbContext `