diff --git a/backend/__tests__/unit/scripts/audit-replied-attention-stamps.test.js b/backend/__tests__/unit/scripts/audit-replied-attention-stamps.test.js new file mode 100644 index 000000000..112341516 --- /dev/null +++ b/backend/__tests__/unit/scripts/audit-replied-attention-stamps.test.js @@ -0,0 +1,36 @@ +// @ts-nocheck + +// Only the argv parsing is under test here; the audit itself is covered in +// __tests__/unit/services/attentionItemService.test.js. Stub the modules the +// script pulls in at import time so this needs no database. +jest.mock('mongoose', () => ({ connect: jest.fn(), disconnect: jest.fn() })); +jest.mock('../../../services/attentionItemService', () => ({ auditRepliedMentionAttention: jest.fn() })); + +const { parseArgs } = require('../../../scripts/audit-replied-attention-stamps'); + +describe('audit-replied-attention-stamps argv', () => { + it('reads --apply and an ISO bound', () => { + const { apply, resolvedBefore } = parseArgs(['node', 'script', '--apply', '--resolved-before=2026-09-06T12:42:00Z']); + expect(apply).toBe(true); + expect(resolvedBefore.toISOString()).toBe('2026-09-06T12:42:00.000Z'); + }); + + it('defaults to a dry run over every replied stamp when no flags are given', () => { + expect(parseArgs(['node', 'script'])).toEqual({ apply: false }); + }); + + // The whole point of the flag is to NARROW the scan to the cutover window. + // Falling through as `undefined` on a bad value widens it to every replied + // stamp ever written — under --apply, an unbounded reopen from a typo. + it.each(['--resolved-before=garbage', '--resolved-before=', '--resolved-before=2026-13-45'])( + 'throws rather than silently widening the scan: %s', + (flag) => { + expect(() => parseArgs(['node', 'script', flag])).toThrow(/not a date/); + }, + ); + + // A test for `split('=').slice(1).join('=')` was written here and deleted: + // no valid date contains an `=`, so truncating at the first one changes + // nothing a test can observe. It passed against both implementations, which + // makes it a claim about nothing. The join stays as defensive code. +}); diff --git a/backend/__tests__/unit/services/attentionItemService.test.js b/backend/__tests__/unit/services/attentionItemService.test.js index e155e28ad..6a7715138 100644 --- a/backend/__tests__/unit/services/attentionItemService.test.js +++ b/backend/__tests__/unit/services/attentionItemService.test.js @@ -215,6 +215,79 @@ describe('attentionItemService', () => { expect(mockMongoMessageExists).not.toHaveBeenCalled(); }); + it('reopens a replied stamp the source contradicts, and keeps the one it corroborates', async () => { + const rows = [ + { + _id: 'attention-false', recipientUserId: 'sam', podId: 'pod-1', kind: 'mention', + source: { type: 'message', id: '42' }, messageId: '42', threadRootId: '40', + sourceCreatedAt: new Date('2026-09-05T09:00:00.000Z'), + }, + { + _id: 'attention-true', recipientUserId: 'sam', podId: 'pod-1', kind: 'mention', + source: { type: 'message', id: '43' }, messageId: '43', threadRootId: '43', + sourceCreatedAt: new Date('2026-09-05T09:30:00.000Z'), + }, + ]; + mockFind.mockReturnValue({ sort: () => ({ lean: async () => rows }) }); + mockPgMessageHasReplyByUserAfter.mockImplementation(async (podId, userId, after, target) => target.messageId === '43'); + + const result = await AttentionItemService.auditRepliedMentionAttention({ apply: true }); + + expect(result).toEqual({ scanned: 2, kept: 1, contradicted: 1, reopened: 1, undecidable: 0, unavailable: 0 }); + expect(mockUpdateOne).toHaveBeenCalledTimes(1); + expect(mockUpdateOne).toHaveBeenCalledWith( + { _id: 'attention-false', kind: 'mention', status: 'resolved', resolvedBy: 'replied' }, + { $set: { status: 'open' }, $unset: { resolvedAt: '', resolvedBy: '' } }, + ); + }); + + it('changes nothing without --apply, and still reports what it would reopen', async () => { + mockFind.mockReturnValue({ sort: () => ({ lean: async () => [{ + _id: 'attention-false', recipientUserId: 'sam', podId: 'pod-1', kind: 'mention', + source: { type: 'message', id: '42' }, messageId: '42', + sourceCreatedAt: new Date('2026-09-05T09:00:00.000Z'), + }] }) }); + mockPgMessageHasReplyByUserAfter.mockResolvedValue(false); + + const result = await AttentionItemService.auditRepliedMentionAttention(); + + expect(result).toMatchObject({ scanned: 1, contradicted: 1, reopened: 0 }); + expect(mockUpdateOne).not.toHaveBeenCalled(); + }); + + it('counts a Mongo-sourced stamp as undecidable rather than reopening it', async () => { + mockFind.mockReturnValue({ sort: () => ({ lean: async () => [{ + _id: 'attention-mongo', recipientUserId: 'sam', podId: 'pod-1', kind: 'mention', + source: { type: 'message', id: '507f191e810c19729de860ea' }, + sourceCreatedAt: new Date('2026-09-05T09:00:00.000Z'), + }] }) }); + + const result = await AttentionItemService.auditRepliedMentionAttention({ apply: true }); + + expect(result).toMatchObject({ scanned: 1, kept: 0, contradicted: 0, reopened: 0, undecidable: 1 }); + expect(mockUpdateOne).not.toHaveBeenCalled(); + expect(mockPgMessageHasReplyByUserAfter).not.toHaveBeenCalled(); + }); + + it('reads only replied stamps, and honours the resolvedBefore bound', async () => { + mockFind.mockReturnValue({ sort: () => ({ lean: async () => [] }) }); + const before = new Date('2026-09-06T00:00:00.000Z'); + + await AttentionItemService.auditRepliedMentionAttention({ resolvedBefore: before }); + + expect(mockFind).toHaveBeenCalledWith({ + kind: 'mention', status: 'resolved', resolvedBy: 'replied', resolvedAt: { $lt: before }, + }); + }); + + it('omits the resolvedAt bound entirely when none is given', async () => { + mockFind.mockReturnValue({ sort: () => ({ lean: async () => [] }) }); + + await AttentionItemService.auditRepliedMentionAttention(); + + expect(mockFind).toHaveBeenCalledWith({ kind: 'mention', status: 'resolved', resolvedBy: 'replied' }); + }); + it('materializes a blocked board row once for each current human recipient', async () => { mockPodFindById.mockReturnValue(chain({ _id: 'pod-1', name: 'Ship room', createdBy: 'owner', members: [{ userId: 'sam' }] })); mockUserFind.mockReturnValue(chain([ diff --git a/backend/package.json b/backend/package.json index 655b86345..b91088acd 100644 --- a/backend/package.json +++ b/backend/package.json @@ -23,6 +23,7 @@ "bootstrap:clawd-bot": "node scripts/bootstrap-clawd-bot.js", "backfill:attention-items": "ts-node scripts/backfill-attention-items.ts", "sweep:resolved-mention-attention": "ts-node scripts/sweep-resolved-mention-attention.ts", + "audit:replied-attention-stamps": "ts-node scripts/audit-replied-attention-stamps.ts", "tsc:check": "tsc --noEmit -p tsconfig.typescheck.json", "tsc:check:all": "tsc --noEmit" }, diff --git a/backend/scripts/audit-replied-attention-stamps.ts b/backend/scripts/audit-replied-attention-stamps.ts new file mode 100644 index 000000000..6f3e4a1af --- /dev/null +++ b/backend/scripts/audit-replied-attention-stamps.ts @@ -0,0 +1,64 @@ +/** + * Audit mention AttentionItems stamped `resolvedBy: 'replied'`. The broad + * pre-#1573 resolver closed every open mention a recipient held in a pod as + * soon as they posted anything there, so some of those stamps assert a reply + * nobody wrote. This re-reads each row's source and reopens only the rows the + * source contradicts; rows whose source cannot answer the question are + * counted and left alone. + * + * Dry run by default: + * npm run audit:replied-attention-stamps + * Bound the population to stamps written before the narrow resolver deployed: + * npm run audit:replied-attention-stamps -- --resolved-before=2026-09-06T00:00:00Z + * Apply the source-backed reopens: + * npm run audit:replied-attention-stamps -- --apply + */ +/* eslint-disable no-console */ +const mongoose = require('mongoose'); +const { auditRepliedMentionAttention } = require('../services/attentionItemService'); + +type PgPool = { end: () => Promise }; + +/** + * Read the flags out of argv. Exported so the throw below is testable without + * a database: an unparsable bound must NOT fall through as `undefined`. It + * would widen the audit from the cutover window to every replied stamp ever + * written, and under `--apply` that is an unbounded reopen from a typo. + */ +export const parseArgs = (argv: string[]): { apply: boolean; resolvedBefore?: Date } => { + const apply = argv.includes('--apply'); + const flag = argv.find((arg) => arg.startsWith('--resolved-before=')); + if (!flag) return { apply }; + const raw = flag.split('=').slice(1).join('='); + const parsed = new Date(raw); + if (!raw.trim() || Number.isNaN(parsed.getTime())) { + throw new Error(`--resolved-before is not a date: ${flag}`); + } + return { apply, resolvedBefore: parsed }; +}; + +export const main = async (): Promise => { + if (!process.env.MONGO_URI) throw new Error('MONGO_URI is required'); + const { apply: APPLY, resolvedBefore } = parseArgs(process.argv); + // The audit reads legacy PostgreSQL mention sources as well as Mongo ones. + // Close that pool like the sweep does so the one-shot process exits after + // printing its measured result. + // eslint-disable-next-line global-require + const { pool } = require('../config/db-pg') as { pool: PgPool | null }; + await mongoose.connect(process.env.MONGO_URI); + try { + const result = await auditRepliedMentionAttention({ apply: APPLY, resolvedBefore }); + console.log(JSON.stringify({ ...result, apply: APPLY, resolvedBefore: resolvedBefore?.toISOString() || null })); + if (!APPLY) console.log('DRY RUN — no AttentionItems changed. Re-run with --apply after review.'); + } finally { + await mongoose.disconnect(); + if (pool) await pool.end(); + } +}; + +if (require.main === module) { + main().catch((error) => { + console.error('replied-stamp audit failed:', error); + process.exit(1); + }); +} diff --git a/backend/services/attentionItemService.ts b/backend/services/attentionItemService.ts index d639f9182..7d17d71d5 100644 --- a/backend/services/attentionItemService.ts +++ b/backend/services/attentionItemService.ts @@ -236,6 +236,59 @@ export const sweepResolvedMentionAttention = async ({ apply = false }: { apply?: return { scanned: rows.length, eligible, resolved, unavailable }; }; +/** + * Audit rows already stamped `resolvedBy: 'replied'`. Before the narrow + * resolver landed, any post by the recipient in the pod closed every open + * mention they held there, so a row can assert a reply that was never + * written. This re-reads each row's source with the same evidence the sweep + * uses and reopens only the ones the source contradicts. + * + * Three outcomes, not two. `undecidable` exists because Mongo-fallback + * sources persist no reply or thread edges: absence of evidence there is not + * evidence the recipient stayed silent, and reopening those would re-raise + * attention a user has already dealt with. They are counted and left alone. + */ +export const auditRepliedMentionAttention = async ( + { apply = false, resolvedBefore }: { apply?: boolean; resolvedBefore?: Date } = {}, +) => { + const before = validDate(resolvedBefore); + const rows = await AttentionItem.find({ + kind: 'mention', + status: 'resolved', + resolvedBy: 'replied', + ...(before ? { resolvedAt: { $lt: before } } : {}), + }).sort({ createdAt: 1 }).lean(); + let kept = 0; + let contradicted = 0; + let reopened = 0; + let undecidable = 0; + let unavailable = 0; + for (const row of rows) { + try { + const sourceId = String(row?.source?.id || ''); + // Only a PostgreSQL source carries the reply and thread edges this + // audit needs; see recipientRepliedAfterMention. + if (!/^\d+$/.test(sourceId)) { undecidable += 1; continue; } + const sourceCreatedAt = await sourceTimeForMention(row); + if (!sourceCreatedAt) { undecidable += 1; continue; } + if (await recipientRepliedAfterMention(row, sourceCreatedAt)) { kept += 1; continue; } + contradicted += 1; + if (!apply) continue; + // Repeat the stamp in the filter so a row an operator acknowledged + // between the read and the write is not reopened underneath them. + const result = await AttentionItem.updateOne( + { _id: row._id, kind: 'mention', status: 'resolved', resolvedBy: 'replied' }, + { $set: { status: 'open' }, $unset: { resolvedAt: '', resolvedBy: '' } }, + ); + reopened += Number(result.modifiedCount || 0); + } catch (error) { + unavailable += 1; + console.warn('[attention] replied-stamp audit skipped an unreadable source:', (error as Error).message); + } + } + return { scanned: rows.length, kept, contradicted, reopened, undecidable, unavailable }; +}; + export const recordApproval = async (approval: any): Promise => { try { const podId = approval?.podId; @@ -375,6 +428,6 @@ export const acknowledgeMention = async (recipientUserId: unknown, attentionItem return result.modifiedCount === 1 ? { success: true } : { success: false, error: 'Attention item not found' }; }; -export default { recordMentionedUsers, resolveMentionAttentionForReply, sweepResolvedMentionAttention, recordApproval, recordDecision, recordTaskAttention, resolveTaskAttention, resolve, resolveMany, getOpenQueue, acknowledgeMention }; +export default { recordMentionedUsers, resolveMentionAttentionForReply, sweepResolvedMentionAttention, auditRepliedMentionAttention, recordApproval, recordDecision, recordTaskAttention, resolveTaskAttention, resolve, resolveMany, getOpenQueue, acknowledgeMention }; // eslint-disable-next-line @typescript-eslint/no-require-imports -module.exports = { recordMentionedUsers, resolveMentionAttentionForReply, sweepResolvedMentionAttention, recordApproval, recordDecision, recordTaskAttention, resolveTaskAttention, resolve, resolveMany, getOpenQueue, acknowledgeMention, TASK_HANDOFF_RE }; +module.exports = { recordMentionedUsers, resolveMentionAttentionForReply, sweepResolvedMentionAttention, auditRepliedMentionAttention, recordApproval, recordDecision, recordTaskAttention, resolveTaskAttention, resolve, resolveMany, getOpenQueue, acknowledgeMention, TASK_HANDOFF_RE };