diff --git a/.changeset/admin-ui-channel-settings.md b/.changeset/admin-ui-channel-settings.md
new file mode 100644
index 0000000000..cfb9da109b
--- /dev/null
+++ b/.changeset/admin-ui-channel-settings.md
@@ -0,0 +1,38 @@
+---
+---
+
+**Admin UI for editorial + announcement channels; Stage 1 env-var → DB migration**
+
+Closes two follow-ups flagged during Workflow B Stage 2/3 reviews.
+
+**Admin UI (`admin-settings.html`).** Two new sections on the System
+Settings page:
+
+- *Editorial review channel* — private-channel picker. Stores into the
+ existing `editorial_slack_channel` setting. Workflow B Stage 1 review
+ cards land here.
+- *Public announcement channel* — public-channel picker (pulls from the
+ `?visibility=public` variant of the picker endpoint). Stores into
+ the `announcement_slack_channel` setting added in Stage 2.
+
+Previously both settings were DB-backed but API-only — editorial team
+had to `curl PUT /api/admin/settings/editorial-channel` to configure.
+
+**Stage 1 reader migration.** `runAnnouncementTriggerJob` and the
+backfill script now call a new `resolveEditorialChannel()` that prefers
+the DB setting and falls back to the legacy
+`SLACK_EDITORIAL_REVIEW_CHANNEL` env var. Safe rollout: existing prod
+config keeps working on deploy; once the admin UI is in prod an
+operator can set the DB value and we can drop the env var in a later
+PR.
+
+Also: transient DB read failures fall back to env rather than blocking
+the job — the job cares about *any* way of getting a channel id, not
+whichever storage won the coin flip this hour.
+
+**Tests.** `tests/announcement/announcement-channel-resolver.test.ts`
+— 8 tests covering DB-populated wins over env, env-fallback-on-null,
+both-null → null, env whitespace/empty handling, DB-throws → env
+fallback, DB-throws + env-unset → null.
+
+Full announcement suite 143/143 pass; typecheck clean.
diff --git a/server/public/admin-settings.html b/server/public/admin-settings.html
index 759d8142fe..0f579da6d5 100644
--- a/server/public/admin-settings.html
+++ b/server/public/admin-settings.html
@@ -243,6 +243,60 @@
Admin notifications channel
+
+
+
Editorial review channel
+
+ Private channel where the editorial working group approves new-member
+ announcements, content drafts, and other posts that need human review
+ before they go public.
+
+
+
+ Loading...
+
+
+
+
+
+
+ Only private channels are shown. Invite Addie to your editorial channel first.
+
+
+
+
+
+
+
+
Public announcement channel public
+
+ Public channel where approved new-member announcements are posted.
+ This is the only picker here that accepts public channels —
+ announcements are meant for broad visibility.
+
+
+
+ Loading...
+
+
+
+
+
+
+ Only public channels are shown. Invite Addie to the channel first.
+
}
}
- // Load Slack channels for picker
+ // Load Slack channels for all pickers. Private channels fill six
+ // of the seven dropdowns; the announcement dropdown pulls from the
+ // separate `visibility=public` endpoint because an announcement
+ // posted to a private channel would defeat the point.
+ //
+ // The two fetches are independent: if public fails, the five
+ // private pickers must still populate, so the public fetch is
+ // isolated in its own try/catch and degrades to a distinct
+ // error state on the announcement dropdown only.
+ let publicSlackChannels = [];
+ let publicFetchError = null;
async function loadSlackChannels() {
const billingSelect = document.getElementById('billingChannel');
const escalationSelect = document.getElementById('escalationChannel');
const adminSelect = document.getElementById('adminChannel');
const prospectSelect = document.getElementById('prospectChannel');
const errorSelect = document.getElementById('errorChannel');
+ const editorialSelect = document.getElementById('editorialChannel');
+ const announcementSelect = document.getElementById('announcementChannel');
try {
- const response = await fetch('/api/admin/settings/slack-channels');
- if (!response.ok) {
- const data = await response.json();
- throw new Error(data.message || 'Failed to load channels');
+ const [privateRes, publicRes] = await Promise.all([
+ fetch('/api/admin/settings/slack-channels'),
+ fetch('/api/admin/settings/slack-channels?visibility=public'),
+ ]);
+ if (!privateRes.ok) {
+ const data = await privateRes.json();
+ throw new Error(data.message || 'Failed to load private channels');
}
- const data = await response.json();
- slackChannels = data.channels;
+ const privateData = await privateRes.json();
+ slackChannels = privateData.channels;
- // Build options for all dropdowns
+ // Public fetch: keep isolated so a failure here doesn't wipe
+ // the private dropdowns. publicFetchError distinguishes
+ // "fetch failed" from "really zero public channels".
+ try {
+ if (!publicRes.ok) {
+ const data = await publicRes.json().catch(() => ({}));
+ throw new Error(data.message || `Public-channel fetch returned ${publicRes.status}`);
+ }
+ publicSlackChannels = (await publicRes.json()).channels ?? [];
+ publicFetchError = null;
+ } catch (pubErr) {
+ publicSlackChannels = [];
+ publicFetchError = pubErr instanceof Error ? pubErr.message : String(pubErr);
+ showStatusMessage('Could not load public channels: ' + publicFetchError, 'error');
+ }
+
+ // Build options for all private-channel dropdowns
let billingHtml = '';
let escalationHtml = '';
let adminHtml = '';
let prospectHtml = '';
let errorHtml = '';
+ let editorialHtml = '';
if (slackChannels.length === 0) {
const emptyOption = '';
@@ -395,6 +483,7 @@
Automatic prospect triage
adminSelect.innerHTML = emptyOption;
prospectSelect.innerHTML = emptyOption;
errorSelect.innerHTML = emptyOption;
+ editorialSelect.innerHTML = emptyOption;
showStatusMessage('Addie is not in any private channels. Invite @Addie to your channels and refresh.', 'error');
} else {
slackChannels.forEach(ch => {
@@ -403,11 +492,13 @@