From 0676c7f95e23ba91561770ea9ea766626e710041 Mon Sep 17 00:00:00 2001 From: hz Date: Thu, 30 Jul 2026 04:15:57 -0500 Subject: [PATCH] Require production approval for question media --- ENVIRONMENTS.md | 2 +- .../ContentPromotionTests.cs | 38 +++++++++++++++++++ .../Services/ContentPromotionCommand.cs | 5 ++- .../Services/QuestionPromotionCommand.cs | 31 +++++++++++++-- 4 files changed, 70 insertions(+), 6 deletions(-) diff --git a/ENVIRONMENTS.md b/ENVIRONMENTS.md index 5e07be0..9fc5f31 100644 --- a/ENVIRONMENTS.md +++ b/ENVIRONMENTS.md @@ -71,7 +71,7 @@ The hostname change creates a new browser origin, so testers should expect to si ## Question promotion behavior -The manual workflow runs the deployed PRD API image as a short-lived pod. It copies only questions that are published and not flagged for review, plus their referenced media. It never copies CCS content, users, attempts, ratings, statistics, simulations, reports, feedback, notes, or donations. +The manual workflow runs the deployed PRD API image as a short-lived pod. It copies only questions that are published and not flagged for review, plus their referenced media. Any promoted question containing media is flagged again in PRD and remains hidden from students until a SUPER user explicitly approves it in production. UAT media approval is not treated as production approval. It never copies CCS content, users, attempts, ratings, statistics, simulations, reports, feedback, notes, or donations. Promotion is idempotent and does not delete or implicitly unpublish PRD content. Its dry run reports inserts, updates, unchanged questions, and production extras. Apply mode verifies the promoted target records and writes a scoped `ContentPromotions` audit document. Corrective unpublishing remains an explicit SUPER/API or database operation. diff --git a/api/QuestionsApi.Tests/ContentPromotionTests.cs b/api/QuestionsApi.Tests/ContentPromotionTests.cs index 8d476bb..a338d13 100644 --- a/api/QuestionsApi.Tests/ContentPromotionTests.cs +++ b/api/QuestionsApi.Tests/ContentPromotionTests.cs @@ -49,6 +49,44 @@ public sealed class ContentPromotionTests Assert.False(QuestionPromotionCommand.IsEligible(new BsonDocument("IsPublished", false))); } + [Fact] + public void ProductionPromotionFlagsMediaForSeparateApproval() + { + var updated = new DateTime(2026, 7, 30, 12, 0, 0, DateTimeKind.Utc); + var source = new BsonDocument + { + ["ExternalId"] = "media-question", + ["IsPublished"] = true, + ["FlaggedForReviewUtc"] = BsonNull.Value, + ["UpdatedUtc"] = updated, + ["Media"] = new BsonArray { ObjectId.GenerateNewId().ToString() } + }; + + var prepared = QuestionPromotionCommand.PrepareForProduction(source); + + Assert.Equal(updated, prepared["FlaggedForReviewUtc"].ToUniversalTime()); + Assert.Equal(QuestionPromotionCommand.ProductionMediaReviewReason, prepared["FlagReason"].AsString); + Assert.Equal(QuestionPromotionCommand.ProductionMediaReviewActor, prepared["FlaggedByUserId"].AsString); + Assert.True(source["FlaggedForReviewUtc"].IsBsonNull); + } + + [Fact] + public void ProductionPromotionLeavesMediaFreeQuestionApprovalStateAlone() + { + var source = new BsonDocument + { + ["ExternalId"] = "text-question", + ["IsPublished"] = true, + ["FlaggedForReviewUtc"] = BsonNull.Value, + ["Media"] = new BsonArray() + }; + + var prepared = QuestionPromotionCommand.PrepareForProduction(source); + + Assert.True(prepared["FlaggedForReviewUtc"].IsBsonNull); + Assert.False(prepared.Contains("FlagReason")); + } + [Fact] public void QuestionMediaIdsSupportStoredApiUrlsAndRawIds() { diff --git a/api/QuestionsApi/Services/ContentPromotionCommand.cs b/api/QuestionsApi/Services/ContentPromotionCommand.cs index a1f49e6..3cecb1c 100644 --- a/api/QuestionsApi/Services/ContentPromotionCommand.cs +++ b/api/QuestionsApi/Services/ContentPromotionCommand.cs @@ -25,8 +25,11 @@ public static class ContentPromotionCommand await source.RunCommandAsync(new BsonDocument("ping", 1)); await target.RunCommandAsync(new BsonDocument("ping", 1)); - var questions = await source.GetCollection("Questions") + var sourceQuestions = await source.GetCollection("Questions") .Find(Builders.Filter.Eq("IsPublished", true)).ToListAsync(); + var questions = sourceQuestions + .Select(QuestionPromotionCommand.PrepareForProduction) + .ToList(); var mediaIds = CollectMediaIds(questions); var media = mediaIds.Count == 0 ? [] diff --git a/api/QuestionsApi/Services/QuestionPromotionCommand.cs b/api/QuestionsApi/Services/QuestionPromotionCommand.cs index fca017d..ae8dc93 100644 --- a/api/QuestionsApi/Services/QuestionPromotionCommand.cs +++ b/api/QuestionsApi/Services/QuestionPromotionCommand.cs @@ -9,6 +9,8 @@ namespace QuestionsApi.Services; public static class QuestionPromotionCommand { public const string Confirmation = "PROMOTE_TO_PRD"; + internal const string ProductionMediaReviewReason = "Contains media - pending production review"; + internal const string ProductionMediaReviewActor = "system:production-promotion"; public static async Task RunAsync(string[] args, TextWriter output) { @@ -28,9 +30,9 @@ public static class QuestionPromotionCommand var questionFilter = Builders.Filter.Eq("IsPublished", true) & Builders.Filter.Eq("FlaggedForReviewUtc", BsonNull.Value); - var questions = await source.GetCollection("Questions") + var sourceQuestions = await source.GetCollection("Questions") .Find(questionFilter).ToListAsync(); - var duplicateIds = questions + var duplicateIds = sourceQuestions .GroupBy(ExternalId, StringComparer.Ordinal) .Where(group => group.Count() > 1) .Select(group => group.Key) @@ -38,6 +40,7 @@ public static class QuestionPromotionCommand if (duplicateIds.Count > 0) throw new InvalidOperationException($"duplicate eligible ExternalIds: {string.Join(", ", duplicateIds)}"); + var questions = sourceQuestions.Select(PrepareForProduction).ToList(); var mediaIds = CollectMediaIds(questions); var media = mediaIds.Count == 0 ? [] @@ -95,7 +98,7 @@ public static class QuestionPromotionCommand ["SourceDatabase"] = options.SourceDatabase, ["TargetDatabase"] = options.TargetDatabase, ["Scope"] = "questions", - ["Eligibility"] = "published_unflagged", + ["Eligibility"] = "published_unflagged; media_requires_production_review", ["Questions"] = summary.EligibleQuestions, ["Inserted"] = summary.Inserted, ["Updated"] = summary.Updated, @@ -118,6 +121,21 @@ public static class QuestionPromotionCommand question.GetValue("IsPublished", false).ToBoolean() && (!question.TryGetValue("FlaggedForReviewUtc", out var flagged) || flagged.IsBsonNull); + internal static BsonDocument PrepareForProduction(BsonDocument source) + { + var prepared = new BsonDocument(source); + if (!HasMedia(prepared)) + return prepared; + + var reviewUtc = prepared.TryGetValue("UpdatedUtc", out var updated) && updated.IsValidDateTime + ? updated + : new BsonDateTime(DateTime.UnixEpoch); + prepared["FlaggedForReviewUtc"] = reviewUtc; + prepared["FlagReason"] = ProductionMediaReviewReason; + prepared["FlaggedByUserId"] = ProductionMediaReviewActor; + return prepared; + } + internal static HashSet CollectMediaIds(IEnumerable questions) { var ids = new HashSet(); @@ -192,6 +210,11 @@ public static class QuestionPromotionCommand private static string ExternalId(BsonDocument document) => document.GetValue("ExternalId", "").AsString; + private static bool HasMedia(BsonDocument question) => + question.TryGetValue("Media", out var media) + && media.IsBsonArray + && media.AsBsonArray.Count > 0; + private static async Task UpsertQuestionsAsync(IMongoDatabase target, IEnumerable questions) { var collection = target.GetCollection("Questions"); @@ -224,7 +247,7 @@ public static class QuestionPromotionCommand { mode, scope = "questions", - eligibility = "published_unflagged", + eligibility = "published_unflagged; media_requires_production_review", source = options.SourceDatabase, target = options.TargetDatabase, eligibleQuestions = summary.EligibleQuestions,