Split templates table into emailApi, cvMatcher, and myAi schemas #25

Merged
gelu merged 6 commits from feature/split-templates into main 2026-05-28 05:57:16 +00:00
34 changed files with 992 additions and 97 deletions
+17
View File
@@ -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<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<Microsoft.AspNetCore.StaticFiles.IContentTypeProvider, Microsoft.AspNetCore.StaticFiles.FileExtensionContentTypeProvider>();
+12 -10
View File
@@ -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<EmailApiEmailSender> _log;
public EmailApiEmailSender(
@@ -23,14 +23,14 @@ public sealed class EmailApiEmailSender : IEmailSender
IOptions<ContactSettings> contact,
IOptions<SubscribeSettings> subscribe,
IOptions<FileStorageSettings> fileStorage,
ITemplateService templates,
IEmailTemplateService emailTemplates,
ILogger<EmailApiEmailSender> 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<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 (!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 => $"<li>{r}</li>")) + "</ul>"
: "<p style=\"color:#6c757d\">—</p>";
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"));
}
+1
View File
@@ -36,6 +36,7 @@
<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" />
@@ -0,0 +1,6 @@
namespace CvMatcher.Data.Repositories.Contracts;
public interface IAiPromptsRepository
{
Task<string?> GetAsync(string key, string language, CancellationToken ct);
}
@@ -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<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 -18
View File
@@ -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<MyAiDbContext>(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<ITemplateService, DbTemplateService>();
builder.Services.AddScoped<IMatcherRepository, EfMatcherRepository>();
builder.Services.AddScoped<IAiPromptsRepository, EfAiPromptsRepository>();
builder.Services.AddScoped<ICvMatcherService, CvMatcherService>();
builder.Services.AddScoped<IJobTokenService, JobTokenService>();
@@ -122,11 +110,6 @@ 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();
@@ -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<MatcherSettings> options,
ITemplateService templates)
IAiPromptsRepository aiPrompts,
IOptions<MatcherSettings> options)
{
_rag = rag;
_jobTextExtractor = jobTextExtractor;
_ai = ai;
_repository = repository;
_aiPrompts = aiPrompts;
_settings = options.Value;
_templates = templates;
}
public async Task<CvUploadResponse> 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:
@@ -83,6 +83,5 @@
<ProjectReference Include="..\cv-matcher-data\cv-matcher-data.csproj" />
<ProjectReference Include="..\common\common.csproj" />
<ProjectReference Include="..\..\Helpers\startup-helpers\startup-helpers.csproj" />
<ProjectReference Include="..\myai-data\myai-data.csproj" />
</ItemGroup>
</Project>
@@ -14,6 +14,7 @@ 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)
{
@@ -41,5 +42,16 @@ 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()");
});
}
}
@@ -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; }
}
@@ -0,0 +1,130 @@
// <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
}
}
}
@@ -0,0 +1,49 @@
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");
}
}
}
@@ -23,6 +23,37 @@ 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 =>
{
b.Property<string>("Id")
+31
View File
@@ -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<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);
});
}
}
@@ -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;
}
@@ -0,0 +1,69 @@
// <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
}
}
}
@@ -0,0 +1,192 @@
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 &#8594;</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 &#8594;</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");
}
}
}
@@ -0,0 +1,66 @@
// <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
}
}
}
@@ -0,0 +1,8 @@
using EmailApi.Data.Entities;
namespace EmailApi.Data.Repositories.Contracts;
public interface IEmailTemplateRepository
{
Task<IReadOnlyList<EmailTemplateEntity>> GetAllAsync(CancellationToken ct);
}
@@ -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<IReadOnlyList<EmailTemplateEntity>> GetAllAsync(CancellationToken ct)
=> await _db.EmailTemplates.AsNoTracking().ToListAsync(ct);
}
@@ -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<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;
}
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<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.");
}
}
private static string CacheKey(string key, string language) => $"{key}::{language}";
}
@@ -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);
}
+16
View File
@@ -0,0 +1,16 @@
<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>
+25
View File
@@ -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<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();
@@ -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<EmailApiDbContext>();
db.Database.Migrate();
}
Log.Information("{Service} startup complete. Listening for requests...", ServiceName);
app.Run();
}
+8 -29
View File
@@ -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<SmtpEmailDispatcher> _log;
private readonly string _environmentName;
private static readonly string HtmlShellStart = """
<!DOCTYPE html>
<html>
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"></head>
<body style="margin:0;padding:0;background:#f4f4f4;font-family:Arial,Helvetica,sans-serif">
<table width="100%" cellpadding="0" cellspacing="0" style="padding:20px 0">
<tr><td align="center">
<table width="600" cellpadding="0" cellspacing="0"
style="background:#ffffff;border-radius:8px;max-width:600px">
<tr><td style="background:#2c5282;padding:24px 32px;border-radius:8px 8px 0 0">
<h1 style="margin:0;color:#ffffff;font-size:22px;font-weight:600">myAi</h1>
</td></tr>
<tr><td style="padding:32px">
""";
private static readonly string HtmlShellEnd = """
</td></tr>
<tr><td style="background:#f8f9fa;padding:16px 32px;text-align:center;
color:#6c757d;font-size:12px;border-radius:0 0 8px 8px">
Automated message from myAi.
</td></tr>
</table>
</td></tr>
</table>
</body>
</html>
""";
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";
}
@@ -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))
+1
View File
@@ -22,6 +22,7 @@
<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>
@@ -0,0 +1,62 @@
// <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
}
}
}
@@ -0,0 +1,24 @@
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.
}
}
}
+13 -3
View File
@@ -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 <MigrationName> `
--project Apis/cv-matcher-data `
--startup-project Apis/cv-matcher-api
# email-api-data (schema: emailApi)
dotnet ef migrations add <MigrationName> `
--context EmailApiDbContext `
--project Apis/email-api-data `
--startup-project Apis/email-api
# rag-data (schema: rag)
dotnet ef migrations add <MigrationName> `
--context RagDbContext `
+10 -11
View File
@@ -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<MyAiDbContext>(options =>
builder.Services.AddDbContext<EmailApiDbContext>(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<ITemplateService, DbTemplateService>();
builder.Services.AddScoped<IEmailTemplateRepository, EfEmailTemplateRepository>();
builder.Services.AddSingleton<IEmailTemplateService, EmailTemplateService>();
builder.Services.AddRefitClient<IEmailApiClient>()
.ConfigureHttpClient((sp, client) =>
@@ -98,11 +102,6 @@ try
var db = scope.ServiceProvider.GetRequiredService<CvSearchDbContext>();
db.Database.Migrate();
}
using (var scope = host.Services.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<MyAiDbContext>();
db.Database.Migrate();
}
Log.Information("{Service} startup complete. Background scheduler is running.", ServiceName);
await host.RunAsync();
@@ -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<CvSearchEmailSender> _logger;
public CvSearchEmailSender(
IEmailApiClient emailApi,
ITemplateService templates,
IConfiguration config,
IEmailTemplateService emailTemplates,
ILogger<CvSearchEmailSender> 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<string>();
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<JobSearchResultEntity> 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()));
}
+1 -1
View File
@@ -21,12 +21,12 @@
<ItemGroup>
<ProjectReference Include="..\..\Apis\cv-matcher-api-models\cv-matcher-api-models.csproj" />
<ProjectReference Include="..\..\Apis\email-api-data\email-api-data.csproj" />
<ProjectReference Include="..\..\Apis\email-api-models\email-api-models.csproj" />
<ProjectReference Include="..\..\Apis\cv-search-data\cv-search-data.csproj" />
<ProjectReference Include="..\..\Apis\common\common.csproj" />
<ProjectReference Include="..\..\Helpers\startup-helpers\startup-helpers.csproj" />
<ProjectReference Include="..\job-scheduler\job-scheduler.csproj" />
<ProjectReference Include="..\..\Apis\myai-data\myai-data.csproj" />
</ItemGroup>
</Project>
+7 -2
View File
@@ -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}
+15
View File
@@ -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}