Skip to content

Commit 2c142dd

Browse files
committed
Merge dev: one-step /mumbl join + Slack teamspace fixes
2 parents f56414d + 616323e commit 2c142dd

3 files changed

Lines changed: 151 additions & 13 deletions

File tree

scripts/slack-join-smoke.mjs

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
import { createClient } from "@supabase/supabase-js";
2+
import { readFileSync } from "node:fs";
3+
4+
// Load env from .env.local (same file db:link:staging uses).
5+
for (const line of readFileSync(new URL("../.env.local", import.meta.url), "utf8").split("\n")) {
6+
const match = line.match(/^\s*([A-Z0-9_]+)\s*=\s*(.*)\s*$/);
7+
if (match && !process.env[match[1]]) process.env[match[1]] = match[2].replace(/^["']|["']$/g, "");
8+
}
9+
10+
const url = process.env.NEXT_PUBLIC_SUPABASE_URL;
11+
const key = process.env.SUPABASE_SERVICE_ROLE_KEY;
12+
if (!url || !key) throw new Error("missing NEXT_PUBLIC_SUPABASE_URL / SUPABASE_SERVICE_ROLE_KEY in .env.local");
13+
14+
const supabase = createClient(url, key, { auth: { persistSession: false } });
15+
16+
const teamId = `T_SMOKE_${crypto.randomUUID().slice(0, 8)}`;
17+
const slackUserId = `U_SMOKE_${crypto.randomUUID().slice(0, 8)}`;
18+
19+
function assert(cond, msg) {
20+
if (!cond) throw new Error(`ASSERT FAILED: ${msg}`);
21+
console.log(` ✓ ${msg}`);
22+
}
23+
24+
// Reference a real space without creating one.
25+
const { data: space, error: spaceErr } = await supabase.from("spaces").select("id,slug").limit(1).single();
26+
if (spaceErr || !space) throw new Error("no spaces in DB to reference for the smoke test");
27+
console.log(`using space ${space.slug} (${space.id})\n`);
28+
29+
let pinId;
30+
try {
31+
// 1) A newcomer joins: pin with mumbl_user_id = null (the migration enables this).
32+
const { data: pin, error: pinErr } = await supabase
33+
.from("slack_pinned_spaces")
34+
.insert({ mumbl_user_id: null, slack_team_id: teamId, slack_user_id: slackUserId, space_id: space.id })
35+
.select("id,mumbl_user_id")
36+
.single();
37+
assert(!pinErr, `null-mumbl_user_id pin insert succeeds${pinErr ? ` — ${pinErr.message}` : ""}`);
38+
assert(pin?.mumbl_user_id === null, "stored mumbl_user_id is null");
39+
pinId = pin.id;
40+
41+
// 2) App Home / manage list keys on Slack identity — must find it without a connection.
42+
const { data: listed } = await supabase
43+
.from("slack_pinned_spaces")
44+
.select("id,space_id")
45+
.eq("slack_team_id", teamId)
46+
.eq("slack_user_id", slackUserId);
47+
assert(listed?.some((row) => row.id === pinId), "pin is listed by (slack_team_id, slack_user_id)");
48+
49+
// 3) Backfill on connect: a fake but valid-shaped uuid; use a real auth user so the FK holds.
50+
const { data: authUser } = await supabase.from("slack_connections").select("mumbl_user_id").limit(1).maybeSingle();
51+
if (authUser?.mumbl_user_id) {
52+
const { error: backfillErr } = await supabase
53+
.from("slack_pinned_spaces")
54+
.update({ mumbl_user_id: authUser.mumbl_user_id })
55+
.eq("slack_team_id", teamId)
56+
.eq("slack_user_id", slackUserId)
57+
.is("mumbl_user_id", null);
58+
assert(!backfillErr, `backfill update sets mumbl_user_id${backfillErr ? ` — ${backfillErr.message}` : ""}`);
59+
const { data: after } = await supabase.from("slack_pinned_spaces").select("mumbl_user_id").eq("id", pinId).single();
60+
assert(after?.mumbl_user_id === authUser.mumbl_user_id, "pin now attributed to the connected mumbl user");
61+
} else {
62+
console.log(" ~ skipped backfill check (no existing slack_connections row to borrow a user id from)");
63+
}
64+
65+
console.log("\nSMOKE PASS ✅");
66+
} finally {
67+
if (pinId) {
68+
await supabase.from("slack_pinned_spaces").delete().eq("id", pinId);
69+
console.log("cleaned up smoke pin");
70+
}
71+
}

src/server/slack.js

Lines changed: 73 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -322,7 +322,21 @@ export async function createSlackStartedSpace({ teamId, slackUserId, name }) {
322322
}
323323

324324
const handoff = await createCreatorHandoff({ spaceId: insertedSpace.id, creatorToken, accessToken });
325-
const teamReadsSetup = await createTeamReadsSetup({ spaceId: insertedSpace.id });
325+
326+
// The room already exists at this point. If building the optional team-reads
327+
// install link fails (e.g. missing Slack OAuth env), don't fail the whole
328+
// creation — the user already has a working room. Just omit the button.
329+
let teamReadsUrl = null;
330+
try {
331+
const teamReadsSetup = await createTeamReadsSetup({ spaceId: insertedSpace.id });
332+
teamReadsUrl = slackTeamReadsInstallUrl(teamReadsSetup);
333+
} catch (error) {
334+
console.error("Slack team reads setup link failed after room creation", {
335+
spaceId: insertedSpace.id,
336+
message: error.message,
337+
});
338+
}
339+
326340
return {
327341
space: insertedSpace,
328342
creatorToken,
@@ -331,7 +345,7 @@ export async function createSlackStartedSpace({ teamId, slackUserId, name }) {
331345
pinned: Boolean(connection),
332346
openUrl: slackSpaceHandoffUrl(handoff),
333347
roomUrl: slackRoomReadsUrl(insertedSpace, accessToken),
334-
teamReadsUrl: slackTeamReadsInstallUrl(teamReadsSetup),
348+
teamReadsUrl,
335349
};
336350
}
337351

@@ -805,6 +819,17 @@ async function findSlackConnectionForAutoPin({ teamId, slackUserId }) {
805819
}
806820
}
807821

822+
// Resolve a connection for joining/pinning a room. If the Slack user already
823+
// has (or can be matched to) a mumbl account, use that real connection so the
824+
// pin is attributed to it. Otherwise synthesize a Slack-identity-only stand-in:
825+
// pins are keyed on (slack_team_id, slack_user_id), so this is enough to pin and
826+
// invite in one step. `mumbl_user_id` stays null and backfills on connect.
827+
async function resolveSlackConnectionForJoin({ teamId, slackUserId }) {
828+
const connection = await findSlackConnectionForAutoPin({ teamId, slackUserId });
829+
if (connection) return connection;
830+
return { slack_team_id: teamId, slack_user_id: slackUserId, mumbl_user_id: null };
831+
}
832+
808833
export async function connectSlackUser({ teamId, slackUserId, mumblUserId }) {
809834
const supabase = getSupabaseAdmin();
810835
const sessionTokenHash = hashToken(`slack:${teamId}:${slackUserId}`);
@@ -824,9 +849,24 @@ export async function connectSlackUser({ teamId, slackUserId, mumblUserId }) {
824849
.single();
825850
if (error) throw error;
826851
await reconcileSlackStartedSpacesForConnection(data);
852+
await backfillPinnedSpacesUserId(data);
827853
return data;
828854
}
829855

856+
// When a Slack user connects their mumbl account, attribute any rooms they
857+
// joined earlier (pinned with a null mumbl_user_id) to that account.
858+
async function backfillPinnedSpacesUserId(connection) {
859+
if (!connection?.mumbl_user_id || !connection.slack_team_id || !connection.slack_user_id) return;
860+
const supabase = getSupabaseAdmin();
861+
const { error } = await supabase
862+
.from("slack_pinned_spaces")
863+
.update({ mumbl_user_id: connection.mumbl_user_id })
864+
.eq("slack_team_id", connection.slack_team_id)
865+
.eq("slack_user_id", connection.slack_user_id)
866+
.is("mumbl_user_id", null);
867+
if (error) throw error;
868+
}
869+
830870
export async function saveSlackDump({ connection, content, sourceMeta = {} }) {
831871
const supabase = getSupabaseAdmin();
832872
const cleanedContent = cleanString(content, 4000);
@@ -1001,7 +1041,7 @@ export async function updateSlackFieldNoteDraft({ teamId, slackUserId, fieldNote
10011041
}
10021042

10031043
export async function pinSlackSpaceBySlug({ teamId, slackUserId, slug }) {
1004-
const connection = await findOrCreateSlackConnectionByEmail({ teamId, slackUserId });
1044+
const connection = await resolveSlackConnectionForJoin({ teamId, slackUserId });
10051045
const space = await findSpaceForSlackPin(slug);
10061046
const pin = await pinSlackSpace({ connection, spaceId: space.id });
10071047
const channelJoin = await inviteSlackUserToSpaceChannel({ teamId, slackUserId, spaceId: space.id });
@@ -1592,7 +1632,7 @@ export function slackRoomCreatedPayload({ space, openUrl, roomUrl, teamReadsUrl,
15921632
blocks: [
15931633
section(`*${escapeSlackText(space.name)} is ready.*\n${status}`),
15941634
actions([
1595-
{ text: "create team reads channel", url: teamReadsUrl, style: "primary" },
1635+
...(teamReadsUrl ? [{ text: "create team reads channel", url: teamReadsUrl, style: "primary" }] : []),
15961636
{ text: creatorLinked ? "open team reads" : "claim room", url: openUrl },
15971637
{ text: "share with team", actionId: "share_room_invite", value: JSON.stringify({ roomUrl, spaceName: space.name }) },
15981638
]),
@@ -1617,7 +1657,7 @@ export function slackRoomCreatedModalView({ space, openUrl, roomUrl, teamReadsUr
16171657
: `*${escapeSlackText(space.name)} is ready.*\nConnect once to claim creator access in Mumbl.`,
16181658
),
16191659
actions([
1620-
{ text: "create team reads channel", url: teamReadsUrl, style: "primary" },
1660+
...(teamReadsUrl ? [{ text: "create team reads channel", url: teamReadsUrl, style: "primary" }] : []),
16211661
{ text: creatorLinked ? "open team reads" : "claim room", url: openUrl },
16221662
{ text: "share with team", actionId: "share_room_invite", value: JSON.stringify({ roomUrl, spaceName: space.name }) },
16231663
]),
@@ -1633,7 +1673,7 @@ export function slackRoomCreatedModalView({ space, openUrl, roomUrl, teamReadsUr
16331673
}
16341674

16351675
export function slackShareRoomInviteModalView({ roomUrl, spaceName }) {
1636-
const inviteMessage = `hey team — just set up a mumbl room for ${spaceName || "us"}.\nwrite private work thoughts, publish as team reads when ready.\n\nto join, run this in Slack:\n${slackJoinCommand(roomUrl)}`;
1676+
const inviteMessage = `📓 hey team — I set up a mumbl room for ${spaceName || "us"}.\n\nmumbl is where we keep the half-formed work thoughts privately, then shape the good ones into team reads when they're ready. ✍️\n\n👉 to join, copy this and run it in Slack:\n${slackJoinCommand(roomUrl)}\n\nyou'll land in our reads channel right away and the room pins to your mumbl App Home. ✨`;
16371677
return {
16381678
type: "modal",
16391679
callback_id: "share_room_invite",
@@ -1963,7 +2003,7 @@ async function slackApi(method, token, body) {
19632003
}
19642004

19652005
function channelNameForSpace(space) {
1966-
return `mumbl-${slugify(space?.slug || space?.name || "team-reads")}`.slice(0, 80);
2006+
return `${slugify(space?.slug || space?.name || "team-reads")}`.slice(0, 80);
19672007
}
19682008

19692009
function teamReadMessage({ space, post, channel, roomAccessToken = "" }) {
@@ -1987,12 +2027,13 @@ async function slackAppHomeBlocks({ teamId, slackUserId }) {
19872027
const { appUrl } = getServerEnv();
19882028
const connection = await findSlackConnection({ teamId, slackUserId });
19892029
const { patternGraphEnabled } = getServerEnv();
1990-
const [pinnedSpaces, pendingPattern] = connection
1991-
? await Promise.all([
1992-
listSlackPinnedSpaces(connection),
1993-
patternGraphEnabled ? findPendingPattern(connection.mumbl_user_id) : null,
1994-
])
1995-
: [[], null];
2030+
// Pins are keyed on Slack identity, so list them even before the user has a
2031+
// linked mumbl account — a teammate who just ran `/mumbl join` should see the
2032+
// room here without connecting first.
2033+
const [pinnedSpaces, pendingPattern] = await Promise.all([
2034+
listSlackPinnedSpaces(connection || { slack_team_id: teamId, slack_user_id: slackUserId }),
2035+
connection && patternGraphEnabled ? findPendingPattern(connection.mumbl_user_id) : null,
2036+
]);
19962037
const topPinnedSpaces = pinnedSpaces.slice(0, 5);
19972038
const pinnedList = topPinnedSpaces
19982039
.map((pin) => {
@@ -2313,6 +2354,25 @@ async function slackPinnedSpacesModal({ pinnedSpaces, notice = "" }) {
23132354
{ text: "open team reads", url: openReadsUrl },
23142355
{ text: "publish a draft", actionId: "review_field_note_drafts" },
23152356
];
2357+
2358+
// Offer a one-step "create reads channel" link for spaces that don't have
2359+
// one yet. Building the install link is optional — skip it if it fails.
2360+
if (!channel?.slack_channel_name && space.id) {
2361+
try {
2362+
const teamReadsSetup = await createTeamReadsSetup({ spaceId: space.id });
2363+
rowActions.push({
2364+
text: "create reads channel",
2365+
url: slackTeamReadsInstallUrl(teamReadsSetup),
2366+
style: "primary",
2367+
});
2368+
} catch (error) {
2369+
console.error("Slack team reads setup link failed in manage teamspaces", {
2370+
spaceId: space.id,
2371+
message: error.message,
2372+
});
2373+
}
2374+
}
2375+
23162376
rowActions.push({ text: "unpin", actionId: "unpin_pinned_space_start", value: pin.id, style: "danger" });
23172377

23182378
blocks.push(
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
-- Allow pinning a Slack space before the Slack user has a linked mumbl account.
2+
-- Pins are keyed and read entirely by (slack_team_id, slack_user_id); the
3+
-- mumbl_user_id column is denormalized and backfilled when the user connects.
4+
-- Dropping NOT NULL lets `/mumbl join` pin a room in one step, with no Google
5+
-- auth, for teammates who have never visited mumbl.
6+
alter table slack_pinned_spaces
7+
alter column mumbl_user_id drop not null;

0 commit comments

Comments
 (0)