diff --git a/app/lib/admin/backend.dart b/app/lib/admin/backend.dart index 6563a9ab7..9a649ca4e 100644 --- a/app/lib/admin/backend.dart +++ b/app/lib/admin/backend.dart @@ -779,6 +779,34 @@ class AdminBackend { return refCase; } + /// Scans datastore and deletes [ModerationCase] entities where the last + /// action was more than 3 years ago. + Future deleteModerationCases({ + @visibleForTesting DateTime? before, + }) async { + before ??= clock.ago(days: 3 * 366).toUtc(); // extra buffer days + + /// Querying the cases that were opened before the threshold, + /// as the resolved timestamp may be null for ongoing cases. + final query = _db.query() + ..filter('opened <', before) + ..order('opened'); + await for (final mc in query.run()) { + // sanity check that both timestamps are before the threshold + if (mc.opened.isAfter(before)) { + continue; + } + final resolved = mc.resolved; + if (resolved == null || resolved.isAfter(before)) { + continue; + } + // delete the entity + _logger.info('Deleting ModerationCase: ${mc.caseId}'); + await _db.commit(deletes: [mc.key]); + _logger.info('Deleted ModerationCase: ${mc.caseId}'); + } + } + /// Scans datastore and deletes moderated subjects where the last action /// was more than 3 years ago. Future deleteModeratedSubjects({ diff --git a/app/lib/tool/neat_task/pub_dev_tasks.dart b/app/lib/tool/neat_task/pub_dev_tasks.dart index 320edb977..ac12f7992 100644 --- a/app/lib/tool/neat_task/pub_dev_tasks.dart +++ b/app/lib/tool/neat_task/pub_dev_tasks.dart @@ -143,6 +143,13 @@ List createPeriodicTaskSchedulers({ task: () async => adminBackend.deleteAdminDeletedEntities(), ), + // Deletes ModerationCase entities. + _weekly( + name: 'delete-moderation-cases', + isRuntimeVersioned: false, + task: () async => adminBackend.deleteModerationCases(), + ), + // Deletes moderated packages, versions, publishers and users. _weekly( name: 'delete-moderated-subjects', diff --git a/app/test/admin/moderation_case_resolve_test.dart b/app/test/admin/moderation_case_resolve_test.dart index 64717dc48..d9be6937b 100644 --- a/app/test/admin/moderation_case_resolve_test.dart +++ b/app/test/admin/moderation_case_resolve_test.dart @@ -4,6 +4,7 @@ import 'package:_pub_shared/data/account_api.dart'; import 'package:_pub_shared/data/admin_api.dart'; +import 'package:clock/clock.dart'; import 'package:pub_dev/admin/backend.dart'; import 'package:pub_dev/admin/models.dart'; import 'package:pub_dev/shared/datastore.dart'; @@ -71,6 +72,15 @@ void main() { return mc!.status!; } + Future _verifyCaseExistence(String caseId, bool exists) async { + final mc = await adminBackend.lookupModerationCase(caseId); + if (exists) { + expect(mc, isNotNull); + } else { + expect(mc, isNull); + } + } + testWithProfile('notification: no action', fn: () async { final mc = await _prepare(apply: null); expect(await _close(mc.caseId), 'no-action'); @@ -82,6 +92,13 @@ void main() { 'SHOUT Deleting object from public bucket: "packages/oxygen-2.0.0-dev.tar.gz".', ], fn: () async { final mc = await _prepare(apply: true); + + // cleanup doesn't remove case prematurely + await _verifyCaseExistence(mc.caseId, true); + await adminBackend.deleteModerationCases(before: clock.now().toUtc()); + await _verifyCaseExistence(mc.caseId, true); + + // close case expect( await _close( mc.caseId, @@ -89,6 +106,11 @@ void main() { ), 'moderation-applied', ); + + // cleanup does remove case after the threshold is reached + await _verifyCaseExistence(mc.caseId, true); + await adminBackend.deleteModerationCases(before: clock.now().toUtc()); + await _verifyCaseExistence(mc.caseId, false); }); testWithProfile('appeal no action: revert', expectedLogMessages: [