diff --git a/nodes/ZOOM_RECAPS_TO_SLACK.md b/nodes/ZOOM_RECAPS_TO_SLACK.md index 287ef9c..14219b6 100644 --- a/nodes/ZOOM_RECAPS_TO_SLACK.md +++ b/nodes/ZOOM_RECAPS_TO_SLACK.md @@ -8,10 +8,13 @@ The workflow has two ways in, and both feed one shared posting path: 1. **Webhook**: Zoom calls the workflow when a meeting's summary finishes, and the recap posts about 1 hour later. Zoom rewrites a summary about 20 minutes after creating it, and the Gate only passes a summary that has been quiet for 15 minutes, so the 1 hour wait lets the rewrite land and the cooldown clear before the recap is fetched. -2. **Daily sweep**: a Schedule Trigger runs each morning, asks Zoom for the previous day's meeting summaries, and posts anything the webhook missed. +2. **Daily sweep**: a Schedule Trigger runs each morning and posts a meeting the webhook missed, as long as no later meeting in its series has posted yet. Zoom's summary webhook sometimes doesn't fire, and the sweep covers those misses. Both paths post only meetings listed in the Config table below. +Within a series, only an occurrence that started after the newest one already posted can post. +A series with nothing posted yet, such as one just added to Config, posts its newest ready occurrence. +Zoom's summary list ignores its date window and returns every summary back to 2024, while the Posted table starts in August 2026, so for a series that has posted, this rule is what stops an old, never-posted occurrence from going out as a new recap. ### Data tables diff --git a/src/zoom-recaps-to-slack/build-candidates.js b/src/zoom-recaps-to-slack/build-candidates.js index cd39cad..753b1ed 100644 --- a/src/zoom-recaps-to-slack/build-candidates.js +++ b/src/zoom-recaps-to-slack/build-candidates.js @@ -1,5 +1,5 @@ // Pick the one meeting to post this run: the newest UNPOSTED occurrence of any configured meeting -// (on a webhook run, of the triggering meeting only) whose summary is ready to post (see readyToPost). An occurrence with nothing to post - a room started +// (on a webhook run, of the triggering meeting only), newer than its series' latest posted occurrence, whose summary is ready to post (see readyToPost). An occurrence with nothing to post - a room started // and stopped in seconds, a summary Zoom never generated, a failed fetch - is skipped, never allowed to // block the occurrences behind it. Calendar guardrail: a meeting headed for a PUBLIC channel // must match a scheduled public-calendar event (same Zoom meeting id + weekday/time) - this stops a @@ -52,9 +52,19 @@ try { trigger = $('Verify Zoom').first().json; } catch (e) { trigger = null; } const triggerId = trigger && trigger.meetingId ? String(trigger.meetingId) : ''; const triggerUuid = trigger && trigger.meetingUuid ? String(trigger.meetingUuid) : ''; -// Every unposted occurrence of a configured meeting, newest first. +// The start of each meeting series' newest posted occurrence. Only a later occurrence can post; +// "How It Works" in nodes/ZOOM_RECAPS_TO_SLACK.md gives the reason. +const latestPosted = {}; +for (const s of all) { + if (!posted.has(s.meeting_uuid)) continue; + const id = String(s.meeting_id), t = new Date(s.meeting_start_time).getTime(); + if (!(latestPosted[id] >= t)) latestPosted[id] = t; +} + +// Every unposted occurrence of a configured meeting newer than its series' latest post, newest first. let eligible = all - .filter(s => config[String(s.meeting_id)] && !posted.has(s.meeting_uuid)) + .filter(s => config[String(s.meeting_id)] && !posted.has(s.meeting_uuid) + && !(new Date(s.meeting_start_time).getTime() <= latestPosted[String(s.meeting_id)])) .filter(s => !triggerId || String(s.meeting_id) === triggerId) .sort((a, b) => new Date(b.meeting_start_time) - new Date(a.meeting_start_time)); // Prefer the exact occurrence that triggered the webhook; if the list doesn't show it yet, fall back to