/** * T-160 — Task due-date reminders (worker core). * * Hourly sweep (maintenance job `task-due-sweep`): for every OPEN task whose * `dueAt` falls in `(now − 1h, now + 24h]`, notify each assignee with a * `TASK_DUE` in-app notification — the 1h look-back covers a sweep that runs * just after the deadline. Idempotency WITHOUT a schema change (additive-only * invariant): a Redis flag per (task, dueAt) — `worker:task-due:{taskId}: * {dueAtMs}` — so a re-run is a no-op and a CHANGED dueAt re-reminds (the * key embeds the timestamp). Same dedupe pattern as the webhook DLQ report. */ import type { Redis } from "ioredis"; import type { PrismaClient } from "@prisma/client"; import { asJson } from "../lib/prisma.js"; import { getLogger } from "../logger.js"; const log = getLogger("tasks"); const REMIND_WINDOW_MS = 24 * 3_600_000; // remind up to 24h before the due date const LOOKBACK_MS = 3_600_000; // cover the hour a sweep runs past the due date const FLAG_TTL_S = 3 * 86_400; // the flag outlives the window (no re-remind) const dueFlag = (taskId: string, dueAt: Date): string => `worker:task-due:${taskId}:${Math.floor(dueAt.getTime() / 1000)}`; const reviewDueFlag = (reviewId: string, deadline: Date): string => `worker:review-due:${reviewId}:${Math.floor(deadline.getTime() / 1000)}`; /** * Claim a once-only reminder (Redis NX+EX). The FIRST caller to claim a key * wins (returns true); later runs / concurrent workers get false → skip. The * key embeds the due/deadline timestamp, so a CHANGED time re-reminds. * Best-effort: if Redis is down we still send (a duplicate beat a missed * reminder here). */ const claimOnce = async (redis: Redis, key: string): Promise => redis .set(key, "1", "EX", FLAG_TTL_S, "NX") .then((r) => r === "OK") .catch(() => true); /** * Run the due-reminder sweep. Returns counts (the report lands in the * maintenance job log). `now` is injectable for deterministic tests. */ export async function runTaskDueSweep( prisma: PrismaClient, redis: Redis, now: Date = new Date() ): Promise<{ checked: number; reminded: number; notifications: number }> { const from = new Date(now.getTime() - LOOKBACK_MS); const to = new Date(now.getTime() + REMIND_WINDOW_MS); const rows = await prisma.task.findMany({ where: { status: { in: ["todo", "in_progress"] }, dueAt: { gt: from, lte: to } }, select: { id: true, workspaceId: true, pageId: true, title: true, dueAt: true, assigneeIds: true, status: true } }); const pageRows = await prisma.page.findMany({ where: { id: { in: rows.map((r) => r.pageId) } }, select: { id: true, trashedAt: true } }); const pages = new Map(pageRows.map((r) => [r.id, r])); let reminded = 0; let notifications = 0; for (const t of rows) { if (!t.assigneeIds.length) continue; // no assignees → nothing to remind (before the flag claim) const page = pages.get(t.pageId); if (!page || page.trashedAt) continue; // trashed → no reminder (before the flag claim) const dueAt = t.dueAt!; // NX + EX: the FIRST sweep to claim a (task, dueAt) sends the reminders; // every later run (or a concurrent worker) skips. if (!(await claimOnce(redis, dueFlag(t.id, dueAt)))) continue; reminded++; const dueIso = dueAt.toISOString(); const title = `Task due: ${t.title || "Untitled"}`; const body = `Due ${dueIso} — status: ${t.status}.`; for (const userId of t.assigneeIds) { try { const row = await prisma.notification.create({ data: { workspaceId: t.workspaceId, userId, type: "TASK_DUE", channel: "IN_APP", title, body, link: asJson({ type: "page", id: t.pageId, pageId: t.pageId }) }, select: { id: true } }); // The worker owns the email channel: publish the same // `notification.created` event the API publishes (the email-decision // consumer applies per-user prefs; no actorId → SYSTEM actor). await redis .xadd("factory:events", "*", "event", "notification.created", "payload", JSON.stringify({ workspaceId: t.workspaceId, notificationId: row.id, userId, type: "TASK_DUE", title, deepLink: { type: "page", id: t.pageId, pageId: t.pageId } })) .catch(() => undefined); notifications++; } catch (err) { log.warn({ err: (err as Error).message, taskId: t.id, userId }, "task-due notification failed (non-fatal)"); } } } log.info({ checked: rows.length, reminded, notifications }, "task due sweep done"); return { checked: rows.length, reminded, notifications }; } /** * T-162/T-163 — review DEADLINE reminders (reuses the T-160 pattern). For * every OPEN review whose `deadline` falls in `(now − 1h, now + 24h]`, remind * each reviewer of record (not the requester) with a `REVIEW_REQUESTED` * "deadline" notification. Same NX-flag idempotency (key = reviewId + * deadline ts). Hourly, offset from the task sweep. */ export async function runReviewDueSweep( prisma: PrismaClient, redis: Redis, now: Date = new Date() ): Promise<{ checked: number; reminded: number; notifications: number }> { const from = new Date(now.getTime() - LOOKBACK_MS); const to = new Date(now.getTime() + REMIND_WINDOW_MS); const rows = await prisma.review.findMany({ where: { closedAt: null, state: "in_review", deadline: { gt: from, lte: to } }, select: { id: true, pageId: true, reviewerIds: true, requestedBy: true, deadline: true, page: { select: { workspaceId: true, title: true, trashedAt: true } } } }); let reminded = 0; let notifications = 0; for (const r of rows) { if (!r.page || r.page.trashedAt) continue; // trashed page → no reminder if (!r.reviewerIds.length) continue; const deadline = r.deadline!; if (!(await claimOnce(redis, reviewDueFlag(r.id, deadline)))) continue; reminded++; const title = `Review deadline: ${r.page.title || "Untitled page"}`; const body = `Decision due ${deadline.toISOString()}.`; for (const userId of r.reviewerIds) { if (userId === r.requestedBy) continue; // never self-remind the requester try { const row = await prisma.notification.create({ data: { workspaceId: r.page.workspaceId, userId, type: "REVIEW_REQUESTED", channel: "IN_APP", title, body, link: asJson({ type: "page", id: r.pageId, pageId: r.pageId }) }, select: { id: true } }); await redis .xadd("factory:events", "*", "event", "notification.created", "payload", JSON.stringify({ workspaceId: r.page.workspaceId, notificationId: row.id, userId, type: "REVIEW_REQUESTED", title, deepLink: { type: "page", id: r.pageId, pageId: r.pageId } })) .catch(() => undefined); notifications++; } catch (err) { log.warn({ err: (err as Error).message, reviewId: r.id, userId }, "review-due notification failed (non-fatal)"); } } } log.info({ checked: rows.length, reminded, notifications }, "review due sweep done"); return { checked: rows.length, reminded, notifications }; }