|
| 1 | +import { logger } from '../../utils/logger.js'; |
| 2 | +import { SponsorNotion } from '../notion.js'; |
| 3 | +import { PipelineRow } from '../types.js'; |
| 4 | + |
| 5 | +/** |
| 6 | + * Hourly DRI sync: Notion doesn't record WHEN a person was added to a people property, |
| 7 | + * so PERBot keeps its own ledger on each Pipeline deal (`DRI assigned`: one |
| 8 | + * "<user id> <ISO>" line per current DRI). Each run diffs every deal's DRI set against |
| 9 | + * its ledger — new DRIs are stamped, removed DRIs are dropped, deals whose ledger is |
| 10 | + * empty are backfilled with the deal's creation time (the best evidence for assignments |
| 11 | + * that predate the ledger). The quota audit + `/sponsor quota` count by these stamps, so |
| 12 | + * a re-assignment onto an older deal counts in the week it happened (Arjun's rule, |
| 13 | + * 2026-09-11). Granularity is the sync cadence: an assign→unassign inside one hour is |
| 14 | + * invisible, which is fine. |
| 15 | + * |
| 16 | + * Idempotent by construction: only deals whose ledger would change are written. |
| 17 | + */ |
| 18 | + |
| 19 | +/** The ledger a deal should have now, or null if it's already correct. */ |
| 20 | +export function reconcileLedger(deal: PipelineRow, nowIso: string): Record<string, string> | null { |
| 21 | + const current = new Set(deal.driUserIds.map((id) => id.toLowerCase())); |
| 22 | + const next: Record<string, string> = {}; |
| 23 | + const ledgerEmpty = Object.keys(deal.driAssignedAt).length === 0; |
| 24 | + for (const id of current) { |
| 25 | + // First-ever stamp on a deal → the deal's creation time (pre-ledger assignments); |
| 26 | + // later additions → now. |
| 27 | + next[id] = deal.driAssignedAt[id] ?? (ledgerEmpty && deal.createdTime ? deal.createdTime : nowIso); |
| 28 | + } |
| 29 | + const before = Object.entries(deal.driAssignedAt).sort().join('|'); |
| 30 | + const after = Object.entries(next).sort().join('|'); |
| 31 | + return before === after ? null : next; |
| 32 | +} |
| 33 | + |
| 34 | +export async function syncDriLedger(notion: SponsorNotion = new SponsorNotion()): Promise<{ scanned: number; updated: number }> { |
| 35 | + const deals = await notion.queryAllDeals(); |
| 36 | + const nowIso = new Date().toISOString(); |
| 37 | + let updated = 0; |
| 38 | + for (const deal of deals) { |
| 39 | + const next = reconcileLedger(deal, nowIso); |
| 40 | + if (!next) continue; |
| 41 | + await notion.writeDriLedger(deal.id, next); |
| 42 | + updated += 1; |
| 43 | + logger.info(`DRI sync: ${deal.company || deal.id} → ${Object.keys(next).length} DRI(s) stamped.`); |
| 44 | + } |
| 45 | + logger.info(`DRI sync: scanned ${deals.length} deals, updated ${updated}.`); |
| 46 | + return { scanned: deals.length, updated }; |
| 47 | +} |
| 48 | + |
| 49 | +// Entrypoint when run directly (GitHub Actions). |
| 50 | +const isMain = process.argv[1] && import.meta.url === `file://${process.argv[1]}`; |
| 51 | +if (isMain) { |
| 52 | + syncDriLedger().catch((err) => { |
| 53 | + logger.error('DRI sync job failed.', err); |
| 54 | + process.exitCode = 1; |
| 55 | + }); |
| 56 | +} |
0 commit comments