Skip to content

Commit a60a4f0

Browse files
committed
Route personal Slack notifications to DM, channel for public accountability
Personal reminders (10-min/5-min session warnings, pre-reservation, late check-in) now go directly to the user via Slack DM so the channel is not flooded with repeated @mentions. Grace period exceeded, overdue, no-show, and suspension notifications stay in the channel since they affect shared resources and warrant community visibility. "Move your car" admin requests now DM the owner privately while posting a brief, mention-free note to the channel for transparency. New functions: sendSlackDM (conversations.open + chat.postMessage), notifyUser (DM-first with email fallback). Bot token now requires im:write scope in addition to chat:write and users:read.email.
1 parent 71dcce2 commit a60a4f0

6 files changed

Lines changed: 104 additions & 18 deletions

File tree

web/.env.example

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,5 +20,7 @@ CRON_SECRET=generate-with-openssl-rand-base64-32
2020
NEXT_PUBLIC_POSTHOG_KEY=
2121

2222
# Slack (optional)
23+
# Bot token requires scopes: chat:write, users:read.email, im:write
24+
# im:write is needed to send direct messages to users
2325
SLACK_BOT_TOKEN=
2426
SLACK_WEBHOOK_URL=

web/src/actions/admin.ts

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { assertAdmin } from '@/lib/auth-helpers';
88
import { isComplete } from '@/lib/utils';
99
import {
1010
notifyChannel,
11+
notifyUser,
1112
buildMoveCarMessage,
1213
getSlackChannelMention,
1314
type NotifyChannelConfig,
@@ -193,15 +194,21 @@ export async function notifyOwner(
193194
const appName = configMap.app_name || 'EV Charging';
194195

195196
const message = buildMoveCarMessage(appName, chargerName, channelMention);
197+
const channelMessage = `${appName}: A request was sent to move a car at ${chargerName}.`;
196198

197199
const notifyConfig = buildNotifyConfig(configMap);
198-
const sent = await notifyChannel(message, notifyConfig, session.userId);
200+
201+
// DM the owner directly so only they get pinged
202+
const dmSent = await notifyUser(message, notifyConfig, session.userId);
203+
204+
// Post a brief, mention-free note to the channel so everyone knows action was taken
205+
await notifyChannel(channelMessage, notifyConfig);
199206

200207
const board = await getBoardData(auth);
201208

202209
return {
203-
success: sent,
204-
message: sent
210+
success: dmSent,
211+
message: dmSent
205212
? 'Notification sent to the session owner.'
206213
: 'Unable to send notification. Please contact the owner directly.',
207214
board,

web/src/lib/__tests__/admin.test.ts

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ vi.mock('@/actions/board', () => ({
3838
// Mock notifications
3939
vi.mock('@/lib/notifications', () => ({
4040
notifyChannel: vi.fn(),
41+
notifyUser: vi.fn(),
4142
buildMoveCarMessage: vi.fn(
4243
(appName: string, chargerName: string, channelMention: string) =>
4344
`${appName}: Someone is waiting for ${chargerName}. Please move your car and post any delays in ${channelMention}.`,
@@ -48,7 +49,7 @@ vi.mock('@/lib/notifications', () => ({
4849
import { resetCharger, forceEndSession, notifyOwner } from '@/actions/admin';
4950
import { getConfig } from '@/lib/config';
5051
import { getBoardData } from '@/actions/board';
51-
import { notifyChannel } from '@/lib/notifications';
52+
import { notifyChannel, notifyUser } from '@/lib/notifications';
5253
import type { Auth } from '@/types';
5354

5455
// ---------------------------------------------------------------------------
@@ -251,25 +252,33 @@ describe('notifyOwner', () => {
251252
const session = makeSession({ userId: 'driver@example.com' });
252253

253254
setupDbMocks([[charger], [session]]);
255+
(notifyUser as ReturnType<typeof vi.fn>).mockResolvedValue(true);
254256
(notifyChannel as ReturnType<typeof vi.fn>).mockResolvedValue(true);
255257

256258
const result = await notifyOwner('charger-1', nonAdminAuth);
257259

258260
expect(result.success).toBe(true);
259261
expect(result.message).toBe('Notification sent to the session owner.');
260-
expect(notifyChannel).toHaveBeenCalledWith(
262+
// Owner gets a DM with the full message
263+
expect(notifyUser).toHaveBeenCalledWith(
261264
expect.stringContaining('Someone is waiting for Charger A'),
262265
expect.objectContaining({ appName: 'EV Charging' }),
263266
'driver@example.com',
264267
);
268+
// Channel gets a brief mention-free note
269+
expect(notifyChannel).toHaveBeenCalledWith(
270+
expect.stringContaining('A request was sent to move a car at Charger A'),
271+
expect.objectContaining({ appName: 'EV Charging' }),
272+
);
265273
});
266274

267275
it('returns failure message when notification fails', async () => {
268276
const charger = makeCharger({ activeSessionId: 'session-1' });
269277
const session = makeSession();
270278

271279
setupDbMocks([[charger], [session]]);
272-
(notifyChannel as ReturnType<typeof vi.fn>).mockResolvedValue(false);
280+
(notifyUser as ReturnType<typeof vi.fn>).mockResolvedValue(false);
281+
(notifyChannel as ReturnType<typeof vi.fn>).mockResolvedValue(true);
273282

274283
const result = await notifyOwner('charger-1', nonAdminAuth);
275284

web/src/lib/__tests__/reminders.test.ts

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -42,9 +42,11 @@ vi.mock('@/lib/strikes', () => ({
4242
}));
4343

4444
const mockNotifyChannel = vi.fn().mockResolvedValue(true);
45+
const mockNotifyUser = vi.fn().mockResolvedValue(true);
4546

4647
vi.mock('@/lib/notifications', () => ({
4748
notifyChannel: (...args: unknown[]) => mockNotifyChannel(...args),
49+
notifyUser: (...args: unknown[]) => mockNotifyUser(...args),
4850
getSlackChannelMention: vi.fn((name: string) => `#${name}`),
4951
}));
5052

@@ -149,7 +151,7 @@ describe('processSessionReminders', () => {
149151
const result = await processSessionReminders(now);
150152

151153
expect(result.reminders10Sent).toBe(1);
152-
expect(mockNotifyChannel).toHaveBeenCalledWith(
154+
expect(mockNotifyUser).toHaveBeenCalledWith(
153155
expect.stringContaining('ends in 10 minutes'),
154156
expect.any(Object),
155157
'user@example.com',
@@ -198,7 +200,7 @@ describe('processSessionReminders', () => {
198200
const result = await processSessionReminders(now);
199201

200202
expect(result.reminders5Sent).toBe(1);
201-
expect(mockNotifyChannel).toHaveBeenCalledWith(
203+
expect(mockNotifyUser).toHaveBeenCalledWith(
202204
expect.stringContaining('ends in 5 minutes'),
203205
expect.any(Object),
204206
'user@example.com',
@@ -397,7 +399,7 @@ describe('processSessionReminders', () => {
397399
const result = await processSessionReminders(now);
398400

399401
expect(result.reservationUpcomingReminders).toBe(1);
400-
expect(mockNotifyChannel).toHaveBeenCalledWith(
402+
expect(mockNotifyUser).toHaveBeenCalledWith(
401403
expect.stringContaining('starts in 5 minutes'),
402404
expect.any(Object),
403405
'user@example.com',
@@ -414,7 +416,7 @@ describe('processSessionReminders', () => {
414416
const result = await processSessionReminders(now);
415417

416418
expect(result.reservationLateReminders).toBe(1);
417-
expect(mockNotifyChannel).toHaveBeenCalledWith(
419+
expect(mockNotifyUser).toHaveBeenCalledWith(
418420
expect.stringContaining('will be released'),
419421
expect.any(Object),
420422
'user@example.com',

web/src/lib/notifications.ts

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,46 @@ export async function sendEmailNotification(
121121
return true;
122122
}
123123

124+
// ---------------------------------------------------------------------------
125+
// Slack DM (direct message via bot token)
126+
// ---------------------------------------------------------------------------
127+
128+
/**
129+
* Send a direct message to a Slack user identified by email.
130+
* Opens (or reuses) the DM channel via conversations.open, then posts.
131+
* Returns true on success, false on failure (never throws).
132+
*/
133+
export async function sendSlackDM(
134+
token: string,
135+
email: string,
136+
text: string,
137+
): Promise<boolean> {
138+
if (!token || !email) return false;
139+
140+
try {
141+
const userId = await lookupSlackUserId(token, email);
142+
if (!userId) return false;
143+
144+
const openResponse = await fetch('https://slack.com/api/conversations.open', {
145+
method: 'POST',
146+
headers: {
147+
'Content-Type': 'application/json',
148+
Authorization: `Bearer ${token}`,
149+
},
150+
body: JSON.stringify({ users: userId }),
151+
});
152+
153+
if (!openResponse.ok) return false;
154+
const openData = await openResponse.json();
155+
if (!openData.ok || !openData.channel?.id) return false;
156+
157+
return sendSlackChannelMessage(token, openData.channel.id, text);
158+
} catch (err) {
159+
console.error('[SLACK DM] Failed to send DM:', err);
160+
return false;
161+
}
162+
}
163+
124164
// ---------------------------------------------------------------------------
125165
// Unified channel notification (mirrors notifyChannel_ from engine.js)
126166
// ---------------------------------------------------------------------------
@@ -194,6 +234,31 @@ export async function notifyChannel(
194234
return sentSlack || sentEmail;
195235
}
196236

237+
// ---------------------------------------------------------------------------
238+
// Direct user notification (DM-first, email fallback)
239+
// ---------------------------------------------------------------------------
240+
241+
/**
242+
* Send a notification directly to a user via Slack DM, falling back to email.
243+
* Use this for personal reminders that should not flood the channel.
244+
*
245+
* Returns true if at least one notification was sent.
246+
*/
247+
export async function notifyUser(
248+
text: string,
249+
config: NotifyChannelConfig,
250+
targetEmail?: string,
251+
): Promise<boolean> {
252+
if (!targetEmail) return false;
253+
254+
if (config.slackBotToken) {
255+
const sent = await sendSlackDM(config.slackBotToken, targetEmail, text);
256+
if (sent) return true;
257+
}
258+
259+
return sendEmailNotification(targetEmail, `${config.appName} notification`, text);
260+
}
261+
197262
// ---------------------------------------------------------------------------
198263
// Message builders
199264
// ---------------------------------------------------------------------------

web/src/lib/reminders.ts

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import {
2424
} from '@/lib/utils';
2525
import {
2626
notifyChannel,
27+
notifyUser,
2728
getSlackChannelMention,
2829
type NotifyChannelConfig,
2930
} from '@/lib/notifications';
@@ -204,9 +205,9 @@ export async function processSessionReminders(now: Date): Promise<ReminderResult
204205
updates.complete = false;
205206
}
206207

207-
// 10-minute reminder
208+
// 10-minute reminder — DM only (personal, pre-emptive)
208209
if (reminder10Enabled && !session.reminder10Sent && minutesToEnd <= 10 && minutesToEnd > 5) {
209-
const sent = await notifyChannel(
210+
const sent = await notifyUser(
210211
buildSessionReminderText('tminus10', session, charger, endTime, appName, channelMention, sessionMoveGraceMinutes),
211212
notifyConfig,
212213
session.userId,
@@ -217,9 +218,9 @@ export async function processSessionReminders(now: Date): Promise<ReminderResult
217218
}
218219
}
219220

220-
// 5-minute reminder
221+
// 5-minute reminder — DM only (personal, pre-emptive)
221222
if (reminder5Enabled && !session.reminder5Sent && minutesToEnd <= 5 && minutesToEnd > 0) {
222-
const sent = await notifyChannel(
223+
const sent = await notifyUser(
223224
buildSessionReminderText('tminus5', session, charger, endTime, appName, channelMention, sessionMoveGraceMinutes),
224225
notifyConfig,
225226
session.userId,
@@ -329,9 +330,9 @@ export async function processSessionReminders(now: Date): Promise<ReminderResult
329330

330331
const resUpdates: Record<string, unknown> = {};
331332

332-
// 5 minutes before start reminder
333+
// 5 minutes before start reminder — DM only (personal)
333334
if (!reservation.reminder5BeforeSent && minutesToStart <= 5 && minutesToStart > 0) {
334-
const sent = await notifyChannel(
335+
const sent = await notifyUser(
335336
buildReservationReminderText('upcoming', reservation, charger, startTime, reservationConfig.lateGraceMinutes, appName),
336337
notifyConfig,
337338
reservation.userId,
@@ -342,13 +343,13 @@ export async function processSessionReminders(now: Date): Promise<ReminderResult
342343
}
343344
}
344345

345-
// 5 minutes after start (late) reminder
346+
// 5 minutes after start (late) reminder — DM only (personal, still actionable)
346347
if (
347348
!reservation.reminder5AfterSent &&
348349
minutesSinceStart >= 5 &&
349350
minutesSinceStart < reservationConfig.lateGraceMinutes
350351
) {
351-
const sent = await notifyChannel(
352+
const sent = await notifyUser(
352353
buildReservationReminderText('late', reservation, charger, startTime, reservationConfig.lateGraceMinutes, appName),
353354
notifyConfig,
354355
reservation.userId,

0 commit comments

Comments
 (0)