Skip to content

Commit f55c5e6

Browse files
committed
Add Slack draft review modal
1 parent addf2c9 commit f55c5e6

4 files changed

Lines changed: 254 additions & 2 deletions

File tree

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -87,7 +87,7 @@ Until those variables exist, API routes return a setup `503`.
8787

8888
## Slack Beta Setup
8989

90-
The Slack app is a free beta entry point for private dumps. It supports `/mumbl [text]`, `/mumbl room [team name]`, a `save_to_mumbl` message shortcut, App Home private dumping, and App Home field-note drafting from recent private dumps. It does not read channel history.
90+
The Slack app is a free beta entry point for private dumps. It supports `/mumbl [text]`, `/mumbl room [team name]`, a `save_to_mumbl` message shortcut, App Home private dumping, and App Home field-note drafting/review from recent private dumps. It does not read channel history.
9191

9292
In Slack app settings:
9393

app/api/slack/interactions/route.js

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
ephemeralText,
99
findMumblUserByEmail,
1010
findSlackConnection,
11+
getSlackFieldNoteDraft,
1112
getSlackUserEmail,
1213
parseVerifiedSlackForm,
1314
postSlackResponse,
@@ -17,14 +18,18 @@ import {
1718
slackConnectUrl,
1819
slackDumpConnectModalView,
1920
slackDumpSavedModalView,
21+
slackFieldNoteEditModalView,
2022
slackFieldNoteDraftingModalView,
2123
slackFieldNoteDraftReadyModalView,
2224
slackFieldNoteDraftErrorModalView,
25+
slackFieldNoteSavedModalView,
2326
slackSavedDumpPayload,
2427
openSlackDumpModal,
2528
openSlackFieldNoteDraftModal,
29+
openSlackFieldNoteReviewModal,
2630
openSlackRoomModal,
2731
updateSlackView,
32+
updateSlackFieldNoteDraft,
2833
} from "../../../../src/server/slack";
2934
import { cleanString } from "../../../../src/server/validation";
3035

@@ -57,6 +62,13 @@ export async function POST(request) {
5762
triggerId: cleanString(payload.trigger_id, 200),
5863
});
5964
}
65+
if (actionId === "review_field_note_drafts") {
66+
await openSlackFieldNoteReviewModal({
67+
teamId: slackTeamId(payload),
68+
slackUserId: cleanString(payload.user?.id, 80),
69+
triggerId: cleanString(payload.trigger_id, 200),
70+
});
71+
}
6072
} catch (error) {
6173
console.error("Slack App Home action failed", error);
6274
}
@@ -116,6 +128,58 @@ export async function POST(request) {
116128
return ok({ response_action: "update", view: slackFieldNoteDraftingModalView() });
117129
}
118130

131+
if (payload.type === "view_submission" && payload.view?.callback_id === "review_field_note_drafts") {
132+
const teamId = slackTeamId(payload);
133+
const slackUserId = cleanString(payload.user?.id, 80);
134+
const fieldNoteId = cleanString(payload.view?.state?.values?.field_note_id?.value?.selected_option?.value, 64);
135+
if (!fieldNoteId) {
136+
return ok({
137+
response_action: "errors",
138+
errors: { field_note_id: "choose a draft." },
139+
});
140+
}
141+
142+
try {
143+
const fieldNote = await getSlackFieldNoteDraft({ teamId, slackUserId, fieldNoteId });
144+
return ok({ response_action: "update", view: slackFieldNoteEditModalView(fieldNote) });
145+
} catch (error) {
146+
return ok({
147+
response_action: "errors",
148+
errors: { field_note_id: error.message || "couldn't open that draft." },
149+
});
150+
}
151+
}
152+
153+
if (payload.type === "view_submission" && payload.view?.callback_id === "edit_field_note_draft") {
154+
const teamId = slackTeamId(payload);
155+
const slackUserId = cleanString(payload.user?.id, 80);
156+
const fieldNoteId = cleanString(payload.view?.private_metadata, 64);
157+
const title = cleanString(payload.view?.state?.values?.field_note_title?.value?.value, 120);
158+
const content = cleanString(payload.view?.state?.values?.field_note_content?.value?.value, 4000);
159+
if (!title) {
160+
return ok({
161+
response_action: "errors",
162+
errors: { field_note_title: "title the draft first." },
163+
});
164+
}
165+
if (!content) {
166+
return ok({
167+
response_action: "errors",
168+
errors: { field_note_content: "write the field note first." },
169+
});
170+
}
171+
172+
try {
173+
const result = await updateSlackFieldNoteDraft({ teamId, slackUserId, fieldNoteId, title, content });
174+
return ok({ response_action: "update", view: slackFieldNoteSavedModalView(result) });
175+
} catch (error) {
176+
return ok({
177+
response_action: "errors",
178+
errors: { field_note_content: error.message || "couldn't save that draft." },
179+
});
180+
}
181+
}
182+
119183
if (payload.type === "view_submission" && payload.view?.callback_id === "create_mumbl_room") {
120184
const teamId = slackTeamId(payload);
121185
const slackUserId = cleanString(payload.user?.id, 80);

docs/free-tier-compromises.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,7 @@ Current compromise:
7676
- Slack is free during beta. There is no 30-day countdown, no credit card, and no usage gate.
7777
- The beta supports only explicit user actions: `/mumbl [text]` and the `save_to_mumbl` message shortcut.
7878
- `/mumbl room [team name]` can create a Mumbl room from Slack with an expiring creator handoff link. `/mumbl start [team name]` remains an alias.
79-
- App Home can create private dumps and draft a field note from selected recent private dumps. Drafting uses the existing field-note OpenAI daily limit, and publishing still requires review in Mumbl.
79+
- App Home can create private dumps, draft a field note from selected recent private dumps, and edit recent private drafts. Drafting uses the existing field-note OpenAI daily limit, and publishing still requires review in Mumbl.
8080
- The Slack app requests `commands`, `users:read`, and `users:read.email` only. It does not request channel history or message history scopes.
8181
- Team-read posting to Slack is optional per room. When a creator enables it, Mumbl asks for an optional `chat:write` and `groups:write` permission upgrade only so it can create one private channel and post published team reads.
8282
- Slack daily check-in reminders are deferred because a 15-minute scheduler does not fit the free-tier posture.

src/server/slack.js

Lines changed: 188 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -452,6 +452,29 @@ export async function openSlackFieldNoteDraftModal({ teamId, slackUserId, trigge
452452
});
453453
}
454454

455+
export async function openSlackFieldNoteReviewModal({ teamId, slackUserId, triggerId }) {
456+
const installation = await getSlackInstallation(teamId);
457+
const token = decryptSlackToken({
458+
ciphertext: installation.bot_access_token_ciphertext,
459+
iv: installation.bot_access_token_iv,
460+
tag: installation.bot_access_token_tag,
461+
});
462+
463+
let view;
464+
try {
465+
const connection = await findOrCreateSlackConnectionByEmail({ teamId, slackUserId });
466+
const fieldNotes = await recentSlackFieldNoteDrafts(connection);
467+
view = fieldNotes.length ? slackFieldNoteReviewModal({ fieldNotes }) : slackNoFieldNoteDraftsModal();
468+
} catch (error) {
469+
view = slackFieldNoteDraftUnavailableModal(error.message || "connect mumbl first.");
470+
}
471+
472+
return slackApi("views.open", token, {
473+
trigger_id: triggerId,
474+
view,
475+
});
476+
}
477+
455478
export async function updateSlackView({ teamId, viewId, view }) {
456479
const installation = await getSlackInstallation(teamId);
457480
const token = decryptSlackToken({
@@ -615,6 +638,56 @@ export async function recentSlackPrivateDumps(connection) {
615638
return data || [];
616639
}
617640

641+
export async function recentSlackFieldNoteDrafts(connection) {
642+
const supabase = getSupabaseAdmin();
643+
const { data, error } = await supabase
644+
.from("field_notes")
645+
.select("id, title, content, created_at, source_dump_ids")
646+
.eq("user_id", connection.mumbl_user_id)
647+
.eq("is_published", false)
648+
.order("created_at", { ascending: false })
649+
.limit(10);
650+
if (error) throw error;
651+
return data || [];
652+
}
653+
654+
export async function getSlackFieldNoteDraft({ teamId, slackUserId, fieldNoteId }) {
655+
const connection = await findOrCreateSlackConnectionByEmail({ teamId, slackUserId });
656+
const supabase = getSupabaseAdmin();
657+
const { data, error } = await supabase
658+
.from("field_notes")
659+
.select("id, title, content, created_at, source_dump_ids")
660+
.eq("user_id", connection.mumbl_user_id)
661+
.eq("is_published", false)
662+
.eq("id", cleanString(fieldNoteId, 64))
663+
.single();
664+
if (error) throw error;
665+
return data;
666+
}
667+
668+
export async function updateSlackFieldNoteDraft({ teamId, slackUserId, fieldNoteId, title, content }) {
669+
const connection = await findOrCreateSlackConnectionByEmail({ teamId, slackUserId });
670+
const cleanedTitle = cleanString(title, 120);
671+
const cleanedContent = cleanString(content, 4000);
672+
if (!cleanedTitle) throw new Error("field note title is required.");
673+
if (!cleanedContent) throw new Error("field note content is required.");
674+
675+
const supabase = getSupabaseAdmin();
676+
const { data, error } = await supabase
677+
.from("field_notes")
678+
.update({ title: cleanedTitle, content: cleanedContent })
679+
.eq("user_id", connection.mumbl_user_id)
680+
.eq("is_published", false)
681+
.eq("id", cleanString(fieldNoteId, 64))
682+
.select("id, title")
683+
.single();
684+
if (error) throw error;
685+
return {
686+
fieldNote: data,
687+
url: `${getServerEnv().appUrl}/dump?fieldNote=${encodeURIComponent(data.id)}`,
688+
};
689+
}
690+
618691
export async function createSlackFieldNoteDraft({ teamId, slackUserId, dumpIds }) {
619692
const cleanedDumpIds = Array.isArray(dumpIds) ? dumpIds.map((id) => cleanString(id, 64)).filter(Boolean) : [];
620693
if (!cleanedDumpIds.length) throw new Error("choose at least one dump.");
@@ -804,6 +877,70 @@ export function slackFieldNoteDraftReadyModalView({ fieldNote, url, visibilityRe
804877
};
805878
}
806879

880+
export function slackFieldNoteEditModalView(fieldNote) {
881+
const content = cleanString(fieldNote.content, 4000);
882+
if (content.length > 3000) {
883+
const { appUrl } = getServerEnv();
884+
return {
885+
type: "modal",
886+
title: { type: "plain_text", text: "open in mumbl" },
887+
close: { type: "plain_text", text: "done" },
888+
blocks: [
889+
section("*this draft is too long for Slack editing.*\nOpen it in Mumbl to keep the whole thing intact."),
890+
actions([{ text: "open in mumbl", url: `${appUrl}/dump?fieldNote=${encodeURIComponent(fieldNote.id)}` }]),
891+
],
892+
};
893+
}
894+
895+
return {
896+
type: "modal",
897+
callback_id: "edit_field_note_draft",
898+
private_metadata: fieldNote.id,
899+
title: { type: "plain_text", text: "edit draft" },
900+
submit: { type: "plain_text", text: "save" },
901+
close: { type: "plain_text", text: "cancel" },
902+
blocks: [
903+
{
904+
type: "input",
905+
block_id: "field_note_title",
906+
label: { type: "plain_text", text: "title" },
907+
element: {
908+
type: "plain_text_input",
909+
action_id: "value",
910+
initial_value: cleanString(fieldNote.title, 120) || "field note",
911+
max_length: 120,
912+
},
913+
},
914+
{
915+
type: "input",
916+
block_id: "field_note_content",
917+
label: { type: "plain_text", text: "field note" },
918+
element: {
919+
type: "plain_text_input",
920+
action_id: "value",
921+
initial_value: content,
922+
multiline: true,
923+
max_length: 3000,
924+
},
925+
},
926+
context("Saving keeps this private. Publish to team reads from Mumbl after one last review."),
927+
],
928+
};
929+
}
930+
931+
export function slackFieldNoteSavedModalView({ fieldNote, url }) {
932+
return {
933+
type: "modal",
934+
title: { type: "plain_text", text: "draft saved" },
935+
close: { type: "plain_text", text: "done" },
936+
blocks: [
937+
section(`*${escapeSlackText(fieldNote.title || "field note draft")}*`),
938+
section("Saved privately. Open it in Mumbl when you are ready to publish to team reads."),
939+
actions([{ text: "open in mumbl", url }]),
940+
],
941+
};
942+
}
943+
807944
export function slackFieldNoteDraftErrorModalView(message) {
808945
return {
809946
type: "modal",
@@ -944,6 +1081,8 @@ function slackAppHomeBlocks() {
9441081
actions([{ text: "new private dump", actionId: "new_private_dump" }]),
9451082
section("*draft a team read*\nChoose a few recent private dumps and Mumbl will turn them into a private field-note draft."),
9461083
actions([{ text: "draft team read", actionId: "draft_team_read" }]),
1084+
section("*review drafts*\nEdit recent private field-note drafts before anything goes to the team."),
1085+
actions([{ text: "review drafts", actionId: "review_field_note_drafts" }]),
9471086
section("*start a team space from Slack*\nType `/mumbl room platform team` to create the room without leaving Slack."),
9481087
actions([{ text: "start a team room", actionId: "start_room_modal" }]),
9491088
section("*team reads on Slack*\nAfter a room is created, use its `enable Slack team reads` button to create one private channel."),
@@ -1029,6 +1168,43 @@ function slackFieldNoteDraftModal({ dumps }) {
10291168
};
10301169
}
10311170

1171+
function slackFieldNoteReviewModal({ fieldNotes }) {
1172+
return {
1173+
type: "modal",
1174+
callback_id: "review_field_note_drafts",
1175+
title: { type: "plain_text", text: "review drafts" },
1176+
submit: { type: "plain_text", text: "edit" },
1177+
close: { type: "plain_text", text: "cancel" },
1178+
blocks: [
1179+
{
1180+
type: "input",
1181+
block_id: "field_note_id",
1182+
label: { type: "plain_text", text: "draft" },
1183+
element: {
1184+
type: "static_select",
1185+
action_id: "value",
1186+
placeholder: { type: "plain_text", text: "choose a draft" },
1187+
options: fieldNotes.map((fieldNote) => fieldNoteOption(fieldNote)),
1188+
},
1189+
},
1190+
context("Drafts stay private here. Publishing still happens from Mumbl."),
1191+
],
1192+
};
1193+
}
1194+
1195+
function slackNoFieldNoteDraftsModal() {
1196+
const { appUrl } = getServerEnv();
1197+
return {
1198+
type: "modal",
1199+
title: { type: "plain_text", text: "no drafts" },
1200+
close: { type: "plain_text", text: "done" },
1201+
blocks: [
1202+
section("*no private field-note drafts yet.*\nDraft one from recent dumps first."),
1203+
actions([{ text: "open your dump", url: `${appUrl}/dump` }]),
1204+
],
1205+
};
1206+
}
1207+
10321208
function slackFieldNoteNoDumpsModal() {
10331209
const { appUrl } = getServerEnv();
10341210
return {
@@ -1067,6 +1243,18 @@ function dumpOption(dump) {
10671243
};
10681244
}
10691245

1246+
function fieldNoteOption(fieldNote) {
1247+
const date = new Date(fieldNote.created_at);
1248+
return {
1249+
text: { type: "plain_text", text: truncatePlain(fieldNote.title || firstLine(fieldNote.content), 75) },
1250+
value: fieldNote.id,
1251+
description: {
1252+
type: "plain_text",
1253+
text: truncatePlain(date.toLocaleDateString("en-US", { month: "short", day: "numeric" }), 75),
1254+
},
1255+
};
1256+
}
1257+
10701258
function blockResponse({ text, blocks, replaceOriginal = false }) {
10711259
return {
10721260
response_type: "ephemeral",

0 commit comments

Comments
 (0)