From c4ecb58ea6084d339bd227463ae147f0cabe17e7 Mon Sep 17 00:00:00 2001 From: Istvan Soos Date: Fri, 16 Oct 2026 17:30:17 +0200 Subject: [PATCH 1/4] Mirror ModerationCase entities to SQL. --- CHANGELOG.md | 1 + app/lib/account/backend.dart | 4 + app/lib/admin/actions/moderate_package.dart | 6 +- .../actions/moderate_package_versions.dart | 6 +- app/lib/admin/actions/moderate_publisher.dart | 3 + .../admin/actions/moderation_case_create.dart | 2 + .../admin/actions/moderation_case_delete.dart | 2 + .../actions/moderation_case_resolve.dart | 2 + .../admin/actions/moderation_case_update.dart | 1 + app/lib/admin/backend.dart | 143 ++ app/lib/database/schema.dart | 7 + app/lib/database/schema.g.dart | 1991 +++++++++++++++++ app/lib/database/schema.moderation.dart | 90 + app/lib/frontend/handlers/report.dart | 4 +- .../tool/backfill/backfill_new_fields.dart | 5 + app/migrations/000021_moderation_case.sql | 36 + app/migrations/sha256sum.txt | 1 + app/test/admin/moderation_case_sql_test.dart | 124 + app/test/database/moderation_sql_utils.dart | 85 + 19 files changed, 2510 insertions(+), 3 deletions(-) create mode 100644 app/lib/database/schema.moderation.dart create mode 100644 app/migrations/000021_moderation_case.sql create mode 100644 app/test/admin/moderation_case_sql_test.dart create mode 100644 app/test/database/moderation_sql_utils.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index f657abc530..d9e737d256 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ AppEngine version, listed here to ease deployment and troubleshooting. ## Next Release (replace with git tag when deployed) * Note: `Publisher` and `PublisherMember` are now mirrored to SQL. * Note: `Like` is now mirrored to SQL. + * Note: `ModerationCase` is now mirrored to SQL. ## `20261014t070500-all` * Bump runtimeVersion to `2026.10.13`. diff --git a/app/lib/account/backend.dart b/app/lib/account/backend.dart index 88bc9c1387..f1013c18f9 100644 --- a/app/lib/account/backend.dart +++ b/app/lib/account/backend.dart @@ -12,6 +12,7 @@ import 'package:logging/logging.dart'; import 'package:meta/meta.dart'; // ignore: import_of_legacy_library_into_null_safe import 'package:neat_cache/neat_cache.dart'; +import 'package:pub_dev/admin/backend.dart'; import 'package:pub_dev/admin/models.dart'; import 'package:typed_sql/typed_sql.dart' hide AuthenticationException; @@ -722,6 +723,9 @@ class AccountBackend { return user; }); await mirrorUserToSql(user); + if (refCaseKey != null) { + await adminBackend.mirrorModerationCaseToSqlById(refCaseKey.id as String); + } await _expireSessionsForUserId(userId); await purgeAccountCache(userId: userId); } diff --git a/app/lib/admin/actions/moderate_package.dart b/app/lib/admin/actions/moderate_package.dart index 944eddf1c7..88317c9838 100644 --- a/app/lib/admin/actions/moderate_package.dart +++ b/app/lib/admin/actions/moderate_package.dart @@ -38,7 +38,7 @@ Note: the action may take a longer time to complete as the public archive bucket final refCase = await adminBackend .loadAndVerifyModerationCaseForAdminAction(caseId); - return await adminMarkPackageVisibility( + final result = await adminMarkPackageVisibility( package, state: state, whenUpdating: (tx, p, valueToSet) async { @@ -59,6 +59,10 @@ Note: the action may take a longer time to complete as the public archive bucket 'moderatedAt': p.moderatedAt?.toIso8601String(), }, ); + if (refCase != null) { + await adminBackend.mirrorModerationCaseToSqlById(refCase.caseId); + } + return result; }, ); diff --git a/app/lib/admin/actions/moderate_package_versions.dart b/app/lib/admin/actions/moderate_package_versions.dart index b1c32f1024..9957423aa3 100644 --- a/app/lib/admin/actions/moderate_package_versions.dart +++ b/app/lib/admin/actions/moderate_package_versions.dart @@ -43,7 +43,7 @@ Set the moderated flag on a package version (updating the flag and the timestamp final refCase = await adminBackend .loadAndVerifyModerationCaseForAdminAction(caseId); - return await adminMarkPackageVersionVisibility( + final result = await adminMarkPackageVersionVisibility( package, version, state: state, @@ -65,6 +65,10 @@ Set the moderated flag on a package version (updating the flag and the timestamp 'moderatedAt': v.moderatedAt?.toIso8601String(), }, ); + if (refCase != null) { + await adminBackend.mirrorModerationCaseToSqlById(refCase.caseId); + } + return result; }, ); diff --git a/app/lib/admin/actions/moderate_publisher.dart b/app/lib/admin/actions/moderate_publisher.dart index ca7630a3d7..1d948b1c82 100644 --- a/app/lib/admin/actions/moderate_publisher.dart +++ b/app/lib/admin/actions/moderate_publisher.dart @@ -78,6 +78,9 @@ can't be updated, administrators must not be able to update publisher options. return p; }); await publisherBackend.mirrorPublisherToSql(publisherId); + if (refCase != null) { + await adminBackend.mirrorModerationCaseToSqlById(refCase.caseId); + } await purgePublisherCache(publisherId); } diff --git a/app/lib/admin/actions/moderation_case_create.dart b/app/lib/admin/actions/moderation_case_create.dart index ee396dc2f1..f8b4638f71 100644 --- a/app/lib/admin/actions/moderation_case_create.dart +++ b/app/lib/admin/actions/moderation_case_create.dart @@ -3,6 +3,7 @@ // BSD-style license that can be found in the LICENSE file. import 'package:pub_dev/account/agent.dart'; +import 'package:pub_dev/admin/backend.dart'; import 'package:pub_dev/admin/models.dart'; import 'package:pub_dev/frontend/handlers/report.dart'; import 'package:pub_dev/service/email/email_templates.dart'; @@ -72,6 +73,7 @@ Returns the fields on the newly created moderation case. tx.insert(mc); return mc; }); + await adminBackend.mirrorModerationCaseToSql(mc); return mc.toDebugInfo(); }, diff --git a/app/lib/admin/actions/moderation_case_delete.dart b/app/lib/admin/actions/moderation_case_delete.dart index 2d43f8ada1..25f9e8de2a 100644 --- a/app/lib/admin/actions/moderation_case_delete.dart +++ b/app/lib/admin/actions/moderation_case_delete.dart @@ -2,6 +2,7 @@ // for details. All rights reserved. Use of this source code is governed by a // BSD-style license that can be found in the LICENSE file. +import 'package:pub_dev/admin/backend.dart'; import 'package:pub_dev/admin/models.dart'; import 'package:pub_dev/shared/datastore.dart'; @@ -31,6 +32,7 @@ Deletes a moderation case. throw NotFoundException.resource(caseId!); } }); + await adminBackend.deleteModerationCaseFromSql(caseId!); return {'deleted': true}; }, diff --git a/app/lib/admin/actions/moderation_case_resolve.dart b/app/lib/admin/actions/moderation_case_resolve.dart index fb81cd443e..a122e2a8d4 100644 --- a/app/lib/admin/actions/moderation_case_resolve.dart +++ b/app/lib/admin/actions/moderation_case_resolve.dart @@ -3,6 +3,7 @@ // BSD-style license that can be found in the LICENSE file. 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'; import 'package:pub_dev/shared/utils.dart'; @@ -130,6 +131,7 @@ Closes the moderation case and updates the status based on the actions logged on tx.insert(mc); return mc; }); + await adminBackend.mirrorModerationCaseToSqlById(mc.caseId); return mc.toDebugInfo(); }, diff --git a/app/lib/admin/actions/moderation_case_update.dart b/app/lib/admin/actions/moderation_case_update.dart index 8339cd7ebe..f4bdf54cfc 100644 --- a/app/lib/admin/actions/moderation_case_update.dart +++ b/app/lib/admin/actions/moderation_case_update.dart @@ -76,6 +76,7 @@ Returns the fields of the updated moderation case. tx.insert(mc); }); final mc = await adminBackend.lookupModerationCase(caseId); + await adminBackend.mirrorModerationCaseToSqlById(caseId); return mc!.toDebugInfo(); }, diff --git a/app/lib/admin/backend.dart b/app/lib/admin/backend.dart index 211b74859c..3c533fa919 100644 --- a/app/lib/admin/backend.dart +++ b/app/lib/admin/backend.dart @@ -15,6 +15,8 @@ import 'package:gcloud/service_scope.dart' as ss; import 'package:logging/logging.dart'; import 'package:meta/meta.dart'; import 'package:pool/pool.dart'; +import 'package:typed_sql/typed_sql.dart'; +import 'package:ulid/ulid.dart'; import '../account/backend.dart'; import '../account/consent_backend.dart'; @@ -23,6 +25,8 @@ import '../account/models.dart'; import '../admin/models.dart'; import '../audit/backend.dart'; import '../audit/models.dart'; +import '../database/database.dart'; +import '../database/schema.dart'; import '../package/backend.dart' show checkPackageVersionParams, packageBackend, triggerPackagePostUpdates; import '../package/models.dart'; @@ -905,6 +909,144 @@ class AdminBackend { ); } + /// Upserts [mc] and its action log into SQL. + Future mirrorModerationCaseToSql(ModerationCase mc) async { + await primaryDatabase.transactWithRetry((db) async { + await db.moderationCases + .upsertValue( + caseId: mc.caseId, + reporterEmail: mc.reporterEmail, + openedAt: mc.opened, + resolvedAt: mc.resolved, + source: mc.source, + kind: mc.kind, + subject: mc.subject, + url: mc.url, + appealedCaseId: mc.appealedCaseId, + isSubjectOwner: mc.isSubjectOwner, + status: mc.status!, + grounds: mc.grounds, + violation: mc.violation, + reason: mc.reason, + ) + .execute(); + await db.moderationActionLogs + .where((l) => l.caseId.equalsValue(mc.caseId)) + .delete() + .execute(); + final entries = mc.getActionLog().entries.sorted( + (a, b) => a.timestamp.compareTo(b.timestamp), + ); + for (var i = 0; i < entries.length; i++) { + final e = entries[i]; + await db.moderationActionLogs + .upsertValue( + caseId: mc.caseId, + logId: Ulid(millis: e.timestamp.millisecondsSinceEpoch).toUuid(), + createdAt: e.timestamp, + subject: e.subject, + action: e.moderationAction.name, + note: e.note, + ) + .execute(); + } + }); + } + + /// Looks up the [ModerationCase] with [caseId] in Datastore and mirrors it + /// into SQL. + Future mirrorModerationCaseToSqlById(String caseId) async { + final mc = await lookupModerationCase(caseId); + if (mc != null) { + await mirrorModerationCaseToSql(mc); + } + } + + /// Deletes the SQL rows of the case with [caseId] (including its action log). + Future deleteModerationCaseFromSql(String caseId) async { + await primaryDatabase.withRetry( + (db) => db.moderationCases.delete(caseId).execute(), + ); + } + + /// Syncs SQL with the [ModerationCase] entities: upserts the missing or + /// changed cases, and removes the rows without a matching entity. + /// + /// Returns the number of cases that were changed in SQL. + Future backfillModerationCasesSqlFromDatastore() async { + var count = 0; + final sqlCaseIds = await primaryDatabase.withRetry( + (db) => db.moderationCases.select((c) => (c.caseId,)).fetch(), + ); + final remainingCaseIds = sqlCaseIds.toSet(); + + await for (final mc in _db.query().run()) { + remainingCaseIds.remove(mc.caseId); + if (await _isModerationCaseInSql(mc)) { + continue; + } + await mirrorModerationCaseToSql(mc); + count++; + } + // Rows left over seem to have no Datastore entity: double-check before + // deleting, in case the entity was created after the scan. + for (final caseId in remainingCaseIds) { + if (await lookupModerationCase(caseId) != null) { + continue; + } + await deleteModerationCaseFromSql(caseId); + count++; + } + return count; + } + + Future _isModerationCaseInSql(ModerationCase mc) async { + final row = await primaryDatabase.withRetry( + (db) => db.moderationCases.byKey(mc.caseId).fetch(), + ); + if (row == null || + row.reporterEmail != mc.reporterEmail || + !row.openedAt.isAtSameMomentAs(mc.opened) || + !_isSameMomentOrBothNull(row.resolvedAt, mc.resolved) || + row.source != mc.source || + row.kind != mc.kind || + row.subject != mc.subject || + row.url != mc.url || + row.appealedCaseId != mc.appealedCaseId || + row.isSubjectOwner != mc.isSubjectOwner || + row.status != mc.status || + row.grounds != mc.grounds || + row.violation != mc.violation || + row.reason != mc.reason) { + return false; + } + final logs = await primaryDatabase.withRetry( + (db) => db.moderationActionLogs + .where((l) => l.caseId.equalsValue(mc.caseId)) + .orderBy((l) => [(l.logId, Order.ascending)]) + .fetch(), + ); + final entries = mc.getActionLog().entries; + if (logs.length != entries.length) { + return false; + } + final sorted = entries.sorted((a, b) => a.timestamp.compareTo(b.timestamp)); + for (var i = 0; i < logs.length; i++) { + final l = logs[i]; + final e = sorted[i]; + if (!l.createdAt.isAtSameMomentAs(e.timestamp) || + l.subject != e.subject || + l.action != e.moderationAction.name || + l.note != e.note) { + return false; + } + } + return true; + } + + static bool _isSameMomentOrBothNull(DateTime? a, DateTime? b) => + a == null ? b == null : (b != null && a.isAtSameMomentAs(b)); + /// Returns a valid [ModerationCase] if it exists. /// Returns `null` if [caseId] is `none`. /// @@ -944,6 +1086,7 @@ class AdminBackend { // delete the entity _logger.info('Deleting ModerationCase: ${mc.caseId}'); await withRetryTransaction(_db, (tx) async => tx.delete(mc.key)); + await deleteModerationCaseFromSql(mc.caseId); _logger.info('Deleted ModerationCase: ${mc.caseId}'); } } diff --git a/app/lib/database/schema.dart b/app/lib/database/schema.dart index 872ec11490..678047883c 100644 --- a/app/lib/database/schema.dart +++ b/app/lib/database/schema.dart @@ -16,6 +16,7 @@ part 'schema.audit.dart'; part 'schema.consent.dart'; part 'schema.email.dart'; part 'schema.global_lock.dart'; +part 'schema.moderation.dart'; part 'schema.neat_task.dart'; part 'schema.package.dart'; part 'schema.publisher.dart'; @@ -50,6 +51,12 @@ abstract final class PrimarySchema extends Schema { Table get globalLockStates; + // moderation tables + + Table get moderationCases; + + Table get moderationActionLogs; + // neat periodic task status table Table get neatTaskStatuses; diff --git a/app/lib/database/schema.g.dart b/app/lib/database/schema.g.dart index b4007d671c..ba128b2ae6 100644 --- a/app/lib/database/schema.g.dart +++ b/app/lib/database/schema.g.dart @@ -38,6 +38,8 @@ extension PrimarySchemaSchema on Database { _$ConsentRow._$table, _$OutgoingEmailRow._$table, _$GlobalLockState._$table, + _$ModerationCaseRow._$table, + _$ModerationActionLog._$table, _$NeatTaskStatusRow._$table, _$PackageRow._$table, _$PackageUploader._$table, @@ -80,6 +82,12 @@ extension PrimarySchemaSchema on Database { Table get globalLockStates => $ForGeneratedCode.declareTable(this, _$GlobalLockState._$table); + Table get moderationCases => + $ForGeneratedCode.declareTable(this, _$ModerationCaseRow._$table); + + Table get moderationActionLogs => + $ForGeneratedCode.declareTable(this, _$ModerationActionLog._$table); + Table get neatTaskStatuses => $ForGeneratedCode.declareTable(this, _$NeatTaskStatusRow._$table); @@ -6855,6 +6863,1989 @@ extension InsertOnConflictSingleGlobalLockStateExt ); } +final class _$ModerationCaseRow extends ModerationCaseRow { + _$ModerationCaseRow._( + this.caseId, + this.reporterEmail, + this.openedAt, + this.resolvedAt, + this.source, + this.kind, + this.subject, + this.url, + this.appealedCaseId, + this.isSubjectOwner, + this.status, + this.grounds, + this.violation, + this.reason, + ); + + @override + final String caseId; + + @override + final String reporterEmail; + + @override + final DateTime openedAt; + + @override + final DateTime? resolvedAt; + + @override + final String source; + + @override + final String kind; + + @override + final String subject; + + @override + final String? url; + + @override + final String? appealedCaseId; + + @override + final bool isSubjectOwner; + + @override + final String status; + + @override + final String? grounds; + + @override + final String? violation; + + @override + final String? reason; + + static final _$table = $ForGeneratedCode.tableDefinition( + tableName: 'moderation_cases', + columns: [ + 'case_id', + 'reporter_email', + 'opened_at', + 'resolved_at', + 'source', + 'kind', + 'subject', + 'url', + 'appealed_case_id', + 'is_subject_owner', + 'status', + 'grounds', + 'violation', + 'reason', + ], + columnInfo: [ + $ForGeneratedCode.columnDefinition( + type: $ForGeneratedCode.text, + isNotNull: true, + defaultValue: null, + autoIncrement: false, + overrides: [], + ), + $ForGeneratedCode.columnDefinition( + type: $ForGeneratedCode.text, + isNotNull: true, + defaultValue: null, + autoIncrement: false, + overrides: [], + ), + $ForGeneratedCode.columnDefinition( + type: $ForGeneratedCode.dateTime, + isNotNull: true, + defaultValue: null, + autoIncrement: false, + overrides: [], + ), + $ForGeneratedCode.columnDefinition( + type: $ForGeneratedCode.dateTime, + isNotNull: false, + defaultValue: null, + autoIncrement: false, + overrides: [], + ), + $ForGeneratedCode.columnDefinition( + type: $ForGeneratedCode.text, + isNotNull: true, + defaultValue: null, + autoIncrement: false, + overrides: [], + ), + $ForGeneratedCode.columnDefinition( + type: $ForGeneratedCode.text, + isNotNull: true, + defaultValue: null, + autoIncrement: false, + overrides: [], + ), + $ForGeneratedCode.columnDefinition( + type: $ForGeneratedCode.text, + isNotNull: true, + defaultValue: null, + autoIncrement: false, + overrides: [], + ), + $ForGeneratedCode.columnDefinition( + type: $ForGeneratedCode.text, + isNotNull: false, + defaultValue: null, + autoIncrement: false, + overrides: [], + ), + $ForGeneratedCode.columnDefinition( + type: $ForGeneratedCode.text, + isNotNull: false, + defaultValue: null, + autoIncrement: false, + overrides: [], + ), + $ForGeneratedCode.columnDefinition( + type: $ForGeneratedCode.boolean, + isNotNull: true, + defaultValue: null, + autoIncrement: false, + overrides: [], + ), + $ForGeneratedCode.columnDefinition( + type: $ForGeneratedCode.text, + isNotNull: true, + defaultValue: null, + autoIncrement: false, + overrides: [], + ), + $ForGeneratedCode.columnDefinition( + type: $ForGeneratedCode.text, + isNotNull: false, + defaultValue: null, + autoIncrement: false, + overrides: [], + ), + $ForGeneratedCode.columnDefinition( + type: $ForGeneratedCode.text, + isNotNull: false, + defaultValue: null, + autoIncrement: false, + overrides: [], + ), + $ForGeneratedCode.columnDefinition( + type: $ForGeneratedCode.text, + isNotNull: false, + defaultValue: null, + autoIncrement: false, + overrides: [], + ), + ], + primaryKey: ['case_id'], + unique: >[], + foreignKeys: [], + indexes: [ + $ForGeneratedCode.indexDefinition( + name: null, + sqlName: null, + columns: ['subject'], + ), + $ForGeneratedCode.indexDefinition( + name: null, + sqlName: null, + columns: ['appealed_case_id'], + ), + ], + readRow: _$ModerationCaseRow._$fromDatabase, + ); + + static ModerationCaseRow? _$fromDatabase(RowReader row) { + final caseId = row.readString(); + final reporterEmail = row.readString(); + final openedAt = row.readDateTime(); + final resolvedAt = row.readDateTime(); + final source = row.readString(); + final kind = row.readString(); + final subject = row.readString(); + final url = row.readString(); + final appealedCaseId = row.readString(); + final isSubjectOwner = row.readBool(); + final status = row.readString(); + final grounds = row.readString(); + final violation = row.readString(); + final reason = row.readString(); + if (caseId == null && + reporterEmail == null && + openedAt == null && + resolvedAt == null && + source == null && + kind == null && + subject == null && + url == null && + appealedCaseId == null && + isSubjectOwner == null && + status == null && + grounds == null && + violation == null && + reason == null) { + return null; + } + return _$ModerationCaseRow._( + caseId!, + reporterEmail!, + openedAt!, + resolvedAt, + source!, + kind!, + subject!, + url, + appealedCaseId, + isSubjectOwner!, + status!, + grounds, + violation, + reason, + ); + } + + @override + String toString() => + 'ModerationCaseRow(caseId: "$caseId", reporterEmail: "$reporterEmail", openedAt: "$openedAt", resolvedAt: "$resolvedAt", source: "$source", kind: "$kind", subject: "$subject", url: "$url", appealedCaseId: "$appealedCaseId", isSubjectOwner: "$isSubjectOwner", status: "$status", grounds: "$grounds", violation: "$violation", reason: "$reason")'; +} + +/// Extension methods for table defined in [ModerationCaseRow]. +extension TableModerationCaseRowExt on Table { + /// Insert row into the `moderationCases` table. + /// + /// Returns a [InsertSingle] statement on which `.execute` must be + /// called for the row to be inserted. + InsertSingle insert({ + required Expr caseId, + required Expr reporterEmail, + required Expr openedAt, + Expr? resolvedAt, + required Expr source, + required Expr kind, + required Expr subject, + Expr? url, + Expr? appealedCaseId, + required Expr isSubjectOwner, + required Expr status, + Expr? grounds, + Expr? violation, + Expr? reason, + }) => $ForGeneratedCode.insertInto( + table: this, + values: [ + caseId, + reporterEmail, + openedAt, + resolvedAt, + source, + kind, + subject, + url, + appealedCaseId, + isSubjectOwner, + status, + grounds, + violation, + reason, + ], + ); + + /// Insert row into the `moderationCases` table, or update the + /// existing row if it conflicts with the _primary key_. + /// + /// This is a shorthand for calling `.insert(...)` followed by + /// `.onConflict(.primaryKey)` and `.update(...)` to overwrite + /// the fields `reporterEmail`, `openedAt`, `resolvedAt`, `source`, `kind`, `subject`, `url`, `appealedCaseId`, `isSubjectOwner`, `status`, `grounds`, `violation`, `reason`, + /// with the values given, leaving the _primary key_ untouched. + /// + /// Returns an [UpsertSingle] statement on which `.execute()` must be + /// called for the row to be inserted or updated. + UpsertSingle upsert({ + required Expr caseId, + required Expr reporterEmail, + required Expr openedAt, + Expr? resolvedAt, + required Expr source, + required Expr kind, + required Expr subject, + Expr? url, + Expr? appealedCaseId, + required Expr isSubjectOwner, + required Expr status, + Expr? grounds, + Expr? violation, + Expr? reason, + }) => + insert( + caseId: caseId, + reporterEmail: reporterEmail, + openedAt: openedAt, + resolvedAt: resolvedAt, + source: source, + kind: kind, + subject: subject, + url: url, + appealedCaseId: appealedCaseId, + isSubjectOwner: isSubjectOwner, + status: status, + grounds: grounds, + violation: violation, + reason: reason, + ) + .onConflict(.primaryKey) + .update( + (_, excluded, set) => set( + reporterEmail: excluded.reporterEmail, + openedAt: excluded.openedAt, + resolvedAt: excluded.resolvedAt, + source: excluded.source, + kind: excluded.kind, + subject: excluded.subject, + url: excluded.url, + appealedCaseId: excluded.appealedCaseId, + isSubjectOwner: excluded.isSubjectOwner, + status: excluded.status, + grounds: excluded.grounds, + violation: excluded.violation, + reason: excluded.reason, + ), + ); + + /// Insert row into the `moderationCases` table. + /// + /// Returns a [InsertSingle] statement on which `.execute` must be + /// called for the row to be inserted. + InsertSingle insertValue({ + required String caseId, + required String reporterEmail, + required DateTime openedAt, + DateTime? resolvedAt, + required String source, + required String kind, + required String subject, + String? url, + String? appealedCaseId, + required bool isSubjectOwner, + required String status, + String? grounds, + String? violation, + String? reason, + }) => $ForGeneratedCode.insertInto( + table: this, + values: [ + caseId.asExpr, + reporterEmail.asExpr, + openedAt.asExpr, + resolvedAt.asExpr, + source.asExpr, + kind.asExpr, + subject.asExpr, + url.asExpr, + appealedCaseId.asExpr, + isSubjectOwner.asExpr, + status.asExpr, + grounds.asExpr, + violation.asExpr, + reason.asExpr, + ], + ); + + /// Insert row into the `moderationCases` table, or update the + /// existing row if it conflicts with the _primary key_. + /// + /// This is a shorthand for calling `.insertValue(...)` followed by + /// `.onConflict(.primaryKey)` and `.update(...)` to overwrite + /// the fields `reporterEmail`, `openedAt`, `resolvedAt`, `source`, `kind`, `subject`, `url`, `appealedCaseId`, `isSubjectOwner`, `status`, `grounds`, `violation`, `reason`, + /// with the values given, leaving the _primary key_ untouched. + /// + /// Returns an [UpsertSingle] statement on which `.execute()` must be + /// called for the row to be inserted or updated. + UpsertSingle upsertValue({ + required String caseId, + required String reporterEmail, + required DateTime openedAt, + DateTime? resolvedAt, + required String source, + required String kind, + required String subject, + String? url, + String? appealedCaseId, + required bool isSubjectOwner, + required String status, + String? grounds, + String? violation, + String? reason, + }) => + insertValue( + caseId: caseId, + reporterEmail: reporterEmail, + openedAt: openedAt, + resolvedAt: resolvedAt, + source: source, + kind: kind, + subject: subject, + url: url, + appealedCaseId: appealedCaseId, + isSubjectOwner: isSubjectOwner, + status: status, + grounds: grounds, + violation: violation, + reason: reason, + ) + .onConflict(.primaryKey) + .update( + (_, excluded, set) => set( + reporterEmail: excluded.reporterEmail, + openedAt: excluded.openedAt, + resolvedAt: excluded.resolvedAt, + source: excluded.source, + kind: excluded.kind, + subject: excluded.subject, + url: excluded.url, + appealedCaseId: excluded.appealedCaseId, + isSubjectOwner: excluded.isSubjectOwner, + status: excluded.status, + grounds: excluded.grounds, + violation: excluded.violation, + reason: excluded.reason, + ), + ); + + /// Bulk insert rows into the `moderationCases` table. + /// + /// This method takes an `Iterable` and requires that you provide + /// a _mapping function_ from `T` to each column to be inserted. + /// + /// If a mapping function is omitted, the _default value_ will be + /// inserted, or `NULL` if column is nullable and as no default value. + /// To explicitely insert `NULL`, use a _mapping function_ that maps + /// `T` to `null`. + /// + /// > [!NOTE] + /// > This method aims utilize database specific bulk insertion logic + /// > to ensure good performance. Database adapters may pipeline bulk + /// > insertions through multiple statements inside a transaction. + /// + /// Returns a [Insert] statement on which `.execute` must be + /// called for the rows to be inserted. + Insert insertValuesMapped( + Iterable rows, { + required String Function(T row) caseId, + required String Function(T row) reporterEmail, + required DateTime Function(T row) openedAt, + DateTime? Function(T row)? resolvedAt, + required String Function(T row) source, + required String Function(T row) kind, + required String Function(T row) subject, + String? Function(T row)? url, + String? Function(T row)? appealedCaseId, + required bool Function(T row) isSubjectOwner, + required String Function(T row) status, + String? Function(T row)? grounds, + String? Function(T row)? violation, + String? Function(T row)? reason, + }) => $ForGeneratedCode.insertValuesMapped( + table: this, + rows: rows, + mappings: [ + caseId, + reporterEmail, + openedAt, + resolvedAt, + source, + kind, + subject, + url, + appealedCaseId, + isSubjectOwner, + status, + grounds, + violation, + reason, + ], + ); + + /// Delete a single row from the `moderationCases` table, specified by + /// _primary key_. + /// + /// Returns a [DeleteSingle] statement on which `.execute()` must be + /// called for the row to be deleted. + /// + /// To delete multiple rows, using `.where()` to filter which rows + /// should be deleted. If you wish to delete all rows, use + /// `.where((_) => toExpr(true)).delete()`. + DeleteSingle delete(String caseId) => $ForGeneratedCode + .deleteSingle(byKey(caseId), _$ModerationCaseRow._$table); +} + +/// Extension methods for building queries against the `moderationCases` table. +extension QueryModerationCaseRowExt on Query<(Expr,)> { + /// Lookup a single row in `moderationCases` table using the _primary key_. + /// + /// Returns a [QuerySingle] object, which returns at-most one row, + /// when `.fetch()` is called. + QuerySingle<(Expr,)> byKey(String caseId) => where( + (moderationCaseRow) => moderationCaseRow.caseId.equalsValue(caseId), + ).first; + + /// Update all rows in the `moderationCases` table matching this [Query]. + /// + /// The changes to be applied to each row matching this [Query] are + /// defined using the [updateBuilder], which is given an [Expr] + /// representation of the row being updated and a `set` function to + /// specify which fields should be updated. The result of the `set` + /// function should always be returned from the `updateBuilder`. + /// + /// Returns an [Update] statement on which `.execute()` must be called + /// for the rows to be updated. + /// + /// **Example:** decrementing `1` from the `value` field for each row + /// where `value > 0`. + /// ```dart + /// await db.mytable + /// .where((row) => row.value > toExpr(0)) + /// .update((row, set) => set( + /// value: row.value - toExpr(1), + /// )) + /// .execute(); + /// ``` + /// + /// > [!WARNING] + /// > The `updateBuilder` callback does not make the update, it builds + /// > the expressions for updating the rows. You should **never** invoke + /// > the `set` function more than once, and the result should always + /// > be returned immediately. + Update update( + UpdateSet Function( + Expr moderationCaseRow, + UpdateSet Function({ + Expr caseId, + Expr reporterEmail, + Expr openedAt, + Expr resolvedAt, + Expr source, + Expr kind, + Expr subject, + Expr url, + Expr appealedCaseId, + Expr isSubjectOwner, + Expr status, + Expr grounds, + Expr violation, + Expr reason, + }) + set, + ) + updateBuilder, + ) => $ForGeneratedCode.update( + this, + _$ModerationCaseRow._$table, + (moderationCaseRow) => updateBuilder( + moderationCaseRow, + ({ + Expr? caseId, + Expr? reporterEmail, + Expr? openedAt, + Expr? resolvedAt, + Expr? source, + Expr? kind, + Expr? subject, + Expr? url, + Expr? appealedCaseId, + Expr? isSubjectOwner, + Expr? status, + Expr? grounds, + Expr? violation, + Expr? reason, + }) => $ForGeneratedCode.buildUpdate([ + caseId, + reporterEmail, + openedAt, + resolvedAt, + source, + kind, + subject, + url, + appealedCaseId, + isSubjectOwner, + status, + grounds, + violation, + reason, + ]), + ), + ); + + /// Delete all rows in the `moderationCases` table matching this [Query]. + /// + /// Returns a [Delete] statement on which `.execute()` must be called + /// for the rows to be deleted. + Delete delete() => + $ForGeneratedCode.delete(this, _$ModerationCaseRow._$table); +} + +/// Extension methods for building point queries against the `moderationCases` table. +extension QuerySingleModerationCaseRowExt + on QuerySingle<(Expr,)> { + /// Update the row (if any) in the `moderationCases` table matching this + /// [QuerySingle]. + /// + /// The changes to be applied to the row matching this [QuerySingle] are + /// defined using the [updateBuilder], which is given an [Expr] + /// representation of the row being updated and a `set` function to + /// specify which fields should be updated. The result of the `set` + /// function should always be returned from the `updateBuilder`. + /// + /// Returns an [UpdateSingle] statement on which `.execute()` must be + /// called for the row to be updated. The resulting statement will + /// **not** fail, if there are no rows matching this query exists. + /// + /// **Example:** decrementing `1` from the `value` field the row with + /// `id = 1`. + /// ```dart + /// await db.mytable + /// .byKey(1) + /// .update((row, set) => set( + /// value: row.value - toExpr(1), + /// )) + /// .execute(); + /// ``` + /// + /// > [!WARNING] + /// > The `updateBuilder` callback does not make the update, it builds + /// > the expressions for updating the rows. You should **never** invoke + /// > the `set` function more than once, and the result should always + /// > be returned immediately. + UpdateSingle update( + UpdateSet Function( + Expr moderationCaseRow, + UpdateSet Function({ + Expr caseId, + Expr reporterEmail, + Expr openedAt, + Expr resolvedAt, + Expr source, + Expr kind, + Expr subject, + Expr url, + Expr appealedCaseId, + Expr isSubjectOwner, + Expr status, + Expr grounds, + Expr violation, + Expr reason, + }) + set, + ) + updateBuilder, + ) => $ForGeneratedCode.updateSingle( + this, + _$ModerationCaseRow._$table, + (moderationCaseRow) => updateBuilder( + moderationCaseRow, + ({ + Expr? caseId, + Expr? reporterEmail, + Expr? openedAt, + Expr? resolvedAt, + Expr? source, + Expr? kind, + Expr? subject, + Expr? url, + Expr? appealedCaseId, + Expr? isSubjectOwner, + Expr? status, + Expr? grounds, + Expr? violation, + Expr? reason, + }) => $ForGeneratedCode.buildUpdate([ + caseId, + reporterEmail, + openedAt, + resolvedAt, + source, + kind, + subject, + url, + appealedCaseId, + isSubjectOwner, + status, + grounds, + violation, + reason, + ]), + ), + ); + + /// Delete the row (if any) in the `moderationCases` table matching this [QuerySingle]. + /// + /// Returns a [DeleteSingle] statement on which `.execute()` must be called + /// for the row to be deleted. The resulting statement will **not** + /// fail, if there are no rows matching this query exists. + DeleteSingle delete() => + $ForGeneratedCode.deleteSingle(this, _$ModerationCaseRow._$table); +} + +/// Extension methods for expressions on a row in the `moderationCases` table. +extension ExpressionModerationCaseRowExt on Expr { + /// Matches `ModerationCase.caseId`. + Expr get caseId => + $ForGeneratedCode.field(this, 0, $ForGeneratedCode.text); + + /// The email of the reporter. + Expr get reporterEmail => + $ForGeneratedCode.field(this, 1, $ForGeneratedCode.text); + + /// The case was opened at this time. + Expr get openedAt => + $ForGeneratedCode.field(this, 2, $ForGeneratedCode.dateTime); + + /// The case was resolved at this time (or `null` if it is still open). + Expr get resolvedAt => + $ForGeneratedCode.field(this, 3, $ForGeneratedCode.dateTime); + + /// One of the `ModerationSource` values. + Expr get source => + $ForGeneratedCode.field(this, 4, $ForGeneratedCode.text); + + /// One of the `ModerationKind` values. + Expr get kind => + $ForGeneratedCode.field(this, 5, $ForGeneratedCode.text); + + /// The fully qualified name of the subject (e.g. `package:`). + /// + /// This is not a foreign key: the subject may be a user email, or an entity + /// that has been deleted. + Expr get subject => + $ForGeneratedCode.field(this, 6, $ForGeneratedCode.text); + + /// The URL of the page from where the reporter navigated to the report page. + Expr get url => + $ForGeneratedCode.field(this, 7, $ForGeneratedCode.text); + + /// The `caseId` of the appealed case (or `null`). + Expr get appealedCaseId => + $ForGeneratedCode.field(this, 8, $ForGeneratedCode.text); + + /// Whether the reporter is a known owner of the [subject]. + Expr get isSubjectOwner => + $ForGeneratedCode.field(this, 9, $ForGeneratedCode.boolean); + + /// One of the `ModerationStatus` values. + Expr get status => + $ForGeneratedCode.field(this, 10, $ForGeneratedCode.text); + + /// One of the `ModerationGrounds` values (or `null` while pending). + Expr get grounds => + $ForGeneratedCode.field(this, 11, $ForGeneratedCode.text); + + /// One of the `ModerationViolation` values (or `null` while pending). + Expr get violation => + $ForGeneratedCode.field(this, 12, $ForGeneratedCode.text); + + /// The text from the statement of reasons sent to the user. + Expr get reason => + $ForGeneratedCode.field(this, 13, $ForGeneratedCode.text); + + /// Get [SubQuery] of rows from the `moderationActionLogs` table which + /// reference this row. + /// + /// This returns a [SubQuery] of [ModerationActionLog] rows, + /// where [ModerationActionLog.caseId] + /// references [ModerationCaseRow.caseId] + /// in this row. + SubQuery<(Expr,)> get actionLogs => $ForGeneratedCode + .subqueryTable(_$ModerationActionLog._$table) + .where((r) => r.caseId.equalsUnlessNull(caseId)); +} + +extension ExpressionNullableModerationCaseRowExt on Expr { + /// Matches `ModerationCase.caseId`. + Expr get caseId => + $ForGeneratedCode.field(this, 0, $ForGeneratedCode.text); + + /// The email of the reporter. + Expr get reporterEmail => + $ForGeneratedCode.field(this, 1, $ForGeneratedCode.text); + + /// The case was opened at this time. + Expr get openedAt => + $ForGeneratedCode.field(this, 2, $ForGeneratedCode.dateTime); + + /// The case was resolved at this time (or `null` if it is still open). + Expr get resolvedAt => + $ForGeneratedCode.field(this, 3, $ForGeneratedCode.dateTime); + + /// One of the `ModerationSource` values. + Expr get source => + $ForGeneratedCode.field(this, 4, $ForGeneratedCode.text); + + /// One of the `ModerationKind` values. + Expr get kind => + $ForGeneratedCode.field(this, 5, $ForGeneratedCode.text); + + /// The fully qualified name of the subject (e.g. `package:`). + /// + /// This is not a foreign key: the subject may be a user email, or an entity + /// that has been deleted. + Expr get subject => + $ForGeneratedCode.field(this, 6, $ForGeneratedCode.text); + + /// The URL of the page from where the reporter navigated to the report page. + Expr get url => + $ForGeneratedCode.field(this, 7, $ForGeneratedCode.text); + + /// The `caseId` of the appealed case (or `null`). + Expr get appealedCaseId => + $ForGeneratedCode.field(this, 8, $ForGeneratedCode.text); + + /// Whether the reporter is a known owner of the [subject]. + Expr get isSubjectOwner => + $ForGeneratedCode.field(this, 9, $ForGeneratedCode.boolean); + + /// One of the `ModerationStatus` values. + Expr get status => + $ForGeneratedCode.field(this, 10, $ForGeneratedCode.text); + + /// One of the `ModerationGrounds` values (or `null` while pending). + Expr get grounds => + $ForGeneratedCode.field(this, 11, $ForGeneratedCode.text); + + /// One of the `ModerationViolation` values (or `null` while pending). + Expr get violation => + $ForGeneratedCode.field(this, 12, $ForGeneratedCode.text); + + /// The text from the statement of reasons sent to the user. + Expr get reason => + $ForGeneratedCode.field(this, 13, $ForGeneratedCode.text); + + /// Get [SubQuery] of rows from the `moderationActionLogs` table which + /// reference this row. + /// + /// This returns a [SubQuery] of [ModerationActionLog] rows, + /// where [ModerationActionLog.caseId] + /// references [ModerationCaseRow.caseId] + /// in this row, if any. + /// + /// If this row is `NULL` the subquery is always be empty. + SubQuery<(Expr,)> get actionLogs => $ForGeneratedCode + .subqueryTable(_$ModerationActionLog._$table) + .where((r) => r.caseId.equalsUnlessNull(caseId)); + + /// Check if the row is not `NULL`. + /// + /// This will check if _primary key_ fields in this row are `NULL`. + /// + /// If this is a reference lookup by subquery it might be more efficient + /// to check if the referencing field is `NULL`. + Expr isNotNull() => caseId.isNotNull(); + + /// Check if the row is `NULL`. + /// + /// This will check if _primary key_ fields in this row are `NULL`. + /// + /// If this is a reference lookup by subquery it might be more efficient + /// to check if the referencing field is `NULL`. + Expr isNull() => isNotNull().not(); +} + +extension InnerJoinModerationCaseRowModerationActionLogExt + on InnerJoin<(Expr,), (Expr,)> { + /// Join using the `parent` _foreign key_. + /// + /// This will match rows where [ModerationCaseRow.caseId] = [ModerationActionLog.caseId]. + Query<(Expr, Expr)> usingParent() => + on((a, b) => a.caseId.equalsUnlessNull(b.caseId)); +} + +extension LeftJoinModerationCaseRowModerationActionLogExt + on LeftJoin<(Expr,), (Expr,)> { + /// Join using the `parent` _foreign key_. + /// + /// This will match rows where [ModerationCaseRow.caseId] = [ModerationActionLog.caseId]. + Query<(Expr, Expr)> usingParent() => + on((a, b) => a.caseId.equalsUnlessNull(b.caseId)); +} + +extension RightJoinModerationCaseRowModerationActionLogExt + on RightJoin<(Expr,), (Expr,)> { + /// Join using the `parent` _foreign key_. + /// + /// This will match rows where [ModerationCaseRow.caseId] = [ModerationActionLog.caseId]. + Query<(Expr, Expr)> usingParent() => + on((a, b) => a.caseId.equalsUnlessNull(b.caseId)); +} + +/// `Table` conflict targets for use with `.onConflict`. +enum ModerationCaseRowConflict { + /// Conflict with an existing row that has a matching primary key. + /// + /// Thus, the other row has matching values for: + /// `caseId`. + primaryKey(['case_id']); + + const ModerationCaseRowConflict(this._fields); + + final List _fields; +} + +extension InsertModerationCaseRowExt on Insert { + /// Build an `INSERT` statement with an `ON CONFLICT` clause. + /// + /// The [target] argument specifies the _conflict target_ to be + /// handled. The _conflict target_ is always a `UNIQUE` constraint or + /// `PRIMARY KEY` constraint. + /// + /// If a row to be inserted violates the _conflict target_ constraint, + /// then the conflict action is triggered: + /// * `.doNothing()` to skip insertion of the new row, and, + /// * `.update((moderationCaseRow, excluded, set) => set(...))` to + /// update the conflicting row. + /// + /// If a row to be inserted violates a constraint other than the one + /// specified in _conflict target_ then the entire `INSERT` statement + /// will fail. + /// + /// This is equivalent to `INSERT ... ON CONFLICT (...)` in SQL. + InsertOnConflict onConflict( + ModerationCaseRowConflict target, + ) => $ForGeneratedCode.insertOnConflict(this, target._fields); +} + +extension InsertOnConflictModerationCaseRowExt + on InsertOnConflict { + /// Build an `INSERT` statement an [upsert-clause][1]. + /// + /// When a row to be inserted violates the `UNIQUE` or `PRIMARY KEY` + /// constraint previously specified as _conflict target_, the existing + /// row is updated using the expressions defined with the + /// [updateBuilder]. The [updateBuilder] is given 3 parameters: + /// * `moderationCaseRow` an [Expr] representing the existing row in + /// the database, + /// * `excluded` an [Expr] representing the row to be inserted in the + /// database, and, + /// * `set` a function to specify which fields should be updated and + /// build the [UpdateSet]. + /// + /// The result of the `set` function should always be immediately + /// returned from the [updateBuilder]. + /// + /// **Example:** Insert a counter with `count = 2` or increment the + /// existing row, if a `PRIMARY KEY` conflict occurs. + /// ```dart + /// await db.counters.insertValue( + /// name: 'my-counter', // primary key + /// count: 2, + /// ) + /// .onConflict(.primaryKey) + /// .update((counter, excluded, set) => set( + /// count: counter.count + excluded.count, + /// )) + /// .execute(); + /// ``` + /// + /// This is equivalent to + /// `INSERT ... ON CONFLICT (...) UPDATE SET ...` in SQL. + /// + /// > [!WARNING] + /// > The `updateBuilder` callback does not make the update, it builds + /// > the expressions for updating the rows. You should **never** invoke + /// > the `set` function more than once, and the result should always + /// > be returned immediately. + /// + /// [1]: https://www.sqlite.org/lang_upsert.html + Upsert update( + UpdateSet Function( + Expr moderationCaseRow, + Expr excluded, + UpdateSet Function({ + Expr caseId, + Expr reporterEmail, + Expr openedAt, + Expr resolvedAt, + Expr source, + Expr kind, + Expr subject, + Expr url, + Expr appealedCaseId, + Expr isSubjectOwner, + Expr status, + Expr grounds, + Expr violation, + Expr reason, + }) + set, + ) + updateBuilder, + ) => $ForGeneratedCode.updateOnConflict( + this, + (moderationCaseRow, excluded) => updateBuilder( + moderationCaseRow, + excluded, + ({ + Expr? caseId, + Expr? reporterEmail, + Expr? openedAt, + Expr? resolvedAt, + Expr? source, + Expr? kind, + Expr? subject, + Expr? url, + Expr? appealedCaseId, + Expr? isSubjectOwner, + Expr? status, + Expr? grounds, + Expr? violation, + Expr? reason, + }) => $ForGeneratedCode.buildUpdate([ + caseId, + reporterEmail, + openedAt, + resolvedAt, + source, + kind, + subject, + url, + appealedCaseId, + isSubjectOwner, + status, + grounds, + violation, + reason, + ]), + ), + ); +} + +extension InsertSingleModerationCaseRowExt on InsertSingle { + /// Build an `INSERT` statement with an `ON CONFLICT` clause. + /// + /// The [target] argument specifies the _conflict target_ to be + /// handled. The _conflict target_ is always a `UNIQUE` constraint or + /// `PRIMARY KEY` constraint. + /// + /// If a row to be inserted violates the _conflict target_ constraint, + /// then the conflict action is triggered: + /// * `.doNothing()` to skip insertion of the new row, and, + /// * `.update((moderationCaseRow, excluded, set) => set(...))` to + /// update the conflicting row. + /// + /// If a row to be inserted violates a constraint other than the one + /// specified in _conflict target_ then the entire `INSERT` statement + /// will fail. + /// + /// This is equivalent to `INSERT ... ON CONFLICT (...)` in SQL. + InsertOnConflictSingle onConflict( + ModerationCaseRowConflict target, + ) => $ForGeneratedCode.insertOnConflictSingle(this, target._fields); +} + +extension InsertOnConflictSingleModerationCaseRowExt + on InsertOnConflictSingle { + /// Build an `INSERT` statement an [upsert-clause][1]. + /// + /// When a row to be inserted violates the `UNIQUE` or `PRIMARY KEY` + /// constraint previously specified as _conflict target_, the existing + /// row is updated using the expressions defined with the + /// [updateBuilder]. The [updateBuilder] is given 3 parameters: + /// * `moderationCaseRow` an [Expr] representing the existing row in + /// the database, + /// * `excluded` an [Expr] representing the row to be inserted in the + /// database, and, + /// * `set` a function to specify which fields should be updated and + /// build the [UpdateSet]. + /// + /// The result of the `set` function should always be immediately + /// returned from the [updateBuilder]. + /// + /// **Example:** Insert a counter with `count = 2` or increment the + /// existing row, if a `PRIMARY KEY` conflict occurs. + /// ```dart + /// await db.counters.insertValue( + /// name: 'my-counter', // primary key + /// count: 2, + /// ) + /// .onConflict(.primaryKey) + /// .update((counter, excluded, set) => set( + /// count: counter.count + excluded.count, + /// )) + /// .execute(); + /// ``` + /// + /// This is equivalent to + /// `INSERT ... ON CONFLICT (...) UPDATE SET ...` in SQL. + /// + /// > [!WARNING] + /// > The `updateBuilder` callback does not make the update, it builds + /// > the expressions for updating the rows. You should **never** invoke + /// > the `set` function more than once, and the result should always + /// > be returned immediately. + /// + /// [1]: https://www.sqlite.org/lang_upsert.html + UpsertSingle update( + UpdateSet Function( + Expr moderationCaseRow, + Expr excluded, + UpdateSet Function({ + Expr caseId, + Expr reporterEmail, + Expr openedAt, + Expr resolvedAt, + Expr source, + Expr kind, + Expr subject, + Expr url, + Expr appealedCaseId, + Expr isSubjectOwner, + Expr status, + Expr grounds, + Expr violation, + Expr reason, + }) + set, + ) + updateBuilder, + ) => $ForGeneratedCode.updateOnConflictSingle( + this, + (moderationCaseRow, excluded) => updateBuilder( + moderationCaseRow, + excluded, + ({ + Expr? caseId, + Expr? reporterEmail, + Expr? openedAt, + Expr? resolvedAt, + Expr? source, + Expr? kind, + Expr? subject, + Expr? url, + Expr? appealedCaseId, + Expr? isSubjectOwner, + Expr? status, + Expr? grounds, + Expr? violation, + Expr? reason, + }) => $ForGeneratedCode.buildUpdate([ + caseId, + reporterEmail, + openedAt, + resolvedAt, + source, + kind, + subject, + url, + appealedCaseId, + isSubjectOwner, + status, + grounds, + violation, + reason, + ]), + ), + ); +} + +final class _$ModerationActionLog extends ModerationActionLog { + _$ModerationActionLog._( + this.caseId, + this.logId, + this.createdAt, + this.subject, + this.action, + this.note, + ); + + @override + final String caseId; + + @override + final String logId; + + @override + final DateTime createdAt; + + @override + final String subject; + + @override + final String action; + + @override + final String? note; + + static final _$table = $ForGeneratedCode.tableDefinition( + tableName: 'moderation_action_logs', + columns: [ + 'case_id', + 'log_id', + 'created_at', + 'subject', + 'action', + 'note', + ], + columnInfo: [ + $ForGeneratedCode.columnDefinition( + type: $ForGeneratedCode.text, + isNotNull: true, + defaultValue: null, + autoIncrement: false, + overrides: [], + ), + $ForGeneratedCode.columnDefinition( + type: $ForGeneratedCode.text, + isNotNull: true, + defaultValue: null, + autoIncrement: false, + overrides: [], + ), + $ForGeneratedCode.columnDefinition( + type: $ForGeneratedCode.dateTime, + isNotNull: true, + defaultValue: null, + autoIncrement: false, + overrides: [], + ), + $ForGeneratedCode.columnDefinition( + type: $ForGeneratedCode.text, + isNotNull: true, + defaultValue: null, + autoIncrement: false, + overrides: [], + ), + $ForGeneratedCode.columnDefinition( + type: $ForGeneratedCode.text, + isNotNull: true, + defaultValue: null, + autoIncrement: false, + overrides: [], + ), + $ForGeneratedCode.columnDefinition( + type: $ForGeneratedCode.text, + isNotNull: false, + defaultValue: null, + autoIncrement: false, + overrides: [], + ), + ], + primaryKey: ['case_id', 'log_id'], + unique: >[], + foreignKeys: [ + $ForGeneratedCode.foreignKeyDefinition( + name: 'parent', + columns: ['case_id'], + referencedTable: 'moderation_cases', + referencedColumns: ['case_id'], + onDelete: .cascade, + onUpdate: .cascade, + ), + ], + indexes: [], + readRow: _$ModerationActionLog._$fromDatabase, + ); + + static ModerationActionLog? _$fromDatabase(RowReader row) { + final caseId = row.readString(); + final logId = row.readString(); + final createdAt = row.readDateTime(); + final subject = row.readString(); + final action = row.readString(); + final note = row.readString(); + if (caseId == null && + logId == null && + createdAt == null && + subject == null && + action == null && + note == null) { + return null; + } + return _$ModerationActionLog._( + caseId!, + logId!, + createdAt!, + subject!, + action!, + note, + ); + } + + @override + String toString() => + 'ModerationActionLog(caseId: "$caseId", logId: "$logId", createdAt: "$createdAt", subject: "$subject", action: "$action", note: "$note")'; +} + +/// Extension methods for table defined in [ModerationActionLog]. +extension TableModerationActionLogExt on Table { + /// Insert row into the `moderationActionLogs` table. + /// + /// Returns a [InsertSingle] statement on which `.execute` must be + /// called for the row to be inserted. + InsertSingle insert({ + required Expr caseId, + required Expr logId, + required Expr createdAt, + required Expr subject, + required Expr action, + Expr? note, + }) => $ForGeneratedCode.insertInto( + table: this, + values: [caseId, logId, createdAt, subject, action, note], + ); + + /// Insert row into the `moderationActionLogs` table, or update the + /// existing row if it conflicts with the _primary key_. + /// + /// This is a shorthand for calling `.insert(...)` followed by + /// `.onConflict(.primaryKey)` and `.update(...)` to overwrite + /// the fields `createdAt`, `subject`, `action`, `note`, + /// with the values given, leaving the _primary key_ untouched. + /// + /// Returns an [UpsertSingle] statement on which `.execute()` must be + /// called for the row to be inserted or updated. + UpsertSingle upsert({ + required Expr caseId, + required Expr logId, + required Expr createdAt, + required Expr subject, + required Expr action, + Expr? note, + }) => + insert( + caseId: caseId, + logId: logId, + createdAt: createdAt, + subject: subject, + action: action, + note: note, + ) + .onConflict(.primaryKey) + .update( + (_, excluded, set) => set( + createdAt: excluded.createdAt, + subject: excluded.subject, + action: excluded.action, + note: excluded.note, + ), + ); + + /// Insert row into the `moderationActionLogs` table. + /// + /// Returns a [InsertSingle] statement on which `.execute` must be + /// called for the row to be inserted. + InsertSingle insertValue({ + required String caseId, + required String logId, + required DateTime createdAt, + required String subject, + required String action, + String? note, + }) => $ForGeneratedCode.insertInto( + table: this, + values: [ + caseId.asExpr, + logId.asExpr, + createdAt.asExpr, + subject.asExpr, + action.asExpr, + note.asExpr, + ], + ); + + /// Insert row into the `moderationActionLogs` table, or update the + /// existing row if it conflicts with the _primary key_. + /// + /// This is a shorthand for calling `.insertValue(...)` followed by + /// `.onConflict(.primaryKey)` and `.update(...)` to overwrite + /// the fields `createdAt`, `subject`, `action`, `note`, + /// with the values given, leaving the _primary key_ untouched. + /// + /// Returns an [UpsertSingle] statement on which `.execute()` must be + /// called for the row to be inserted or updated. + UpsertSingle upsertValue({ + required String caseId, + required String logId, + required DateTime createdAt, + required String subject, + required String action, + String? note, + }) => + insertValue( + caseId: caseId, + logId: logId, + createdAt: createdAt, + subject: subject, + action: action, + note: note, + ) + .onConflict(.primaryKey) + .update( + (_, excluded, set) => set( + createdAt: excluded.createdAt, + subject: excluded.subject, + action: excluded.action, + note: excluded.note, + ), + ); + + /// Bulk insert rows into the `moderationActionLogs` table. + /// + /// This method takes an `Iterable` and requires that you provide + /// a _mapping function_ from `T` to each column to be inserted. + /// + /// If a mapping function is omitted, the _default value_ will be + /// inserted, or `NULL` if column is nullable and as no default value. + /// To explicitely insert `NULL`, use a _mapping function_ that maps + /// `T` to `null`. + /// + /// > [!NOTE] + /// > This method aims utilize database specific bulk insertion logic + /// > to ensure good performance. Database adapters may pipeline bulk + /// > insertions through multiple statements inside a transaction. + /// + /// Returns a [Insert] statement on which `.execute` must be + /// called for the rows to be inserted. + Insert insertValuesMapped( + Iterable rows, { + required String Function(T row) caseId, + required String Function(T row) logId, + required DateTime Function(T row) createdAt, + required String Function(T row) subject, + required String Function(T row) action, + String? Function(T row)? note, + }) => $ForGeneratedCode.insertValuesMapped( + table: this, + rows: rows, + mappings: [caseId, logId, createdAt, subject, action, note], + ); + + /// Delete a single row from the `moderationActionLogs` table, specified by + /// _primary key_. + /// + /// Returns a [DeleteSingle] statement on which `.execute()` must be + /// called for the row to be deleted. + /// + /// To delete multiple rows, using `.where()` to filter which rows + /// should be deleted. If you wish to delete all rows, use + /// `.where((_) => toExpr(true)).delete()`. + DeleteSingle delete(String caseId, String logId) => + $ForGeneratedCode.deleteSingle( + byKey(caseId, logId), + _$ModerationActionLog._$table, + ); +} + +/// Extension methods for building queries against the `moderationActionLogs` table. +extension QueryModerationActionLogExt on Query<(Expr,)> { + /// Lookup a single row in `moderationActionLogs` table using the _primary key_. + /// + /// Returns a [QuerySingle] object, which returns at-most one row, + /// when `.fetch()` is called. + QuerySingle<(Expr,)> byKey( + String caseId, + String logId, + ) => where( + (moderationActionLog) => + moderationActionLog.caseId.equalsValue(caseId) & + moderationActionLog.logId.equalsValue(logId), + ).first; + + /// Update all rows in the `moderationActionLogs` table matching this [Query]. + /// + /// The changes to be applied to each row matching this [Query] are + /// defined using the [updateBuilder], which is given an [Expr] + /// representation of the row being updated and a `set` function to + /// specify which fields should be updated. The result of the `set` + /// function should always be returned from the `updateBuilder`. + /// + /// Returns an [Update] statement on which `.execute()` must be called + /// for the rows to be updated. + /// + /// **Example:** decrementing `1` from the `value` field for each row + /// where `value > 0`. + /// ```dart + /// await db.mytable + /// .where((row) => row.value > toExpr(0)) + /// .update((row, set) => set( + /// value: row.value - toExpr(1), + /// )) + /// .execute(); + /// ``` + /// + /// > [!WARNING] + /// > The `updateBuilder` callback does not make the update, it builds + /// > the expressions for updating the rows. You should **never** invoke + /// > the `set` function more than once, and the result should always + /// > be returned immediately. + Update update( + UpdateSet Function( + Expr moderationActionLog, + UpdateSet Function({ + Expr caseId, + Expr logId, + Expr createdAt, + Expr subject, + Expr action, + Expr note, + }) + set, + ) + updateBuilder, + ) => $ForGeneratedCode.update( + this, + _$ModerationActionLog._$table, + (moderationActionLog) => updateBuilder( + moderationActionLog, + ({ + Expr? caseId, + Expr? logId, + Expr? createdAt, + Expr? subject, + Expr? action, + Expr? note, + }) => $ForGeneratedCode.buildUpdate([ + caseId, + logId, + createdAt, + subject, + action, + note, + ]), + ), + ); + + /// Delete all rows in the `moderationActionLogs` table matching this [Query]. + /// + /// Returns a [Delete] statement on which `.execute()` must be called + /// for the rows to be deleted. + Delete delete() => + $ForGeneratedCode.delete(this, _$ModerationActionLog._$table); +} + +/// Extension methods for building point queries against the `moderationActionLogs` table. +extension QuerySingleModerationActionLogExt + on QuerySingle<(Expr,)> { + /// Update the row (if any) in the `moderationActionLogs` table matching this + /// [QuerySingle]. + /// + /// The changes to be applied to the row matching this [QuerySingle] are + /// defined using the [updateBuilder], which is given an [Expr] + /// representation of the row being updated and a `set` function to + /// specify which fields should be updated. The result of the `set` + /// function should always be returned from the `updateBuilder`. + /// + /// Returns an [UpdateSingle] statement on which `.execute()` must be + /// called for the row to be updated. The resulting statement will + /// **not** fail, if there are no rows matching this query exists. + /// + /// **Example:** decrementing `1` from the `value` field the row with + /// `id = 1`. + /// ```dart + /// await db.mytable + /// .byKey(1) + /// .update((row, set) => set( + /// value: row.value - toExpr(1), + /// )) + /// .execute(); + /// ``` + /// + /// > [!WARNING] + /// > The `updateBuilder` callback does not make the update, it builds + /// > the expressions for updating the rows. You should **never** invoke + /// > the `set` function more than once, and the result should always + /// > be returned immediately. + UpdateSingle update( + UpdateSet Function( + Expr moderationActionLog, + UpdateSet Function({ + Expr caseId, + Expr logId, + Expr createdAt, + Expr subject, + Expr action, + Expr note, + }) + set, + ) + updateBuilder, + ) => $ForGeneratedCode.updateSingle( + this, + _$ModerationActionLog._$table, + (moderationActionLog) => updateBuilder( + moderationActionLog, + ({ + Expr? caseId, + Expr? logId, + Expr? createdAt, + Expr? subject, + Expr? action, + Expr? note, + }) => $ForGeneratedCode.buildUpdate([ + caseId, + logId, + createdAt, + subject, + action, + note, + ]), + ), + ); + + /// Delete the row (if any) in the `moderationActionLogs` table matching this [QuerySingle]. + /// + /// Returns a [DeleteSingle] statement on which `.execute()` must be called + /// for the row to be deleted. The resulting statement will **not** + /// fail, if there are no rows matching this query exists. + DeleteSingle delete() => + $ForGeneratedCode.deleteSingle(this, _$ModerationActionLog._$table); +} + +/// Extension methods for expressions on a row in the `moderationActionLogs` table. +extension ExpressionModerationActionLogExt on Expr { + Expr get caseId => + $ForGeneratedCode.field(this, 0, $ForGeneratedCode.text); + + /// A ULID (in UUID format) generated from [createdAt], so ordering by it + /// matches the order of the entries. + Expr get logId => + $ForGeneratedCode.field(this, 1, $ForGeneratedCode.text); + + /// The timestamp of the action. + Expr get createdAt => + $ForGeneratedCode.field(this, 2, $ForGeneratedCode.dateTime); + + /// The fully qualified name of the subject the action was applied to. + Expr get subject => + $ForGeneratedCode.field(this, 3, $ForGeneratedCode.text); + + /// One of the `ModerationAction` enum names (`apply` or `revert`). + Expr get action => + $ForGeneratedCode.field(this, 4, $ForGeneratedCode.text); + + Expr get note => + $ForGeneratedCode.field(this, 5, $ForGeneratedCode.text); + + /// Do a subquery lookup of the row from table + /// `moderationCases` referenced in + /// [caseId]. + /// + /// The gets the row from table `moderationCases` where + /// [ModerationCaseRow.caseId] + /// is equal to [caseId]. + Expr get parent => $ForGeneratedCode + .subqueryTable(_$ModerationCaseRow._$table) + .where((r) => r.caseId.equalsUnlessNull(caseId)) + .first + .asNotNull(); +} + +extension ExpressionNullableModerationActionLogExt + on Expr { + Expr get caseId => + $ForGeneratedCode.field(this, 0, $ForGeneratedCode.text); + + /// A ULID (in UUID format) generated from [createdAt], so ordering by it + /// matches the order of the entries. + Expr get logId => + $ForGeneratedCode.field(this, 1, $ForGeneratedCode.text); + + /// The timestamp of the action. + Expr get createdAt => + $ForGeneratedCode.field(this, 2, $ForGeneratedCode.dateTime); + + /// The fully qualified name of the subject the action was applied to. + Expr get subject => + $ForGeneratedCode.field(this, 3, $ForGeneratedCode.text); + + /// One of the `ModerationAction` enum names (`apply` or `revert`). + Expr get action => + $ForGeneratedCode.field(this, 4, $ForGeneratedCode.text); + + Expr get note => + $ForGeneratedCode.field(this, 5, $ForGeneratedCode.text); + + /// Do a subquery lookup of the row from table + /// `moderationCases` referenced in + /// [caseId]. + /// + /// The gets the row from table `moderationCases` where + /// [ModerationCaseRow.caseId] + /// is equal to [caseId], if any. + /// + /// If this row is `NULL` the subquery is always return `NULL`. + Expr get parent => $ForGeneratedCode + .subqueryTable(_$ModerationCaseRow._$table) + .where((r) => r.caseId.equalsUnlessNull(caseId)) + .first; + + /// Check if the row is not `NULL`. + /// + /// This will check if _primary key_ fields in this row are `NULL`. + /// + /// If this is a reference lookup by subquery it might be more efficient + /// to check if the referencing field is `NULL`. + Expr isNotNull() => caseId.isNotNull() & logId.isNotNull(); + + /// Check if the row is `NULL`. + /// + /// This will check if _primary key_ fields in this row are `NULL`. + /// + /// If this is a reference lookup by subquery it might be more efficient + /// to check if the referencing field is `NULL`. + Expr isNull() => isNotNull().not(); +} + +extension InnerJoinModerationActionLogModerationCaseRowExt + on InnerJoin<(Expr,), (Expr,)> { + /// Join using the `parent` _foreign key_. + /// + /// This will match rows where [ModerationActionLog.caseId] = [ModerationCaseRow.caseId]. + Query<(Expr, Expr)> usingParent() => + on((a, b) => b.caseId.equalsUnlessNull(a.caseId)); +} + +extension LeftJoinModerationActionLogModerationCaseRowExt + on LeftJoin<(Expr,), (Expr,)> { + /// Join using the `parent` _foreign key_. + /// + /// This will match rows where [ModerationActionLog.caseId] = [ModerationCaseRow.caseId]. + Query<(Expr, Expr)> usingParent() => + on((a, b) => b.caseId.equalsUnlessNull(a.caseId)); +} + +extension RightJoinModerationActionLogModerationCaseRowExt + on RightJoin<(Expr,), (Expr,)> { + /// Join using the `parent` _foreign key_. + /// + /// This will match rows where [ModerationActionLog.caseId] = [ModerationCaseRow.caseId]. + Query<(Expr, Expr)> usingParent() => + on((a, b) => b.caseId.equalsUnlessNull(a.caseId)); +} + +/// `Table` conflict targets for use with `.onConflict`. +enum ModerationActionLogConflict { + /// Conflict with an existing row that has a matching primary key. + /// + /// Thus, the other row has matching values for: + /// `caseId`, `logId`. + primaryKey(['case_id', 'log_id']); + + const ModerationActionLogConflict(this._fields); + + final List _fields; +} + +extension InsertModerationActionLogExt on Insert { + /// Build an `INSERT` statement with an `ON CONFLICT` clause. + /// + /// The [target] argument specifies the _conflict target_ to be + /// handled. The _conflict target_ is always a `UNIQUE` constraint or + /// `PRIMARY KEY` constraint. + /// + /// If a row to be inserted violates the _conflict target_ constraint, + /// then the conflict action is triggered: + /// * `.doNothing()` to skip insertion of the new row, and, + /// * `.update((moderationActionLog, excluded, set) => set(...))` to + /// update the conflicting row. + /// + /// If a row to be inserted violates a constraint other than the one + /// specified in _conflict target_ then the entire `INSERT` statement + /// will fail. + /// + /// This is equivalent to `INSERT ... ON CONFLICT (...)` in SQL. + InsertOnConflict onConflict( + ModerationActionLogConflict target, + ) => $ForGeneratedCode.insertOnConflict(this, target._fields); +} + +extension InsertOnConflictModerationActionLogExt + on InsertOnConflict { + /// Build an `INSERT` statement an [upsert-clause][1]. + /// + /// When a row to be inserted violates the `UNIQUE` or `PRIMARY KEY` + /// constraint previously specified as _conflict target_, the existing + /// row is updated using the expressions defined with the + /// [updateBuilder]. The [updateBuilder] is given 3 parameters: + /// * `moderationActionLog` an [Expr] representing the existing row in + /// the database, + /// * `excluded` an [Expr] representing the row to be inserted in the + /// database, and, + /// * `set` a function to specify which fields should be updated and + /// build the [UpdateSet]. + /// + /// The result of the `set` function should always be immediately + /// returned from the [updateBuilder]. + /// + /// **Example:** Insert a counter with `count = 2` or increment the + /// existing row, if a `PRIMARY KEY` conflict occurs. + /// ```dart + /// await db.counters.insertValue( + /// name: 'my-counter', // primary key + /// count: 2, + /// ) + /// .onConflict(.primaryKey) + /// .update((counter, excluded, set) => set( + /// count: counter.count + excluded.count, + /// )) + /// .execute(); + /// ``` + /// + /// This is equivalent to + /// `INSERT ... ON CONFLICT (...) UPDATE SET ...` in SQL. + /// + /// > [!WARNING] + /// > The `updateBuilder` callback does not make the update, it builds + /// > the expressions for updating the rows. You should **never** invoke + /// > the `set` function more than once, and the result should always + /// > be returned immediately. + /// + /// [1]: https://www.sqlite.org/lang_upsert.html + Upsert update( + UpdateSet Function( + Expr moderationActionLog, + Expr excluded, + UpdateSet Function({ + Expr caseId, + Expr logId, + Expr createdAt, + Expr subject, + Expr action, + Expr note, + }) + set, + ) + updateBuilder, + ) => $ForGeneratedCode.updateOnConflict( + this, + (moderationActionLog, excluded) => updateBuilder( + moderationActionLog, + excluded, + ({ + Expr? caseId, + Expr? logId, + Expr? createdAt, + Expr? subject, + Expr? action, + Expr? note, + }) => $ForGeneratedCode.buildUpdate([ + caseId, + logId, + createdAt, + subject, + action, + note, + ]), + ), + ); +} + +extension InsertSingleModerationActionLogExt + on InsertSingle { + /// Build an `INSERT` statement with an `ON CONFLICT` clause. + /// + /// The [target] argument specifies the _conflict target_ to be + /// handled. The _conflict target_ is always a `UNIQUE` constraint or + /// `PRIMARY KEY` constraint. + /// + /// If a row to be inserted violates the _conflict target_ constraint, + /// then the conflict action is triggered: + /// * `.doNothing()` to skip insertion of the new row, and, + /// * `.update((moderationActionLog, excluded, set) => set(...))` to + /// update the conflicting row. + /// + /// If a row to be inserted violates a constraint other than the one + /// specified in _conflict target_ then the entire `INSERT` statement + /// will fail. + /// + /// This is equivalent to `INSERT ... ON CONFLICT (...)` in SQL. + InsertOnConflictSingle onConflict( + ModerationActionLogConflict target, + ) => $ForGeneratedCode.insertOnConflictSingle(this, target._fields); +} + +extension InsertOnConflictSingleModerationActionLogExt + on InsertOnConflictSingle { + /// Build an `INSERT` statement an [upsert-clause][1]. + /// + /// When a row to be inserted violates the `UNIQUE` or `PRIMARY KEY` + /// constraint previously specified as _conflict target_, the existing + /// row is updated using the expressions defined with the + /// [updateBuilder]. The [updateBuilder] is given 3 parameters: + /// * `moderationActionLog` an [Expr] representing the existing row in + /// the database, + /// * `excluded` an [Expr] representing the row to be inserted in the + /// database, and, + /// * `set` a function to specify which fields should be updated and + /// build the [UpdateSet]. + /// + /// The result of the `set` function should always be immediately + /// returned from the [updateBuilder]. + /// + /// **Example:** Insert a counter with `count = 2` or increment the + /// existing row, if a `PRIMARY KEY` conflict occurs. + /// ```dart + /// await db.counters.insertValue( + /// name: 'my-counter', // primary key + /// count: 2, + /// ) + /// .onConflict(.primaryKey) + /// .update((counter, excluded, set) => set( + /// count: counter.count + excluded.count, + /// )) + /// .execute(); + /// ``` + /// + /// This is equivalent to + /// `INSERT ... ON CONFLICT (...) UPDATE SET ...` in SQL. + /// + /// > [!WARNING] + /// > The `updateBuilder` callback does not make the update, it builds + /// > the expressions for updating the rows. You should **never** invoke + /// > the `set` function more than once, and the result should always + /// > be returned immediately. + /// + /// [1]: https://www.sqlite.org/lang_upsert.html + UpsertSingle update( + UpdateSet Function( + Expr moderationActionLog, + Expr excluded, + UpdateSet Function({ + Expr caseId, + Expr logId, + Expr createdAt, + Expr subject, + Expr action, + Expr note, + }) + set, + ) + updateBuilder, + ) => $ForGeneratedCode.updateOnConflictSingle( + this, + (moderationActionLog, excluded) => updateBuilder( + moderationActionLog, + excluded, + ({ + Expr? caseId, + Expr? logId, + Expr? createdAt, + Expr? subject, + Expr? action, + Expr? note, + }) => $ForGeneratedCode.buildUpdate([ + caseId, + logId, + createdAt, + subject, + action, + note, + ]), + ), + ); +} + final class _$NeatTaskStatusRow extends NeatTaskStatusRow { _$NeatTaskStatusRow._( this.taskName, diff --git a/app/lib/database/schema.moderation.dart b/app/lib/database/schema.moderation.dart new file mode 100644 index 0000000000..3f9eae6342 --- /dev/null +++ b/app/lib/database/schema.moderation.dart @@ -0,0 +1,90 @@ +// Copyright (c) 2026, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +part of 'schema.dart'; + +/// Tracks the status of a moderation or appeal case. +/// +/// TODO: rename to `ModerationCase` after the SQL migration. +@PrimaryKey(['caseId']) +abstract final class ModerationCaseRow extends Row { + /// Matches `ModerationCase.caseId`. + String get caseId; + + /// The email of the reporter. + String get reporterEmail; + + /// The case was opened at this time. + DateTime get openedAt; + + /// The case was resolved at this time (or `null` if it is still open). + DateTime? get resolvedAt; + + /// One of the `ModerationSource` values. + String get source; + + /// One of the `ModerationKind` values. + String get kind; + + /// The fully qualified name of the subject (e.g. `package:`). + /// + /// This is not a foreign key: the subject may be a user email, or an entity + /// that has been deleted. + @Index.field() + String get subject; + + /// The URL of the page from where the reporter navigated to the report page. + String? get url; + + /// The `caseId` of the appealed case (or `null`). + @Index.field() + String? get appealedCaseId; + + /// Whether the reporter is a known owner of the [subject]. + bool get isSubjectOwner; + + /// One of the `ModerationStatus` values. + String get status; + + /// One of the `ModerationGrounds` values (or `null` while pending). + String? get grounds; + + /// One of the `ModerationViolation` values (or `null` while pending). + String? get violation; + + /// The text from the statement of reasons sent to the user. + String? get reason; +} + +/// An entry of the action log of a [ModerationCaseRow]. +/// +/// TODO: rename to `ModerationActionLogEntry` after the SQL migration. +@PrimaryKey(['caseId', 'logId']) +@ForeignKey( + ['caseId'], + table: 'moderationCases', + fields: ['caseId'], + name: 'parent', + as: 'actionLogs', + onDelete: .cascade, + onUpdate: .cascade, +) +abstract final class ModerationActionLog extends Row { + String get caseId; + + /// A ULID (in UUID format) generated from [createdAt], so ordering by it + /// matches the order of the entries. + String get logId; + + /// The timestamp of the action. + DateTime get createdAt; + + /// The fully qualified name of the subject the action was applied to. + String get subject; + + /// One of the `ModerationAction` enum names (`apply` or `revert`). + String get action; + + String? get note; +} diff --git a/app/lib/frontend/handlers/report.dart b/app/lib/frontend/handlers/report.dart index 0bb80e4620..444515e33b 100644 --- a/app/lib/frontend/handlers/report.dart +++ b/app/lib/frontend/handlers/report.dart @@ -230,7 +230,7 @@ Future processReportPageHandler( // If the email sending fails, we may have pending [ModerationCase] entities // in the datastore. These would be reviewed and processed manually. - await withRetryTransaction(dbService, (tx) async { + final newCase = await withRetryTransaction(dbService, (tx) async { final mc = ModerationCase.init( caseId: caseId, reporterEmail: userEmail!, @@ -243,7 +243,9 @@ Future processReportPageHandler( appealedCaseId: form.caseId, ); tx.insert(mc); + return mc; }); + await adminBackend.mirrorModerationCaseToSql(newCase); final kind = isAppeal ? 'appeal' : 'report'; final kindLabel = isAppeal ? 'moderation appeal' : 'content policy violation'; diff --git a/app/lib/tool/backfill/backfill_new_fields.dart b/app/lib/tool/backfill/backfill_new_fields.dart index c77f648ad2..ef870f0afa 100644 --- a/app/lib/tool/backfill/backfill_new_fields.dart +++ b/app/lib/tool/backfill/backfill_new_fields.dart @@ -6,6 +6,7 @@ import 'package:logging/logging.dart'; import 'package:pub_dev/account/backend.dart'; import 'package:pub_dev/account/consent_backend.dart'; import 'package:pub_dev/account/like_backend.dart'; +import 'package:pub_dev/admin/backend.dart'; import 'package:pub_dev/package/backend.dart'; import 'package:pub_dev/package/models.dart'; import 'package:pub_dev/publisher/backend.dart'; @@ -65,4 +66,8 @@ Future backfillNewFields() async { // NOTE: Keep this around until Like is migrated to SQL. _logger.info('Backfilling package likes...'); await likeBackend.backfillSqlFromDatastore(); + + // NOTE: Keep this around until ModerationCase is migrated to SQL. + _logger.info('Backfilling moderation cases...'); + await adminBackend.backfillModerationCasesSqlFromDatastore(); } diff --git a/app/migrations/000021_moderation_case.sql b/app/migrations/000021_moderation_case.sql new file mode 100644 index 0000000000..27025b9d63 --- /dev/null +++ b/app/migrations/000021_moderation_case.sql @@ -0,0 +1,36 @@ +-- Create "moderation_cases" table +CREATE TABLE "moderation_cases" ( + "case_id" text NOT NULL, + "reporter_email" text NOT NULL, + "opened_at" timestamptz NOT NULL, + "resolved_at" timestamptz NULL, + "source" text NOT NULL, + "kind" text NOT NULL, + "subject" text NOT NULL, + "url" text NULL, + "appealed_case_id" text NULL, + "is_subject_owner" boolean NOT NULL, + "status" text NOT NULL, + "grounds" text NULL, + "violation" text NULL, + "reason" text NULL, + PRIMARY KEY ("case_id") +); + +-- Create index "moderation_cases_idx_appealed_case_id" to table: "moderation_cases" +CREATE INDEX "moderation_cases_idx_appealed_case_id" ON "moderation_cases" ("appealed_case_id"); + +-- Create index "moderation_cases_idx_subject" to table: "moderation_cases" +CREATE INDEX "moderation_cases_idx_subject" ON "moderation_cases" ("subject"); + +-- Create "moderation_action_logs" table +CREATE TABLE "moderation_action_logs" ( + "case_id" text NOT NULL, + "log_id" text NOT NULL, + "created_at" timestamptz NOT NULL, + "subject" text NOT NULL, + "action" text NOT NULL, + "note" text NULL, + PRIMARY KEY ("case_id", "log_id"), + CONSTRAINT "moderation_action_logs_fk_parent" FOREIGN KEY ("case_id") REFERENCES "moderation_cases" ("case_id") ON UPDATE CASCADE ON DELETE CASCADE +); diff --git a/app/migrations/sha256sum.txt b/app/migrations/sha256sum.txt index 7f26a7d035..91f6c16255 100644 --- a/app/migrations/sha256sum.txt +++ b/app/migrations/sha256sum.txt @@ -18,3 +18,4 @@ a1d582bf359628ebe7ed88d9b5c066185fe702b146dc8c4c9aacaecc56aa2c53 000014_audit_i c2eacad15a797fc11eb464296c73cc32211a07c17b3c8012402ecd92e13f6adf 000018_package_and_version.sql 8454af023ff5db777fa463a33768d5d01af2310d209a823a69dd2fcdf8c1eb1b 000019_publisher.sql 46b4d641734a4acc11ee6266117ed259df43e9865ceaa72ed8b7e24499d9aee4 000020_package_like.sql +671e0aaddb793496268ec7b206501c7e571fd3aedb8f421b773266b5c8c20be4 000021_moderation_case.sql diff --git a/app/test/admin/moderation_case_sql_test.dart b/app/test/admin/moderation_case_sql_test.dart new file mode 100644 index 0000000000..12947c9169 --- /dev/null +++ b/app/test/admin/moderation_case_sql_test.dart @@ -0,0 +1,124 @@ +// Copyright (c) 2026, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'package:_pub_shared/data/account_api.dart'; +import 'package:_pub_shared/data/admin_api.dart'; +import 'package:pub_dev/admin/backend.dart'; +import 'package:pub_dev/admin/models.dart'; +import 'package:pub_dev/database/database.dart'; +import 'package:pub_dev/database/schema.dart'; +import 'package:pub_dev/shared/datastore.dart'; +import 'package:test/test.dart'; +import 'package:typed_sql/typed_sql.dart'; + +import '../database/moderation_sql_utils.dart'; +import '../shared/test_models.dart'; +import '../shared/test_services.dart'; + +void main() { + group('moderation case SQL mirror', () { + testWithProfile( + 'report, moderate, resolve and delete are mirrored', + expectedLogMessages: [ + RegExp( + r'SHOUT Deleting object from public bucket: ".*/api/archives/oxygen-.*.tar.gz".', + ), + ], + fn: () async { + await withRetryPubApiClient((client) async { + await client.postReport( + ReportForm( + email: 'user@pub.dev', + subject: 'package:oxygen', + message: 'Huston, we have a problem.', + ), + ); + }); + await expectModerationCasesInSqlMatchDatastore(); + final mc = (await dbService.query().run().single); + + final api = createPubApiClient(authToken: siteAdminToken); + for (final state in ['true', 'false', 'true']) { + await api.adminInvokeAction( + 'moderate-package', + AdminInvokeActionArguments( + arguments: { + 'case': mc.caseId, + 'package': 'oxygen', + 'state': state, + 'note': 'state $state', + }, + ), + ); + } + await expectModerationCasesInSqlMatchDatastore(); + final logs = await primaryDatabase.withRetry( + (db) => db.moderationActionLogs.fetch(), + ); + expect(logs.map((l) => l.action).toSet(), {'apply', 'revert'}); + expect(logs, hasLength(3)); + + await api.adminInvokeAction( + 'moderation-case-resolve', + AdminInvokeActionArguments( + arguments: { + 'case': mc.caseId, + 'grounds': 'policy', + 'violation': 'scope_of_platform_service', + 'reason': 'This is a long enough reason.', + }, + ), + ); + await expectModerationCasesInSqlMatchDatastore(); + + await api.adminInvokeAction( + 'moderation-case-delete', + AdminInvokeActionArguments(arguments: {'case': mc.caseId}), + ); + await expectModerationCasesInSqlMatchDatastore(); + expect( + await primaryDatabase.withRetry( + (db) => db.moderationActionLogs.fetch(), + ), + isEmpty, + ); + }, + ); + + testWithProfile( + 'backfill syncs missing, stale and orphaned rows', + fn: () async { + final api = createPubApiClient(authToken: siteAdminToken); + final rs = await api.adminInvokeAction( + 'moderation-case-create', + AdminInvokeActionArguments(arguments: {'subject': 'package:oxygen'}), + ); + final caseId = rs.output['caseId'] as String; + expect(await adminBackend.backfillModerationCasesSqlFromDatastore(), 0); + + await adminBackend.deleteModerationCaseFromSql(caseId); + expect(await adminBackend.backfillModerationCasesSqlFromDatastore(), 1); + await expectModerationCasesInSqlMatchDatastore(); + + // stale row + await primaryDatabase.withRetry( + (db) => db.moderationCases + .where((c) => c.caseId.equalsValue(caseId)) + .update((c, set) => set(subject: toExpr('package:other'))) + .execute(), + ); + expect(await adminBackend.backfillModerationCasesSqlFromDatastore(), 1); + await expectModerationCasesInSqlMatchDatastore(); + + // row without an entity + await withRetryTransaction(dbService, (tx) async { + tx.delete(dbService.emptyKey.append(ModerationCase, id: caseId)); + }); + expect(await adminBackend.backfillModerationCasesSqlFromDatastore(), 1); + expect(await adminBackend.backfillModerationCasesSqlFromDatastore(), 0); + await expectModerationCasesInSqlMatchDatastore(); + }, + ); + }); +} diff --git a/app/test/database/moderation_sql_utils.dart b/app/test/database/moderation_sql_utils.dart new file mode 100644 index 0000000000..c8570a8ea8 --- /dev/null +++ b/app/test/database/moderation_sql_utils.dart @@ -0,0 +1,85 @@ +// Copyright (c) 2026, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'package:pub_dev/admin/models.dart'; +import 'package:pub_dev/database/database.dart'; +import 'package:pub_dev/database/schema.dart'; +import 'package:pub_dev/shared/datastore.dart'; +import 'package:test/test.dart'; +import 'package:typed_sql/typed_sql.dart'; + +/// Expects that the SQL moderation cases and their action logs match the +/// [ModerationCase] entities. +Future expectModerationCasesInSqlMatchDatastore() async { + final expectedCases = >{}; + final expectedLogs = >>{}; + await for (final mc in dbService.query().run()) { + expectedCases[mc.caseId] = { + 'reporterEmail': mc.reporterEmail, + 'openedAt': mc.opened.microsecondsSinceEpoch, + 'resolvedAt': mc.resolved?.microsecondsSinceEpoch, + 'source': mc.source, + 'kind': mc.kind, + 'subject': mc.subject, + 'url': mc.url, + 'appealedCaseId': mc.appealedCaseId, + 'isSubjectOwner': mc.isSubjectOwner, + 'status': mc.status, + 'grounds': mc.grounds, + 'violation': mc.violation, + 'reason': mc.reason, + }; + expectedLogs[mc.caseId] = [ + for (final e in mc.getActionLog().entries) + { + 'createdAt': e.timestamp.microsecondsSinceEpoch, + 'subject': e.subject, + 'action': e.moderationAction.name, + 'note': e.note, + }, + ]; + } + + final rows = await primaryDatabase.withRetry( + (db) => db.moderationCases.fetch(), + ); + expect({ + for (final r in rows) + r.caseId: { + 'reporterEmail': r.reporterEmail, + 'openedAt': r.openedAt.microsecondsSinceEpoch, + 'resolvedAt': r.resolvedAt?.microsecondsSinceEpoch, + 'source': r.source, + 'kind': r.kind, + 'subject': r.subject, + 'url': r.url, + 'appealedCaseId': r.appealedCaseId, + 'isSubjectOwner': r.isSubjectOwner, + 'status': r.status, + 'grounds': r.grounds, + 'violation': r.violation, + 'reason': r.reason, + }, + }, expectedCases); + + final logs = await primaryDatabase.withRetry( + (db) => db.moderationActionLogs + .orderBy( + (l) => [(l.caseId, Order.ascending), (l.logId, Order.ascending)], + ) + .fetch(), + ); + final actualLogs = >>{ + for (final caseId in expectedCases.keys) caseId: [], + }; + for (final l in logs) { + actualLogs[l.caseId]!.add({ + 'createdAt': l.createdAt.microsecondsSinceEpoch, + 'subject': l.subject, + 'action': l.action, + 'note': l.note, + }); + } + expect(actualLogs, expectedLogs); +} From 3e557c4cb957a3ae04c577c65717fbeba2433195 Mon Sep 17 00:00:00 2001 From: Istvan Soos Date: Mon, 19 Oct 2026 14:41:49 +0200 Subject: [PATCH 2/4] prevent double-lookup --- app/lib/admin/actions/moderation_case_update.dart | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/lib/admin/actions/moderation_case_update.dart b/app/lib/admin/actions/moderation_case_update.dart index f4bdf54cfc..7070908a48 100644 --- a/app/lib/admin/actions/moderation_case_update.dart +++ b/app/lib/admin/actions/moderation_case_update.dart @@ -76,8 +76,8 @@ Returns the fields of the updated moderation case. tx.insert(mc); }); final mc = await adminBackend.lookupModerationCase(caseId); - await adminBackend.mirrorModerationCaseToSqlById(caseId); + await adminBackend.mirrorModerationCaseToSql(mc!); - return mc!.toDebugInfo(); + return mc.toDebugInfo(); }, ); From 0b124fc1f37db4f75f4f12f9277cf570ca13d89c Mon Sep 17 00:00:00 2001 From: Istvan Soos Date: Mon, 19 Oct 2026 14:42:00 +0200 Subject: [PATCH 3/4] updated dry-run scripts --- app/lib/admin/actions/migrate_dry_run.dart | 25 +++++++++++----------- 1 file changed, 12 insertions(+), 13 deletions(-) diff --git a/app/lib/admin/actions/migrate_dry_run.dart b/app/lib/admin/actions/migrate_dry_run.dart index f2606b78cc..9593e74356 100644 --- a/app/lib/admin/actions/migrate_dry_run.dart +++ b/app/lib/admin/actions/migrate_dry_run.dart @@ -22,19 +22,18 @@ ALTER TABLE "consents" ALTER TABLE "consents" ADD CONSTRAINT "consents_unique_dedup_id" UNIQUE ("dedup_id"); ''', - // TODO: re-enable after ModerationCase migration lands - // // Fails if a case was resolved before it was opened. - // 'moderation-cases-chk-resolved': ''' - // ALTER TABLE "moderation_cases" - // ADD CONSTRAINT "moderation_cases_chk_resolved" - // CHECK (resolved_at IS NULL OR resolved_at >= opened_at); - // ''', - // // Fails if any appealed_case_id has no matching moderation_cases row. - // 'moderation-cases-fk-appealed': ''' - // ALTER TABLE "moderation_cases" - // ADD CONSTRAINT "moderation_cases_fk_appealed" - // FOREIGN KEY ("appealed_case_id") REFERENCES "moderation_cases" ("case_id"); - // ''', + // Fails if a case was resolved before it was opened. + 'moderation-cases-chk-resolved': ''' +ALTER TABLE "moderation_cases" + ADD CONSTRAINT "moderation_cases_chk_resolved" + CHECK (resolved_at IS NULL OR resolved_at >= opened_at); +''', + // Fails if any appealed_case_id has no matching moderation_cases row. + 'moderation-cases-fk-appealed': ''' +ALTER TABLE "moderation_cases" + ADD CONSTRAINT "moderation_cases_fk_appealed" + FOREIGN KEY ("appealed_case_id") REFERENCES "moderation_cases" ("case_id"); +''', // Fails if any package_version_assets.package has no matching packages row. 'package-version-assets-fk-package': ''' ALTER TABLE "package_version_assets" From a0e1f246e9f8f8eceed6ae504dd3f40e3d84e023 Mon Sep 17 00:00:00 2001 From: Istvan Soos Date: Mon, 19 Oct 2026 14:45:14 +0200 Subject: [PATCH 4/4] monotonic ulid --- app/lib/admin/backend.dart | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/app/lib/admin/backend.dart b/app/lib/admin/backend.dart index 3c533fa919..1d09e00289 100644 --- a/app/lib/admin/backend.dart +++ b/app/lib/admin/backend.dart @@ -937,12 +937,14 @@ class AdminBackend { final entries = mc.getActionLog().entries.sorted( (a, b) => a.timestamp.compareTo(b.timestamp), ); - for (var i = 0; i < entries.length; i++) { - final e = entries[i]; + final ulidFactory = UlidFactory(monotonic: true); + for (final e in entries) { await db.moderationActionLogs .upsertValue( caseId: mc.caseId, - logId: Ulid(millis: e.timestamp.millisecondsSinceEpoch).toUuid(), + logId: ulidFactory + .next(millis: e.timestamp.millisecondsSinceEpoch) + .toUuid(), createdAt: e.timestamp, subject: e.subject, action: e.moderationAction.name,