Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 48 additions & 0 deletions .changeset/workflow-b-final-polish.md
Original file line number Diff line number Diff line change
@@ -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.
135 changes: 135 additions & 0 deletions ops/channel-rotation.md
Original file line number Diff line number Diff line change
@@ -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.
71 changes: 70 additions & 1 deletion server/public/admin-announcements.html
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -297,7 +298,7 @@ <h1>Announcements</h1>
return new Date(b.draft_posted_at) - new Date(a.draft_posted_at);
});
if (filtered.length === 0) {
wrap.innerHTML = `<div class="empty">No announcements in state &ldquo;${escapeHtml(STATE_LABELS[activeState])}&rdquo;.</div>`;
wrap.innerHTML = `<div class="empty">${emptyStateFor(activeState)}</div>`;
return;
}
const bodyHtml = filtered.map(renderRow).join('');
Expand All @@ -307,6 +308,7 @@ <h1>Announcements</h1>
<tr>
<th>Org</th>
<th>Tier</th>
<th>Signed up</th>
<th>Draft posted</th>
<th>Slack</th>
<th>LinkedIn</th>
Expand All @@ -318,6 +320,27 @@ <h1>Announcements</h1>
`;
}

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 &ldquo;${escapeHtml(STATE_LABELS[state] ?? state)}&rdquo;.`;
}

function renderRow(r) {
const profileHref = `/admin/accounts/${encodeURIComponent(r.organization_id)}`;
const tier = r.membership_tier ?? '—';
Expand All @@ -327,6 +350,12 @@ <h1>Announcements</h1>
const stuckLabel = stuck
? ` <span class="stuck-warn" aria-label="Draft stuck ${daysPending} days">· stuck ${daysPending}d</span>`
: '';
// 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
? `<span title="${escapeHtml(fmtDate(r.org_created_at))}">${signupRelative(r.org_created_at)}</span>`
: '—';
// 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.
Expand All @@ -343,6 +372,7 @@ <h1>Announcements</h1>
${r.is_backfill ? '<span class="badge badge-backfill" title="Posted via the retroactive backfill script">BACKFILL</span>' : ''}
</td>
<td><span class="badge badge-tier">${escapeHtml(tier)}</span></td>
<td class="signup-cell">${signupCell}</td>
<td>${draftWhen}${stuckLabel}</td>
<td>${r.slack_posted
? `<span class="status-cell status-done">✓ ${fmtDate(r.slack_posted_at)}</span>`
Expand Down Expand Up @@ -423,14 +453,53 @@ <h1>Announcements</h1>
} 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');
div.textContent = String(text);
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();
</script>
</body>
</html>
6 changes: 6 additions & 0 deletions server/src/addie/jobs/announcement-handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -313,6 +316,7 @@ export async function loadAnnouncementBacklog(): Promise<BacklogRow[]> {
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;
Expand Down Expand Up @@ -365,6 +369,7 @@ export async function loadAnnouncementBacklog(): Promise<BacklogRow[]> {
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,
Expand All @@ -388,6 +393,7 @@ export async function loadAnnouncementBacklog(): Promise<BacklogRow[]> {
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,
Expand Down
1 change: 1 addition & 0 deletions server/src/routes/admin/announcements.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
26 changes: 26 additions & 0 deletions tests/announcement/announcement-backlog-route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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 () => {
Expand Down
Loading
Loading