diff --git a/server/plugins/check-slack-joins.js b/server/plugins/check-slack-joins.js new file mode 100644 index 0000000..9f53ff9 --- /dev/null +++ b/server/plugins/check-slack-joins.js @@ -0,0 +1,29 @@ +// server/plugins/check-slack-joins.js +import { checkSlackJoins } from '../utils/checkSlackJoins.js' + +const INTERVAL_MS = 3600000 // 1 hour + +export default defineNitroPlugin(() => { + // Don't run in test environment + if (process.env.NODE_ENV === 'test') return + + const config = useRuntimeConfig() + const token = config.slackBotToken + + if (!token) { + console.warn('[check-slack-joins] No Slack bot token configured, skipping background job') + return + } + + async function run() { + try { + await checkSlackJoins(token) + } catch (err) { + console.error('[check-slack-joins] Unhandled error:', err.message || err) + } + } + + // Run immediately on server start, then every hour + run() + setInterval(run, INTERVAL_MS) +}) diff --git a/server/utils/checkSlackJoins.js b/server/utils/checkSlackJoins.js new file mode 100644 index 0000000..64d4604 --- /dev/null +++ b/server/utils/checkSlackJoins.js @@ -0,0 +1,59 @@ +// server/utils/checkSlackJoins.js +import Member from '../models/member.js' +import { connectDB } from './mongoose.js' +import { WebClient } from '@slack/web-api' + +const BATCH_SIZE = 15 +const BATCH_DELAY_MS = 1000 + +/** + * Check members with pending Slack invites to see if they've joined the workspace. + * Processes in batches of 15 with 1-second delays (Slack Tier 2 rate limit). + */ +export async function checkSlackJoins(slackBotToken) { + await connectDB() + + const client = new WebClient(slackBotToken) + + const members = await Member.find({ + slackInviteStatus: { $in: ['sent', 'accepted'] } + }).select('_id email slackInviteStatus') + + if (members.length === 0) return { checked: 0, joined: 0 } + + let joined = 0 + + for (let i = 0; i < members.length; i += BATCH_SIZE) { + const batch = members.slice(i, i + BATCH_SIZE) + + for (const member of batch) { + try { + const response = await client.users.lookupByEmail({ email: member.email }) + const userId = response.user?.id + + if (userId) { + await Member.findByIdAndUpdate(member._id, { + slackInviteStatus: 'joined', + slackUserId: userId + }) + joined++ + console.log(`[check-slack-joins] ${member.email} joined Slack (${userId})`) + } + } catch (err) { + // users_not_found is expected for members who haven't joined yet + if (err.data?.error === 'users_not_found') continue + + console.error(`[check-slack-joins] Error checking ${member.email}:`, err.message || err) + // Continue processing remaining members + } + } + + // Delay between batches (skip after last batch) + if (i + BATCH_SIZE < members.length) { + await new Promise((resolve) => setTimeout(resolve, BATCH_DELAY_MS)) + } + } + + console.log(`[check-slack-joins] Done: ${joined}/${members.length} members joined`) + return { checked: members.length, joined } +} diff --git a/tests/server/tasks/check-slack-joins.test.js b/tests/server/tasks/check-slack-joins.test.js new file mode 100644 index 0000000..1e12814 --- /dev/null +++ b/tests/server/tasks/check-slack-joins.test.js @@ -0,0 +1,151 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' + +// Hoist mock functions so vi.mock factories can reference them +const { mockFind, mockFindByIdAndUpdate, mockLookupByEmail } = vi.hoisted(() => ({ + mockFind: vi.fn(), + mockFindByIdAndUpdate: vi.fn(), + mockLookupByEmail: vi.fn() +})) + +// Mock mongoose connection +vi.mock('../../../server/utils/mongoose.js', () => ({ + connectDB: vi.fn() +})) + +// Mock Member model +vi.mock('../../../server/models/member.js', () => ({ + default: { + find: mockFind, + findByIdAndUpdate: mockFindByIdAndUpdate + } +})) + +// Mock @slack/web-api — use function expression so `new WebClient()` works +vi.mock('@slack/web-api', () => ({ + WebClient: vi.fn().mockImplementation(function () { + this.users = { lookupByEmail: mockLookupByEmail } + }) +})) + +import { checkSlackJoins } from '../../../server/utils/checkSlackJoins.js' + +describe('checkSlackJoins', () => { + beforeEach(() => { + vi.clearAllMocks() + // Default: find returns empty with select chain + mockFind.mockReturnValue({ select: vi.fn().mockResolvedValue([]) }) + }) + + it('8.1: detects joined member — updates status and stores slackUserId', async () => { + mockFind.mockReturnValue({ + select: vi.fn().mockResolvedValue([ + { _id: 'm1', email: 'alice@example.com', slackInviteStatus: 'sent' } + ]) + }) + mockLookupByEmail.mockResolvedValue({ user: { id: 'U123ABC' } }) + mockFindByIdAndUpdate.mockResolvedValue({}) + + const result = await checkSlackJoins('xoxb-test-token') + + expect(mockLookupByEmail).toHaveBeenCalledWith({ email: 'alice@example.com' }) + expect(mockFindByIdAndUpdate).toHaveBeenCalledWith('m1', { + slackInviteStatus: 'joined', + slackUserId: 'U123ABC' + }) + expect(result).toEqual({ checked: 1, joined: 1 }) + }) + + it('8.2: no change when member not found in Slack', async () => { + mockFind.mockReturnValue({ + select: vi.fn().mockResolvedValue([ + { _id: 'm2', email: 'bob@example.com', slackInviteStatus: 'sent' } + ]) + }) + // Slack throws users_not_found for unknown emails + mockLookupByEmail.mockRejectedValue({ data: { error: 'users_not_found' } }) + + const result = await checkSlackJoins('xoxb-test-token') + + expect(mockFindByIdAndUpdate).not.toHaveBeenCalled() + expect(result).toEqual({ checked: 1, joined: 0 }) + }) + + it('8.3: skips already-joined members (not included in query)', async () => { + mockFind.mockReturnValue({ + select: vi.fn().mockResolvedValue([]) + }) + + const result = await checkSlackJoins('xoxb-test-token') + + // Verify the query only looks for 'sent' and 'accepted' + expect(mockFind).toHaveBeenCalledWith({ + slackInviteStatus: { $in: ['sent', 'accepted'] } + }) + expect(result).toEqual({ checked: 0, joined: 0 }) + expect(mockLookupByEmail).not.toHaveBeenCalled() + }) + + it('8.4: skips members with null slackInviteStatus (not included in query)', async () => { + mockFind.mockReturnValue({ + select: vi.fn().mockResolvedValue([]) + }) + + await checkSlackJoins('xoxb-test-token') + + // Query uses $in with only 'sent' and 'accepted' — null is excluded + const queryArg = mockFind.mock.calls[0][0] + expect(queryArg.slackInviteStatus.$in).toEqual(['sent', 'accepted']) + expect(queryArg.slackInviteStatus.$in).not.toContain(null) + }) + + it('8.6: partial failure does not abort batch — remaining members still processed', async () => { + mockFind.mockReturnValue({ + select: vi.fn().mockResolvedValue([ + { _id: 'm1', email: 'alice@example.com', slackInviteStatus: 'sent' }, + { _id: 'm2', email: 'bob@example.com', slackInviteStatus: 'sent' }, + { _id: 'm3', email: 'carol@example.com', slackInviteStatus: 'sent' } + ]) + }) + + // First call: unexpected API error + mockLookupByEmail + .mockRejectedValueOnce(new Error('Slack API timeout')) + // Second call: not found + .mockRejectedValueOnce({ data: { error: 'users_not_found' } }) + // Third call: found + .mockResolvedValueOnce({ user: { id: 'U999' } }) + + mockFindByIdAndUpdate.mockResolvedValue({}) + + const result = await checkSlackJoins('xoxb-test-token') + + // All three were checked + expect(mockLookupByEmail).toHaveBeenCalledTimes(3) + // Only the third member joined + expect(mockFindByIdAndUpdate).toHaveBeenCalledTimes(1) + expect(mockFindByIdAndUpdate).toHaveBeenCalledWith('m3', { + slackInviteStatus: 'joined', + slackUserId: 'U999' + }) + expect(result).toEqual({ checked: 3, joined: 1 }) + }) + + it('8.7: handles accepted status members too', async () => { + mockFind.mockReturnValue({ + select: vi.fn().mockResolvedValue([ + { _id: 'm4', email: 'dave@example.com', slackInviteStatus: 'accepted' } + ]) + }) + mockLookupByEmail.mockResolvedValue({ user: { id: 'UABC' } }) + mockFindByIdAndUpdate.mockResolvedValue({}) + + const result = await checkSlackJoins('xoxb-test-token') + + expect(mockLookupByEmail).toHaveBeenCalledWith({ email: 'dave@example.com' }) + expect(mockFindByIdAndUpdate).toHaveBeenCalledWith('m4', { + slackInviteStatus: 'joined', + slackUserId: 'UABC' + }) + expect(result).toEqual({ checked: 1, joined: 1 }) + }) +})