@atproto/ozone 0.2.9 → 0.2.11
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +26 -0
- package/package.json +25 -21
- package/bin/migration-create.ts +0 -38
- package/jest.config.cjs +0 -22
- package/src/api/chat/getActorMetadata.ts +0 -23
- package/src/api/chat/getConvo.ts +0 -23
- package/src/api/chat/getConvoMembers.ts +0 -23
- package/src/api/chat/getConvos.ts +0 -23
- package/src/api/chat/getMessageContext.ts +0 -42
- package/src/api/chat/index.ts +0 -16
- package/src/api/communication/createTemplate.ts +0 -51
- package/src/api/communication/deleteTemplate.ts +0 -23
- package/src/api/communication/listTemplates.ts +0 -31
- package/src/api/communication/updateTemplate.ts +0 -51
- package/src/api/health.ts +0 -27
- package/src/api/index.ts +0 -146
- package/src/api/label/fetchLabels.ts +0 -32
- package/src/api/label/queryLabels.ts +0 -57
- package/src/api/label/subscribeLabels.ts +0 -25
- package/src/api/moderation/cancelScheduledActions.ts +0 -72
- package/src/api/moderation/emitEvent.ts +0 -475
- package/src/api/moderation/getAccountTimeline.ts +0 -160
- package/src/api/moderation/getEvent.ts +0 -19
- package/src/api/moderation/getRecord.ts +0 -40
- package/src/api/moderation/getRecords.ts +0 -50
- package/src/api/moderation/getRepo.ts +0 -34
- package/src/api/moderation/getReporterStats.ts +0 -18
- package/src/api/moderation/getRepos.ts +0 -41
- package/src/api/moderation/getSubjects.ts +0 -101
- package/src/api/moderation/listScheduledActions.ts +0 -45
- package/src/api/moderation/queryEvents.ts +0 -72
- package/src/api/moderation/queryStatuses.ts +0 -23
- package/src/api/moderation/scheduleAction.ts +0 -129
- package/src/api/moderation/searchRepos.ts +0 -46
- package/src/api/moderation/util.ts +0 -96
- package/src/api/proxied.ts +0 -327
- package/src/api/queue/assignModerator.ts +0 -31
- package/src/api/queue/createQueue.ts +0 -62
- package/src/api/queue/deleteQueue.ts +0 -56
- package/src/api/queue/getAssignments.ts +0 -19
- package/src/api/queue/listQueues.ts +0 -39
- package/src/api/queue/routeReports.ts +0 -44
- package/src/api/queue/unassignModerator.ts +0 -26
- package/src/api/queue/updateQueue.ts +0 -54
- package/src/api/report/assignModerator.ts +0 -36
- package/src/api/report/createActivity.ts +0 -57
- package/src/api/report/createReport.ts +0 -93
- package/src/api/report/getAssignments.ts +0 -20
- package/src/api/report/getHistoricalStats.ts +0 -41
- package/src/api/report/getLatestReport.ts +0 -44
- package/src/api/report/getLiveStats.ts +0 -26
- package/src/api/report/getReport.ts +0 -55
- package/src/api/report/listActivities.ts +0 -37
- package/src/api/report/queryActivities.ts +0 -64
- package/src/api/report/queryReports.ts +0 -44
- package/src/api/report/reassignQueue.ts +0 -68
- package/src/api/report/refreshStats.ts +0 -27
- package/src/api/report/unassignModerator.ts +0 -21
- package/src/api/safelink/addRule.ts +0 -48
- package/src/api/safelink/queryEvents.ts +0 -32
- package/src/api/safelink/queryRules.ts +0 -58
- package/src/api/safelink/removeRule.ts +0 -42
- package/src/api/safelink/updateRule.ts +0 -48
- package/src/api/server/getConfig.ts +0 -35
- package/src/api/set/addValues.ts +0 -28
- package/src/api/set/deleteSet.ts +0 -34
- package/src/api/set/deleteValues.ts +0 -31
- package/src/api/set/getValues.ts +0 -42
- package/src/api/set/querySets.ts +0 -36
- package/src/api/set/upsertSet.ts +0 -38
- package/src/api/setting/listOptions.ts +0 -44
- package/src/api/setting/removeOptions.ts +0 -64
- package/src/api/setting/upsertOption.ts +0 -156
- package/src/api/team/addMember.ts +0 -51
- package/src/api/team/deleteMember.ts +0 -29
- package/src/api/team/listMembers.ts +0 -20
- package/src/api/team/updateMember.ts +0 -47
- package/src/api/util.ts +0 -265
- package/src/api/verification/grantVerifications.ts +0 -90
- package/src/api/verification/listVerifications.ts +0 -44
- package/src/api/verification/revokeVerifications.ts +0 -43
- package/src/api/well-known.ts +0 -46
- package/src/assignment/index.ts +0 -728
- package/src/auth-verifier.ts +0 -227
- package/src/background.ts +0 -183
- package/src/communication-service/template.ts +0 -110
- package/src/communication-service/util.ts +0 -8
- package/src/config/config.ts +0 -211
- package/src/config/env.ts +0 -95
- package/src/config/index.ts +0 -3
- package/src/config/secrets.ts +0 -17
- package/src/context.ts +0 -399
- package/src/daemon/blob-diverter.ts +0 -186
- package/src/daemon/context.ts +0 -247
- package/src/daemon/event-pusher.ts +0 -363
- package/src/daemon/event-reverser.ts +0 -128
- package/src/daemon/index.ts +0 -33
- package/src/daemon/job-cursor.ts +0 -33
- package/src/daemon/materialized-view-refresher.ts +0 -33
- package/src/daemon/queue-router.ts +0 -101
- package/src/daemon/scheduled-action-processor.ts +0 -304
- package/src/daemon/stats-computer.ts +0 -101
- package/src/daemon/strike-expiry-processor.ts +0 -95
- package/src/daemon/team-profile-synchronizer.ts +0 -15
- package/src/daemon/verification-listener.ts +0 -169
- package/src/db/index.ts +0 -203
- package/src/db/migrations/20231219T205730722Z-init.ts +0 -170
- package/src/db/migrations/20240116T085607200Z-communication-template.ts +0 -23
- package/src/db/migrations/20240201T051104136Z-mod-event-blobs.ts +0 -15
- package/src/db/migrations/20240208T213404429Z-add-tags-column-to-moderation-subject.ts +0 -31
- package/src/db/migrations/20240228T003647759Z-add-label-sigs.ts +0 -25
- package/src/db/migrations/20240408T192432676Z-mute-reporting.ts +0 -15
- package/src/db/migrations/20240506T225055595Z-message-subject.ts +0 -21
- package/src/db/migrations/20240521T211332580Z-member.ts +0 -17
- package/src/db/migrations/20240814T003647759Z-event-created-at-index.ts +0 -13
- package/src/db/migrations/20240903T205730722Z-add-template-lang.ts +0 -12
- package/src/db/migrations/20240904T205730722Z-add-subject-did-index.ts +0 -13
- package/src/db/migrations/20241001T205730722Z-subject-status-review-state-index.ts +0 -15
- package/src/db/migrations/20241008T205730722Z-sets.ts +0 -53
- package/src/db/migrations/20241018T205730722Z-setting.ts +0 -27
- package/src/db/migrations/20241026T205730722Z-add-hosting-status-to-subject-status.ts +0 -57
- package/src/db/migrations/20241220T144630860Z-stats-materialized-views.ts +0 -215
- package/src/db/migrations/20250204T003647759Z-add-subject-priority-score.ts +0 -22
- package/src/db/migrations/20250211T003647759Z-add-reporter-stats-index.ts +0 -38
- package/src/db/migrations/20250211T132135150Z-moderation-event-message-partial-idx.ts +0 -26
- package/src/db/migrations/20250221T132135150Z-member-details.ts +0 -14
- package/src/db/migrations/20250404T201720309Z-subject-status-sort-idxs.ts +0 -18
- package/src/db/migrations/20250415T201720309Z-verification.ts +0 -34
- package/src/db/migrations/20250417T201720309Z-firehose-cursor.ts +0 -16
- package/src/db/migrations/20250609T110704000Z-safelink.ts +0 -53
- package/src/db/migrations/20250618T180246000Z-add-mod-tool-to-moderation-event.ts +0 -18
- package/src/db/migrations/20250701T000000000Z-add-age-assurance-state.ts +0 -25
- package/src/db/migrations/20250715T000000000Z-add-mod-event-external-id.ts +0 -15
- package/src/db/migrations/20250718T150931000Z-update-appeal-reason-stats.ts +0 -310
- package/src/db/migrations/20250813T000000000Z-mod-tool-batch-id-index.ts +0 -14
- package/src/db/migrations/20250923T000000000Z-scheduled-actions.ts +0 -56
- package/src/db/migrations/20251008T120000000Z-add-strike-system.ts +0 -87
- package/src/db/migrations/20260210T154806448Z-mod-event-created-by-indexes.ts +0 -22
- package/src/db/migrations/20260219T164523000Z-create-report-table.ts +0 -155
- package/src/db/migrations/20260219T165302248Z-moderator-assignment.ts +0 -42
- package/src/db/migrations/20260225T000000000Z-add-report-queue-table.ts +0 -41
- package/src/db/migrations/20260313T000000000Z-add-report-activity-table.ts +0 -48
- package/src/db/migrations/20260318T152058935Z-add-report-stat.ts +0 -35
- package/src/db/migrations/20260428T000000000Z-add-expiring-tag-table.ts +0 -32
- package/src/db/migrations/20260513T202941104Z-add-subject-convo-id.ts +0 -114
- package/src/db/migrations/20260602T120000000Z-add-report-activity-created-index.ts +0 -17
- package/src/db/migrations/index.ts +0 -44
- package/src/db/migrations/provider.ts +0 -26
- package/src/db/pagination.ts +0 -335
- package/src/db/schema/account_events_stats.ts +0 -16
- package/src/db/schema/account_record_events_stats.ts +0 -15
- package/src/db/schema/account_record_status_stats.ts +0 -15
- package/src/db/schema/account_strike.ts +0 -13
- package/src/db/schema/blob_push_event.ts +0 -21
- package/src/db/schema/communication_template.ts +0 -19
- package/src/db/schema/expiring_tag.ts +0 -18
- package/src/db/schema/firehose_cursor.ts +0 -13
- package/src/db/schema/index.ts +0 -60
- package/src/db/schema/job_cursor.ts +0 -13
- package/src/db/schema/label.ts +0 -22
- package/src/db/schema/member.ts +0 -22
- package/src/db/schema/moderation_event.ts +0 -61
- package/src/db/schema/moderation_subject_status.ts +0 -52
- package/src/db/schema/moderator_assignment.ts +0 -16
- package/src/db/schema/ozone_set.ts +0 -24
- package/src/db/schema/record_events_stats.ts +0 -15
- package/src/db/schema/record_push_event.ts +0 -21
- package/src/db/schema/repo_push_event.ts +0 -19
- package/src/db/schema/report.ts +0 -28
- package/src/db/schema/report_activity.ts +0 -22
- package/src/db/schema/report_queue.ts +0 -21
- package/src/db/schema/report_stat.ts +0 -27
- package/src/db/schema/safelink.ts +0 -39
- package/src/db/schema/scheduled-action.ts +0 -25
- package/src/db/schema/setting.ts +0 -24
- package/src/db/schema/signing_key.ts +0 -10
- package/src/db/schema/verification.ts +0 -21
- package/src/db/types.ts +0 -24
- package/src/error.ts +0 -12
- package/src/image-invalidator.ts +0 -7
- package/src/index.ts +0 -154
- package/src/jetstream/service.ts +0 -107
- package/src/logger.ts +0 -29
- package/src/mod-service/expiring-tags.ts +0 -104
- package/src/mod-service/index.ts +0 -1842
- package/src/mod-service/profile.ts +0 -139
- package/src/mod-service/report.ts +0 -429
- package/src/mod-service/status.ts +0 -549
- package/src/mod-service/strike.ts +0 -96
- package/src/mod-service/subject.ts +0 -311
- package/src/mod-service/types.ts +0 -96
- package/src/mod-service/util.ts +0 -99
- package/src/mod-service/views.ts +0 -912
- package/src/queue/service.ts +0 -603
- package/src/report/activity.ts +0 -281
- package/src/report/handle-report-update.ts +0 -209
- package/src/report/reassign.ts +0 -109
- package/src/report/stats.ts +0 -852
- package/src/report/views.ts +0 -239
- package/src/safelink/service.ts +0 -304
- package/src/scheduled-action/service.ts +0 -281
- package/src/scheduled-action/types.ts +0 -17
- package/src/sequencer/index.ts +0 -2
- package/src/sequencer/outbox.ts +0 -123
- package/src/sequencer/sequencer.ts +0 -147
- package/src/set/service.ts +0 -230
- package/src/setting/constants.ts +0 -3
- package/src/setting/service.ts +0 -148
- package/src/setting/types.ts +0 -3
- package/src/setting/validators.ts +0 -333
- package/src/tag-service/content-tagger.ts +0 -30
- package/src/tag-service/embed-tagger.ts +0 -70
- package/src/tag-service/index.ts +0 -70
- package/src/tag-service/language-data.ts +0 -561
- package/src/tag-service/language-tagger.ts +0 -101
- package/src/tag-service/util.ts +0 -13
- package/src/team/index.ts +0 -296
- package/src/util.ts +0 -230
- package/src/verification/issuer.ts +0 -146
- package/src/verification/service.ts +0 -208
- package/src/verification/util.ts +0 -53
- package/test.env +0 -2
- package/tests/3p-labeler.test.ts +0 -288
- package/tests/__snapshots__/account-strikes.test.ts.snap +0 -159
- package/tests/__snapshots__/age-assurance.test.ts.snap +0 -66
- package/tests/__snapshots__/blob-divert.test.ts.snap +0 -219
- package/tests/__snapshots__/get-account-timeline.test.ts.snap +0 -36
- package/tests/__snapshots__/get-record.test.ts.snap +0 -271
- package/tests/__snapshots__/get-records.test.ts.snap +0 -175
- package/tests/__snapshots__/get-repo.test.ts.snap +0 -91
- package/tests/__snapshots__/get-repos.test.ts.snap +0 -127
- package/tests/__snapshots__/get-starter-pack.test.ts.snap +0 -535
- package/tests/__snapshots__/get-subjects.test.ts.snap +0 -529
- package/tests/__snapshots__/moderation-events.test.ts.snap +0 -347
- package/tests/__snapshots__/moderation-statuses.test.ts.snap +0 -276
- package/tests/__snapshots__/moderation.test.ts.snap +0 -85
- package/tests/__snapshots__/report-reason.test.ts.snap +0 -14
- package/tests/__snapshots__/safelink.test.ts.snap +0 -179
- package/tests/__snapshots__/scheduled-action.test.ts.snap +0 -61
- package/tests/__snapshots__/sets.test.ts.snap +0 -46
- package/tests/__snapshots__/settings.test.ts.snap +0 -52
- package/tests/__snapshots__/team.test.ts.snap +0 -374
- package/tests/__snapshots__/verification-listener.test.ts.snap +0 -152
- package/tests/__snapshots__/verification.test.ts.snap +0 -302
- package/tests/_util.ts +0 -242
- package/tests/account-strikes.test.ts +0 -184
- package/tests/ack-all-subjects-of-account.test.ts +0 -177
- package/tests/age-assurance.test.ts +0 -372
- package/tests/blob-divert.test.ts +0 -106
- package/tests/communication-templates.test.ts +0 -149
- package/tests/content-tagger.test.ts +0 -170
- package/tests/db.test.ts +0 -184
- package/tests/expiring-label.test.ts +0 -72
- package/tests/expiring-tags.test.ts +0 -232
- package/tests/get-account-timeline.test.ts +0 -85
- package/tests/get-config.test.ts +0 -55
- package/tests/get-lists.test.ts +0 -111
- package/tests/get-profiles.test.ts +0 -70
- package/tests/get-record.test.ts +0 -130
- package/tests/get-records.test.ts +0 -91
- package/tests/get-repo.test.ts +0 -171
- package/tests/get-report.test.ts +0 -136
- package/tests/get-reporter-stats.test.ts +0 -132
- package/tests/get-repos.test.ts +0 -91
- package/tests/get-starter-pack.test.ts +0 -115
- package/tests/get-subjects.test.ts +0 -81
- package/tests/mod-tool.test.ts +0 -268
- package/tests/moderation-appeals.test.ts +0 -260
- package/tests/moderation-events.test.ts +0 -756
- package/tests/moderation-status-tags.test.ts +0 -140
- package/tests/moderation-statuses.test.ts +0 -495
- package/tests/moderation.test.ts +0 -992
- package/tests/protected-tags.test.ts +0 -218
- package/tests/query-labels.test.ts +0 -238
- package/tests/query-reports.test.ts +0 -608
- package/tests/queue-assignment.test.ts +0 -428
- package/tests/queue-router.test.ts +0 -306
- package/tests/queues.test.ts +0 -690
- package/tests/record-and-account-events.test.ts +0 -197
- package/tests/repo-search.test.ts +0 -136
- package/tests/report-action.test.ts +0 -308
- package/tests/report-activity.test.ts +0 -711
- package/tests/report-assignment.test.ts +0 -517
- package/tests/report-muting.test.ts +0 -100
- package/tests/report-reason.test.ts +0 -154
- package/tests/report-reassign-queue.test.ts +0 -340
- package/tests/report-routing.test.ts +0 -245
- package/tests/report-stats.test.ts +0 -545
- package/tests/revoke-account-credentials.test.ts +0 -54
- package/tests/safelink.test.ts +0 -534
- package/tests/scheduled-action-processor.test.ts +0 -488
- package/tests/scheduled-action.test.ts +0 -334
- package/tests/sequencer.test.ts +0 -227
- package/tests/server.test.ts +0 -62
- package/tests/sets.test.ts +0 -246
- package/tests/settings.test.ts +0 -308
- package/tests/strike-expiry-processor.test.ts +0 -299
- package/tests/subject-priority-score.test.ts +0 -96
- package/tests/takedown.test.ts +0 -105
- package/tests/team.test.ts +0 -216
- package/tests/verification-listener.test.ts +0 -129
- package/tests/verification.test.ts +0 -186
- package/tsconfig.build.json +0 -9
- package/tsconfig.build.tsbuildinfo +0 -1
- package/tsconfig.json +0 -7
- package/tsconfig.tests.json +0 -8
package/src/report/stats.ts
DELETED
|
@@ -1,852 +0,0 @@
|
|
|
1
|
-
import { Selectable, sql } from 'kysely'
|
|
2
|
-
import { MINUTE } from '@atproto/common'
|
|
3
|
-
import { Database } from '../db/index.js'
|
|
4
|
-
import { ComputedAtIdKeyset, paginate } from '../db/pagination.js'
|
|
5
|
-
import { ReportStat } from '../db/schema/report_stat.js'
|
|
6
|
-
import { jsonb } from '../db/types.js'
|
|
7
|
-
import { dbLogger } from '../logger.js'
|
|
8
|
-
|
|
9
|
-
/**
|
|
10
|
-
* Grouped report types. Stats are computed per group rather than per individual report type.
|
|
11
|
-
*/
|
|
12
|
-
export const REPORT_TYPE_GROUPS: Record<string, string[]> = {
|
|
13
|
-
Legacy: [
|
|
14
|
-
'com.atproto.moderation.defs#reasonSpam',
|
|
15
|
-
'com.atproto.moderation.defs#reasonViolation',
|
|
16
|
-
'com.atproto.moderation.defs#reasonMisleading',
|
|
17
|
-
'com.atproto.moderation.defs#reasonSexual',
|
|
18
|
-
'com.atproto.moderation.defs#reasonRude',
|
|
19
|
-
'com.atproto.moderation.defs#reasonOther',
|
|
20
|
-
'com.atproto.moderation.defs#reasonAppeal',
|
|
21
|
-
],
|
|
22
|
-
Appeal: ['tools.ozone.report.defs#reasonAppeal'],
|
|
23
|
-
Violence: [
|
|
24
|
-
'tools.ozone.report.defs#reasonViolenceAnimalWelfare',
|
|
25
|
-
'tools.ozone.report.defs#reasonViolenceThreats',
|
|
26
|
-
'tools.ozone.report.defs#reasonViolenceGraphicContent',
|
|
27
|
-
'tools.ozone.report.defs#reasonViolenceSelfHarm',
|
|
28
|
-
'tools.ozone.report.defs#reasonViolenceGlorification',
|
|
29
|
-
'tools.ozone.report.defs#reasonViolenceExtremistContent',
|
|
30
|
-
'tools.ozone.report.defs#reasonViolenceTrafficking',
|
|
31
|
-
'tools.ozone.report.defs#reasonViolenceOther',
|
|
32
|
-
],
|
|
33
|
-
Sexual: [
|
|
34
|
-
'tools.ozone.report.defs#reasonSexualAbuseContent',
|
|
35
|
-
'tools.ozone.report.defs#reasonSexualNCII',
|
|
36
|
-
'tools.ozone.report.defs#reasonSexualSextortion',
|
|
37
|
-
'tools.ozone.report.defs#reasonSexualDeepfake',
|
|
38
|
-
'tools.ozone.report.defs#reasonSexualAnimal',
|
|
39
|
-
'tools.ozone.report.defs#reasonSexualUnlabeled',
|
|
40
|
-
'tools.ozone.report.defs#reasonSexualOther',
|
|
41
|
-
],
|
|
42
|
-
'Child Safety': [
|
|
43
|
-
'tools.ozone.report.defs#reasonChildSafetyCSAM',
|
|
44
|
-
'tools.ozone.report.defs#reasonChildSafetyGroom',
|
|
45
|
-
'tools.ozone.report.defs#reasonChildSafetyMinorPrivacy',
|
|
46
|
-
'tools.ozone.report.defs#reasonChildSafetyEndangerment',
|
|
47
|
-
'tools.ozone.report.defs#reasonChildSafetyHarassment',
|
|
48
|
-
'tools.ozone.report.defs#reasonChildSafetyPromotion',
|
|
49
|
-
'tools.ozone.report.defs#reasonChildSafetyOther',
|
|
50
|
-
],
|
|
51
|
-
Harassment: [
|
|
52
|
-
'tools.ozone.report.defs#reasonHarassmentTroll',
|
|
53
|
-
'tools.ozone.report.defs#reasonHarassmentTargeted',
|
|
54
|
-
'tools.ozone.report.defs#reasonHarassmentHateSpeech',
|
|
55
|
-
'tools.ozone.report.defs#reasonHarassmentDoxxing',
|
|
56
|
-
'tools.ozone.report.defs#reasonHarassmentOther',
|
|
57
|
-
],
|
|
58
|
-
Misleading: [
|
|
59
|
-
'tools.ozone.report.defs#reasonMisleadingBot',
|
|
60
|
-
'tools.ozone.report.defs#reasonMisleadingImpersonation',
|
|
61
|
-
'tools.ozone.report.defs#reasonMisleadingSpam',
|
|
62
|
-
'tools.ozone.report.defs#reasonMisleadingScam',
|
|
63
|
-
'tools.ozone.report.defs#reasonMisleadingSyntheticContent',
|
|
64
|
-
'tools.ozone.report.defs#reasonMisleadingMisinformation',
|
|
65
|
-
'tools.ozone.report.defs#reasonMisleadingOther',
|
|
66
|
-
],
|
|
67
|
-
'Rule Violations': [
|
|
68
|
-
'tools.ozone.report.defs#reasonRuleSiteSecurity',
|
|
69
|
-
'tools.ozone.report.defs#reasonRuleStolenContent',
|
|
70
|
-
'tools.ozone.report.defs#reasonRuleProhibitedSales',
|
|
71
|
-
'tools.ozone.report.defs#reasonRuleBanEvasion',
|
|
72
|
-
'tools.ozone.report.defs#reasonRuleOther',
|
|
73
|
-
],
|
|
74
|
-
Civic: [
|
|
75
|
-
'tools.ozone.report.defs#reasonCivicElectoralProcess',
|
|
76
|
-
'tools.ozone.report.defs#reasonCivicDisclosure',
|
|
77
|
-
'tools.ozone.report.defs#reasonCivicInterference',
|
|
78
|
-
'tools.ozone.report.defs#reasonCivicMisinformation',
|
|
79
|
-
'tools.ozone.report.defs#reasonCivicImpersonation',
|
|
80
|
-
],
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
const REPORT_STAT_LIVE_TTL = 15 * MINUTE
|
|
84
|
-
|
|
85
|
-
export type ReportStatsServiceCreator = (db: Database) => ReportStatsService
|
|
86
|
-
|
|
87
|
-
export type ReportStatGroup = {
|
|
88
|
-
queueId: number | null
|
|
89
|
-
moderatorDid: string | null
|
|
90
|
-
reportTypes: string[] | null
|
|
91
|
-
}
|
|
92
|
-
export type AggregateStatistics = {
|
|
93
|
-
inboundCount: number
|
|
94
|
-
pendingCount: number
|
|
95
|
-
actionedCount: number
|
|
96
|
-
escalatedCount: number
|
|
97
|
-
actionRate: number
|
|
98
|
-
avgHandlingTimeSec?: number
|
|
99
|
-
}
|
|
100
|
-
export type QueueStatistics = {
|
|
101
|
-
inboundCount: number
|
|
102
|
-
pendingCount: number
|
|
103
|
-
actionedCount: number
|
|
104
|
-
escalatedCount: number
|
|
105
|
-
actionRate: number
|
|
106
|
-
avgHandlingTimeSec?: number
|
|
107
|
-
}
|
|
108
|
-
export type ModeratorStatistics = {
|
|
109
|
-
inboundCount: number
|
|
110
|
-
actionedCount: number
|
|
111
|
-
avgHandlingTimeSec?: number
|
|
112
|
-
}
|
|
113
|
-
export type ReportTypeStatistics = {
|
|
114
|
-
inboundCount: number
|
|
115
|
-
pendingCount: number
|
|
116
|
-
actionedCount: number
|
|
117
|
-
escalatedCount: number
|
|
118
|
-
actionRate: number
|
|
119
|
-
avgHandlingTimeSec?: number
|
|
120
|
-
}
|
|
121
|
-
export type ReportStatistics =
|
|
122
|
-
| QueueStatistics
|
|
123
|
-
| ModeratorStatistics
|
|
124
|
-
| AggregateStatistics
|
|
125
|
-
| ReportTypeStatistics
|
|
126
|
-
|
|
127
|
-
// Batched query result types
|
|
128
|
-
type QueueCountRow = {
|
|
129
|
-
queueId: number | null
|
|
130
|
-
count: string
|
|
131
|
-
}
|
|
132
|
-
type QueueWindowRow = {
|
|
133
|
-
queueId: number | null
|
|
134
|
-
inboundCount: string
|
|
135
|
-
actionedCount: string
|
|
136
|
-
escalatedCount: string
|
|
137
|
-
handlingTimeSum: string | null
|
|
138
|
-
handlingTimeCount: string
|
|
139
|
-
}
|
|
140
|
-
type TypeCountRow = {
|
|
141
|
-
reportType: string
|
|
142
|
-
count: string
|
|
143
|
-
}
|
|
144
|
-
type TypeWindowRow = {
|
|
145
|
-
reportType: string
|
|
146
|
-
inboundCount: string
|
|
147
|
-
actionedCount: string
|
|
148
|
-
escalatedCount: string
|
|
149
|
-
handlingTimeSum: string | null
|
|
150
|
-
handlingTimeCount: string
|
|
151
|
-
}
|
|
152
|
-
type ModeratorWindowRow = {
|
|
153
|
-
did: string
|
|
154
|
-
inboundCount: string
|
|
155
|
-
actionedCount: string
|
|
156
|
-
handlingTimeSum: string | null
|
|
157
|
-
handlingTimeCount: string
|
|
158
|
-
}
|
|
159
|
-
type BatchedStats = {
|
|
160
|
-
queuePending: QueueCountRow[]
|
|
161
|
-
queueWindow: QueueWindowRow[]
|
|
162
|
-
typePending: TypeCountRow[]
|
|
163
|
-
typeWindow: TypeWindowRow[]
|
|
164
|
-
moderator: ModeratorWindowRow[]
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
type UpsertRow = {
|
|
168
|
-
date: string
|
|
169
|
-
queueId: number | null
|
|
170
|
-
moderatorDid: string | null
|
|
171
|
-
reportTypes: string[] | null
|
|
172
|
-
inboundCount: number | null
|
|
173
|
-
pendingCount: number | null
|
|
174
|
-
actionedCount: number | null
|
|
175
|
-
escalatedCount: number | null
|
|
176
|
-
actionRate: number | null
|
|
177
|
-
avgHandlingTimeSec: number | null
|
|
178
|
-
computedAt: string
|
|
179
|
-
}
|
|
180
|
-
|
|
181
|
-
export class ReportStatsService {
|
|
182
|
-
constructor(public db: Database) {}
|
|
183
|
-
|
|
184
|
-
static creator(): ReportStatsServiceCreator {
|
|
185
|
-
return (db: Database) => new ReportStatsService(db)
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
/**
|
|
189
|
-
* Compute stats for today and finalize yesterday if needed.
|
|
190
|
-
* Called periodically by the StatsComputer daemon.
|
|
191
|
-
*/
|
|
192
|
-
async materializeAll(opts?: { force?: boolean }): Promise<void> {
|
|
193
|
-
try {
|
|
194
|
-
const start = Date.now()
|
|
195
|
-
const today = toDateString(new Date())
|
|
196
|
-
const yesterday = toDateString(new Date(Date.now() - 24 * 60 * 60 * 1000))
|
|
197
|
-
|
|
198
|
-
// Always compute today's stats
|
|
199
|
-
await this.materializeDate(today, opts)
|
|
200
|
-
|
|
201
|
-
// Finalize yesterday if its snapshot is missing or stale
|
|
202
|
-
if (!opts?.force) {
|
|
203
|
-
const yesterdayRow = await this.db.db
|
|
204
|
-
.selectFrom('report_stat')
|
|
205
|
-
.select('computedAt')
|
|
206
|
-
.where('date', '=', yesterday)
|
|
207
|
-
.orderBy('computedAt', 'desc')
|
|
208
|
-
.executeTakeFirst()
|
|
209
|
-
const endOfYesterday = new Date(`${yesterday}T23:59:59.999Z`).getTime()
|
|
210
|
-
if (
|
|
211
|
-
!yesterdayRow ||
|
|
212
|
-
new Date(yesterdayRow.computedAt).getTime() < endOfYesterday
|
|
213
|
-
) {
|
|
214
|
-
await this.materializeDate(yesterday, { force: true })
|
|
215
|
-
}
|
|
216
|
-
} else {
|
|
217
|
-
await this.materializeDate(yesterday, { force: true })
|
|
218
|
-
}
|
|
219
|
-
|
|
220
|
-
const duration = Date.now() - start
|
|
221
|
-
dbLogger.info({ duration }, 'report stats materialization completed')
|
|
222
|
-
} catch (err) {
|
|
223
|
-
dbLogger.error({ err }, 'report stats materialization errored')
|
|
224
|
-
}
|
|
225
|
-
}
|
|
226
|
-
|
|
227
|
-
/**
|
|
228
|
-
* Compute stats for a specific date range. Used by the refreshStats endpoint.
|
|
229
|
-
*/
|
|
230
|
-
async refreshDateRange(opts: {
|
|
231
|
-
startDate: string
|
|
232
|
-
endDate: string
|
|
233
|
-
queueIds?: number[]
|
|
234
|
-
}): Promise<void> {
|
|
235
|
-
const start = new Date(opts.startDate)
|
|
236
|
-
const end = new Date(opts.endDate)
|
|
237
|
-
|
|
238
|
-
for (let d = new Date(start); d <= end; d.setUTCDate(d.getUTCDate() + 1)) {
|
|
239
|
-
const dateStr = toDateString(d)
|
|
240
|
-
if (opts.queueIds?.length) {
|
|
241
|
-
// Recompute only specific queue groups for this date
|
|
242
|
-
const batched = await this.computeBatchedStats(dateStr)
|
|
243
|
-
const rows: UpsertRow[] = []
|
|
244
|
-
for (const queueId of opts.queueIds) {
|
|
245
|
-
const group: ReportStatGroup = {
|
|
246
|
-
queueId,
|
|
247
|
-
moderatorDid: null,
|
|
248
|
-
reportTypes: null,
|
|
249
|
-
}
|
|
250
|
-
const stats = this.resolveGroupStats(group, batched)
|
|
251
|
-
rows.push(this.buildUpsertRow(dateStr, group, stats))
|
|
252
|
-
}
|
|
253
|
-
await this.bulkUpsert(rows)
|
|
254
|
-
} else {
|
|
255
|
-
await this.materializeDate(dateStr, { force: true })
|
|
256
|
-
}
|
|
257
|
-
}
|
|
258
|
-
}
|
|
259
|
-
|
|
260
|
-
/** Compute and write all groups for a single date. */
|
|
261
|
-
private async materializeDate(
|
|
262
|
-
date: string,
|
|
263
|
-
opts?: { force?: boolean },
|
|
264
|
-
): Promise<void> {
|
|
265
|
-
const groups = await this.enumerateGroups()
|
|
266
|
-
const batched = await this.computeBatchedStats(date)
|
|
267
|
-
const today = toDateString(new Date())
|
|
268
|
-
const isToday = date === today
|
|
269
|
-
|
|
270
|
-
// Batch the cache check so we don't issue one SELECT per group.
|
|
271
|
-
const existingByKey = !opts?.force
|
|
272
|
-
? await this.fetchExistingStatsByKey(date)
|
|
273
|
-
: null
|
|
274
|
-
|
|
275
|
-
const rows: UpsertRow[] = []
|
|
276
|
-
for (const group of groups) {
|
|
277
|
-
try {
|
|
278
|
-
if (existingByKey) {
|
|
279
|
-
const cached = existingByKey.get(groupKey(group))
|
|
280
|
-
if (cached) {
|
|
281
|
-
// Historical dates: never recompute. Today: recompute if stale.
|
|
282
|
-
if (!isToday) continue
|
|
283
|
-
const age = Date.now() - new Date(cached.computedAt).getTime()
|
|
284
|
-
if (age < REPORT_STAT_LIVE_TTL) continue
|
|
285
|
-
}
|
|
286
|
-
}
|
|
287
|
-
const stats = this.resolveGroupStats(group, batched)
|
|
288
|
-
rows.push(this.buildUpsertRow(date, group, stats))
|
|
289
|
-
} catch (err) {
|
|
290
|
-
dbLogger.error(
|
|
291
|
-
{ err, group, date },
|
|
292
|
-
'error preparing report stats group',
|
|
293
|
-
)
|
|
294
|
-
}
|
|
295
|
-
}
|
|
296
|
-
|
|
297
|
-
await this.bulkUpsert(rows)
|
|
298
|
-
}
|
|
299
|
-
|
|
300
|
-
/** Fetch all stat rows for a date, keyed by groupKey for O(1) lookup. */
|
|
301
|
-
private async fetchExistingStatsByKey(
|
|
302
|
-
date: string,
|
|
303
|
-
): Promise<Map<string, Selectable<ReportStat>>> {
|
|
304
|
-
const existing = await this.db.db
|
|
305
|
-
.selectFrom('report_stat')
|
|
306
|
-
.selectAll()
|
|
307
|
-
.where('date', '=', date)
|
|
308
|
-
.execute()
|
|
309
|
-
const map = new Map<string, Selectable<ReportStat>>()
|
|
310
|
-
for (const row of existing) {
|
|
311
|
-
map.set(
|
|
312
|
-
groupKey({
|
|
313
|
-
queueId: row.queueId,
|
|
314
|
-
moderatorDid: row.moderatorDid,
|
|
315
|
-
reportTypes: row.reportTypes,
|
|
316
|
-
}),
|
|
317
|
-
row,
|
|
318
|
-
)
|
|
319
|
-
}
|
|
320
|
-
return map
|
|
321
|
-
}
|
|
322
|
-
|
|
323
|
-
/** List out the groups to compute stats for. */
|
|
324
|
-
private async enumerateGroups(): Promise<ReportStatGroup[]> {
|
|
325
|
-
const groups: ReportStatGroup[] = []
|
|
326
|
-
|
|
327
|
-
const queues = await this.db.db
|
|
328
|
-
.selectFrom('report_queue')
|
|
329
|
-
.selectAll()
|
|
330
|
-
.where('enabled', '=', true)
|
|
331
|
-
.where('deletedAt', 'is', null)
|
|
332
|
-
.execute()
|
|
333
|
-
const members = await this.db.db
|
|
334
|
-
.selectFrom('member')
|
|
335
|
-
.select('did')
|
|
336
|
-
.where('disabled', '=', false)
|
|
337
|
-
.where('role', 'in', [
|
|
338
|
-
'tools.ozone.team.defs#roleAdmin',
|
|
339
|
-
'tools.ozone.team.defs#roleModerator',
|
|
340
|
-
'tools.ozone.team.defs#roleTriage',
|
|
341
|
-
])
|
|
342
|
-
.execute()
|
|
343
|
-
|
|
344
|
-
// aggregate
|
|
345
|
-
groups.push({ queueId: null, moderatorDid: null, reportTypes: null })
|
|
346
|
-
// per queue
|
|
347
|
-
for (const queue of queues) {
|
|
348
|
-
groups.push({ queueId: queue.id, moderatorDid: null, reportTypes: null })
|
|
349
|
-
}
|
|
350
|
-
// unqueued
|
|
351
|
-
groups.push({ queueId: -1, moderatorDid: null, reportTypes: null })
|
|
352
|
-
// per moderator
|
|
353
|
-
for (const member of members) {
|
|
354
|
-
groups.push({
|
|
355
|
-
queueId: null,
|
|
356
|
-
moderatorDid: member.did,
|
|
357
|
-
reportTypes: null,
|
|
358
|
-
})
|
|
359
|
-
}
|
|
360
|
-
// per report type group
|
|
361
|
-
for (const groupTypes of Object.values(REPORT_TYPE_GROUPS)) {
|
|
362
|
-
groups.push({
|
|
363
|
-
queueId: null,
|
|
364
|
-
moderatorDid: null,
|
|
365
|
-
reportTypes: groupTypes,
|
|
366
|
-
})
|
|
367
|
-
}
|
|
368
|
-
|
|
369
|
-
return groups
|
|
370
|
-
}
|
|
371
|
-
|
|
372
|
-
/**
|
|
373
|
-
* Run batched GROUP BY queries for a calendar date.
|
|
374
|
-
* Returns 5 result sets covering all group types.
|
|
375
|
-
*/
|
|
376
|
-
private async computeBatchedStats(date: string): Promise<BatchedStats> {
|
|
377
|
-
const dayStart = `${date}T00:00:00.000Z`
|
|
378
|
-
const dayEnd = `${nextDate(date)}T00:00:00.000Z`
|
|
379
|
-
|
|
380
|
-
const [queuePending, aggregatePending] = await Promise.all([
|
|
381
|
-
// Pending count is a snapshot of all non-closed reports at time of computation
|
|
382
|
-
this.db.db
|
|
383
|
-
.selectFrom('report')
|
|
384
|
-
.select(['queueId', sql<string>`count(*)`.as('count')])
|
|
385
|
-
.where('status', '!=', 'closed')
|
|
386
|
-
.where('queueId', 'is not', null)
|
|
387
|
-
.groupBy('queueId')
|
|
388
|
-
.execute(),
|
|
389
|
-
// Aggregate pending (includes all reports, even un-routed)
|
|
390
|
-
this.db.db
|
|
391
|
-
.selectFrom('report')
|
|
392
|
-
.select(sql<string>`count(*)`.as('count'))
|
|
393
|
-
.where('status', '!=', 'closed')
|
|
394
|
-
.executeTakeFirst(),
|
|
395
|
-
])
|
|
396
|
-
|
|
397
|
-
const queueWindow = await this.db.db
|
|
398
|
-
.selectFrom('report')
|
|
399
|
-
.select([
|
|
400
|
-
'queueId',
|
|
401
|
-
sql<string>`count(*)`.as('inboundCount'),
|
|
402
|
-
sql<string>`count(*) filter (where "status" = 'closed' and "closedAt" >= ${dayStart} and "closedAt" < ${dayEnd})`.as(
|
|
403
|
-
'actionedCount',
|
|
404
|
-
),
|
|
405
|
-
sql<string>`count(*) filter (where "status" = 'escalated')`.as(
|
|
406
|
-
'escalatedCount',
|
|
407
|
-
),
|
|
408
|
-
sql<string>`sum(extract(epoch from ("closedAt"::timestamp - "createdAt"::timestamp))) filter (where "status" = 'closed' and "closedAt" is not null and "closedAt" >= ${dayStart} and "closedAt" < ${dayEnd})`.as(
|
|
409
|
-
'handlingTimeSum',
|
|
410
|
-
),
|
|
411
|
-
sql<string>`count(*) filter (where "status" = 'closed' and "closedAt" is not null and "closedAt" >= ${dayStart} and "closedAt" < ${dayEnd})`.as(
|
|
412
|
-
'handlingTimeCount',
|
|
413
|
-
),
|
|
414
|
-
])
|
|
415
|
-
.where('createdAt', '>=', dayStart)
|
|
416
|
-
.where('createdAt', '<', dayEnd)
|
|
417
|
-
.where('queueId', 'is not', null)
|
|
418
|
-
.groupBy('queueId')
|
|
419
|
-
.execute()
|
|
420
|
-
|
|
421
|
-
// Aggregate windowed (includes all reports)
|
|
422
|
-
const aggregateWindow = await this.db.db
|
|
423
|
-
.selectFrom('report')
|
|
424
|
-
.select([
|
|
425
|
-
sql<string>`count(*)`.as('inboundCount'),
|
|
426
|
-
sql<string>`count(*) filter (where "status" = 'closed' and "closedAt" >= ${dayStart} and "closedAt" < ${dayEnd})`.as(
|
|
427
|
-
'actionedCount',
|
|
428
|
-
),
|
|
429
|
-
sql<string>`count(*) filter (where "status" = 'escalated')`.as(
|
|
430
|
-
'escalatedCount',
|
|
431
|
-
),
|
|
432
|
-
sql<string>`sum(extract(epoch from ("closedAt"::timestamp - "createdAt"::timestamp))) filter (where "status" = 'closed' and "closedAt" is not null and "closedAt" >= ${dayStart} and "closedAt" < ${dayEnd})`.as(
|
|
433
|
-
'handlingTimeSum',
|
|
434
|
-
),
|
|
435
|
-
sql<string>`count(*) filter (where "status" = 'closed' and "closedAt" is not null and "closedAt" >= ${dayStart} and "closedAt" < ${dayEnd})`.as(
|
|
436
|
-
'handlingTimeCount',
|
|
437
|
-
),
|
|
438
|
-
])
|
|
439
|
-
.where('createdAt', '>=', dayStart)
|
|
440
|
-
.where('createdAt', '<', dayEnd)
|
|
441
|
-
.executeTakeFirst()
|
|
442
|
-
|
|
443
|
-
const typePending = await this.db.db
|
|
444
|
-
.selectFrom('report')
|
|
445
|
-
.select(['reportType', sql<string>`count(*)`.as('count')])
|
|
446
|
-
.where('status', '!=', 'closed')
|
|
447
|
-
.groupBy('reportType')
|
|
448
|
-
.execute()
|
|
449
|
-
|
|
450
|
-
const typeWindow = await this.db.db
|
|
451
|
-
.selectFrom('report')
|
|
452
|
-
.select([
|
|
453
|
-
'reportType',
|
|
454
|
-
sql<string>`count(*)`.as('inboundCount'),
|
|
455
|
-
sql<string>`count(*) filter (where "status" = 'closed' and "closedAt" >= ${dayStart} and "closedAt" < ${dayEnd})`.as(
|
|
456
|
-
'actionedCount',
|
|
457
|
-
),
|
|
458
|
-
sql<string>`count(*) filter (where "status" = 'escalated')`.as(
|
|
459
|
-
'escalatedCount',
|
|
460
|
-
),
|
|
461
|
-
sql<string>`sum(extract(epoch from ("closedAt"::timestamp - "createdAt"::timestamp))) filter (where "status" = 'closed' and "closedAt" is not null and "closedAt" >= ${dayStart} and "closedAt" < ${dayEnd})`.as(
|
|
462
|
-
'handlingTimeSum',
|
|
463
|
-
),
|
|
464
|
-
sql<string>`count(*) filter (where "status" = 'closed' and "closedAt" is not null and "closedAt" >= ${dayStart} and "closedAt" < ${dayEnd})`.as(
|
|
465
|
-
'handlingTimeCount',
|
|
466
|
-
),
|
|
467
|
-
])
|
|
468
|
-
.where('createdAt', '>=', dayStart)
|
|
469
|
-
.where('createdAt', '<', dayEnd)
|
|
470
|
-
.groupBy('reportType')
|
|
471
|
-
.execute()
|
|
472
|
-
|
|
473
|
-
const moderator = await this.db.db
|
|
474
|
-
.selectFrom('report as r')
|
|
475
|
-
.innerJoin('moderator_assignment as ma', (join) =>
|
|
476
|
-
join.onRef('ma.reportId', '=', 'r.id').on('ma.endAt', 'is', null),
|
|
477
|
-
)
|
|
478
|
-
.select([
|
|
479
|
-
'ma.did',
|
|
480
|
-
sql<string>`count(*)`.as('inboundCount'),
|
|
481
|
-
sql<string>`count(*) filter (where r."status" = 'closed')`.as(
|
|
482
|
-
'actionedCount',
|
|
483
|
-
),
|
|
484
|
-
sql<string>`sum(extract(epoch from (r."closedAt"::timestamp - ma."startAt"::timestamp))) filter (where r."status" = 'closed' and r."closedAt" is not null)`.as(
|
|
485
|
-
'handlingTimeSum',
|
|
486
|
-
),
|
|
487
|
-
sql<string>`count(*) filter (where r."status" = 'closed' and r."closedAt" is not null)`.as(
|
|
488
|
-
'handlingTimeCount',
|
|
489
|
-
),
|
|
490
|
-
])
|
|
491
|
-
.where('r.createdAt', '>=', dayStart)
|
|
492
|
-
.where('r.createdAt', '<', dayEnd)
|
|
493
|
-
.groupBy('ma.did')
|
|
494
|
-
.execute()
|
|
495
|
-
|
|
496
|
-
// Inject aggregate as a synthetic row with queueId=null so resolveQueueStats can find it
|
|
497
|
-
const allQueuePending: QueueCountRow[] = [
|
|
498
|
-
...queuePending,
|
|
499
|
-
{ queueId: null, count: aggregatePending?.count ?? '0' },
|
|
500
|
-
]
|
|
501
|
-
const allQueueWindow: QueueWindowRow[] = aggregateWindow
|
|
502
|
-
? [
|
|
503
|
-
...queueWindow,
|
|
504
|
-
{
|
|
505
|
-
queueId: null,
|
|
506
|
-
inboundCount: aggregateWindow.inboundCount,
|
|
507
|
-
actionedCount: aggregateWindow.actionedCount,
|
|
508
|
-
escalatedCount: aggregateWindow.escalatedCount,
|
|
509
|
-
handlingTimeSum: aggregateWindow.handlingTimeSum,
|
|
510
|
-
handlingTimeCount: aggregateWindow.handlingTimeCount,
|
|
511
|
-
},
|
|
512
|
-
]
|
|
513
|
-
: queueWindow
|
|
514
|
-
|
|
515
|
-
return {
|
|
516
|
-
queuePending: allQueuePending,
|
|
517
|
-
queueWindow: allQueueWindow,
|
|
518
|
-
typePending,
|
|
519
|
-
typeWindow,
|
|
520
|
-
moderator,
|
|
521
|
-
}
|
|
522
|
-
}
|
|
523
|
-
|
|
524
|
-
/** Resolve a single group's stats from batched query results (pure in-memory). */
|
|
525
|
-
private resolveGroupStats(
|
|
526
|
-
group: ReportStatGroup,
|
|
527
|
-
batched: BatchedStats,
|
|
528
|
-
): ReportStatistics {
|
|
529
|
-
if (group.moderatorDid) {
|
|
530
|
-
return this.resolveModeratorStats(group.moderatorDid, batched.moderator)
|
|
531
|
-
}
|
|
532
|
-
if (group.reportTypes !== null) {
|
|
533
|
-
return this.resolveReportTypeStats(group.reportTypes, batched)
|
|
534
|
-
}
|
|
535
|
-
return this.resolveQueueStats(group.queueId, batched)
|
|
536
|
-
}
|
|
537
|
-
|
|
538
|
-
private resolveQueueStats(
|
|
539
|
-
queueId: number | null,
|
|
540
|
-
batched: BatchedStats,
|
|
541
|
-
): AggregateStatistics | QueueStatistics {
|
|
542
|
-
// queueId=null is the synthetic aggregate row
|
|
543
|
-
const pending = batched.queuePending.find((r) => r.queueId === queueId)
|
|
544
|
-
const window = batched.queueWindow.find((r) => r.queueId === queueId)
|
|
545
|
-
|
|
546
|
-
const pendingCount = num(pending?.count)
|
|
547
|
-
const inboundCount = num(window?.inboundCount)
|
|
548
|
-
const actionedCount = num(window?.actionedCount)
|
|
549
|
-
const escalatedCount = num(window?.escalatedCount)
|
|
550
|
-
const handlingTimeSum = Number(window?.handlingTimeSum ?? 0)
|
|
551
|
-
const handlingTimeCount = num(window?.handlingTimeCount)
|
|
552
|
-
const actionRate =
|
|
553
|
-
inboundCount > 0 ? Math.round((actionedCount / inboundCount) * 100) : 0
|
|
554
|
-
const avgHandlingTimeSec =
|
|
555
|
-
handlingTimeCount > 0
|
|
556
|
-
? Math.round(handlingTimeSum / handlingTimeCount)
|
|
557
|
-
: undefined
|
|
558
|
-
|
|
559
|
-
return {
|
|
560
|
-
inboundCount,
|
|
561
|
-
pendingCount,
|
|
562
|
-
actionedCount,
|
|
563
|
-
escalatedCount,
|
|
564
|
-
actionRate,
|
|
565
|
-
avgHandlingTimeSec,
|
|
566
|
-
}
|
|
567
|
-
}
|
|
568
|
-
|
|
569
|
-
private resolveReportTypeStats(
|
|
570
|
-
reportTypes: string[],
|
|
571
|
-
batched: BatchedStats,
|
|
572
|
-
): ReportTypeStatistics {
|
|
573
|
-
const types = new Set(reportTypes)
|
|
574
|
-
|
|
575
|
-
const matchingPending = batched.typePending.filter((r) =>
|
|
576
|
-
types.has(r.reportType),
|
|
577
|
-
)
|
|
578
|
-
const matchingWindow = batched.typeWindow.filter((r) =>
|
|
579
|
-
types.has(r.reportType),
|
|
580
|
-
)
|
|
581
|
-
|
|
582
|
-
const pendingCount = sumNum(matchingPending, 'count')
|
|
583
|
-
const inboundCount = sumNum(matchingWindow, 'inboundCount')
|
|
584
|
-
const actionedCount = sumNum(matchingWindow, 'actionedCount')
|
|
585
|
-
const escalatedCount = sumNum(matchingWindow, 'escalatedCount')
|
|
586
|
-
const handlingTimeSum = matchingWindow.reduce(
|
|
587
|
-
(sum, r) => sum + Number(r.handlingTimeSum ?? 0),
|
|
588
|
-
0,
|
|
589
|
-
)
|
|
590
|
-
const handlingTimeCount = sumNum(matchingWindow, 'handlingTimeCount')
|
|
591
|
-
|
|
592
|
-
const actionRate =
|
|
593
|
-
inboundCount > 0 ? Math.round((actionedCount / inboundCount) * 100) : 0
|
|
594
|
-
const avgHandlingTimeSec =
|
|
595
|
-
handlingTimeCount > 0
|
|
596
|
-
? Math.round(handlingTimeSum / handlingTimeCount)
|
|
597
|
-
: undefined
|
|
598
|
-
|
|
599
|
-
return {
|
|
600
|
-
inboundCount,
|
|
601
|
-
pendingCount,
|
|
602
|
-
actionedCount,
|
|
603
|
-
escalatedCount,
|
|
604
|
-
actionRate,
|
|
605
|
-
avgHandlingTimeSec,
|
|
606
|
-
}
|
|
607
|
-
}
|
|
608
|
-
|
|
609
|
-
private resolveModeratorStats(
|
|
610
|
-
moderatorDid: string,
|
|
611
|
-
rows: ModeratorWindowRow[],
|
|
612
|
-
): ModeratorStatistics {
|
|
613
|
-
const row = rows.find((r) => r.did === moderatorDid)
|
|
614
|
-
|
|
615
|
-
const inboundCount = num(row?.inboundCount)
|
|
616
|
-
const actionedCount = num(row?.actionedCount)
|
|
617
|
-
const handlingTimeCount = num(row?.handlingTimeCount)
|
|
618
|
-
const avgHandlingTimeSec =
|
|
619
|
-
handlingTimeCount > 0 && row?.handlingTimeSum
|
|
620
|
-
? Math.round(Number(row.handlingTimeSum) / handlingTimeCount)
|
|
621
|
-
: undefined
|
|
622
|
-
|
|
623
|
-
return { inboundCount, actionedCount, avgHandlingTimeSec }
|
|
624
|
-
}
|
|
625
|
-
|
|
626
|
-
/** Build an upsert row from (date, group, stats). */
|
|
627
|
-
private buildUpsertRow(
|
|
628
|
-
date: string,
|
|
629
|
-
group: ReportStatGroup,
|
|
630
|
-
stats: ReportStatistics,
|
|
631
|
-
): UpsertRow {
|
|
632
|
-
const pendingCount =
|
|
633
|
-
'pendingCount' in stats ? stats.pendingCount ?? null : null
|
|
634
|
-
const escalatedCount =
|
|
635
|
-
'escalatedCount' in stats ? stats.escalatedCount ?? null : null
|
|
636
|
-
const actionRate = 'actionRate' in stats ? stats.actionRate ?? null : null
|
|
637
|
-
|
|
638
|
-
return {
|
|
639
|
-
date,
|
|
640
|
-
queueId: group.queueId,
|
|
641
|
-
moderatorDid: group.moderatorDid,
|
|
642
|
-
reportTypes: group.reportTypes,
|
|
643
|
-
inboundCount: stats.inboundCount ?? null,
|
|
644
|
-
pendingCount,
|
|
645
|
-
actionedCount: stats.actionedCount ?? null,
|
|
646
|
-
escalatedCount,
|
|
647
|
-
actionRate,
|
|
648
|
-
avgHandlingTimeSec: stats.avgHandlingTimeSec ?? null,
|
|
649
|
-
computedAt: new Date().toISOString(),
|
|
650
|
-
}
|
|
651
|
-
}
|
|
652
|
-
|
|
653
|
-
/**
|
|
654
|
-
* Wraps a DELETE+INSERT for each row in a single transaction so we pay one
|
|
655
|
-
* commit per cycle instead of one per group. NULL-aware WHERE clauses match
|
|
656
|
-
* the existing PG <15 NULL semantics without needing a unique index.
|
|
657
|
-
*/
|
|
658
|
-
private async bulkUpsert(rows: UpsertRow[]): Promise<void> {
|
|
659
|
-
if (!rows.length) return
|
|
660
|
-
|
|
661
|
-
await this.db.transaction(async (dbTxn) => {
|
|
662
|
-
for (const r of rows) {
|
|
663
|
-
let del = dbTxn.db.deleteFrom('report_stat').where('date', '=', r.date)
|
|
664
|
-
del =
|
|
665
|
-
r.queueId !== null
|
|
666
|
-
? del.where('queueId', '=', r.queueId)
|
|
667
|
-
: del.where('queueId', 'is', null)
|
|
668
|
-
del =
|
|
669
|
-
r.moderatorDid !== null
|
|
670
|
-
? del.where('moderatorDid', '=', r.moderatorDid)
|
|
671
|
-
: del.where('moderatorDid', 'is', null)
|
|
672
|
-
del =
|
|
673
|
-
r.reportTypes !== null
|
|
674
|
-
? del.where(
|
|
675
|
-
sql<boolean>`"reportTypes"::jsonb = ${jsonb(r.reportTypes)}::jsonb`,
|
|
676
|
-
)
|
|
677
|
-
: del.where('reportTypes', 'is', null)
|
|
678
|
-
await del.execute()
|
|
679
|
-
|
|
680
|
-
await dbTxn.db
|
|
681
|
-
.insertInto('report_stat')
|
|
682
|
-
.values({
|
|
683
|
-
date: r.date,
|
|
684
|
-
queueId: r.queueId,
|
|
685
|
-
moderatorDid: r.moderatorDid,
|
|
686
|
-
reportTypes: r.reportTypes !== null ? jsonb(r.reportTypes) : null,
|
|
687
|
-
inboundCount: r.inboundCount,
|
|
688
|
-
pendingCount: r.pendingCount,
|
|
689
|
-
actionedCount: r.actionedCount,
|
|
690
|
-
escalatedCount: r.escalatedCount,
|
|
691
|
-
actionRate: r.actionRate,
|
|
692
|
-
avgHandlingTimeSec: r.avgHandlingTimeSec,
|
|
693
|
-
computedAt: r.computedAt,
|
|
694
|
-
})
|
|
695
|
-
.execute()
|
|
696
|
-
}
|
|
697
|
-
})
|
|
698
|
-
}
|
|
699
|
-
|
|
700
|
-
// ─── Read methods ───
|
|
701
|
-
|
|
702
|
-
/** Get a single stat row for a date + group. */
|
|
703
|
-
private async getStatForDate(
|
|
704
|
-
date: string,
|
|
705
|
-
group: ReportStatGroup,
|
|
706
|
-
): Promise<Selectable<ReportStat> | undefined> {
|
|
707
|
-
let qb = this.db.db
|
|
708
|
-
.selectFrom('report_stat')
|
|
709
|
-
.selectAll()
|
|
710
|
-
.where('date', '=', date)
|
|
711
|
-
if (group.queueId !== null) {
|
|
712
|
-
qb = qb.where('queueId', '=', group.queueId)
|
|
713
|
-
} else {
|
|
714
|
-
qb = qb.where('queueId', 'is', null)
|
|
715
|
-
}
|
|
716
|
-
if (group.moderatorDid) {
|
|
717
|
-
qb = qb.where('moderatorDid', '=', group.moderatorDid)
|
|
718
|
-
} else {
|
|
719
|
-
qb = qb.where('moderatorDid', 'is', null)
|
|
720
|
-
}
|
|
721
|
-
if (group.reportTypes !== null) {
|
|
722
|
-
qb = qb.where(
|
|
723
|
-
sql<boolean>`"reportTypes"::jsonb = ${jsonb(group.reportTypes)}::jsonb`,
|
|
724
|
-
)
|
|
725
|
-
} else {
|
|
726
|
-
qb = qb.where('reportTypes', 'is', null)
|
|
727
|
-
}
|
|
728
|
-
return qb.executeTakeFirst()
|
|
729
|
-
}
|
|
730
|
-
|
|
731
|
-
/** Get today's live stats for a group. */
|
|
732
|
-
async getLiveStats(
|
|
733
|
-
group: ReportStatGroup,
|
|
734
|
-
): Promise<Selectable<ReportStat> | undefined> {
|
|
735
|
-
const today = toDateString(new Date())
|
|
736
|
-
return this.getStatForDate(today, group)
|
|
737
|
-
}
|
|
738
|
-
|
|
739
|
-
/** Get live stats for multiple queues in a single query. */
|
|
740
|
-
async getLiveStatsForQueues(
|
|
741
|
-
queueIds: number[],
|
|
742
|
-
): Promise<Map<number, Selectable<ReportStat>>> {
|
|
743
|
-
if (!queueIds.length) return new Map()
|
|
744
|
-
|
|
745
|
-
const today = toDateString(new Date())
|
|
746
|
-
const rows = await this.db.db
|
|
747
|
-
.selectFrom('report_stat')
|
|
748
|
-
.selectAll()
|
|
749
|
-
.where('date', '=', today)
|
|
750
|
-
.where('queueId', 'in', queueIds)
|
|
751
|
-
.where('moderatorDid', 'is', null)
|
|
752
|
-
.where('reportTypes', 'is', null)
|
|
753
|
-
.execute()
|
|
754
|
-
|
|
755
|
-
const result = new Map<number, Selectable<ReportStat>>()
|
|
756
|
-
for (const row of rows) {
|
|
757
|
-
if (row.queueId !== null) {
|
|
758
|
-
result.set(row.queueId, row)
|
|
759
|
-
}
|
|
760
|
-
}
|
|
761
|
-
return result
|
|
762
|
-
}
|
|
763
|
-
|
|
764
|
-
/** Get historical stats for a date range, paginated. */
|
|
765
|
-
async getHistoricalStats(opts: {
|
|
766
|
-
group: ReportStatGroup
|
|
767
|
-
startDate?: string
|
|
768
|
-
endDate?: string
|
|
769
|
-
limit: number
|
|
770
|
-
cursor?: string
|
|
771
|
-
}): Promise<{ stats: Selectable<ReportStat>[]; cursor?: string }> {
|
|
772
|
-
const { group, startDate, endDate, limit } = opts
|
|
773
|
-
const { queueId, moderatorDid, reportTypes } = group
|
|
774
|
-
const { ref } = this.db.db.dynamic
|
|
775
|
-
|
|
776
|
-
let qb = this.db.db.selectFrom('report_stat').selectAll()
|
|
777
|
-
|
|
778
|
-
if (queueId !== null) {
|
|
779
|
-
qb = qb.where('queueId', '=', queueId)
|
|
780
|
-
} else {
|
|
781
|
-
qb = qb.where('queueId', 'is', null)
|
|
782
|
-
}
|
|
783
|
-
if (moderatorDid) {
|
|
784
|
-
qb = qb.where('moderatorDid', '=', moderatorDid)
|
|
785
|
-
} else {
|
|
786
|
-
qb = qb.where('moderatorDid', 'is', null)
|
|
787
|
-
}
|
|
788
|
-
if (reportTypes !== null) {
|
|
789
|
-
qb = qb.where(
|
|
790
|
-
sql<boolean>`"reportTypes"::jsonb = ${jsonb(reportTypes)}::jsonb`,
|
|
791
|
-
)
|
|
792
|
-
} else {
|
|
793
|
-
qb = qb.where('reportTypes', 'is', null)
|
|
794
|
-
}
|
|
795
|
-
if (startDate) {
|
|
796
|
-
qb = qb.where('date', '>=', toDateString(new Date(startDate)))
|
|
797
|
-
}
|
|
798
|
-
if (endDate) {
|
|
799
|
-
qb = qb.where('date', '<=', toDateString(new Date(endDate)))
|
|
800
|
-
}
|
|
801
|
-
|
|
802
|
-
const keyset = new ComputedAtIdKeyset(ref('computedAt'), ref('id'))
|
|
803
|
-
const paginatedBuilder = paginate(qb, {
|
|
804
|
-
limit,
|
|
805
|
-
cursor: opts.cursor,
|
|
806
|
-
keyset,
|
|
807
|
-
direction: 'desc',
|
|
808
|
-
tryIndex: true,
|
|
809
|
-
})
|
|
810
|
-
|
|
811
|
-
const stats = await paginatedBuilder.execute()
|
|
812
|
-
|
|
813
|
-
return { stats, cursor: keyset.packFromResult(stats) }
|
|
814
|
-
}
|
|
815
|
-
}
|
|
816
|
-
|
|
817
|
-
// ─── Helpers ───
|
|
818
|
-
|
|
819
|
-
/** Parse a pg bigint string to number, defaulting to 0. */
|
|
820
|
-
function num(val: string | undefined | null): number {
|
|
821
|
-
return val ? Number(val) : 0
|
|
822
|
-
}
|
|
823
|
-
|
|
824
|
-
/** Sum a numeric string field across rows. */
|
|
825
|
-
function sumNum<T>(rows: T[], field: keyof T): number {
|
|
826
|
-
return rows.reduce((sum, r) => sum + Number(r[field] ?? 0), 0)
|
|
827
|
-
}
|
|
828
|
-
|
|
829
|
-
/**
|
|
830
|
-
* Stable cache-key for a stat group. Used to look up an existing row in the
|
|
831
|
-
* batched cache map without issuing per-group SELECTs. Report types are
|
|
832
|
-
* stringified in stored order, which matches REPORT_TYPE_GROUPS.
|
|
833
|
-
*/
|
|
834
|
-
function groupKey(g: ReportStatGroup): string {
|
|
835
|
-
return [
|
|
836
|
-
g.queueId ?? 'null',
|
|
837
|
-
g.moderatorDid ?? 'null',
|
|
838
|
-
g.reportTypes ? JSON.stringify(g.reportTypes) : 'null',
|
|
839
|
-
].join('|')
|
|
840
|
-
}
|
|
841
|
-
|
|
842
|
-
/** Convert a Date to an ISO date string (YYYY-MM-DD). */
|
|
843
|
-
function toDateString(d: Date): string {
|
|
844
|
-
return d.toISOString().slice(0, 10)
|
|
845
|
-
}
|
|
846
|
-
|
|
847
|
-
/** Get the next calendar date string. */
|
|
848
|
-
function nextDate(dateStr: string): string {
|
|
849
|
-
const d = new Date(`${dateStr}T00:00:00.000Z`)
|
|
850
|
-
d.setUTCDate(d.getUTCDate() + 1)
|
|
851
|
-
return toDateString(d)
|
|
852
|
-
}
|