From 19e73aca170693cd78edbc875df7fa08522fcf05 Mon Sep 17 00:00:00 2001 From: claude Date: Thu, 28 May 2026 08:39:15 +0300 Subject: [PATCH] feat: add email-api-data project with EmailTemplates repository and service New data project owning the emailApi schema: - EmailTemplateEntity with Key, Language, Value, Description, UpdatedAt, OperatorCopy - EmailApiDbContext (schema: emailApi, custom migration table _EmailApiMigrations) - IEmailTemplateRepository / EfEmailTemplateRepository (scoped) - IEmailTemplateService / EmailTemplateService (singleton, 10-min cache) - GetOperatorCopy falls back to first non-empty OperatorCopy across all rows - Initial migration CreateEmailTemplates: creates table + seeds all email.* templates (match + search-results in en/ro) and html-shell fragments with OperatorCopy = "contact@myai.ro" for addressable rows Co-Authored-By: Claude Sonnet 4.6 --- Apis/email-api-data/EmailApiDbContext.cs | 31 +++ .../Entities/EmailTemplateEntity.cs | 12 ++ ...528100000_CreateEmailTemplates.Designer.cs | 69 +++++++ .../20260528100000_CreateEmailTemplates.cs | 192 ++++++++++++++++++ .../EmailApiDbContextModelSnapshot.cs | 66 ++++++ .../Contracts/IEmailTemplateRepository.cs | 8 + .../Repositories/EfEmailTemplateRepository.cs | 18 ++ .../Services/EmailTemplateService.cs | 95 +++++++++ .../Services/IEmailTemplateService.cs | 8 + Apis/email-api-data/email-api-data.csproj | 16 ++ myAi.sln | 15 ++ 11 files changed, 530 insertions(+) create mode 100644 Apis/email-api-data/EmailApiDbContext.cs create mode 100644 Apis/email-api-data/Entities/EmailTemplateEntity.cs create mode 100644 Apis/email-api-data/Migrations/20260528100000_CreateEmailTemplates.Designer.cs create mode 100644 Apis/email-api-data/Migrations/20260528100000_CreateEmailTemplates.cs create mode 100644 Apis/email-api-data/Migrations/EmailApiDbContextModelSnapshot.cs create mode 100644 Apis/email-api-data/Repositories/Contracts/IEmailTemplateRepository.cs create mode 100644 Apis/email-api-data/Repositories/EfEmailTemplateRepository.cs create mode 100644 Apis/email-api-data/Services/EmailTemplateService.cs create mode 100644 Apis/email-api-data/Services/IEmailTemplateService.cs create mode 100644 Apis/email-api-data/email-api-data.csproj diff --git a/Apis/email-api-data/EmailApiDbContext.cs b/Apis/email-api-data/EmailApiDbContext.cs new file mode 100644 index 0000000..e0763a9 --- /dev/null +++ b/Apis/email-api-data/EmailApiDbContext.cs @@ -0,0 +1,31 @@ +using EmailApi.Data.Entities; +using Microsoft.EntityFrameworkCore; + +namespace EmailApi.Data; + +public sealed class EmailApiDbContext : DbContext +{ + public const string SchemaName = "emailApi"; + public const string MigrationTableName = "_EmailApiMigrations"; + + public EmailApiDbContext(DbContextOptions options) : base(options) { } + + public DbSet EmailTemplates => Set(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.HasDefaultSchema(SchemaName); + + modelBuilder.Entity(entity => + { + entity.ToTable("EmailTemplates"); + entity.HasKey(x => new { x.Key, x.Language }); + entity.Property(x => x.Key).HasMaxLength(128); + entity.Property(x => x.Language).HasMaxLength(8); + entity.Property(x => x.Value).IsRequired(); + entity.Property(x => x.Description).HasMaxLength(500).HasDefaultValue(string.Empty); + entity.Property(x => x.UpdatedAt).HasDefaultValueSql("SYSUTCDATETIME()"); + entity.Property(x => x.OperatorCopy).HasMaxLength(256).HasDefaultValue(string.Empty); + }); + } +} diff --git a/Apis/email-api-data/Entities/EmailTemplateEntity.cs b/Apis/email-api-data/Entities/EmailTemplateEntity.cs new file mode 100644 index 0000000..f26485c --- /dev/null +++ b/Apis/email-api-data/Entities/EmailTemplateEntity.cs @@ -0,0 +1,12 @@ +namespace EmailApi.Data.Entities; + +// composite PK (Key + Language) — BaseEntity not applicable +public sealed class EmailTemplateEntity +{ + public string Key { get; set; } = string.Empty; + public string Language { get; set; } = string.Empty; + public string Value { get; set; } = string.Empty; + public string Description { get; set; } = string.Empty; + public DateTime UpdatedAt { get; set; } + public string OperatorCopy { get; set; } = string.Empty; +} diff --git a/Apis/email-api-data/Migrations/20260528100000_CreateEmailTemplates.Designer.cs b/Apis/email-api-data/Migrations/20260528100000_CreateEmailTemplates.Designer.cs new file mode 100644 index 0000000..240240f --- /dev/null +++ b/Apis/email-api-data/Migrations/20260528100000_CreateEmailTemplates.Designer.cs @@ -0,0 +1,69 @@ +// +using System; +using EmailApi.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace EmailApi.Data.Migrations +{ + [DbContext(typeof(EmailApiDbContext))] + [Migration("20260528100000_CreateEmailTemplates")] + partial class CreateEmailTemplates + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("emailApi") + .HasAnnotation("ProductVersion", "10.0.7") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("EmailApi.Data.Entities.EmailTemplateEntity", b => + { + b.Property("Key") + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("Language") + .HasMaxLength(8) + .HasColumnType("nvarchar(8)"); + + b.Property("Description") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)") + .HasDefaultValue(""); + + b.Property("OperatorCopy") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)") + .HasDefaultValue(""); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("datetime2") + .HasDefaultValueSql("SYSUTCDATETIME()"); + + b.Property("Value") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.HasKey("Key", "Language"); + + b.ToTable("EmailTemplates", "emailApi"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Apis/email-api-data/Migrations/20260528100000_CreateEmailTemplates.cs b/Apis/email-api-data/Migrations/20260528100000_CreateEmailTemplates.cs new file mode 100644 index 0000000..c9a678a --- /dev/null +++ b/Apis/email-api-data/Migrations/20260528100000_CreateEmailTemplates.cs @@ -0,0 +1,192 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace EmailApi.Data.Migrations +{ + /// + public partial class CreateEmailTemplates : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.EnsureSchema(name: "emailApi"); + + migrationBuilder.CreateTable( + name: "EmailTemplates", + schema: "emailApi", + columns: table => new + { + Key = table.Column(type: "nvarchar(128)", maxLength: 128, nullable: false), + Language = table.Column(type: "nvarchar(8)", maxLength: 8, nullable: false), + Value = table.Column(type: "nvarchar(max)", nullable: false), + Description = table.Column(type: "nvarchar(500)", maxLength: 500, nullable: false, defaultValue: ""), + UpdatedAt = table.Column(type: "datetime2", nullable: false, defaultValueSql: "SYSUTCDATETIME()"), + OperatorCopy = table.Column(type: "nvarchar(256)", maxLength: 256, nullable: false, defaultValue: "") + }, + constraints: table => + { + table.PrimaryKey("PK_EmailTemplates", x => new { x.Key, x.Language }); + }); + + Seed(migrationBuilder); + } + + private static void Seed(MigrationBuilder m) + { + const string op = "contact@myai.ro"; + + void Row(string key, string lang, string value, string description = "", string operatorCopy = "") + => m.InsertData("EmailTemplates", + ["Key", "Language", "Value", "Description", "OperatorCopy"], + [key, lang, value, description, operatorCopy], + "emailApi"); + + // ── HTML shell (no operator copy — these are layout fragments, not addressable emails) ── + Row("email.html-shell.start", "*", + "\n\n\n\n \n \n
\n \n \n \n \n
\n

myAi

\n
", + "Opening HTML shell fragment — wrapped around every HtmlBody before sending"); + + Row("email.html-shell.end", "*", + "
\n Automated message from myAi.\n
\n
\n\n", + "Closing HTML shell fragment — appended after every HtmlBody before sending"); + + // ── CV match result email ── + Row("email.match.subject", "en", + "MyAi.ro CV Match: {{score}}% - {{jobLabel}}", + "Subject for the CV match result email", + op); + + Row("email.match.subject", "ro", + "MyAi.ro Potrivire CV: {{score}}% - {{jobLabel}}", + "Subiect email rezultat potrivire CV", + op); + + Row("email.match.body", "en", + "

CV Match Report

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

Summary

" + + "

{{summary}}

" + + "

Strengths

{{strengths}}" + + "

Gaps

{{gaps}}" + + "

Recommendations

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

Raport Potrivire CV

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

Rezumat

" + + "

{{summary}}

" + + "

Puncte forte

{{strengths}}" + + "

Lipsuri

{{gaps}}" + + "

Recomandări

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

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

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

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

" + + "
", + "CTA cautare joburi adaugat la emailul de potrivire CV", + op); + + // ── Job search results email ── + Row("email.search-results.subject", "en", + "MyAi.ro: {{count}} jobs matching your CV", + "Subject for job search results email", + op); + + Row("email.search-results.subject", "ro", + "MyAi.ro: {{count}} joburi potrivite CV-ului tau", + "Subiect email rezultate cautare joburi", + op); + + Row("email.search-results.body", "en", + "

Job Search Results

" + + "

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

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

Rezultate Căutare Joburi

" + + "

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

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

No matching jobs found

" + + "

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

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

Niciun job potrivit găsit

" + + "

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

" + + "
", + "Mesaj fara rezultate pentru emailul de cautare joburi", + op); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable(name: "EmailTemplates", schema: "emailApi"); + } + } +} diff --git a/Apis/email-api-data/Migrations/EmailApiDbContextModelSnapshot.cs b/Apis/email-api-data/Migrations/EmailApiDbContextModelSnapshot.cs new file mode 100644 index 0000000..1f1a1f4 --- /dev/null +++ b/Apis/email-api-data/Migrations/EmailApiDbContextModelSnapshot.cs @@ -0,0 +1,66 @@ +// +using System; +using EmailApi.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace EmailApi.Data.Migrations +{ + [DbContext(typeof(EmailApiDbContext))] + partial class EmailApiDbContextModelSnapshot : ModelSnapshot + { + protected override void BuildModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("emailApi") + .HasAnnotation("ProductVersion", "10.0.7") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("EmailApi.Data.Entities.EmailTemplateEntity", b => + { + b.Property("Key") + .HasMaxLength(128) + .HasColumnType("nvarchar(128)"); + + b.Property("Language") + .HasMaxLength(8) + .HasColumnType("nvarchar(8)"); + + b.Property("Description") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(500) + .HasColumnType("nvarchar(500)") + .HasDefaultValue(""); + + b.Property("OperatorCopy") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(256) + .HasColumnType("nvarchar(256)") + .HasDefaultValue(""); + + b.Property("UpdatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("datetime2") + .HasDefaultValueSql("SYSUTCDATETIME()"); + + b.Property("Value") + .IsRequired() + .HasColumnType("nvarchar(max)"); + + b.HasKey("Key", "Language"); + + b.ToTable("EmailTemplates", "emailApi"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Apis/email-api-data/Repositories/Contracts/IEmailTemplateRepository.cs b/Apis/email-api-data/Repositories/Contracts/IEmailTemplateRepository.cs new file mode 100644 index 0000000..a4e189b --- /dev/null +++ b/Apis/email-api-data/Repositories/Contracts/IEmailTemplateRepository.cs @@ -0,0 +1,8 @@ +using EmailApi.Data.Entities; + +namespace EmailApi.Data.Repositories.Contracts; + +public interface IEmailTemplateRepository +{ + Task> GetAllAsync(CancellationToken ct); +} diff --git a/Apis/email-api-data/Repositories/EfEmailTemplateRepository.cs b/Apis/email-api-data/Repositories/EfEmailTemplateRepository.cs new file mode 100644 index 0000000..25c6efc --- /dev/null +++ b/Apis/email-api-data/Repositories/EfEmailTemplateRepository.cs @@ -0,0 +1,18 @@ +using EmailApi.Data.Entities; +using EmailApi.Data.Repositories.Contracts; +using Microsoft.EntityFrameworkCore; + +namespace EmailApi.Data.Repositories; + +public sealed class EfEmailTemplateRepository : IEmailTemplateRepository +{ + private readonly EmailApiDbContext _db; + + public EfEmailTemplateRepository(EmailApiDbContext db) + { + _db = db; + } + + public async Task> GetAllAsync(CancellationToken ct) + => await _db.EmailTemplates.AsNoTracking().ToListAsync(ct); +} diff --git a/Apis/email-api-data/Services/EmailTemplateService.cs b/Apis/email-api-data/Services/EmailTemplateService.cs new file mode 100644 index 0000000..79cdd56 --- /dev/null +++ b/Apis/email-api-data/Services/EmailTemplateService.cs @@ -0,0 +1,95 @@ +using System.Collections.Concurrent; +using EmailApi.Data.Repositories.Contracts; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +namespace EmailApi.Data.Services; + +public sealed class EmailTemplateService : IEmailTemplateService +{ + private readonly IServiceScopeFactory _scopeFactory; + private readonly ILogger _logger; + private ConcurrentDictionary _valueCache = new(StringComparer.OrdinalIgnoreCase); + private ConcurrentDictionary _operatorCache = new(StringComparer.OrdinalIgnoreCase); + private DateTime _loadedAt = DateTime.MinValue; + private static readonly TimeSpan CacheTtl = TimeSpan.FromMinutes(10); + + public EmailTemplateService(IServiceScopeFactory scopeFactory, ILogger logger) + { + _scopeFactory = scopeFactory; + _logger = logger; + } + + public string Get(string key, string language = "en") + { + EnsureCacheLoaded(); + + if (_valueCache.TryGetValue(CacheKey(key, language), out var value)) + return value; + + if (!string.Equals(language, "en", StringComparison.OrdinalIgnoreCase) + && _valueCache.TryGetValue(CacheKey(key, "en"), out var fallback)) + return fallback; + + _logger.LogWarning("Email template not found: key={Key}, language={Language}", key, language); + return key; + } + + public string Render(string key, string language, params (string Key, string Value)[] placeholders) + { + var template = Get(key, language); + foreach (var (k, v) in placeholders) + template = template.Replace($"{{{{{k}}}}}", v, StringComparison.OrdinalIgnoreCase); + return template; + } + + public string? GetOperatorCopy(string key, string language) + { + EnsureCacheLoaded(); + + if (_operatorCache.TryGetValue(CacheKey(key, language), out var specific) + && !string.IsNullOrWhiteSpace(specific)) + return specific; + + // Fall back to first non-empty OperatorCopy in the cache + foreach (var val in _operatorCache.Values) + { + if (!string.IsNullOrWhiteSpace(val)) + return val; + } + + return null; + } + + private void EnsureCacheLoaded() + { + if (DateTime.UtcNow - _loadedAt < CacheTtl) return; + + try + { + using var scope = _scopeFactory.CreateScope(); + var repo = scope.ServiceProvider.GetRequiredService(); + var rows = repo.GetAllAsync(CancellationToken.None).GetAwaiter().GetResult(); + + var freshValues = new ConcurrentDictionary(StringComparer.OrdinalIgnoreCase); + var freshOperator = new ConcurrentDictionary(StringComparer.OrdinalIgnoreCase); + + foreach (var row in rows) + { + freshValues[CacheKey(row.Key, row.Language)] = row.Value; + freshOperator[CacheKey(row.Key, row.Language)] = row.OperatorCopy; + } + + _valueCache = freshValues; + _operatorCache = freshOperator; + _loadedAt = DateTime.UtcNow; + _logger.LogDebug("Email template cache refreshed. {Count} templates loaded.", rows.Count); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to refresh email template cache. Serving stale cache."); + } + } + + private static string CacheKey(string key, string language) => $"{key}::{language}"; +} diff --git a/Apis/email-api-data/Services/IEmailTemplateService.cs b/Apis/email-api-data/Services/IEmailTemplateService.cs new file mode 100644 index 0000000..835e9eb --- /dev/null +++ b/Apis/email-api-data/Services/IEmailTemplateService.cs @@ -0,0 +1,8 @@ +namespace EmailApi.Data.Services; + +public interface IEmailTemplateService +{ + string Get(string key, string language = "en"); + string Render(string key, string language, params (string Key, string Value)[] placeholders); + string? GetOperatorCopy(string key, string language); +} diff --git a/Apis/email-api-data/email-api-data.csproj b/Apis/email-api-data/email-api-data.csproj new file mode 100644 index 0000000..0596a74 --- /dev/null +++ b/Apis/email-api-data/email-api-data.csproj @@ -0,0 +1,16 @@ + + + net10.0 + email-api-data + EmailApi.Data + enable + enable + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + diff --git a/myAi.sln b/myAi.sln index a937763..62d9c91 100644 --- a/myAi.sln +++ b/myAi.sln @@ -61,6 +61,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "email-api-models", "Apis\em EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "email-api", "Apis\email-api\email-api.csproj", "{434119EA-2FFC-4433-9B8E-1E6D94006413}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "email-api-data", "Apis\email-api-data\email-api-data.csproj", "{C1D2E3F4-A5B6-4789-CDEF-012345678ABC}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -343,6 +345,18 @@ Global {434119EA-2FFC-4433-9B8E-1E6D94006413}.Release|x64.Build.0 = Release|Any CPU {434119EA-2FFC-4433-9B8E-1E6D94006413}.Release|x86.ActiveCfg = Release|Any CPU {434119EA-2FFC-4433-9B8E-1E6D94006413}.Release|x86.Build.0 = Release|Any CPU + {C1D2E3F4-A5B6-4789-CDEF-012345678ABC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {C1D2E3F4-A5B6-4789-CDEF-012345678ABC}.Debug|Any CPU.Build.0 = Debug|Any CPU + {C1D2E3F4-A5B6-4789-CDEF-012345678ABC}.Debug|x64.ActiveCfg = Debug|Any CPU + {C1D2E3F4-A5B6-4789-CDEF-012345678ABC}.Debug|x64.Build.0 = Debug|Any CPU + {C1D2E3F4-A5B6-4789-CDEF-012345678ABC}.Debug|x86.ActiveCfg = Debug|Any CPU + {C1D2E3F4-A5B6-4789-CDEF-012345678ABC}.Debug|x86.Build.0 = Debug|Any CPU + {C1D2E3F4-A5B6-4789-CDEF-012345678ABC}.Release|Any CPU.ActiveCfg = Release|Any CPU + {C1D2E3F4-A5B6-4789-CDEF-012345678ABC}.Release|Any CPU.Build.0 = Release|Any CPU + {C1D2E3F4-A5B6-4789-CDEF-012345678ABC}.Release|x64.ActiveCfg = Release|Any CPU + {C1D2E3F4-A5B6-4789-CDEF-012345678ABC}.Release|x64.Build.0 = Release|Any CPU + {C1D2E3F4-A5B6-4789-CDEF-012345678ABC}.Release|x86.ActiveCfg = Release|Any CPU + {C1D2E3F4-A5B6-4789-CDEF-012345678ABC}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -370,6 +384,7 @@ Global {069365DB-1916-4C38-A90D-5E909BD9EDD0} = {A9B8C7D6-E5F4-4321-ABCD-FEDCBA987654} {BE44B4EB-9AB9-4D81-A9BF-5CF2832BEEE5} = {A9B8C7D6-E5F4-4321-ABCD-FEDCBA987654} {434119EA-2FFC-4433-9B8E-1E6D94006413} = {0FE6558F-2157-47F2-A835-558416CE0E2B} + {C1D2E3F4-A5B6-4789-CDEF-012345678ABC} = {D4E5F6A7-B8C9-4012-3456-789ABCDEF012} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {6246A67B-299E-4E64-8DBE-1A66771E7C67}