From 3535d67a3ac344cfd9ad16d1b99a5ccaf23f0653 Mon Sep 17 00:00:00 2001 From: Nader Nikbakht Date: Sat, 8 Aug 2026 17:59:30 -0400 Subject: [PATCH 1/5] fix(slack-channels): translate Slack API errors into an actionable instruction Live: "Could not add to 3 channel(s): #help: user_is_ultra_restricted; ..." -- a bare API code that says nothing about who must do what. It actually means the MEMBER is a single-channel guest, which Slack caps at one channel, so no bot permission can fix it: a workspace admin has to change their account type. explainSlackError() now maps the common codes to a sentence naming the channel and the required action: user_is_ultra_restricted (single-channel guest -> admin must upgrade the account type), user_is_restricted, is_archived (drop it from the configured list), channel_not_found (bad id or private + bot cannot see it), not_in_channel (/invite @BBQS), cant_invite_self. Unknown codes still pass through verbatim rather than being swallowed. --- supabase/functions/slack-channels/index.ts | 23 +++++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/supabase/functions/slack-channels/index.ts b/supabase/functions/slack-channels/index.ts index 9b5e30c6..d10bbf6d 100644 --- a/supabase/functions/slack-channels/index.ts +++ b/supabase/functions/slack-channels/index.ts @@ -41,6 +41,27 @@ async function slack(method: string, token: string, params: Record #name for display. Falls back to the raw id if the lookup fails. */ async function channelNames(ids: string[], token: string): Promise> { const out: Record = {}; @@ -167,7 +188,7 @@ Deno.serve(async (req) => { } } if (r?.ok || r?.error === "already_in_channel") invited.push(channel); - else failed.push({ channel, error: `${label(channel)}: ${String(r?.error ?? "unknown")}` }); + else failed.push({ channel, error: explainSlackError(String(r?.error ?? "unknown"), label(channel)) }); } return json({ ok: failed.length === 0, From 52349afbbceecbf4fecacfff4d37e79f1eaa9ba2 Mon Sep 17 00:00:00 2001 From: Nader Nikbakht Date: Sat, 8 Aug 2026 18:05:23 -0400 Subject: [PATCH 2/5] =?UTF-8?q?feat(onboarding-console):=20batch=20Slack?= =?UTF-8?q?=20triage=20=E2=80=94=20one=20group=20guest=20invite=20instead?= =?UTF-8?q?=20of=20one-by-one?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Inviting guests one at a time meant discovering "not in the workspace" per person, mid-flow. A new "Slack invites" button collects everyone whose slack step is still open and classifies them in one pass, then gives each group its batch action: - NOT in the workspace -> "Copy all emails" (comma-separated) to paste straight into Slack's Invite people box as a single group invite; - IN the workspace but missing channels -> "Add all to channels" in one click, then re-checks; - already complete -> just counted. Backed by a new action:'bulk_check' on slack-channels: takes people[] (capped at 100, run in small sequential batches because users.lookupByEmail is rate-limited) and returns needs_guest_invite / needs_channels / complete, with channel NAMES not ids. Read-only. tsc clean, guards 3/3. --- .../admin/OnboardingPipelinePanel.tsx | 8 + src/components/admin/SlackInvitesDialog.tsx | 170 ++++++++++++++++++ supabase/functions/slack-channels/index.ts | 44 ++++- 3 files changed, 220 insertions(+), 2 deletions(-) create mode 100644 src/components/admin/SlackInvitesDialog.tsx diff --git a/src/components/admin/OnboardingPipelinePanel.tsx b/src/components/admin/OnboardingPipelinePanel.tsx index 50e7c5c1..08d68b5d 100644 --- a/src/components/admin/OnboardingPipelinePanel.tsx +++ b/src/components/admin/OnboardingPipelinePanel.tsx @@ -13,6 +13,7 @@ import { toast } from "sonner"; import { edgeError } from "@/lib/edgeError"; import { OnboardMemberDialog } from "@/components/admin/OnboardMemberDialog"; import { GroupAuditDialog } from "@/components/admin/GroupAuditDialog"; +import { SlackInvitesDialog } from "@/components/admin/SlackInvitesDialog"; type PipelineRow = { id: string; @@ -90,6 +91,12 @@ export function OnboardingPipelinePanel({ embedded }: { embedded?: boolean } = { return rows; }, [rows, filter]); const stuckCount = useMemo(() => rows.filter((r) => r.is_stuck).length, [rows]); + // Everyone whose Slack step is still open — fed to the batch guest-invite triage. + const slackPending = useMemo( + () => rows.filter((r) => r.checklist && "slack" in r.checklist && !isDone(r.checklist.slack)) + .map((r) => ({ email: r.email, name: r.name, role: r.role })), + [rows], + ); // Run an action, toast, refresh. Serialized via `busy` to avoid double-clicks. const act = async (fn: () => Promise, ok: string) => { @@ -148,6 +155,7 @@ export function OnboardingPipelinePanel({ embedded }: { embedded?: boolean } = { ))}
+ Onboard member} /> diff --git a/src/components/admin/SlackInvitesDialog.tsx b/src/components/admin/SlackInvitesDialog.tsx new file mode 100644 index 00000000..a8132f4e --- /dev/null +++ b/src/components/admin/SlackInvitesDialog.tsx @@ -0,0 +1,170 @@ +import { useState } from "react"; +import { useQueryClient } from "@tanstack/react-query"; +import { supabase } from "@/integrations/supabase/client"; +import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { Loader2, Slack, Copy, Check, UserPlus, Hash } from "lucide-react"; +import { toast } from "sonner"; +import { edgeError } from "@/lib/edgeError"; + +type Person = { email: string; name: string | null; role: string | null }; +type Row = { email: string; name: string | null; in_workspace: boolean; missing?: string[]; missing_ids?: string[]; reason?: string }; +type Result = { ok?: boolean; checked?: number; needs_guest_invite?: Row[]; needs_channels?: Row[]; complete?: Row[]; error?: string }; + +/** Batch Slack triage: split everyone with an unfinished Slack step into + * (a) not in the workspace -> copy ALL their emails and send ONE group guest invite, and + * (b) in the workspace but missing channels -> add them all in one click. */ +export function SlackInvitesDialog({ people }: { people: Person[] }) { + const queryClient = useQueryClient(); + const [open, setOpen] = useState(false); + const [busy, setBusy] = useState(false); + const [res, setRes] = useState(null); + const [copied, setCopied] = useState(false); + + const check = async () => { + setBusy(true); + try { + const { data, error } = await supabase.functions.invoke("slack-channels", { + body: { action: "bulk_check", people }, + }); + if (error) throw new Error(await edgeError(error, data)); + const r = (data ?? {}) as Result; + if (r.error) throw new Error(r.error); + setRes(r); + toast.success(`Checked ${r.checked ?? 0} member(s)`); + } catch (e: any) { + toast.error(e?.message ?? "Slack check failed"); + } finally { + setBusy(false); + } + }; + + const guests = res?.needs_guest_invite ?? []; + const chans = res?.needs_channels ?? []; + + const copyEmails = async () => { + const list = guests.map((g) => g.email).join(", "); + try { + await navigator.clipboard.writeText(list); + setCopied(true); + setTimeout(() => setCopied(false), 2500); + toast.success(`${guests.length} email(s) copied — paste into Slack's invite box`); + } catch { + toast.error("Could not copy — select the list and copy manually"); + } + }; + + const addAllChannels = async () => { + setBusy(true); + let ok = 0; + const failed: string[] = []; + try { + for (const p of chans) { + const { data, error } = await supabase.functions.invoke("slack-channels", { + body: { email: p.email, role: people.find((x) => x.email === p.email)?.role, action: "invite" }, + }); + if (error || (data as any)?.ok === false) failed.push(`${p.email}: ${(data as any)?.error ?? "failed"}`); + else ok++; + } + toast[failed.length ? "warning" : "success"]( + failed.length ? `Added ${ok}, ${failed.length} failed` : `Added ${ok} member(s) to their channels`, + ); + if (failed.length) console.warn("slack add failures:", failed); + queryClient.invalidateQueries({ queryKey: ["onboarding-pipeline"] }); + await check(); + } finally { + setBusy(false); + } + }; + + return ( + { setOpen(o); if (!o) { setRes(null); setCopied(false); } }}> + + + + + + Slack invites + + Checks everyone whose Slack step is unfinished ({people.length} member{people.length === 1 ? "" : "s"}) and splits + them into those who still need a workspace guest invite and those who just need channels. + + + +
+ {!res ? ( +

Run the check to see who needs what. Nothing is sent.

+ ) : ( + <> + {/* Needs a guest invite — the batch action */} +
+
+
+ + Not in Slack yet — {guests.length} +
+ {guests.length > 0 && ( + + )} +
+ {guests.length === 0 ? ( +

Everyone is already in the workspace.

+ ) : ( + <> +

+ Copy these, then in Slack: Invite people → paste the whole list → send one group invite. +

+
+ {guests.map((g) => g.email).join(", ")} +
+ + )} +
+ + {/* Already in the workspace, just missing channels */} +
+
+
+ + In Slack, missing channels — {chans.length} +
+ {chans.length > 0 && ( + + )} +
+ {chans.length === 0 ? ( +

Nobody is missing a channel.

+ ) : ( +
    + {chans.map((c) => ( +
  • + {c.name ?? c.email} — missing {(c.missing ?? []).join(", ")} +
  • + ))} +
+ )} +
+ + {(res.complete ?? []).length > 0 && ( +

+ {(res.complete ?? []).length} already fully set up in Slack. +

+ )} + + )} +
+ + + + + +
+
+ ); +} diff --git a/supabase/functions/slack-channels/index.ts b/supabase/functions/slack-channels/index.ts index d10bbf6d..26a77c36 100644 --- a/supabase/functions/slack-channels/index.ts +++ b/supabase/functions/slack-channels/index.ts @@ -77,8 +77,11 @@ async function channelNames(ids: string[], token: string): Promise { if (req.method === "OPTIONS") return new Response("ok", { headers: corsHeaders }); try { - const { email, role, action } = await req.json().catch(() => ({})); - if (!email || typeof email !== "string") return json({ ok: false, error: "Provide an email" }, 400); + const { email, role, action, people } = await req.json().catch(() => ({})); + const isBulk = action === "bulk_check" && Array.isArray(people); + if (!isBulk && (!email || typeof email !== "string")) { + return json({ ok: false, error: "Provide an email, or people[] with action:'bulk_check'" }, 400); + } // Authz: admin/curator only, checked under the caller's own JWT. const authHeader = req.headers.get("Authorization") || ""; @@ -131,6 +134,43 @@ Deno.serve(async (req) => { error: `Slack is not configured on this project: ${!token ? "SLACK_BOT_TOKEN missing" : ""}${!token && !base.length ? "; " : ""}${!base.length ? "SLACK_ONBOARDING_CHANNELS missing" : ""}. Set them with: supabase secrets set SLACK_BOT_TOKEN=xoxb-… SLACK_ONBOARDING_CHANNELS="C…,C…"`, }, 500); } + // BULK: classify many members at once so an admin can send ONE group guest invite in Slack + // instead of discovering "not in workspace" one person at a time. Read-only. + if (isBulk) { + const list = (people as Array<{ email?: string; name?: string; role?: string }>) + .filter((p) => p?.email).slice(0, 100); + const names = await channelNames([...new Set([...base, ...yi])], token); + const out: Array> = []; + // Small sequential batches — users.lookupByEmail is rate-limited per workspace. + for (let i = 0; i < list.length; i += 5) { + const batch = list.slice(i, i + 5); + const res = await Promise.all(batch.map(async (p) => { + const em = String(p.email).toLowerCase(); + const lk = await slack("users.lookupByEmail", token, { email: em }); + if (!lk?.ok) { + return { email: em, name: p.name ?? null, in_workspace: false, + reason: String(lk?.error ?? "unknown") }; + } + const want = [...new Set([...base, ...(YI_ROLES.has(String(p.role ?? "").toLowerCase()) ? yi : [])])]; + const cv = await slack("users.conversations", token, { + user: lk.user.id, types: "public_channel,private_channel", limit: "200", exclude_archived: "true", + }); + const cur: string[] = cv?.ok ? (cv.channels ?? []).map((c: { id: string }) => c.id) : []; + const miss = want.filter((c) => !cur.includes(c)); + return { email: em, name: p.name ?? null, in_workspace: true, + missing: miss.map((c) => names[c] ?? c), missing_ids: miss }; + })); + out.push(...res); + } + return json({ + ok: true, + checked: out.length, + needs_guest_invite: out.filter((p) => !p.in_workspace), + needs_channels: out.filter((p) => p.in_workspace && (p.missing_ids as string[]).length > 0), + complete: out.filter((p) => p.in_workspace && (p.missing_ids as string[]).length === 0), + }); + } + const isYI = YI_ROLES.has(String(role ?? "").toLowerCase()); const target = [...new Set([...base, ...(isYI ? yi : [])])]; From 1f6ccc523a3772858b632e69c16d351914678da0 Mon Sep 17 00:00:00 2001 From: Nader Nikbakht Date: Sat, 8 Aug 2026 18:29:17 -0400 Subject: [PATCH 3/5] feat(slack-channels): per-working-group channels (they were missing entirely) Correction: Slack membership is not just "everyone-channels + the young-investigator channel". Each working group has its own channel, mirroring the wg-*@ Google Groups -- and the function had no concept of that, so a member's WG channels were never considered. New SLACK_WG_CHANNELS mapping, e.g. "WG-Analytics=C1,WG-Devices=C2|C3,WG-ELSI=C4,WG-Standards=C5" (a group may map to several channels via "|"; the WG token is matched case-insensitively). targetsFor() now computes a member's channels as: everyone-channels + YI channels (postdocs and grad students) + one entry per working group they belong to. Both the single-person resolver and the bulk triage pass working_groups, so "missing" is per-member and correct. Also adds action:'list_channels' -- returns every channel the bot can see (id, #name, private, is_member) plus the CURRENT configured mapping, so channel IDs can be copied from one call instead of hunted down in Slack. tsc clean, guards 3/3. --- .../admin/OnboardingPipelinePanel.tsx | 2 +- src/components/admin/ResolveStageDialog.tsx | 2 +- src/components/admin/SlackInvitesDialog.tsx | 5 +- supabase/functions/slack-channels/index.ts | 54 +++++++++++++++++-- 4 files changed, 55 insertions(+), 8 deletions(-) diff --git a/src/components/admin/OnboardingPipelinePanel.tsx b/src/components/admin/OnboardingPipelinePanel.tsx index 08d68b5d..aee7a77b 100644 --- a/src/components/admin/OnboardingPipelinePanel.tsx +++ b/src/components/admin/OnboardingPipelinePanel.tsx @@ -94,7 +94,7 @@ export function OnboardingPipelinePanel({ embedded }: { embedded?: boolean } = { // Everyone whose Slack step is still open — fed to the batch guest-invite triage. const slackPending = useMemo( () => rows.filter((r) => r.checklist && "slack" in r.checklist && !isDone(r.checklist.slack)) - .map((r) => ({ email: r.email, name: r.name, role: r.role })), + .map((r) => ({ email: r.email, name: r.name, role: r.role, working_groups: r.working_groups })), [rows], ); diff --git a/src/components/admin/ResolveStageDialog.tsx b/src/components/admin/ResolveStageDialog.tsx index 83b43635..0f03baff 100644 --- a/src/components/admin/ResolveStageDialog.tsx +++ b/src/components/admin/ResolveStageDialog.tsx @@ -113,7 +113,7 @@ export function ResolveStageDialog({ target, onClose }: { target: StageTarget | setBusy(true); try { const { data, error } = await supabase.functions.invoke("slack-channels", { - body: { email: target.email, role: target.role, action }, + body: { email: target.email, role: target.role, working_groups: target.working_groups ?? [], action }, }); if (error) throw new Error(await edgeError(error, data)); const res = (data ?? {}) as Record; diff --git a/src/components/admin/SlackInvitesDialog.tsx b/src/components/admin/SlackInvitesDialog.tsx index a8132f4e..d36958e2 100644 --- a/src/components/admin/SlackInvitesDialog.tsx +++ b/src/components/admin/SlackInvitesDialog.tsx @@ -7,7 +7,7 @@ import { Loader2, Slack, Copy, Check, UserPlus, Hash } from "lucide-react"; import { toast } from "sonner"; import { edgeError } from "@/lib/edgeError"; -type Person = { email: string; name: string | null; role: string | null }; +type Person = { email: string; name: string | null; role: string | null; working_groups: string[] | null }; type Row = { email: string; name: string | null; in_workspace: boolean; missing?: string[]; missing_ids?: string[]; reason?: string }; type Result = { ok?: boolean; checked?: number; needs_guest_invite?: Row[]; needs_channels?: Row[]; complete?: Row[]; error?: string }; @@ -61,7 +61,8 @@ export function SlackInvitesDialog({ people }: { people: Person[] }) { try { for (const p of chans) { const { data, error } = await supabase.functions.invoke("slack-channels", { - body: { email: p.email, role: people.find((x) => x.email === p.email)?.role, action: "invite" }, + body: { email: p.email, role: people.find((x) => x.email === p.email)?.role, + working_groups: people.find((x) => x.email === p.email)?.working_groups ?? [], action: "invite" }, }); if (error || (data as any)?.ok === false) failed.push(`${p.email}: ${(data as any)?.error ?? "failed"}`); else ok++; diff --git a/supabase/functions/slack-channels/index.ts b/supabase/functions/slack-channels/index.ts index 26a77c36..50418509 100644 --- a/supabase/functions/slack-channels/index.ts +++ b/supabase/functions/slack-channels/index.ts @@ -26,6 +26,33 @@ const corsHeaders = { const YI_ROLES = new Set(["postdoc", "graduate_student"]); const csv = (v: string | undefined) => (v ?? "").split(",").map((s) => s.trim()).filter(Boolean); +/** Per-working-group Slack channels, mirroring the wg-*@ Google Groups. + * SLACK_WG_CHANNELS="WG-Analytics=C1,WG-Devices=C2|C3,WG-ELSI=C4,WG-Standards=C5" + * (a group may map to several channels with "|"). Case-insensitive on the WG token. */ +function wgChannelMap(): Record { + const out: Record = {}; + for (const pair of csv(Deno.env.get("SLACK_WG_CHANNELS"))) { + const i = pair.indexOf("="); + if (i < 1) continue; + const wg = pair.slice(0, i).trim().toLowerCase(); + const ids = pair.slice(i + 1).split("|").map((x) => x.trim()).filter(Boolean); + if (wg && ids.length) out[wg] = ids; + } + return out; +} + +/** Channels a member should be in: everyone-channels + YI (trainees) + their working groups. */ +function targetsFor(base: string[], yi: string[], wgMap: Record, + role: unknown, workingGroups: unknown): string[] { + const t = [...base]; + if (YI_ROLES.has(String(role ?? "").toLowerCase())) t.push(...yi); + for (const wg of Array.isArray(workingGroups) ? workingGroups : []) { + const ids = wgMap[String(wg).trim().toLowerCase()]; + if (ids) t.push(...ids); + } + return [...new Set(t)]; +} + const json = (b: unknown, s = 200) => new Response(JSON.stringify(b), { status: s, headers: { ...corsHeaders, "Content-Type": "application/json" } }); @@ -77,7 +104,7 @@ async function channelNames(ids: string[], token: string): Promise { if (req.method === "OPTIONS") return new Response("ok", { headers: corsHeaders }); try { - const { email, role, action, people } = await req.json().catch(() => ({})); + const { email, role, action, people, working_groups } = await req.json().catch(() => ({})); const isBulk = action === "bulk_check" && Array.isArray(people); if (!isBulk && (!email || typeof email !== "string")) { return json({ ok: false, error: "Provide an email, or people[] with action:'bulk_check'" }, 400); @@ -134,12 +161,31 @@ Deno.serve(async (req) => { error: `Slack is not configured on this project: ${!token ? "SLACK_BOT_TOKEN missing" : ""}${!token && !base.length ? "; " : ""}${!base.length ? "SLACK_ONBOARDING_CHANNELS missing" : ""}. Set them with: supabase secrets set SLACK_BOT_TOKEN=xoxb-… SLACK_ONBOARDING_CHANNELS="C…,C…"`, }, 500); } + const wgMap = wgChannelMap(); + + // Discovery: list every channel the bot can see, so an admin can copy IDs for + // SLACK_ONBOARDING_CHANNELS / SLACK_WG_CHANNELS instead of digging through Slack. + if (action === "list_channels") { + const out: Array<{ id: string; name: string; is_private: boolean; is_member: boolean }> = []; + let cursor: string | undefined; + do { + const p: Record = { types: "public_channel,private_channel", limit: "200", exclude_archived: "true" }; + if (cursor) p.cursor = cursor; + const r = await slack("conversations.list", token, p); + if (!r?.ok) return json({ ok: false, error: `Slack list failed: ${String(r?.error ?? "unknown")}` }, 502); + for (const c of r.channels ?? []) out.push({ id: c.id, name: `#${c.name}`, is_private: !!c.is_private, is_member: !!c.is_member }); + cursor = r.response_metadata?.next_cursor || undefined; + } while (cursor); + out.sort((a, b) => a.name.localeCompare(b.name)); + return json({ ok: true, channels: out, configured: { onboarding: base, yi, working_groups: wgMap } }); + } + // BULK: classify many members at once so an admin can send ONE group guest invite in Slack // instead of discovering "not in workspace" one person at a time. Read-only. if (isBulk) { const list = (people as Array<{ email?: string; name?: string; role?: string }>) .filter((p) => p?.email).slice(0, 100); - const names = await channelNames([...new Set([...base, ...yi])], token); + const names = await channelNames([...new Set([...base, ...yi, ...Object.values(wgMap).flat()])], token); const out: Array> = []; // Small sequential batches — users.lookupByEmail is rate-limited per workspace. for (let i = 0; i < list.length; i += 5) { @@ -151,7 +197,7 @@ Deno.serve(async (req) => { return { email: em, name: p.name ?? null, in_workspace: false, reason: String(lk?.error ?? "unknown") }; } - const want = [...new Set([...base, ...(YI_ROLES.has(String(p.role ?? "").toLowerCase()) ? yi : [])])]; + const want = targetsFor(base, yi, wgMap, p.role, (p as { working_groups?: unknown }).working_groups); const cv = await slack("users.conversations", token, { user: lk.user.id, types: "public_channel,private_channel", limit: "200", exclude_archived: "true", }); @@ -172,7 +218,7 @@ Deno.serve(async (req) => { } const isYI = YI_ROLES.has(String(role ?? "").toLowerCase()); - const target = [...new Set([...base, ...(isYI ? yi : [])])]; + const target = targetsFor(base, yi, wgMap, role, working_groups); // 1. Resolve the person in the workspace (external guests must be invited manually first). const lookup = await slack("users.lookupByEmail", token, { email: String(email).toLowerCase() }); From dca953644f53f276a5ddf21741b58092ba998e49 Mon Sep 17 00:00:00 2001 From: Nader Nikbakht Date: Sat, 8 Aug 2026 18:36:46 -0400 Subject: [PATCH 4/5] feat(slack): channels follow working-group membership automatically Adding someone to a working group now adds them to that group's Slack channel, the way Google Groups already work. New trg_sync_slack_channels fires on the same events and calls slack-channels with action:'sync'. Fires on INSERT as well as UPDATE, deliberately: the Google-Group trigger is UPDATE-only, which is exactly why a member created with working groups already set was never provisioned (the drift group-audit exists to find). This trigger does not repeat that mistake. The trigger path is untrusted by design. A DB trigger can only send the public anon key, so the function IGNORES every value in the body except the email, re-reads role/working_groups with the service role, and syncs from what the database says; it returns a bare {ok} so the path cannot be used to probe who is in Slack. Admin/curator JWT is still required for all other actions. A Slack failure never blocks the profile edit. --- supabase/functions/slack-channels/index.ts | 46 +++++++++++++--- ...0807220000_sync_slack_channels_trigger.sql | 54 +++++++++++++++++++ 2 files changed, 92 insertions(+), 8 deletions(-) create mode 100644 supabase/migrations/20260807220000_sync_slack_channels_trigger.sql diff --git a/supabase/functions/slack-channels/index.ts b/supabase/functions/slack-channels/index.ts index 50418509..9006dcc8 100644 --- a/supabase/functions/slack-channels/index.ts +++ b/supabase/functions/slack-channels/index.ts @@ -115,12 +115,29 @@ Deno.serve(async (req) => { const supa = createClient(Deno.env.get("SUPABASE_URL")!, Deno.env.get("SUPABASE_ANON_KEY")!, { global: { headers: { Authorization: authHeader } }, }); - const { data: userData } = await supa.auth.getUser(); - const uid = userData?.user?.id; - if (!uid) return json({ ok: false, error: "Not authenticated" }, 401); - const { data: roles } = await supa.from("user_roles").select("role").eq("user_id", uid); - if (!(roles || []).some((r: { role: string }) => r.role === "admin" || r.role === "curator")) { - return json({ ok: false, error: "Admin or curator only" }, 403); + // TRIGGER PATH: the DB trigger fires with only the public anon key (the established + // pattern here — see sync_member_groups). It is NOT trusted: we ignore every value in the + // body except the email, re-read the member with the service role, and act ONLY on what + // the database says. It also returns a bare {ok} so this path can't be used to probe who + // is in Slack. Everything else still requires an admin/curator JWT. + const triggerMode = action === "sync"; + let dbRole: unknown = role; + let dbWGs: unknown = working_groups; + if (triggerMode) { + const admin = createClient(Deno.env.get("SUPABASE_URL")!, Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!); + const { data: inv } = await admin.from("investigators") + .select("role,working_groups").ilike("email", String(email)).maybeSingle(); + if (!inv) return json({ ok: true, skipped: "no such member" }); + dbRole = inv.role; + dbWGs = inv.working_groups; + } else { + const { data: userData } = await supa.auth.getUser(); + const uid = userData?.user?.id; + if (!uid) return json({ ok: false, error: "Not authenticated" }, 401); + const { data: roles } = await supa.from("user_roles").select("role").eq("user_id", uid); + if (!(roles || []).some((r: { role: string }) => r.role === "admin" || r.role === "curator")) { + return json({ ok: false, error: "Admin or curator only" }, 403); + } } // Report WHICH piece of config is missing (presence only — never the values), so a 500 @@ -217,8 +234,10 @@ Deno.serve(async (req) => { }); } - const isYI = YI_ROLES.has(String(role ?? "").toLowerCase()); - const target = targetsFor(base, yi, wgMap, role, working_groups); + const effRole = triggerMode ? dbRole : role; + const effWGs = triggerMode ? dbWGs : working_groups; + const isYI = YI_ROLES.has(String(effRole ?? "").toLowerCase()); + const target = targetsFor(base, yi, wgMap, effRole, effWGs); // 1. Resolve the person in the workspace (external guests must be invited manually first). const lookup = await slack("users.lookupByEmail", token, { email: String(email).toLowerCase() }); @@ -244,6 +263,17 @@ Deno.serve(async (req) => { const names = await channelNames(target, token); const label = (id: string) => names[id] ?? id; + if (triggerMode) { + for (const channel of missing) { + let r = await slack("conversations.invite", token, { channel, users: userId }, true); + if (!r?.ok && r?.error === "not_in_channel") { + const j = await slack("conversations.join", token, { channel }, true); + if (j?.ok) await slack("conversations.invite", token, { channel, users: userId }, true); + } + } + return json({ ok: true, synced: missing.length }); + } + if (action !== "invite") { return json({ ok: true, user_id: userId, is_young_investigator: isYI, diff --git a/supabase/migrations/20260807220000_sync_slack_channels_trigger.sql b/supabase/migrations/20260807220000_sync_slack_channels_trigger.sql new file mode 100644 index 00000000..754d80e8 --- /dev/null +++ b/supabase/migrations/20260807220000_sync_slack_channels_trigger.sql @@ -0,0 +1,54 @@ +-- Slack channels follow working-group membership automatically. +-- +-- Requirement: "if anyone is at any point added to any of the working groups, they should +-- automatically be added to the corresponding channels as well." Google Groups already work +-- this way (trg_sync_member_groups); this is the Slack counterpart, firing on the same events +-- so BOTH follow a profile edit no matter which surface made it (member, curator, agent, +-- console, or a SQL backfill). +-- +-- The edge function ignores everything in the body except the email: it re-reads the member +-- with the service role and syncs from what the DATABASE says, so this call cannot be used to +-- grant channels that the record does not justify. +-- +-- Requires: supabase functions deploy slack-channels, and SLACK_WG_CHANNELS configured. +-- KG migrations are NOT applied by `db push` — run this in the KG SQL editor. + +CREATE EXTENSION IF NOT EXISTS pg_net; + +CREATE OR REPLACE FUNCTION public.sync_slack_channels() +RETURNS trigger +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = public +AS $$ +BEGIN + IF NEW.email IS NULL OR btrim(NEW.email) = '' THEN + RETURN NEW; -- no email → nothing to resolve in Slack + END IF; + + PERFORM net.http_post( + url := 'https://vpexxhfpvghlejljwpvt.supabase.co/functions/v1/slack-channels', + headers := '{"Content-Type":"application/json","apikey":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6InZwZXh4aGZwdmdobGVqbGp3cHZ0Iiwicm9sZSI6ImFub24iLCJpYXQiOjE3Njk3MDg2NDUsImV4cCI6MjA4NTI4NDY0NX0.M107rJ9Ji17zAyd8Jolt5GQFZmu9vvAG1UiIq0GQh8U"}'::jsonb, + body := jsonb_build_object('email', NEW.email, 'action', 'sync') + ); + RETURN NEW; +EXCEPTION WHEN OTHERS THEN + -- A Slack failure must NEVER block the profile edit from saving. + RAISE WARNING 'sync_slack_channels failed: %', SQLERRM; + RETURN NEW; +END; +$$; + +-- Fires on the same conditions as the Google-Group sync, plus INSERT: a member created with +-- working groups already set would otherwise never be synced (the Google-Group trigger has +-- exactly that gap — it is UPDATE-only, which is why group-audit found drift). +DROP TRIGGER IF EXISTS trg_sync_slack_channels ON public.investigators; +CREATE TRIGGER trg_sync_slack_channels + AFTER INSERT OR UPDATE ON public.investigators + FOR EACH ROW + WHEN ( + TG_OP = 'INSERT' + OR OLD.working_groups IS DISTINCT FROM NEW.working_groups + OR OLD.role IS DISTINCT FROM NEW.role + ) + EXECUTE FUNCTION public.sync_slack_channels(); From 715fe2cb377952a5f50afc6894810424097581bd Mon Sep 17 00:00:00 2001 From: Nader Nikbakht Date: Sat, 8 Aug 2026 18:40:38 -0400 Subject: [PATCH 5/5] docs: record the Slack channel map (all four WG channels now exist) Analytics channel #bbqs-wg-analytics (C0BP1AN59CZ) was created, completing the mapping: everyone -> #general; trainees -> #younginvestigators; and one channel per working group. Documented as the source of truth alongside the note that the same lists exist agent-side in consortium_settings and must be kept in sync. --- docs/ONBOARDING_CONSOLE.md | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/docs/ONBOARDING_CONSOLE.md b/docs/ONBOARDING_CONSOLE.md index bcce7a28..edc67618 100644 --- a/docs/ONBOARDING_CONSOLE.md +++ b/docs/ONBOARDING_CONSOLE.md @@ -79,6 +79,26 @@ Both surfaces (agent + console) operate on the SAME KG state. The onboarding pip function reports that explicitly rather than failing silently. - **P3 NEXT** — `offboard_member` RPC + offboard wizard. +## Slack channel map (source of truth) +Membership = everyone-channel + young-investigator channel (postdoc/graduate_student) + one +channel per working group the member belongs to. Kept in KG project secrets: + +| Scope | Channel | ID | Secret | +|---|---|---|---| +| Everyone | `#general` | `C07UA8763SA` | `SLACK_ONBOARDING_CHANNELS` | +| Postdocs / grad students / trainees | `#younginvestigators` | `C09673P9D1A` | `SLACK_YI_CHANNELS` | +| WG-Analytics | `#bbqs-wg-analytics` | `C0BP1AN59CZ` | `SLACK_WG_CHANNELS` | +| WG-Devices | `#bbqs-wg-devices` | `C09633EE5M5` | `SLACK_WG_CHANNELS` | +| WG-ELSI | `#bbqs-wg-elsi` | `C098CRMDFUK` | `SLACK_WG_CHANNELS` | +| WG-Standards | `#bbqs-wg-standards` | `C097J7SLNJY` | `SLACK_WG_CHANNELS` | + +`trg_sync_slack_channels` (migration `20260807220000`) keeps these in step automatically on any +INSERT/UPDATE of `role`/`working_groups`, from any surface. The bot must be a member of each +channel (`/invite @BBQS`) — it can self-join PUBLIC channels only, and only with the +`channels:join` scope. NOTE: the everyone/YI/WG lists ALSO exist agent-side in +`consortium_settings` (`slack_onboarding_channels`, `slack_young_investigator_channels`); keep +them in sync or the two surfaces will invite people differently. + ## Manual apply / config (KG side) - Apply migrations in the SQL editor (in order): `20260806120000`, `20260806130000`, `20260806140000`, `20260806150000`, `20260806160000`. - Deploy edge functions (keys already on the project): `parse-onboard` (LOVABLE_API_KEY),