diff --git a/src/mentorSummary.ts b/src/mentorSummary.ts index 64b1ae7..dd3166e 100644 --- a/src/mentorSummary.ts +++ b/src/mentorSummary.ts @@ -14,21 +14,19 @@ import { listEventsStartingInRange, } from "./db/repo.js"; import { log } from "./logger.js"; -import { - deleteWeeklySummary, - postWeeklySummary, -} from "./slack/weeklySummary.js"; +import { postWeeklySummary } from "./slack/weeklySummary.js"; import { toWeeklySummaryEventInfo } from "./weeklySummary.js"; /** - * The Mentor/Teacher Weekly Summary: if due, deletes the previous post and - * sends a fresh one covering the 14 days starting tomorrow — a rolling - * look-ahead (see upcomingTwoWeekRange), wider than the Team Meeting Weekly - * Summary's one-week span for more lead time on travel and unavailability - * conflicts. Unlike the Team Meeting Weekly Summary, there is no mid-window - * edit-in-place here — a change mid-week shows up corrected on the next - * scheduled post rather than a live edit, since this is an admin-only FYI - * digest, not something people are actively tracking. + * The Mentor/Teacher Weekly Summary: if due, sends a fresh post covering + * the 14 days starting tomorrow — a rolling look-ahead (see + * upcomingTwoWeekRange), wider than the Team Meeting Weekly Summary's + * one-week span for more lead time on travel and unavailability conflicts. + * Every prior post stays, never replaced (see ADR-0014). Unlike the Team + * Meeting Weekly Summary, there is no mid-window edit-in-place here — a + * change mid-week shows up corrected on the next scheduled post rather + * than a live edit, since this is an admin-only FYI digest, not something + * people are actively tracking. */ export async function postDueMentorSummary(client: WebClient): Promise { const calendarId = getSetting("mentor_calendar_id"); @@ -41,19 +39,6 @@ export async function postDueMentorSummary(client: WebClient): Promise { const now = new Date(); if (!isWeeklySummaryDue(lastPostedAt, timing, now)) return; - if (mostRecent) { - await deleteWeeklySummary( - client, - mostRecent.channel, - mostRecent.message_ts - ).catch((err) => - log.error("could not delete previous mentor summary", { - mentorSummaryId: mostRecent.id, - error: String(err), - }) - ); - } - const { start, end } = upcomingTwoWeekRange(now); const events = listEventsStartingInRange( start.toISOString(), diff --git a/src/scheduler.ts b/src/scheduler.ts index ab7a52e..83c04a0 100644 --- a/src/scheduler.ts +++ b/src/scheduler.ts @@ -52,6 +52,7 @@ import { log } from "./logger.js"; import { announceEventEdited, announceEventRemoved, + lockCheckinPost, postCheckinPost, } from "./slack/checkin.js"; import { syncAttendanceFromSlack } from "./slack/attendanceEvents.js"; @@ -594,7 +595,7 @@ async function postEventAttendanceReport( } /** - * Resync, verify, and — only on success — credit hours, delete the + * Resync, verify, and — only on success — credit hours, lock the * Check-in Post, finalize, and post the Event Attendance Report. Shared by * the scheduler's normal sweep and the manual `/hawkbot event retry-cutoff` * command, so a retry is exactly the same flow run again on demand. @@ -651,19 +652,12 @@ export async function attemptEventCutoff( setHoursCredited(event.id, row.user_id, hours); } - if (event.checkin_channel && event.checkin_message_ts) { - await client.chat - .delete({ - channel: event.checkin_channel, - ts: event.checkin_message_ts, - }) - .catch((err) => - log.error("could not delete check-in post", { - eventId: event.id, - error: String(err), - }) - ); - } + await lockCheckinPost(client, event).catch((err) => + log.error("could not lock check-in post", { + eventId: event.id, + error: String(err), + }) + ); markEventFinalized(event.id); log.info("event finalized", { eventId: event.id }); diff --git a/src/slack/checkin.ts b/src/slack/checkin.ts index 6c1f628..2372a9c 100644 --- a/src/slack/checkin.ts +++ b/src/slack/checkin.ts @@ -42,6 +42,14 @@ const REACTION_LEGEND = [ "❌, or anything else — I can't make it (reply in this thread with why, if you want)", ]; +/** When, where, and description — shared by every post-title variant of the meeting details. */ +function meetingDetailBodyLines(event: EventRow): string[] { + const lines = [formatEventTime(event)]; + if (event.location) lines.push(`📍 ${event.location}`); + if (event.description) lines.push(event.description); + return lines; +} + /** * Title, when, where, and description — the part of the post worth keeping * even once it's removed. `updateNote`, when given, marks the title line as @@ -52,16 +60,27 @@ function meetingDetailsText(event: EventRow, updateNote?: string): string { const titleLine = updateNote ? ` ✏️ *${event.title}* _(updated: ${updateNote})_` : ` *${event.title}*`; - const lines = [titleLine, formatEventTime(event)]; - if (event.location) lines.push(`📍 ${event.location}`); - if (event.description) lines.push(event.description); - return lines.join("\n"); + return [titleLine, ...meetingDetailBodyLines(event)].join("\n"); } function checkinMessageText(event: EventRow): string { return [meetingDetailsText(event), ...REACTION_LEGEND].join("\n"); } +/** + * The Reaction Cutoff's locked-post text, replacing `checkinMessageText` + * once attendance closes — see ADR-0013. Drops `` (nothing left + * to ping about) and the reaction legend entirely (not struck through: + * unlike Calendar Change Handling's "removed" case, there's no value in + * showing what the legend used to say). Meeting details stay exactly as + * `meetingDetailsText` already renders them, since the meeting itself + * still happened. + */ +export function lockedCheckinMessageText(event: EventRow): string { + const titleLine = `✅ *${event.title}* — attendance closed`; + return [titleLine, ...meetingDetailBodyLines(event)].join("\n"); +} + /** * Posts the Event Check-in Post, pre-populates its reactions, and returns * the channel's current membership (minus the bot itself) to snapshot as @@ -153,6 +172,26 @@ async function notifyReactionSeedFailure( } } +/** + * The Reaction Cutoff's lock step, replacing the old delete — see + * ADR-0013. Rewrites the Check-in Post in place with + * `lockedCheckinMessageText`; leaves every reaction on the post (seeded + * and human) and the thread (Attendance Notes and all) exactly as they + * are — a bot token can't touch a human's reaction or reply anyway, and + * there's no reason to strip its own seeded ones either. + */ +export async function lockCheckinPost( + client: WebClient, + event: EventRow +): Promise { + if (!event.checkin_channel || !event.checkin_message_ts) return; + await client.chat.update({ + channel: event.checkin_channel, + ts: event.checkin_message_ts, + text: lockedCheckinMessageText(event), + }); +} + /** One `~old~ → new` line per changed field worth showing a before/after for. */ function changedDetailLines( previous: EventRow, diff --git a/src/slack/weeklySummary.ts b/src/slack/weeklySummary.ts index 3ca6c01..1de140f 100644 --- a/src/slack/weeklySummary.ts +++ b/src/slack/weeklySummary.ts @@ -1,14 +1,14 @@ import type { WebClient } from "@slack/web-api"; /** - * Posting, editing, and deleting the Weekly Summary Post and its - * Informational Calendar reply. Deliberately plain — no broadcast (see - * ADR-0005) — each message is always rebuilt and replaced in place, never - * appended to at the Slack API level. The scheduler decides what the - * current full text should be; this module just gets it onto (or off of) + * Posting and editing the Weekly Summary Post and its Informational + * Calendar reply. Deliberately plain — no broadcast (see ADR-0005) — each + * message is always rebuilt and edited in place, never appended to at the + * Slack API level, and never deleted (see ADR-0014). The scheduler decides + * what the current full text should be; this module just gets it onto * Slack. The Informational reply is the one exception that does thread — - * see postInformationalReply — but is still edited/deleted the same - * generic way as the parent once it exists. + * see postInformationalReply — but is still edited the same generic way + * as the parent once it exists. */ export async function postWeeklySummary( @@ -31,9 +31,9 @@ export async function postWeeklySummary( /** * Posts the Informational Calendar's digest as a threaded reply under a - * Weekly Summary Post. Once posted, it's edited and deleted exactly like - * the parent — via updateWeeklySummary/deleteWeeklySummary below — so no - * separate update/delete function exists for it. + * Weekly Summary Post. Once posted, it's edited exactly like the parent — + * via updateWeeklySummary below — so no separate update function exists + * for it. */ export async function postInformationalReply( client: WebClient, @@ -63,11 +63,3 @@ export async function updateWeeklySummary( ): Promise { await client.chat.update({ channel, ts, text }); } - -export async function deleteWeeklySummary( - client: WebClient, - channel: string, - ts: string -): Promise { - await client.chat.delete({ channel, ts }); -} diff --git a/src/weeklySummary.ts b/src/weeklySummary.ts index bf30129..77a3909 100644 --- a/src/weeklySummary.ts +++ b/src/weeklySummary.ts @@ -34,7 +34,6 @@ import { } from "./db/repo.js"; import { log } from "./logger.js"; import { - deleteWeeklySummary, postInformationalReply, postWeeklySummary, updateWeeklySummary, @@ -323,8 +322,8 @@ export async function reflectWeeklySummaryChange( } /** - * If due, deletes the previous Weekly Summary Post (and its Informational - * reply, if it had one) and posts this week's — then, if the Informational + * If due, posts this week's Weekly Summary Post — alongside every prior + * week's, never replacing them (see ADR-0014) — then, if the Informational * Calendar is enabled and has anything in range, posts this week's * Informational reply threaded under it. */ @@ -338,34 +337,6 @@ export async function postDueWeeklySummary(client: WebClient): Promise { const now = new Date(); if (!isWeeklySummaryDue(lastPostedAt, timing, now)) return; - if (mostRecent) { - await deleteWeeklySummary( - client, - mostRecent.channel, - mostRecent.message_ts - ).catch((err) => - log.error("could not delete previous weekly summary", { - weeklySummaryId: mostRecent.id, - error: String(err), - }) - ); - if ( - mostRecent.informational_channel && - mostRecent.informational_message_ts - ) { - await deleteWeeklySummary( - client, - mostRecent.informational_channel, - mostRecent.informational_message_ts - ).catch((err) => - log.error("could not delete previous informational reply", { - weeklySummaryId: mostRecent.id, - error: String(err), - }) - ); - } - } - const { start, end } = upcomingWeekRange(now); const events = listEventsStartingInRange( start.toISOString(), diff --git a/test/checkin.test.ts b/test/checkin.test.ts new file mode 100644 index 0000000..c09eb4f --- /dev/null +++ b/test/checkin.test.ts @@ -0,0 +1,67 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { lockedCheckinMessageText } from "../src/slack/checkin.js"; +import type { EventRow } from "../src/db/repo.js"; + +process.env.TZ = "UTC"; + +const baseEvent: EventRow = { + id: 1, + calendar_event_id: "abc", + calendar_link: null, + source: "google_calendar", + calendar_role: "team_meeting", + title: "Team Meeting", + description: "Regular build season meeting.", + location: "Main Shop", + meeting_type: "hourly", + starts_at: "2026-08-20T18:00:00Z", + ends_at: "2026-08-20T20:00:00Z", + checkin_at: "2026-08-20T14:00:00-04:00", + reaction_cutoff_at: "2026-08-21T00:00:00-04:00", + checkin_channel: "C123", + checkin_message_ts: "1700000000.000100", + checkin_posted_at: "2026-08-20T14:00:00-04:00", + finalized_at: null, + removed_at: null, + verification_failed_at: null, + multiday_parent_id: null, + created_at: "2026-08-01T00:00:00Z", + updated_at: "2026-08-01T00:00:00Z", +}; + +test("the locked message says attendance is closed", () => { + const text = lockedCheckinMessageText(baseEvent); + assert.match(text, /attendance closed/); +}); + +test("the locked message drops the @channel mention", () => { + const text = lockedCheckinMessageText(baseEvent); + assert.doesNotMatch(text, //); +}); + +test("the locked message drops the reaction legend entirely", () => { + const text = lockedCheckinMessageText(baseEvent); + assert.doesNotMatch(text, /React to let the team know/); + assert.doesNotMatch(text, /👍/); + assert.doesNotMatch(text, /🕐/); + assert.doesNotMatch(text, /❌/); +}); + +test("the locked message keeps the title, time, location, and description", () => { + const text = lockedCheckinMessageText(baseEvent); + assert.match(text, /Team Meeting/); + assert.match(text, /6:00 PM.*8:00 PM/); + assert.match(text, /Main Shop/); + assert.match(text, /Regular build season meeting\./); +}); + +test("the locked message skips an empty location and description", () => { + const text = lockedCheckinMessageText({ + ...baseEvent, + location: "", + description: "", + }); + assert.doesNotMatch(text, /📍/); + assert.equal(text.split("\n").length, 2); +});