From 5cc07b56d2b71f3333da0ef7d8076ecbf6e82ebe Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Fri, 24 Apr 2026 12:28:26 -0400 Subject: [PATCH 1/4] feat(admin-announcements): final polish + ops runbook Closes the remaining review-deferred polish on the announcement backlog view and adds the incident-response runbook flagged during the #3003 review. Backlog view (/admin/announcements): - New "Signed up" column between Tier and Draft posted. Renders relative ("3d ago", "2mo ago", "1y ago") so editorial can tell recent welcome from stale backfill without a click-through. Backend exposes org_created_at from organizations.created_at; null-safe for orphan drafts. - Empty-state copy: "Pending review" and "LinkedIn pending" now show actionable copy instead of the generic "No announcements in state X". "Nothing to review. New drafts are posted hourly by the trigger job." tells editorial the system is working, no refresh needed. - Arrow-key tab nav: Left/Right cycle, Home/End jump. Pairs with the aria-selected toggling already in place so the filter tabs follow the WAI-ARIA tabs pattern. Ops runbook (docs/ops/channel-rotation.md): - Quick reference for rotating a Slack channel wired into an admin setting. Covers happy path, write/send-time failure modes, incident scenario for archived review channel, and break-glass direct-SQL rotation with audit-table capture for when the admin UI is down. Referenced during #3003 review. Tests: 2 new backlog query tests + 2 route tests cover the new org_created_at field across happy-path and orphan-draft cases. 172/172 announcement tests pass; server typecheck clean. Skipped: sortable headers (stuck-first default already answers the question at current scale), vitest pool isolation (band-aid default works), Slack deep-link helper (UX scope beyond this thread). Co-Authored-By: Claude Opus 4.7 --- .changeset/workflow-b-final-polish.md | 48 +++++++ docs/ops/channel-rotation.md | 124 ++++++++++++++++++ server/public/admin-announcements.html | 65 ++++++++- .../src/addie/jobs/announcement-handlers.ts | 6 + server/src/routes/admin/announcements.ts | 1 + .../announcement-backlog-route.test.ts | 26 ++++ .../announcement/announcement-backlog.test.ts | 48 +++++++ 7 files changed, 317 insertions(+), 1 deletion(-) create mode 100644 .changeset/workflow-b-final-polish.md create mode 100644 docs/ops/channel-rotation.md diff --git a/.changeset/workflow-b-final-polish.md b/.changeset/workflow-b-final-polish.md new file mode 100644 index 0000000000..f37cf2bc20 --- /dev/null +++ b/.changeset/workflow-b-final-polish.md @@ -0,0 +1,48 @@ +--- +--- + +**Workflow B final polish + ops runbook** + +Closes the remaining review-deferred polish on the announcement +backlog view and adds the incident-response runbook flagged during +the #3003 review. + +**Backlog view (`/admin/announcements`):** + +- **Signup-age column** — new "Signed up" column between Tier and + Draft posted. Renders relative ("3d ago", "2mo ago", "1y ago") so + editorial can tell "recent welcome" from "stale backfill" without a + click-through. ISO date in the title attribute for precise reads. + Backend exposes `org_created_at` from `organizations.created_at`; + null-safe for orphan drafts where the org row was deleted. +- **Empty-state copy** — "Pending review" and "LinkedIn pending" now + show actionable copy ("Nothing to review. New drafts are posted + hourly by the trigger job." / "Nothing waiting on LinkedIn right + now.") instead of the generic "No announcements in state X." +- **Arrow-key tab nav** — Left/Right cycle, Home/End jump. Pairs + with the `aria-selected` toggling already in place so the filter + tabs follow the WAI-ARIA tabs pattern. + +**Ops runbook:** + +- New `docs/ops/channel-rotation.md` — quick reference for rotating a + Slack channel wired into an admin setting (all seven — billing, + escalation, admin, prospect, error, editorial, announcement). + Covers happy path, write/send-time failure modes, incident scenario + for archived review channel, and break-glass direct-SQL rotation + with audit-table capture for when the admin UI is down. Referenced + during the #3003 review. + +**Tests:** 2 new backlog query tests (org_created_at happy + orphan), +2 new route tests (ISO serialization + null passthrough). Full +announcement suite 172/172 pass; server typecheck clean. + +**Skipped from the final polish list** (not worth the bytes at +current scale): + +- Sortable column headers — the stuck-first default sort already + answers the question; at 30-50 rows, scanning is fine. +- Vitest pool isolation for `tests/announcement/**` — the + `mockResolvedValue({rows: []})` default works as a band-aid. +- Slack deep-link helper in the admin cannot_verify error state — + UX work outside this thread. diff --git a/docs/ops/channel-rotation.md b/docs/ops/channel-rotation.md new file mode 100644 index 0000000000..511bfaaddf --- /dev/null +++ b/docs/ops/channel-rotation.md @@ -0,0 +1,124 @@ +# Slack channel rotation — ops runbook + +Quick reference for rotating a Slack channel wired into an admin +setting (billing / escalation / admin / prospect / error / editorial / +announcement). Written with the incident-response case in mind: +"something just happened, I need to reroute fast." + +## Happy path (planned rotation) + +1. Open `/admin/settings` in the admin portal. +2. Scroll to the relevant channel section (e.g. "Editorial review + channel"). The current value is shown as `Current: #channel-name`. +3. If the new channel isn't in the dropdown: invite @Addie to it in + Slack first, then reload `/admin/settings`. The picker only shows + channels the bot is a member of — picks that wouldn't work at save + time never appear. +4. Select the new channel, click **Save**. The admin UI writes the + new value to `system_settings`, which is the sole source of truth + for the active channel. +5. All setting writes go through `system_settings_audit` — the prior + value, new value, and the WorkOS user who made the change are + recorded. `getSettingAuditHistory(limit)` in + `server/src/db/system-settings-db.ts` reads them back. + +## Write-time failure modes + +The `verifyChannelPrivacyForWrite` helper (`server/src/slack/client.ts`) +gates every setting write: + +- **`cannot_verify`** — Slack returned nothing for the channel ID + (bot not a member, Slack throttled, transient 5xx, wrong scope). + The admin UI surfaces: *"Could not verify the channel for X. Invite + @Addie to the channel in Slack and save again."* Fix is usually + inviting the bot. +- **`wrong_privacy`** — the channel is the wrong kind (e.g. public + channel picked for a private-required setting, or vice versa). + Admin picks a different channel. + +Both responses are 400s with distinct error strings. Neither writes +to the DB, so retrying is safe. + +## Send-time safety net + +For sensitive-content channels (billing / escalation / admin / +prospect / error / editorial), `sendChannelMessage(..., { requirePrivate: true })` +runs a fresh check at post time and refuses to post if the channel +drifted public between write and send. Editorial review drafts, Slack +review cards, and LI reminders all go through this gate. + +## Incident scenario: review channel archived or gone + +If the editorial review channel gets archived or the bot gets kicked, +Stage 1 (`runAnnouncementTriggerJob`) and the Stage 5 reminder job +will log a warning and skip. The LI reminder job has a dead-parent +path: if the original review card's thread can't be replied to, it +posts a fresh non-threaded notice to the same channel pointing at +`/admin/announcements`, then burns one of the three reminder slots so +it doesn't retry indefinitely. + +**Recovery:** + +1. Un-archive the channel in Slack, or pick a different private + channel. +2. Re-invite @Addie. +3. Set the new value via `/admin/settings`. The admin UI is the only + supported path — there is no env-var fallback anymore. + +## DB-only rotation (break-glass) + +If the admin UI is down but prod DB is reachable (say, Workos outage +taking out the session layer), a channel can be set directly via SQL. +This is break-glass only; normal ops should go through the UI so +audit + verification run. + +```sql +-- Replace keys as needed: +-- editorial_slack_channel, billing_slack_channel, escalation_slack_channel, +-- admin_slack_channel, prospect_slack_channel, error_slack_channel, +-- announcement_slack_channel. + +INSERT INTO system_settings (key, value, updated_at, updated_by) +VALUES ( + 'editorial_slack_channel', + '{"channel_id":"C0NEWREVIEW","channel_name":"admin-editorial-review"}'::jsonb, + NOW(), + NULL -- no WorkOS id available in break-glass mode +) +ON CONFLICT (key) DO UPDATE +SET value = EXCLUDED.value, + updated_at = NOW(), + updated_by = NULL; + +-- Record the break-glass change in the audit table for after-the-fact +-- review. Keep this row even though the UI write path would have +-- created it automatically — SQL-direct writes bypass setSetting(). +INSERT INTO system_settings_audit (key, old_value, new_value, changed_by, changed_at) +SELECT + 'editorial_slack_channel', + NULL, + '{"channel_id":"C0NEWREVIEW","channel_name":"admin-editorial-review"}'::jsonb, + 'break-glass:runbook', + NOW(); +``` + +Notes: + +- `verifyChannelPrivacyForWrite` is skipped in this path. Confirm the + channel is the right kind yourself before running. +- The send-time `requirePrivate` gate still fires on the next + outbound post, so if you rotate to a public channel by mistake the + job will refuse to post review content. +- File an incident note so the rotation can be reconciled with + `/admin/settings` once the UI is back. + +## Related + +- `server/src/slack/client.ts` — `verifyChannelPrivacyForWrite`, + `verifyChannelStillPrivate`, `sendChannelMessage`. +- `server/src/routes/admin/settings.ts` — write-side route handlers + and the `requireChannelPrivacy` wrapper. +- `server/src/db/system-settings-db.ts` — `setSetting` atomically + writes the value plus an audit row. +- `server/public/admin-settings.html` — admin UI. +- Issue #3003 — fail-closed write-time privacy check. diff --git a/server/public/admin-announcements.html b/server/public/admin-announcements.html index 3f3727a547..e00f2074d6 100644 --- a/server/public/admin-announcements.html +++ b/server/public/admin-announcements.html @@ -99,6 +99,7 @@ line-height: 1.4; } .badge-tier { background: var(--color-gray-100); color: var(--color-text-secondary); } + .signup-cell { color: var(--color-text-secondary); font-size: var(--text-xs); white-space: nowrap; } .badge-backfill { background: #fef3c7; color: #92400e; margin-left: var(--space-2); } .status-cell { display: inline-flex; @@ -297,7 +298,7 @@

Announcements

return new Date(b.draft_posted_at) - new Date(a.draft_posted_at); }); if (filtered.length === 0) { - wrap.innerHTML = `
No announcements in state “${escapeHtml(STATE_LABELS[activeState])}”.
`; + wrap.innerHTML = `
${emptyStateFor(activeState)}
`; return; } const bodyHtml = filtered.map(renderRow).join(''); @@ -307,6 +308,7 @@

Announcements

Org Tier + Signed up Draft posted Slack LinkedIn @@ -318,6 +320,22 @@

Announcements

`; } + function emptyStateFor(state) { + // Informational vs actionable: + // - The two "live" tabs (pending_review, li_pending) are what + // editorial checks daily. An empty state there means "nothing + // to do right now" — tell them the flow keeps running without + // them needing to refresh. + // - all/done/skipped: informational; the plain label is fine. + if (state === 'pending_review') { + return 'Nothing to review. New drafts are posted hourly by the trigger job.'; + } + if (state === 'li_pending') { + return 'Nothing waiting on LinkedIn right now.'; + } + return `No announcements in state “${escapeHtml(STATE_LABELS[state])}”.`; + } + function renderRow(r) { const profileHref = `/admin/accounts/${encodeURIComponent(r.organization_id)}`; const tier = r.membership_tier ?? '—'; @@ -327,6 +345,12 @@

Announcements

const stuckLabel = stuck ? ` · stuck ${daysPending}d` : ''; + // Signup-age answers "is this still timely?" without a click. + // Org row can be null (orphan draft) so the column falls back + // to em-dash. + const signupCell = r.org_created_at + ? `${signupRelative(r.org_created_at)}` + : '—'; // Inline Mark-LI button shows only when Slack is posted and // LinkedIn isn't. Uses the same POST endpoint as the account // detail page surface from PR #2981; idempotent server-side. @@ -343,6 +367,7 @@

Announcements

${r.is_backfill ? 'BACKFILL' : ''} ${escapeHtml(tier)} + ${signupCell} ${draftWhen}${stuckLabel} ${r.slack_posted ? `✓ ${fmtDate(r.slack_posted_at)}` @@ -423,6 +448,20 @@

Announcements

} catch { return 0; } } + function signupRelative(iso) { + // Relative age — "3d ago", "2mo ago", "1y ago" — so an editorial + // scan of the table reads "this member joined recently" vs + // "this is a stale backfill" without mental math on a date. + if (!iso) return '—'; + const days = daysAgo(iso); + if (days < 1) return 'today'; + if (days < 30) return `${days}d ago`; + const months = Math.floor(days / 30); + if (months < 12) return `${months}mo ago`; + const years = Math.floor(days / 365); + return `${years}y ago`; + } + function escapeHtml(text) { if (text === null || text === undefined) return ''; const div = document.createElement('div'); @@ -430,7 +469,31 @@

Announcements

return div.innerHTML; } + // Arrow-key nav across the filter tabs. Follows the WAI-ARIA + // "tabs" pattern: Left/Right cycle, Home/End jump to ends. + // Keeps focus+selection in sync. + function installTabKeyboard() { + const tabs = Array.from(document.querySelectorAll('.filter-tabs .tab')); + const tablist = document.querySelector('.filter-tabs'); + if (!tablist) return; + tablist.addEventListener('keydown', (e) => { + const idx = tabs.indexOf(document.activeElement); + if (idx < 0) return; + let next = idx; + if (e.key === 'ArrowRight') next = (idx + 1) % tabs.length; + else if (e.key === 'ArrowLeft') next = (idx - 1 + tabs.length) % tabs.length; + else if (e.key === 'Home') next = 0; + else if (e.key === 'End') next = tabs.length - 1; + else return; + e.preventDefault(); + tabs[next].focus(); + const state = tabs[next].dataset.state; + if (state) setFilter(state); + }); + } + loadBacklog(); + installTabKeyboard(); diff --git a/server/src/addie/jobs/announcement-handlers.ts b/server/src/addie/jobs/announcement-handlers.ts index ce69148957..8b3a48db95 100644 --- a/server/src/addie/jobs/announcement-handlers.ts +++ b/server/src/addie/jobs/announcement-handlers.ts @@ -296,6 +296,9 @@ export interface BacklogRow { org_name: string; membership_tier: string | null; profile_slug: string | null; + /** When the org was created in WorkOS / organizations row. + * `null` when the org row was deleted (orphan draft). */ + org_created_at: Date | null; draft_posted_at: Date; visual_source: string | null; is_backfill: boolean; @@ -313,6 +316,7 @@ export async function loadAnnouncementBacklog(): Promise { org_name: string | null; membership_tier: string | null; profile_slug: string | null; + org_created_at: Date | null; draft_posted_at: Date; visual_source: string | null; is_backfill: boolean; @@ -365,6 +369,7 @@ export async function loadAnnouncementBacklog(): Promise { ld.organization_id, o.name AS org_name, o.membership_tier, + o.created_at AS org_created_at, mp.slug AS profile_slug, ld.draft_posted_at, ld.metadata->>'visual_source' AS visual_source, @@ -388,6 +393,7 @@ export async function loadAnnouncementBacklog(): Promise { org_name: r.org_name ?? r.organization_id, membership_tier: r.membership_tier, profile_slug: r.profile_slug, + org_created_at: r.org_created_at, draft_posted_at: r.draft_posted_at, visual_source: r.visual_source, is_backfill: r.is_backfill === true, diff --git a/server/src/routes/admin/announcements.ts b/server/src/routes/admin/announcements.ts index fb1fd35276..2a6b5c95f1 100644 --- a/server/src/routes/admin/announcements.ts +++ b/server/src/routes/admin/announcements.ts @@ -54,6 +54,7 @@ export function setupAnnouncementsRoutes( org_name: r.org_name, membership_tier: r.membership_tier, profile_slug: r.profile_slug, + org_created_at: r.org_created_at?.toISOString() ?? null, draft_posted_at: r.draft_posted_at.toISOString(), slack_posted_at: r.slack_posted_at?.toISOString() ?? null, linkedin_marked_at: r.linkedin_marked_at?.toISOString() ?? null, diff --git a/tests/announcement/announcement-backlog-route.test.ts b/tests/announcement/announcement-backlog-route.test.ts index 02b079d20d..0ff121a8ff 100644 --- a/tests/announcement/announcement-backlog-route.test.ts +++ b/tests/announcement/announcement-backlog-route.test.ts @@ -120,11 +120,13 @@ describe('GET /api/admin/announcements', () => { it('returns ISO-format date strings, not Date objects', async () => { const when = new Date('2026-04-01T12:00:00Z'); + const orgCreated = new Date('2024-06-15T10:00:00Z'); mockLoadAnnouncementBacklog.mockResolvedValueOnce([ { ...base, organization_id: 'org_A', org_name: 'A', + org_created_at: orgCreated, draft_posted_at: when, slack_posted_at: when, linkedin_marked_at: null, @@ -140,6 +142,30 @@ describe('GET /api/admin/announcements', () => { expect(res.body.rows[0].draft_posted_at).toBe('2026-04-01T12:00:00.000Z'); expect(res.body.rows[0].slack_posted_at).toBe('2026-04-01T12:00:00.000Z'); expect(res.body.rows[0].linkedin_marked_at).toBeNull(); + // Signup-age column reads org_created_at. Null safe when the org + // row was deleted (orphan draft). + expect(res.body.rows[0].org_created_at).toBe('2024-06-15T10:00:00.000Z'); + }); + + it('org_created_at is null when backlog row lacks it', async () => { + mockLoadAnnouncementBacklog.mockResolvedValueOnce([ + { + ...base, + organization_id: 'org_ORPHAN', + org_name: 'org_ORPHAN', + org_created_at: null, + draft_posted_at: new Date(), + slack_posted_at: null, + linkedin_marked_at: null, + skipped_at: null, + slack_posted: false, + linkedin_posted: false, + skipped: false, + }, + ]); + const app = await buildApp(); + const res = await request(app).get('/api/admin/announcements'); + expect(res.body.rows[0].org_created_at).toBeNull(); }); it('500 on backend failure', async () => { diff --git a/tests/announcement/announcement-backlog.test.ts b/tests/announcement/announcement-backlog.test.ts index 7e7e0fb224..70e371b669 100644 --- a/tests/announcement/announcement-backlog.test.ts +++ b/tests/announcement/announcement-backlog.test.ts @@ -140,6 +140,53 @@ describe('loadAnnouncementBacklog', () => { expect(sql).toMatch(/LEFT JOIN organizations o/); }); + it('exposes org_created_at so the UI can render signup-age', async () => { + const orgCreated = new Date('2024-06-15T10:00:00Z'); + mockQuery.mockResolvedValueOnce({ + rows: [ + { + organization_id: 'org_AAA', + org_name: 'Alpha Co', + membership_tier: 'builder', + profile_slug: 'alpha', + org_created_at: orgCreated, + draft_posted_at: new Date(), + visual_source: 'brand_logo', + is_backfill: false, + slack_posted_at: null, + linkedin_marked_at: null, + skipped_at: null, + }, + ], + }); + const { loadAnnouncementBacklog } = await import('../../server/src/addie/jobs/announcement-handlers.js'); + const rows = await loadAnnouncementBacklog(); + expect(rows[0].org_created_at).toEqual(orgCreated); + }); + + it('org_created_at is null when the org row was deleted (orphan draft)', async () => { + mockQuery.mockResolvedValueOnce({ + rows: [ + { + organization_id: 'org_GONE', + org_name: null, + membership_tier: null, + profile_slug: null, + org_created_at: null, + draft_posted_at: new Date(), + visual_source: null, + is_backfill: false, + slack_posted_at: null, + linkedin_marked_at: null, + skipped_at: null, + }, + ], + }); + const { loadAnnouncementBacklog } = await import('../../server/src/addie/jobs/announcement-handlers.js'); + const rows = await loadAnnouncementBacklog(); + expect(rows[0].org_created_at).toBeNull(); + }); + it('falls back to organization_id when the joined org row is null', async () => { mockQuery.mockResolvedValueOnce({ rows: [ @@ -148,6 +195,7 @@ describe('loadAnnouncementBacklog', () => { org_name: null, // LEFT JOIN returned no org row membership_tier: null, profile_slug: null, + org_created_at: null, draft_posted_at: new Date(), visual_source: null, is_backfill: false, From 2282f008e25276c74a024c4cf6b5941dce954ba5 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Fri, 24 Apr 2026 12:50:17 -0400 Subject: [PATCH 2/4] fix(workflow-b-polish): CI + reviewer follow-ups MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI: - Move docs/ops/channel-rotation.md → ops/channel-rotation.md. The check-seo workflow enforces Mintlify frontmatter (description, og:title) on everything under docs/. The runbook is internal ops, not published docs — wrong home. ops/ is a top-level cousin of scripts/, explicitly outside the SEO check's scope. Code-reviewer follow-ups: - Break-glass SQL now runs as one atomic CTE matching setSetting() shape from server/src/db/system-settings-db.ts. Captures the actual prior old_value from system_settings instead of hardcoding NULL — otherwise post-incident review loses the before-state, which is the exact thing the audit table exists for. - Defensive STATE_LABELS[state] ?? state fallback in the empty-state renderer — not reachable today, but cheap guard against "undefined" surfacing if a future caller passes a new bucket name. - Changeset body path updated to match the new runbook location. Co-Authored-By: Claude Opus 4.7 --- .changeset/workflow-b-final-polish.md | 2 +- {docs/ops => ops}/channel-rotation.md | 45 ++++++++++++++++---------- server/public/admin-announcements.html | 2 +- 3 files changed, 30 insertions(+), 19 deletions(-) rename {docs/ops => ops}/channel-rotation.md (83%) diff --git a/.changeset/workflow-b-final-polish.md b/.changeset/workflow-b-final-polish.md index f37cf2bc20..9cfa57733c 100644 --- a/.changeset/workflow-b-final-polish.md +++ b/.changeset/workflow-b-final-polish.md @@ -25,7 +25,7 @@ the #3003 review. **Ops runbook:** -- New `docs/ops/channel-rotation.md` — quick reference for rotating a +- New `ops/channel-rotation.md` — quick reference for rotating a Slack channel wired into an admin setting (all seven — billing, escalation, admin, prospect, error, editorial, announcement). Covers happy path, write/send-time failure modes, incident scenario diff --git a/docs/ops/channel-rotation.md b/ops/channel-rotation.md similarity index 83% rename from docs/ops/channel-rotation.md rename to ops/channel-rotation.md index 511bfaaddf..cc3c21a371 100644 --- a/docs/ops/channel-rotation.md +++ b/ops/channel-rotation.md @@ -72,34 +72,45 @@ taking out the session layer), a channel can be set directly via SQL. This is break-glass only; normal ops should go through the UI so audit + verification run. +```sql +Run this as one atomic CTE. It mirrors the shape of `setSetting()` in +`server/src/db/system-settings-db.ts` — capturing the *actual* +`old_value` (not hardcoded NULL) so post-incident review can see what +the channel was before the break-glass flip. + ```sql -- Replace keys as needed: -- editorial_slack_channel, billing_slack_channel, escalation_slack_channel, -- admin_slack_channel, prospect_slack_channel, error_slack_channel, -- announcement_slack_channel. -INSERT INTO system_settings (key, value, updated_at, updated_by) -VALUES ( - 'editorial_slack_channel', - '{"channel_id":"C0NEWREVIEW","channel_name":"admin-editorial-review"}'::jsonb, - NOW(), - NULL -- no WorkOS id available in break-glass mode +WITH old AS ( + SELECT value AS old_value FROM system_settings + WHERE key = 'editorial_slack_channel' +), +upserted AS ( + INSERT INTO system_settings (key, value, updated_at, updated_by) + VALUES ( + 'editorial_slack_channel', + '{"channel_id":"C0NEWREVIEW","channel_name":"admin-editorial-review"}'::jsonb, + NOW(), + NULL -- no WorkOS id available in break-glass mode + ) + ON CONFLICT (key) DO UPDATE + SET value = EXCLUDED.value, + updated_at = NOW(), + updated_by = NULL + RETURNING value AS new_value ) -ON CONFLICT (key) DO UPDATE -SET value = EXCLUDED.value, - updated_at = NOW(), - updated_by = NULL; - --- Record the break-glass change in the audit table for after-the-fact --- review. Keep this row even though the UI write path would have --- created it automatically — SQL-direct writes bypass setSetting(). INSERT INTO system_settings_audit (key, old_value, new_value, changed_by, changed_at) SELECT 'editorial_slack_channel', - NULL, - '{"channel_id":"C0NEWREVIEW","channel_name":"admin-editorial-review"}'::jsonb, + old.old_value, + upserted.new_value, 'break-glass:runbook', - NOW(); + NOW() +FROM upserted +LEFT JOIN old ON true; ``` Notes: diff --git a/server/public/admin-announcements.html b/server/public/admin-announcements.html index e00f2074d6..c6f164b067 100644 --- a/server/public/admin-announcements.html +++ b/server/public/admin-announcements.html @@ -333,7 +333,7 @@

Announcements

if (state === 'li_pending') { return 'Nothing waiting on LinkedIn right now.'; } - return `No announcements in state “${escapeHtml(STATE_LABELS[state])}”.`; + return `No announcements in state “${escapeHtml(STATE_LABELS[state] ?? state)}”.`; } function renderRow(r) { From c75d857b4f02c31cd722b1f28154956b84452f25 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Fri, 24 Apr 2026 13:30:51 -0400 Subject: [PATCH 3/4] polish(admin-announcements): signupRelative unit consistency + all-state empty copy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Code-reviewer nits from the final-polish review pass. - signupRelative: years now derives from months (Math.floor(months/12)) instead of raw days (Math.floor(days/365)). Same output today but consistent with the days→months step above it — one unit computed off the next-smaller, rather than two independent floor() calls that drift on leap years. A tiny scan-readability win. - emptyStateFor("all"): "No announcements in state 'All'" reads oddly when the only reason to be on the All tab empty is that nothing's ever been drafted. Reword to "No announcements yet." Full announcement suite 172/172 pass; typecheck clean. Co-Authored-By: Claude Opus 4.7 --- server/public/admin-announcements.html | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/server/public/admin-announcements.html b/server/public/admin-announcements.html index c6f164b067..efad9563b7 100644 --- a/server/public/admin-announcements.html +++ b/server/public/admin-announcements.html @@ -326,13 +326,18 @@

Announcements

// editorial checks daily. An empty state there means "nothing // to do right now" — tell them the flow keeps running without // them needing to refresh. - // - all/done/skipped: informational; the plain label is fine. + // - `all` empty = no announcements on the system yet — "state + // All" reads oddly, so special-case. + // - done/skipped: plain label is fine. if (state === 'pending_review') { return 'Nothing to review. New drafts are posted hourly by the trigger job.'; } if (state === 'li_pending') { return 'Nothing waiting on LinkedIn right now.'; } + if (state === 'all') { + return 'No announcements yet.'; + } return `No announcements in state “${escapeHtml(STATE_LABELS[state] ?? state)}”.`; } @@ -452,14 +457,15 @@

Announcements

// Relative age — "3d ago", "2mo ago", "1y ago" — so an editorial // scan of the table reads "this member joined recently" vs // "this is a stale backfill" without mental math on a date. + // Each unit derives from the next-smaller one so the transitions + // line up (30d boundary → 1mo, 12mo boundary → 1y). if (!iso) return '—'; const days = daysAgo(iso); if (days < 1) return 'today'; if (days < 30) return `${days}d ago`; const months = Math.floor(days / 30); if (months < 12) return `${months}mo ago`; - const years = Math.floor(days / 365); - return `${years}y ago`; + return `${Math.floor(months / 12)}y ago`; } function escapeHtml(text) { From 1b4fb20f463548eba3486cb2644ea4e47a08afa2 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Fri, 24 Apr 2026 13:36:35 -0400 Subject: [PATCH 4/4] nit(admin-announcements): drop dead next initializer in installTabKeyboard Flagged by github-code-quality bot: `let next = idx;` is always overwritten before read. Every handled key branch assigns `next` before `tabs[next].focus()` runs, and the unhandled-key branch early-returns. Declare without an initializer. No behavior change. Co-Authored-By: Claude Opus 4.7 --- server/public/admin-announcements.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/public/admin-announcements.html b/server/public/admin-announcements.html index efad9563b7..e7334ae319 100644 --- a/server/public/admin-announcements.html +++ b/server/public/admin-announcements.html @@ -485,7 +485,7 @@

Announcements

tablist.addEventListener('keydown', (e) => { const idx = tabs.indexOf(document.activeElement); if (idx < 0) return; - let next = idx; + let next; if (e.key === 'ArrowRight') next = (idx + 1) % tabs.length; else if (e.key === 'ArrowLeft') next = (idx - 1 + tabs.length) % tabs.length; else if (e.key === 'Home') next = 0;