Skip to content

Commit 020a511

Browse files
Add messages to people opted out or skipping
1 parent 6b56bc9 commit 020a511

2 files changed

Lines changed: 351 additions & 0 deletions

File tree

src/coffeeChats/coffeeChatService.ts

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -549,6 +549,13 @@ export const createCoffeeChatsForChannel = async (
549549
},
550550
);
551551

552+
// Notify opted-out and skipped users that a new pairing was sent out
553+
await notifyExcludedUsers(
554+
config.channelId,
555+
config.channelName,
556+
nextPairingDate,
557+
);
558+
552559
// Clear skip flags for all users who skipped this round
553560
await clearSkipFlags(config.channelId);
554561

@@ -1073,6 +1080,113 @@ export const getTrioPairingPreference = async (
10731080
return preference?.preferTrioPairing ?? false;
10741081
};
10751082

1083+
/**
1084+
* Sends a DM to users who are opted out or skipping this round to let them
1085+
* know that a new pairing was sent out without them.
1086+
*/
1087+
export const notifyExcludedUsers = async (
1088+
channelId: string,
1089+
channelName: string,
1090+
nextPairingDate: moment.Moment,
1091+
): Promise<void> => {
1092+
const excludedPrefs = await CoffeeChatUserPreferenceModel.find({
1093+
channelId,
1094+
$or: [{ isOptedIn: false }, { skipNextPairing: true }],
1095+
});
1096+
1097+
if (excludedPrefs.length === 0) {
1098+
return;
1099+
}
1100+
1101+
logWithTime(
1102+
`Notifying ${excludedPrefs.length} excluded user(s) in channel ${channelName}`,
1103+
);
1104+
1105+
await Promise.all(
1106+
excludedPrefs.map(async (pref) => {
1107+
const isSkipping = pref.skipNextPairing && pref.isOptedIn;
1108+
const text = isSkipping
1109+
? `:wave: Hey! A new coffee chat pairing just went out in *#${channelName}*, but you asked to skip this round. You'll automatically be included in the next pairing on ${nextPairingDate.format("MMMM Do")}. :calendar:`
1110+
: `:wave: Hey! A new coffee chat pairing just went out in *#${channelName}*, but you're currently opted out so you weren't included. Click below if you'd like to rejoin future rounds!`;
1111+
1112+
try {
1113+
const dm = await slackbot.client.conversations.open({
1114+
users: pref.userId,
1115+
});
1116+
1117+
if (!dm.ok || !dm.channel?.id) {
1118+
logWithTime(`Failed to open DM with excluded user ${pref.userId}`);
1119+
return;
1120+
}
1121+
1122+
await slackbot.client.chat.postMessage({
1123+
channel: dm.channel.id,
1124+
text,
1125+
blocks: [
1126+
{
1127+
type: "section" as const,
1128+
text: {
1129+
type: "mrkdwn" as const,
1130+
text,
1131+
},
1132+
},
1133+
// For opted-out users, include a button to opt back in;
1134+
// for skipping users, offer to skip the next round too
1135+
...(!pref.isOptedIn
1136+
? [
1137+
{
1138+
type: "actions" as const,
1139+
elements: [
1140+
{
1141+
type: "button" as const,
1142+
text: {
1143+
type: "plain_text" as const,
1144+
text: "▶️ Resume Pairings",
1145+
},
1146+
style: "primary" as const,
1147+
action_id: "coffee_chat_opt_in",
1148+
value: channelId,
1149+
},
1150+
],
1151+
},
1152+
]
1153+
: isSkipping
1154+
? [
1155+
{
1156+
type: "actions" as const,
1157+
elements: [
1158+
{
1159+
type: "button" as const,
1160+
text: {
1161+
type: "plain_text" as const,
1162+
text: "⏭️ Skip Next Round Too",
1163+
},
1164+
action_id: "coffee_chat_skip_next",
1165+
value: channelId,
1166+
},
1167+
{
1168+
type: "button" as const,
1169+
text: {
1170+
type: "plain_text" as const,
1171+
text: "🚫 Opt Out",
1172+
},
1173+
style: "danger" as const,
1174+
action_id: "coffee_chat_opt_out",
1175+
value: channelId,
1176+
},
1177+
],
1178+
},
1179+
]
1180+
: []),
1181+
],
1182+
});
1183+
} catch (err) {
1184+
logWithTime(`Error notifying excluded user ${pref.userId}: ${err}`);
1185+
}
1186+
}),
1187+
);
1188+
};
1189+
10761190
/**
10771191
* Clears the skip flag for users in a specific channel
10781192
*/

tests/coffeeChats/coffeeChatService.test.ts

Lines changed: 237 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1384,3 +1384,240 @@ describe("coffeeChatService", () => {
13841384
expect(pairing2?.midpointReminderSent).toBe(true);
13851385
});
13861386
});
1387+
1388+
describe("notifyExcludedUsers", () => {
1389+
const mockChannelId = "C12345";
1390+
const mockChannelName = "coffee-chats";
1391+
const nextPairingDate = moment("2026-03-18").tz("America/New_York");
1392+
1393+
let mockConversationsOpen: jest.Mock;
1394+
let mockChatPostMessage: jest.Mock;
1395+
1396+
beforeEach(() => {
1397+
jest.clearAllMocks();
1398+
mockConversationsOpen =
1399+
jest.requireMock("../../src/slackbot").default.client.conversations.open;
1400+
mockChatPostMessage =
1401+
jest.requireMock("../../src/slackbot").default.client.chat.postMessage;
1402+
});
1403+
1404+
it("should not send any DMs when there are no excluded users", async () => {
1405+
// No preferences in the DB — everyone is implicitly opted in
1406+
await coffeeChatService.notifyExcludedUsers(
1407+
mockChannelId,
1408+
mockChannelName,
1409+
nextPairingDate,
1410+
);
1411+
1412+
expect(mockConversationsOpen).not.toHaveBeenCalled();
1413+
expect(mockChatPostMessage).not.toHaveBeenCalled();
1414+
});
1415+
1416+
it("should not send DMs to opted-in users who are not skipping", async () => {
1417+
await new CoffeeChatUserPreferenceModel({
1418+
channelId: mockChannelId,
1419+
userId: "U11111",
1420+
isOptedIn: true,
1421+
skipNextPairing: false,
1422+
}).save();
1423+
1424+
await coffeeChatService.notifyExcludedUsers(
1425+
mockChannelId,
1426+
mockChannelName,
1427+
nextPairingDate,
1428+
);
1429+
1430+
expect(mockConversationsOpen).not.toHaveBeenCalled();
1431+
expect(mockChatPostMessage).not.toHaveBeenCalled();
1432+
});
1433+
1434+
it("should DM an opted-out user with the opted-out message and Resume Pairings button", async () => {
1435+
await new CoffeeChatUserPreferenceModel({
1436+
channelId: mockChannelId,
1437+
userId: "U22222",
1438+
isOptedIn: false,
1439+
skipNextPairing: false,
1440+
}).save();
1441+
1442+
await coffeeChatService.notifyExcludedUsers(
1443+
mockChannelId,
1444+
mockChannelName,
1445+
nextPairingDate,
1446+
);
1447+
1448+
expect(mockConversationsOpen).toHaveBeenCalledWith({ users: "U22222" });
1449+
expect(mockChatPostMessage).toHaveBeenCalledTimes(1);
1450+
1451+
const call = mockChatPostMessage.mock.calls[0][0];
1452+
expect(call.channel).toBe("D12345");
1453+
expect(call.text).toContain("opted out");
1454+
expect(call.text).not.toContain("skip");
1455+
1456+
// Should have a section block and an actions block with Resume Pairings
1457+
expect(call.blocks).toHaveLength(2);
1458+
const actionsBlock = call.blocks[1];
1459+
expect(actionsBlock.type).toBe("actions");
1460+
expect(actionsBlock.elements[0].action_id).toBe("coffee_chat_opt_in");
1461+
expect(actionsBlock.elements[0].text.text).toContain("Resume Pairings");
1462+
});
1463+
1464+
it("should DM a skipping user with the skip message and Skip Next Round Too + Opt Out buttons", async () => {
1465+
await new CoffeeChatUserPreferenceModel({
1466+
channelId: mockChannelId,
1467+
userId: "U33333",
1468+
isOptedIn: true,
1469+
skipNextPairing: true,
1470+
}).save();
1471+
1472+
await coffeeChatService.notifyExcludedUsers(
1473+
mockChannelId,
1474+
mockChannelName,
1475+
nextPairingDate,
1476+
);
1477+
1478+
expect(mockConversationsOpen).toHaveBeenCalledWith({ users: "U33333" });
1479+
expect(mockChatPostMessage).toHaveBeenCalledTimes(1);
1480+
1481+
const call = mockChatPostMessage.mock.calls[0][0];
1482+
expect(call.channel).toBe("D12345");
1483+
expect(call.text).toContain("skip this round");
1484+
expect(call.text).toContain(nextPairingDate.format("MMMM Do"));
1485+
1486+
// Should have a section block and an actions block with two buttons
1487+
expect(call.blocks).toHaveLength(2);
1488+
const actionsBlock = call.blocks[1];
1489+
expect(actionsBlock.type).toBe("actions");
1490+
expect(actionsBlock.elements).toHaveLength(2);
1491+
expect(actionsBlock.elements[0].action_id).toBe("coffee_chat_skip_next");
1492+
expect(actionsBlock.elements[0].text.text).toContain("Skip Next Round Too");
1493+
expect(actionsBlock.elements[1].action_id).toBe("coffee_chat_opt_out");
1494+
expect(actionsBlock.elements[1].text.text).toContain("Opt Out");
1495+
});
1496+
1497+
it("should pass the channelId as the button value for all button types", async () => {
1498+
await new CoffeeChatUserPreferenceModel({
1499+
channelId: mockChannelId,
1500+
userId: "U44444",
1501+
isOptedIn: false,
1502+
skipNextPairing: false,
1503+
}).save();
1504+
await new CoffeeChatUserPreferenceModel({
1505+
channelId: mockChannelId,
1506+
userId: "U55555",
1507+
isOptedIn: true,
1508+
skipNextPairing: true,
1509+
}).save();
1510+
1511+
mockConversationsOpen
1512+
.mockResolvedValueOnce({ ok: true, channel: { id: "D44444" } })
1513+
.mockResolvedValueOnce({ ok: true, channel: { id: "D55555" } });
1514+
1515+
await coffeeChatService.notifyExcludedUsers(
1516+
mockChannelId,
1517+
mockChannelName,
1518+
nextPairingDate,
1519+
);
1520+
1521+
expect(mockChatPostMessage).toHaveBeenCalledTimes(2);
1522+
1523+
for (const call of mockChatPostMessage.mock.calls) {
1524+
const actionsBlock = call[0].blocks[1];
1525+
for (const element of actionsBlock.elements) {
1526+
expect(element.value).toBe(mockChannelId);
1527+
}
1528+
}
1529+
});
1530+
1531+
it("should DM both opted-out and skipping users in the same channel", async () => {
1532+
await new CoffeeChatUserPreferenceModel({
1533+
channelId: mockChannelId,
1534+
userId: "U66666",
1535+
isOptedIn: false,
1536+
skipNextPairing: false,
1537+
}).save();
1538+
await new CoffeeChatUserPreferenceModel({
1539+
channelId: mockChannelId,
1540+
userId: "U77777",
1541+
isOptedIn: true,
1542+
skipNextPairing: true,
1543+
}).save();
1544+
1545+
await coffeeChatService.notifyExcludedUsers(
1546+
mockChannelId,
1547+
mockChannelName,
1548+
nextPairingDate,
1549+
);
1550+
1551+
expect(mockConversationsOpen).toHaveBeenCalledTimes(2);
1552+
expect(mockChatPostMessage).toHaveBeenCalledTimes(2);
1553+
});
1554+
1555+
it("should not DM users excluded from a different channel", async () => {
1556+
await new CoffeeChatUserPreferenceModel({
1557+
channelId: "C_OTHER",
1558+
userId: "U88888",
1559+
isOptedIn: false,
1560+
skipNextPairing: false,
1561+
}).save();
1562+
1563+
await coffeeChatService.notifyExcludedUsers(
1564+
mockChannelId,
1565+
mockChannelName,
1566+
nextPairingDate,
1567+
);
1568+
1569+
expect(mockConversationsOpen).not.toHaveBeenCalled();
1570+
expect(mockChatPostMessage).not.toHaveBeenCalled();
1571+
});
1572+
1573+
it("should handle a failed conversations.open gracefully and still notify other users", async () => {
1574+
await new CoffeeChatUserPreferenceModel({
1575+
channelId: mockChannelId,
1576+
userId: "U99991",
1577+
isOptedIn: false,
1578+
skipNextPairing: false,
1579+
}).save();
1580+
await new CoffeeChatUserPreferenceModel({
1581+
channelId: mockChannelId,
1582+
userId: "U99992",
1583+
isOptedIn: false,
1584+
skipNextPairing: false,
1585+
}).save();
1586+
1587+
// First open fails, second succeeds
1588+
mockConversationsOpen
1589+
.mockResolvedValueOnce({ ok: false, channel: null })
1590+
.mockResolvedValueOnce({ ok: true, channel: { id: "D99992" } });
1591+
1592+
await expect(
1593+
coffeeChatService.notifyExcludedUsers(
1594+
mockChannelId,
1595+
mockChannelName,
1596+
nextPairingDate,
1597+
),
1598+
).resolves.not.toThrow();
1599+
1600+
// Only the second user should receive a message
1601+
expect(mockChatPostMessage).toHaveBeenCalledTimes(1);
1602+
expect(mockChatPostMessage.mock.calls[0][0].channel).toBe("D99992");
1603+
});
1604+
1605+
it("should only include a section block (no action buttons) for no matching case", async () => {
1606+
// A user who is opted in but somehow has skipNextPairing=false — excluded from query,
1607+
// so this just confirms the no-excluded-users path is clean.
1608+
await new CoffeeChatUserPreferenceModel({
1609+
channelId: mockChannelId,
1610+
userId: "UNONE",
1611+
isOptedIn: true,
1612+
skipNextPairing: false,
1613+
}).save();
1614+
1615+
await coffeeChatService.notifyExcludedUsers(
1616+
mockChannelId,
1617+
mockChannelName,
1618+
nextPairingDate,
1619+
);
1620+
1621+
expect(mockChatPostMessage).not.toHaveBeenCalled();
1622+
});
1623+
});

0 commit comments

Comments
 (0)