using MongoDB.Bson; using MongoDB.Driver; using System.Security.Cryptography; using System.Text; using System.Text.Json; namespace QuestionsApi.Services; public static class ContentPromotionCommand { public const string Confirmation = "PROMOTE_TO_PRD"; public static async Task RunAsync(string[] args, TextWriter output) { try { var options = Parse(args); if (options.Apply && options.Confirmation != Confirmation) throw new ArgumentException($"--confirm {Confirmation} is required with --apply"); if (options.SourceDatabase.Equals(options.TargetDatabase, StringComparison.OrdinalIgnoreCase) && options.SourceConnection == options.TargetConnection) throw new ArgumentException("source and target databases must be different"); var source = new MongoClient(options.SourceConnection).GetDatabase(options.SourceDatabase); var target = new MongoClient(options.TargetConnection).GetDatabase(options.TargetDatabase); await source.RunCommandAsync(new BsonDocument("ping", 1)); await target.RunCommandAsync(new BsonDocument("ping", 1)); var questions = await source.GetCollection("Questions") .Find(Builders.Filter.Eq("IsPublished", true)).ToListAsync(); var mediaIds = CollectMediaIds(questions); var media = mediaIds.Count == 0 ? [] : await source.GetCollection("QuestionMediaAssets") .Find(Builders.Filter.In("_id", mediaIds)).ToListAsync(); if (media.Count != mediaIds.Count) throw new InvalidOperationException($"{mediaIds.Count - media.Count} referenced question media assets are missing"); var cases = await source.GetCollection("CcsCaseRevisions") .Find(Builders.Filter.Eq("IsCurrent", true) & Builders.Filter.Eq("IsPublished", true)).ToListAsync(); var orders = await source.GetCollection("CcsOrderCatalog") .Find(FilterDefinition.Empty).ToListAsync(); var summary = new PromotionSummary( questions.Count, media.Count, cases.Count, orders.Count, HashDocuments(questions.Concat(media).Concat(cases).Concat(orders))); await output.WriteLineAsync(JsonSerializer.Serialize(new { mode = options.Apply ? "apply" : "dry-run", source = options.SourceDatabase, target = options.TargetDatabase, summary.Questions, summary.MediaAssets, summary.CcsCases, summary.CcsOrders, summary.ContentHash })); if (!options.Apply) return 0; await UpsertMediaAsync(target, media); await UpsertByIdAsync(target, "CcsOrderCatalog", orders); await UpsertByFieldsAsync(target, "Questions", questions, ["ExternalId"]); await UpsertCasesAsync(target, cases); await target.GetCollection("ContentPromotions").InsertOneAsync(new BsonDocument { ["PromotionId"] = Guid.NewGuid().ToString("N"), ["AppliedUtc"] = DateTime.UtcNow, ["Operator"] = options.Operator, ["BuildRevision"] = options.BuildRevision, ["SourceDatabase"] = options.SourceDatabase, ["TargetDatabase"] = options.TargetDatabase, ["Questions"] = summary.Questions, ["MediaAssets"] = summary.MediaAssets, ["CcsCases"] = summary.CcsCases, ["CcsOrders"] = summary.CcsOrders, ["ContentHash"] = summary.ContentHash }); await output.WriteLineAsync("Content promotion completed."); return 0; } catch (Exception exception) { await output.WriteLineAsync($"Content promotion failed: {exception.Message}"); return 1; } } internal static HashSet CollectMediaIds(IEnumerable questions) { var ids = new HashSet(); foreach (var question in questions) { if (!question.TryGetValue("Media", out var raw) || !raw.IsBsonArray) continue; foreach (var value in raw.AsBsonArray) if (value.IsString && ObjectId.TryParse(value.AsString, out var id)) ids.Add(id); } return ids; } internal static string HashDocuments(IEnumerable documents) { using var hash = IncrementalHash.CreateHash(HashAlgorithmName.SHA256); foreach (var document in documents.OrderBy(DocumentIdentity, StringComparer.Ordinal)) hash.AppendData(Encoding.UTF8.GetBytes(document.ToJson())); return Convert.ToHexString(hash.GetHashAndReset()).ToLowerInvariant(); } private static string DocumentIdentity(BsonDocument document) => document.GetValue("ExternalId", document.GetValue("_id", BsonNull.Value)).ToString() ?? ""; private static async Task UpsertMediaAsync(IMongoDatabase target, IEnumerable documents) => await UpsertByIdAsync(target, "QuestionMediaAssets", documents); private static async Task UpsertByIdAsync(IMongoDatabase target, string collectionName, IEnumerable documents) { var collection = target.GetCollection(collectionName); foreach (var document in documents) await collection.ReplaceOneAsync(Builders.Filter.Eq("_id", document["_id"]), document, new ReplaceOptions { IsUpsert = true }); } private static async Task UpsertByFieldsAsync( IMongoDatabase target, string collectionName, IEnumerable documents, IReadOnlyList keys) { var collection = target.GetCollection(collectionName); foreach (var source in documents) { var filter = keys.Aggregate( Builders.Filter.Empty, (current, key) => current & Builders.Filter.Eq(key, source[key])); var values = new BsonDocument(source.Where(element => element.Name != "_id")); await collection.UpdateOneAsync(filter, new BsonDocument("$set", values), new UpdateOptions { IsUpsert = true }); } } private static async Task UpsertCasesAsync(IMongoDatabase target, IEnumerable cases) { var collection = target.GetCollection("CcsCaseRevisions"); foreach (var source in cases) { await collection.UpdateManyAsync( Builders.Filter.Eq("ExternalId", source["ExternalId"]), Builders.Update.Set("IsCurrent", false)); await UpsertByFieldsAsync(target, "CcsCaseRevisions", [source], ["ExternalId", "Version"]); } } private static PromotionOptions Parse(IReadOnlyList args) { string Required(string name, string environmentName) { var index = args.IndexOf($"--{name}"); var value = index >= 0 && index + 1 < args.Count ? args[index + 1] : Environment.GetEnvironmentVariable(environmentName); return string.IsNullOrWhiteSpace(value) ? throw new ArgumentException($"--{name} is required") : value; } string Optional(string name, string fallback) { var index = args.IndexOf($"--{name}"); return index >= 0 && index + 1 < args.Count ? args[index + 1] : fallback; } return new PromotionOptions( Required("source-connection", "SOURCE_MONGODB_CONNECTION_STRING"), Required("source-database", "SOURCE_MONGODB_DATABASE"), Required("target-connection", "TARGET_MONGODB_CONNECTION_STRING"), Required("target-database", "TARGET_MONGODB_DATABASE"), args.Contains("--apply"), Optional("confirm", ""), Optional("operator", "unknown"), Optional("build-revision", "unknown")); } private sealed record PromotionOptions( string SourceConnection, string SourceDatabase, string TargetConnection, string TargetDatabase, bool Apply, string Confirmation, string Operator, string BuildRevision); internal sealed record PromotionSummary(int Questions, int MediaAssets, int CcsCases, int CcsOrders, string ContentHash); } internal static class ArgumentListExtensions { public static int IndexOf(this IReadOnlyList values, string value) { for (var index = 0; index < values.Count; index++) if (values[index] == value) return index; return -1; } }