using MongoDB.Bson; using MongoDB.Driver; using System.Security.Cryptography; using System.Text; using System.Text.Json; namespace QuestionsApi.Services; public static class QuestionPromotionCommand { 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 questionFilter = Builders.Filter.Eq("IsPublished", true) & Builders.Filter.Eq("FlaggedForReviewUtc", BsonNull.Value); var questions = await source.GetCollection("Questions") .Find(questionFilter).ToListAsync(); var duplicateIds = questions .GroupBy(ExternalId, StringComparer.Ordinal) .Where(group => group.Count() > 1) .Select(group => group.Key) .ToList(); if (duplicateIds.Count > 0) throw new InvalidOperationException($"duplicate eligible ExternalIds: {string.Join(", ", duplicateIds)}"); 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 sourceIds = questions.Select(ExternalId).ToHashSet(StringComparer.Ordinal); var targetCollection = target.GetCollection("Questions"); var targetQuestions = sourceIds.Count == 0 ? [] : await targetCollection.Find( Builders.Filter.In("ExternalId", sourceIds)).ToListAsync(); var targetTotal = await targetCollection.CountDocumentsAsync(FilterDefinition.Empty); var comparison = ClassifyQuestions(questions, targetQuestions); var summary = new PromotionSummary( questions.Count, media.Count, comparison.Inserted, comparison.Updated, comparison.Unchanged, checked((int)Math.Max(0, targetTotal - targetQuestions.Count)), HashQuestions(questions)); await WriteSummaryAsync(output, options.Apply ? "apply" : "dry-run", options, summary); if (!options.Apply) return 0; await UpsertMediaAsync(target, media); await UpsertQuestionsAsync(target, questions); var verifiedQuestions = sourceIds.Count == 0 ? [] : await targetCollection.Find( Builders.Filter.In("ExternalId", sourceIds)).ToListAsync(); var verification = ClassifyQuestions(questions, verifiedQuestions); if (verification.Inserted != 0 || verification.Updated != 0) throw new InvalidOperationException( $"target verification failed: {verification.Inserted} missing and {verification.Updated} mismatched questions"); var verifiedMedia = mediaIds.Count == 0 ? 0 : checked((int)await target.GetCollection("QuestionMediaAssets") .CountDocumentsAsync(Builders.Filter.In("_id", mediaIds))); if (verifiedMedia != mediaIds.Count) throw new InvalidOperationException( $"target verification failed: {mediaIds.Count - verifiedMedia} media assets are missing"); 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, ["Scope"] = "questions", ["Eligibility"] = "published_unflagged", ["Questions"] = summary.EligibleQuestions, ["Inserted"] = summary.Inserted, ["Updated"] = summary.Updated, ["Unchanged"] = summary.Unchanged, ["MediaAssets"] = summary.MediaAssets, ["TargetExtras"] = summary.TargetExtras, ["ContentHash"] = summary.ContentHash }); await output.WriteLineAsync("Question promotion completed and verified."); return 0; } catch (Exception exception) { await output.WriteLineAsync($"Question promotion failed: {exception.Message}"); return 1; } } internal static bool IsEligible(BsonDocument question) => question.GetValue("IsPublished", false).ToBoolean() && (!question.TryGetValue("FlaggedForReviewUtc", out var flagged) || flagged.IsBsonNull); 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) continue; var reference = value.AsString.Trim(); var candidate = reference.TrimEnd('/').Split('/').LastOrDefault() ?? ""; if (ObjectId.TryParse(reference, out var direct)) ids.Add(direct); else if (ObjectId.TryParse(candidate, out var fromPath)) ids.Add(fromPath); else if (reference.Contains("/api/questions/media/", StringComparison.OrdinalIgnoreCase)) throw new InvalidOperationException( $"question {ExternalId(question)} has an invalid local media reference: {reference}"); } } return ids; } internal static QuestionComparison ClassifyQuestions( IEnumerable sourceQuestions, IEnumerable targetQuestions) { var target = targetQuestions.ToDictionary(ExternalId, StringComparer.Ordinal); var inserted = 0; var updated = 0; var unchanged = 0; foreach (var source in sourceQuestions) { var externalId = ExternalId(source); if (!target.TryGetValue(externalId, out var existing)) inserted++; else if (EquivalentQuestion(source, existing)) unchanged++; else updated++; } return new QuestionComparison(inserted, updated, unchanged); } internal static string HashQuestions(IEnumerable questions) { using var hash = IncrementalHash.CreateHash(HashAlgorithmName.SHA256); foreach (var document in questions.OrderBy(ExternalId, StringComparer.Ordinal)) hash.AppendData(Encoding.UTF8.GetBytes(CanonicalQuestion(document).ToJson())); return Convert.ToHexString(hash.GetHashAndReset()).ToLowerInvariant(); } private static bool EquivalentQuestion(BsonDocument source, BsonDocument target) => CanonicalQuestion(source).Equals(CanonicalQuestion(target)); private static BsonDocument CanonicalQuestion(BsonDocument source) => Canonicalize(new BsonDocument(source.Where(element => element.Name != "_id"))).AsBsonDocument; private static BsonValue Canonicalize(BsonValue value) { if (value.IsBsonDocument) return new BsonDocument(value.AsBsonDocument .OrderBy(element => element.Name, StringComparer.Ordinal) .Select(element => new BsonElement(element.Name, Canonicalize(element.Value)))); if (value.IsBsonArray) return new BsonArray(value.AsBsonArray.Select(Canonicalize)); return value; } private static string ExternalId(BsonDocument document) => document.GetValue("ExternalId", "").AsString; private static async Task UpsertQuestionsAsync(IMongoDatabase target, IEnumerable questions) { var collection = target.GetCollection("Questions"); foreach (var source in questions) { var replacement = new BsonDocument(source.Where(element => element.Name != "_id")); await collection.ReplaceOneAsync( Builders.Filter.Eq("ExternalId", source["ExternalId"]), replacement, new ReplaceOptions { IsUpsert = true }); } } private static async Task UpsertMediaAsync(IMongoDatabase target, IEnumerable media) { var collection = target.GetCollection("QuestionMediaAssets"); foreach (var source in media) await collection.ReplaceOneAsync( Builders.Filter.Eq("_id", source["_id"]), source, new ReplaceOptions { IsUpsert = true }); } private static Task WriteSummaryAsync( TextWriter output, string mode, PromotionOptions options, PromotionSummary summary) => output.WriteLineAsync(JsonSerializer.Serialize(new { mode, scope = "questions", eligibility = "published_unflagged", source = options.SourceDatabase, target = options.TargetDatabase, eligibleQuestions = summary.EligibleQuestions, mediaAssets = summary.MediaAssets, inserted = summary.Inserted, updated = summary.Updated, unchanged = summary.Unchanged, targetExtras = summary.TargetExtras, contentHash = summary.ContentHash })); 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 QuestionComparison(int Inserted, int Updated, int Unchanged); internal sealed record PromotionSummary( int EligibleQuestions, int MediaAssets, int Inserted, int Updated, int Unchanged, int TargetExtras, string ContentHash); }