diff --git a/.changeset/workflow-b-final-polish.md b/.changeset/workflow-b-final-polish.md new file mode 100644 index 0000000000..9cfa57733c --- /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 `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/ops/channel-rotation.md b/ops/channel-rotation.md new file mode 100644 index 0000000000..cc3c21a371 --- /dev/null +++ b/ops/channel-rotation.md @@ -0,0 +1,135 @@ +# 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 +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. + +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 +) +INSERT INTO system_settings_audit (key, old_value, new_value, changed_by, changed_at) +SELECT + 'editorial_slack_channel', + old.old_value, + upserted.new_value, + 'break-glass:runbook', + NOW() +FROM upserted +LEFT JOIN old ON true; +``` + +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..e7334ae319 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,27 @@

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` 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)}”.`; + } + function renderRow(r) { const profileHref = `/admin/accounts/${encodeURIComponent(r.organization_id)}`; const tier = r.membership_tier ?? '—'; @@ -327,6 +350,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 +372,7 @@

Announcements

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

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. + // 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`; + return `${Math.floor(months / 12)}y ago`; + } + function escapeHtml(text) { if (text === null || text === undefined) return ''; const div = document.createElement('div'); @@ -430,7 +475,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; + 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,