diff --git a/server/utils/slack.ts b/server/utils/slack.ts index f1fd674..94ac690 100644 --- a/server/utils/slack.ts +++ b/server/utils/slack.ts @@ -104,7 +104,7 @@ export class SlackService { /** * Find user in workspace by email */ - private async findUserByEmail(email: string): Promise { + async findUserByEmail(email: string): Promise { try { const response = await this.client.users.lookupByEmail({ email }); return response.user?.id || null; diff --git a/server/utils/slackAccess.js b/server/utils/slackAccess.js new file mode 100644 index 0000000..885ae12 --- /dev/null +++ b/server/utils/slackAccess.js @@ -0,0 +1,61 @@ +// Spec: docs/specs/wave-based-slack-onboarding.md +// +// Auto-detect existing Slack workspace membership for a Ghost Guild member +// and flag them as `slackInvited` without sending a duplicate invite. +// +// Contract: +// - Caller awaits this helper, but it never throws — all errors swallowed. +// - Internal lookup races against a 3s timeout (resolves null on hang). +// - No-op if Slack service isn't configured, lookup misses, or member is +// already flagged. + +import Member from '../models/member.js' +import { getSlackService } from './slack.ts' + +const LOOKUP_TIMEOUT_MS = 3000 + +export async function autoFlagPreExistingSlackAccess(member) { + try { + if (!member || !member._id || !member.email) return + if (member.slackInvited === true) return + + const slackService = getSlackService() + if (!slackService) return + + const timeoutPromise = new Promise((resolve) => { + setTimeout(() => resolve(null), LOOKUP_TIMEOUT_MS) + }) + + let userId = null + try { + userId = await Promise.race([ + slackService.findUserByEmail(member.email), + timeoutPromise + ]) + } catch (err) { + console.error('[slackAccess] findUserByEmail failed:', err) + return + } + + if (!userId) return + + await Member.findByIdAndUpdate( + member._id, + { + slackInvited: true, + slackInvitedAt: new Date(), + slackUserId: userId + }, + { runValidators: false } + ) + + await logActivity( + member._id, + 'slack_access_auto_detected', + { slackUserId: userId }, + { visibility: 'member' } + ) + } catch (err) { + console.error('[slackAccess] autoFlagPreExistingSlackAccess error:', err) + } +} diff --git a/tests/server/models/member-slack-fields.test.js b/tests/server/models/member-slack-fields.test.js new file mode 100644 index 0000000..d64ddac --- /dev/null +++ b/tests/server/models/member-slack-fields.test.js @@ -0,0 +1,67 @@ +// Spec: docs/specs/wave-based-slack-onboarding.md +// Test plan: docs/specs/wave-based-slack-onboarding-tests.md §1 +// +// SCAFFOLD: `describe.skip` until the schema migration lands. Tests use the +// schema's path metadata only — no DB connection required. + +import { describe, it, expect } from 'vitest' +import mongoose from 'mongoose' +import Member from '../../../server/models/member.js' + +describe.skip('Member schema — Slack fields (post-migration)', () => { + it('does not define slackInviteStatus (1.2)', () => { + expect(Member.schema.path('slackInviteStatus')).toBeUndefined() + }) + + it('defines slackInvited as Boolean with default false (1.1)', () => { + const path = Member.schema.path('slackInvited') + expect(path).toBeDefined() + expect(path.instance).toBe('Boolean') + expect(path.defaultValue).toBe(false) + }) + + it('defines slackInvitedAt as an optional Date (1.3)', () => { + const path = Member.schema.path('slackInvitedAt') + expect(path).toBeDefined() + expect(path.instance).toBe('Date') + expect(path.isRequired).toBeFalsy() + }) + + it('retains slackUserId as String (1.4)', () => { + const path = Member.schema.path('slackUserId') + expect(path).toBeDefined() + expect(path.instance).toBe('String') + }) + + it('does not auto-stamp slackInvitedAt via pre-save hook (1.5)', () => { + // Constructing a doc and flipping slackInvited should NOT set slackInvitedAt + // — call sites are responsible (project convention). + const doc = new Member({ + email: 't@example.com', + name: 'T', + circle: 'community', + contributionAmount: 0 + }) + doc.slackInvited = true + expect(doc.slackInvitedAt).toBeUndefined() + }) + + it('new member defaults: slackInvited false, slackInvitedAt unset (1.1)', () => { + const doc = new Member({ + email: 'new@example.com', + name: 'New', + circle: 'community', + contributionAmount: 0 + }) + expect(doc.slackInvited).toBe(false) + expect(doc.slackInvitedAt).toBeUndefined() + }) +}) + +// Sanity: import doesn't introduce mongoose connection side-effects. +describe('mongoose import sanity', () => { + it('imports without error', () => { + expect(mongoose).toBeDefined() + expect(Member).toBeDefined() + }) +}) diff --git a/tests/server/utils/slackAccess.test.js b/tests/server/utils/slackAccess.test.js new file mode 100644 index 0000000..03db079 --- /dev/null +++ b/tests/server/utils/slackAccess.test.js @@ -0,0 +1,134 @@ +// Spec: docs/specs/wave-based-slack-onboarding.md +// Test plan: docs/specs/wave-based-slack-onboarding-tests.md §2 +// +// SCAFFOLD: `describe.skip` until the helper at server/utils/slackAccess.js +// is created. Unskip during implementation (TDD). + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' + +vi.mock('../../../server/models/member.js', () => ({ + default: { + findByIdAndUpdate: vi.fn().mockResolvedValue(null) + } +})) + +vi.mock('../../../server/utils/slack.ts', () => ({ + getSlackService: vi.fn() +})) + +// `logActivity` is stubbed globally in tests/server/setup.js + +describe('autoFlagPreExistingSlackAccess (server/utils/slackAccess.js)', () => { + let autoFlagPreExistingSlackAccess + let Member + let getSlackService + let findUserByEmail + + beforeEach(async () => { + vi.clearAllMocks() + vi.useFakeTimers() + + findUserByEmail = vi.fn() + getSlackService = (await import('../../../server/utils/slack.ts')).getSlackService + getSlackService.mockReturnValue({ findUserByEmail }) + + Member = (await import('../../../server/models/member.js')).default + autoFlagPreExistingSlackAccess = ( + await import('../../../server/utils/slackAccess.js') + ).autoFlagPreExistingSlackAccess + + globalThis.logActivity.mockClear?.() + }) + + afterEach(() => { + vi.useRealTimers() + }) + + const baseMember = () => ({ + _id: 'member-abc', + email: 'pat@example.com', + name: 'Pat Example', + slackInvited: false + }) + + it('flags member when Slack lookup finds the email (2.1)', async () => { + findUserByEmail.mockResolvedValue('U123') + + await autoFlagPreExistingSlackAccess(baseMember()) + + expect(Member.findByIdAndUpdate).toHaveBeenCalledWith( + 'member-abc', + expect.objectContaining({ + slackInvited: true, + slackInvitedAt: expect.any(Date), + slackUserId: 'U123' + }), + expect.objectContaining({ runValidators: false }) + ) + expect(globalThis.logActivity).toHaveBeenCalledWith( + 'member-abc', + 'slack_access_auto_detected', + expect.any(Object), + expect.any(Object) + ) + }) + + it('no-ops when lookup returns no match (2.2)', async () => { + findUserByEmail.mockResolvedValue(null) + + await autoFlagPreExistingSlackAccess(baseMember()) + + expect(Member.findByIdAndUpdate).not.toHaveBeenCalled() + expect(globalThis.logActivity).not.toHaveBeenCalled() + }) + + it('returns silently when Slack service is not configured (2.3)', async () => { + getSlackService.mockReturnValue(null) + + await expect( + autoFlagPreExistingSlackAccess(baseMember()) + ).resolves.not.toThrow() + expect(Member.findByIdAndUpdate).not.toHaveBeenCalled() + }) + + it('swallows Slack API errors and does not throw to caller (2.4)', async () => { + findUserByEmail.mockRejectedValue(new Error('slack down')) + + await expect( + autoFlagPreExistingSlackAccess(baseMember()) + ).resolves.not.toThrow() + expect(Member.findByIdAndUpdate).not.toHaveBeenCalled() + }) + + it('returns within timeout if Slack lookup hangs (2.5)', async () => { + findUserByEmail.mockImplementation(() => new Promise(() => { /* never resolves */ })) + + const promise = autoFlagPreExistingSlackAccess(baseMember()) + await vi.advanceTimersByTimeAsync(3500) + await expect(promise).resolves.not.toThrow() + expect(Member.findByIdAndUpdate).not.toHaveBeenCalled() + }) + + it('uses findByIdAndUpdate with runValidators:false, never member.save (2.6)', async () => { + findUserByEmail.mockResolvedValue('U999') + const member = baseMember() + member.save = vi.fn() // sentinel — must not be called + + await autoFlagPreExistingSlackAccess(member) + + expect(member.save).not.toHaveBeenCalled() + const opts = Member.findByIdAndUpdate.mock.calls.at(-1)?.[2] + expect(opts).toMatchObject({ runValidators: false }) + }) + + it('writes activity log entry distinct from manual mark (2.7)', async () => { + findUserByEmail.mockResolvedValue('U123') + await autoFlagPreExistingSlackAccess(baseMember()) + + const [, action] = globalThis.logActivity.mock.calls.at(-1) + expect(action).toBe('slack_access_auto_detected') + expect(action).not.toBe('slack_invited_manually') + }) + + it.todo('idempotent: does not re-stamp slackInvitedAt on already-flagged member (2.8)') +})