Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 29 additions & 15 deletions src/services/PairingSessionCloser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,17 @@ import { pairingSessionsRepo } from '@repos/pairingSessionsRepo';
import { userRepo } from '@repos/userRepo';
import { chatService } from '@/services/ChatService';
import { App } from '@slack/bolt';
import { WebClient } from '@/slackTypes';
import { formatSlot, mention, ul } from '@utils/text';
import { reviewLockManager } from '@utils/reviewLockManager';
import log from '@utils/log';

async function finalize(client: WebClient, threadId: string, message: string): Promise<void> {
await chatService.replyToReviewThread(client, threadId, message);
await pairingSessionsRepo.remove(threadId);
reviewLockManager.releaseLock(threadId);
}

export function findConfirmedSlots(interview: PairingSession): PairingSlot[] {
return interview.slots.filter(slot =>
isSlotConfirmed(slot, interview.format, interview.teammatesNeededCount),
Expand Down Expand Up @@ -51,29 +58,36 @@ export const pairingSessionCloser = {
).values(),
);
const slotLines = confirmedSlots.map(s => formatSlot(s.date, s.startTime, s.endTime));
await chatService.replyToReviewThread(
app.client,
threadId,
const message =
`${mention({ id: interview.requestorId })} Pairing session for *${interview.candidateName}* is ready to schedule!\n\n` +
`*Teammates:* ${teammates.map(t => mention({ id: t.userId })).join(', ')}\n\n` +
`*Available slots (${confirmedSlots.length}):*\n${ul(...slotLines)}`,
);
`*Teammates:* ${teammates.map(t => mention({ id: t.userId })).join(', ')}\n\n` +
`*Available slots (${confirmedSlots.length}):*\n${ul(...slotLines)}`;
await Promise.all(teammates.map(t => userRepo.markNowAsLastPairingReviewedDate(t.userId)));
await pairingSessionsRepo.remove(threadId);
reviewLockManager.releaseLock(threadId);
await finalize(app.client, threadId, message);
return true;
}

const isUnfulfilled = interview.pendingTeammates.length === 0;

if (isUnfulfilled) {
await chatService.replyToReviewThread(
app.client,
threadId,
`${mention({ id: interview.requestorId })} No teammates available to cover all slots for ${interview.candidateName}'s pairing session.`,
);
await pairingSessionsRepo.remove(threadId);
reviewLockManager.releaseLock(threadId);
const slotsWithInterest = interview.slots.filter(s => s.interestedTeammates.length > 0);
const header = `${mention({ id: interview.requestorId })} Couldn't fill all slots for *${interview.candidateName}*'s pairing session.`;

const message =
slotsWithInterest.length > 0
? `${header}\n\n` +
`Some teammates did sign up — you may be able to reach out to them directly:\n` +
ul(
...slotsWithInterest.map(
s =>
`${formatSlot(s.date, s.startTime, s.endTime)} — ${s.interestedTeammates
.map(t => mention({ id: t.userId }))
.join(', ')}`,
),
)
: `${header} No teammates signed up.`;

await finalize(app.client, threadId, message);
return true;
}

Expand Down
47 changes: 46 additions & 1 deletion src/services/__tests__/PairingSessionCloser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -194,12 +194,57 @@ describe('PairingSessionCloser', () => {
expect(chatService.replyToReviewThread).toHaveBeenCalledWith(
app.client,
'thread-1',
expect.stringContaining('No teammates available'),
expect.stringContaining('No teammates signed up'),
);
expect(pairingSessionsRepo.remove).toHaveBeenCalledWith('thread-1');
expect(reviewLockManager.releaseLock).toHaveBeenCalledWith('thread-1');
});

it('should list partial sign-ups when unfulfilled but some teammates were interested', async () => {
const slot1 = makeSlot({
id: 'slot-1',
date: '2026-03-31',
startTime: '13:00',
endTime: '15:00',
interestedTeammates: [{ userId: 'u1', acceptedAt: 1, formats: [InterviewFormat.REMOTE] }],
});
const slot2 = makeSlot({
id: 'slot-2',
date: '2026-04-01',
startTime: '10:00',
endTime: '12:00',
interestedTeammates: [],
});
const slot3 = makeSlot({
id: 'slot-3',
date: '2026-04-02',
startTime: '14:00',
endTime: '16:00',
interestedTeammates: [
{ userId: 'u2', acceptedAt: 2, formats: [InterviewFormat.REMOTE] },
{ userId: 'u3', acceptedAt: 3, formats: [InterviewFormat.REMOTE] },
],
});
const interview = makeInterview({
format: InterviewFormat.HYBRID,
pendingTeammates: [],
slots: [slot1, slot2, slot3],
});
pairingSessionsRepo.getByThreadIdOrUndefined = jest.fn().mockResolvedValue(interview);

await pairingSessionCloser.closeIfComplete(app, 'thread-1');

const message = (chatService.replyToReviewThread as jest.Mock).mock.calls[0][2];
expect(message).toContain("Couldn't fill all slots");
expect(message).toContain('Some teammates did sign up');
expect(message).toContain('<@u1>');
expect(message).toContain('<@u2>');
expect(message).toContain('<@u3>');
expect(message).not.toContain('Apr 01');
expect(pairingSessionsRepo.remove).toHaveBeenCalledWith('thread-1');
expect(reviewLockManager.releaseLock).toHaveBeenCalledWith('thread-1');
});

it('should handle a concurrently-closed interview gracefully', async () => {
pairingSessionsRepo.getByThreadIdOrUndefined = jest.fn().mockResolvedValue(undefined);

Expand Down
Loading