Split templates table into emailApi, cvMatcher, and myAi schemas #25
@@ -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>();
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
+69
@@ -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 →</a><br>" +
|
||||
"<small style=\"color:#6c757d\">Link valid for {{expiryDays}} days.</small>" +
|
||||
"</p>" +
|
||||
"</div>",
|
||||
"Job search CTA appended to match result email",
|
||||
op);
|
||||
|
||||
Row("email.match.job-search-footer", "ro",
|
||||
"<div style=\"background:#f0f4f8;border-left:4px solid #2c5282;padding:16px;margin-top:24px;border-radius:4px\">" +
|
||||
"<p style=\"margin:0;color:#495057\">" +
|
||||
"Vrei să găsești joburi potrivite automat? " +
|
||||
"<a href=\"{{jobSearchLink}}\" style=\"color:#2c5282;font-weight:600\">Pornește o căutare de joburi →</a><br>" +
|
||||
"<small style=\"color:#6c757d\">Link valabil {{expiryDays}} zile.</small>" +
|
||||
"</p>" +
|
||||
"</div>",
|
||||
"CTA cautare joburi adaugat la emailul de potrivire CV",
|
||||
op);
|
||||
|
||||
// ── Job search results email ──
|
||||
Row("email.search-results.subject", "en",
|
||||
"MyAi.ro: {{count}} jobs matching your CV",
|
||||
"Subject for job search results email",
|
||||
op);
|
||||
|
||||
Row("email.search-results.subject", "ro",
|
||||
"MyAi.ro: {{count}} joburi potrivite CV-ului tau",
|
||||
"Subiect email rezultate cautare joburi",
|
||||
op);
|
||||
|
||||
Row("email.search-results.body", "en",
|
||||
"<h2 style=\"margin:0 0 16px;color:#2c5282\">Job Search Results</h2>" +
|
||||
"<p style=\"color:#495057\">Found <strong>{{count}}</strong> matching job(s):</p>" +
|
||||
"{{items}}",
|
||||
"Body preamble for job search results email",
|
||||
op);
|
||||
|
||||
Row("email.search-results.body", "ro",
|
||||
"<h2 style=\"margin:0 0 16px;color:#2c5282\">Rezultate Căutare Joburi</h2>" +
|
||||
"<p style=\"color:#495057\">Am găsit <strong>{{count}}</strong> job(uri) potrivite:</p>" +
|
||||
"{{items}}",
|
||||
"Corpul emailului de rezultate cautare joburi",
|
||||
op);
|
||||
|
||||
Row("email.search-results.empty", "en",
|
||||
"<div style=\"text-align:center;padding:32px;color:#6c757d\">" +
|
||||
"<p style=\"font-size:18px;margin:0 0 8px;color:#495057\">No matching jobs found</p>" +
|
||||
"<p style=\"margin:0\">Your job search completed but no matching jobs were found. Try again later or adjust your CV.</p>" +
|
||||
"</div>",
|
||||
"No results message for job search results email",
|
||||
op);
|
||||
|
||||
Row("email.search-results.empty", "ro",
|
||||
"<div style=\"text-align:center;padding:32px;color:#6c757d\">" +
|
||||
"<p style=\"font-size:18px;margin:0 0 8px;color:#495057\">Niciun job potrivit găsit</p>" +
|
||||
"<p style=\"margin:0\">Căutarea s-a finalizat dar nu au fost găsite joburi potrivite. Încearcă mai târziu sau ajustează CV-ul.</p>" +
|
||||
"</div>",
|
||||
"Mesaj fara rezultate pentru emailul de cautare joburi",
|
||||
op);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(name: "EmailTemplates", schema: "emailApi");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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>
|
||||
|
||||
+62
@@ -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.
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 `
|
||||
|
||||
@@ -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()));
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user