diff --git a/backend/src/business/email/templates/StoriesReviewQueueStaleTemplate.ts b/backend/src/business/email/templates/StoriesReviewQueueStaleTemplate.ts index 3d8c5ca49..4b66f30df 100644 --- a/backend/src/business/email/templates/StoriesReviewQueueStaleTemplate.ts +++ b/backend/src/business/email/templates/StoriesReviewQueueStaleTemplate.ts @@ -6,6 +6,8 @@ type StoryReviewEmailTemplateData = { storiesCount: number; reviewStoriesUrl: string; stats: { reviewer: string; count: number }[]; + // Switches the wording between the early warning and the missed-SLO version. + isApproachingSlo: boolean; }; class StoriesReviewQueueStaleTemplate extends EmailTemplate< diff --git a/backend/src/cron/checkStaleStories.ts b/backend/src/cron/checkStaleStories.ts index 7a48e2341..8d82e3b45 100644 --- a/backend/src/cron/checkStaleStories.ts +++ b/backend/src/cron/checkStaleStories.ts @@ -3,7 +3,13 @@ import ReviewQueueStaleTemplate from '../business/email/templates/StoriesReviewQ import StoryState from '../enum/StoryState'; import StoryRepository from '../repositories/StoryRepository'; -const STALENESS_THRESHOLD_MS = 1000 * 60 * 60 * 24 * 2; +const ONE_DAY_MS = 1000 * 60 * 60 * 24; + +// Stories should be reviewed within two days of submission. +const SLO_THRESHOLD_MS = ONE_DAY_MS * 2; + +// Warn a day before the SLO is missed so moderators have time to act. +const WARNING_THRESHOLD_MS = SLO_THRESHOLD_MS - ONE_DAY_MS; function forgeReviewStoriesUrl(): string { const storyEditUrl: URL = new URL( @@ -24,19 +30,23 @@ async function getReviewerStats(): Promise< return stats; } +// How long the story at the head of the review queue has been waiting, or 0 +// when the queue is empty. +async function getLongestWaitMs(): Promise { + const oldestStory = await StoryRepository() + .createQueryBuilder('story') + .where({ state: StoryState.SUBMITTED }) + .orderBy('story.updated_at', 'ASC') + .getOne(); + + return oldestStory ? Date.now() - oldestStory.updatedAt.getTime() : 0; +} + export default async function checkStaleStories(): Promise { - const hasStaleStories = - (await StoryRepository() - .createQueryBuilder('story') - .where({ state: StoryState.SUBMITTED }) - .andWhere('story.updated_at < :stalenessThreshold', { - stalenessThreshold: new Date(Date.now() - STALENESS_THRESHOLD_MS), - }) - .orderBy('story.updated_at', 'ASC') - .getCount()) > 0; - - if (!hasStaleStories) { - console.log('No stale stories found'); + const longestWaitMs = await getLongestWaitMs(); + + if (longestWaitMs < WARNING_THRESHOLD_MS) { + console.log('No stories approaching the review SLO'); return; } @@ -51,6 +61,7 @@ export default async function checkStaleStories(): Promise { reviewStoriesUrl, storiesCount, stats, + isApproachingSlo: longestWaitMs < SLO_THRESHOLD_MS, }, metadata: {}, to: process.env.MODERATORS_TO_EMAIL ?? '',